Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fa227643 | ||
|
|
bbd81fd7b4 | ||
|
|
5635f483e4 | ||
|
|
dad1e3f04a | ||
|
|
8df66a2a94 | ||
|
|
11fba61b60 | ||
|
|
5c4526e578 | ||
|
|
6a7ea0cd8d | ||
|
|
e2bddec525 | ||
|
|
550a4cc04b | ||
|
|
be5043196f | ||
|
|
85ac3eb26f | ||
|
|
b08bd1dcac | ||
|
|
67cef72482 | ||
|
|
66d390b03d | ||
|
|
f5769716e9 | ||
|
|
1577ce38e1 | ||
|
|
2c266742b2 | ||
|
|
0970617385 | ||
|
|
3476b29713 | ||
|
|
dd65dd0c24 | ||
|
|
943be3e2fb | ||
|
|
94238f5bdf | ||
|
|
5cc39816ba | ||
|
|
2e0f1a23cc | ||
|
|
df16effa4b | ||
|
|
e40884ba7f | ||
|
|
16bcf25391 | ||
|
|
acb62df859 | ||
|
|
f6ce39a40b | ||
|
|
abbd461e3f | ||
|
|
1af261058f | ||
|
|
07dbac50c8 | ||
|
|
148602f7d9 | ||
|
|
67acee4b72 | ||
|
|
7ee8a14dfa | ||
|
|
cd9612500c | ||
|
|
baa18fe9f7 | ||
|
|
0c5817e42c | ||
|
|
6d4cbbf03b | ||
|
|
8d9b60def0 | ||
|
|
a39c4fa26d | ||
|
|
b629b9a6e2 | ||
|
|
4899943cfc | ||
|
|
d75d286dee | ||
|
|
e732eb7a20 | ||
|
|
3890e36078 | ||
|
|
2a4579cf89 | ||
|
|
eee4297a36 | ||
|
|
ac7fb25e47 | ||
|
|
f780a8eb3e | ||
|
|
f92449c493 | ||
|
|
bc3413f183 | ||
|
|
eeae53bd01 | ||
|
|
9975ac17a6 | ||
|
|
f32b8aebe5 | ||
|
|
38a53c013a | ||
|
|
be4917ce80 | ||
|
|
75dfdac920 | ||
|
|
97f5f5c1ca | ||
|
|
3ba7fa7b1a | ||
|
|
31bd4f32f8 | ||
|
|
e585aa9458 | ||
|
|
acb8c34df6 | ||
|
|
8a2a90e9ef | ||
|
|
7358503666 | ||
|
|
8a47bf52f2 | ||
|
|
f123adb15f | ||
|
|
bee5b91fef | ||
|
|
990653cb2f | ||
|
|
903d0d2fa6 | ||
|
|
07ba104edd | ||
|
|
1581938c64 | ||
|
|
5a614cb4eb | ||
|
|
9fe1ad3d29 | ||
|
|
1945f61e00 | ||
|
|
1577ef46be | ||
|
|
4241afab85 | ||
|
|
68c748e9bc | ||
|
|
5f2e08a9cc | ||
|
|
c0e4d36205 | ||
|
|
9d7694da21 | ||
|
|
3ca3f377b0 | ||
|
|
06631f8069 | ||
|
|
2e4c925a2c | ||
|
|
97515cca96 | ||
|
|
caef693c0e | ||
|
|
0747cd9440 | ||
|
|
2c55109cb5 | ||
|
|
7d8aeba9ac | ||
|
|
f0e3717823 | ||
|
|
936992ce0e | ||
|
|
fe009aa5e8 | ||
|
|
02ef654f24 | ||
|
|
eae8808562 | ||
|
|
ec9421e0ed | ||
|
|
9483a7065b | ||
|
|
99af75ea25 | ||
|
|
d878721098 |
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://vxchat.netlify.app/"><img src="./public/voxa.svg" alt="VOXA"></a>
|
<a href="https://vxchat.netlify.app/"><img src="./public/axiom.svg" alt="Axiom" height="30"></a>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<h4 align="center">Chat. Connect. Collaborate.</h4>
|
<h4 align="center">Chat. Connect. Collaborate.</h4>
|
||||||
@@ -34,33 +34,33 @@ SUPABASE_SERVICE_ROLE_KEY=""
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Private: Messages can be handled by the server / node, voxa can't access them.
|
- Private: Messages can be handled by the server / node, axiom can't access them.
|
||||||
- Customizable: Customize your server with plugins, a replacement for bots.
|
- Customizable: Customize your server with plugins, a replacement for bots.
|
||||||
|
|
||||||
## Keywords
|
## Keywords
|
||||||
|
|
||||||
- Voxa Client: The user-side app which communicates with all below.
|
- Axiom Client: The user-side app which communicates with all below.
|
||||||
- Voxa Cloud: The authentication and user management handler.
|
- Axiom Cloud: The authentication and user management handler.
|
||||||
- Voxa Server: The self-host-able community/friends communication service.
|
- Axiom Server: The self-host-able community/friends communication service.
|
||||||
- Voxa Node: The self-host-able Private Message communication service, also used for sending notifications.
|
- Axiom Node: The self-host-able Private Message communication service, also used for sending notifications.
|
||||||
|
|
||||||
## Node Structure
|
## Node Structure
|
||||||
|
|
||||||
The Direct-Message structure works like Email, but real-time and the node uses a temporary auth, a node is what stores messages that are sent by a user.
|
The Direct-Message structure works like Email, but real-time and the node uses a temporary auth, a node is what stores messages that are sent by a user.
|
||||||
|
|
||||||
This diagram shows that in order to send a message to a user, you must connect to their chosen node, which can be accessed via the Voxa Cloud and connected to directly.
|
This diagram shows that in order to send a message to a user, you must connect to their chosen node, which can be accessed via the Axiom Cloud and connected to directly.
|
||||||
|
|
||||||
A node message can be a DM (Direct Message between two parties), or a SM (Server Message from a community).
|
A node message can be a DM (Direct Message between two parties), or a SM (Server Message from a community).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant AliceClient as Alice's Client
|
participant AliceClient as Alice's Client
|
||||||
participant Voxa as Voxa Cloud
|
participant Axiom as Axiom Cloud
|
||||||
participant BobNode as Bob's Selected Node (Node2)
|
participant BobNode as Bob's Selected Node (Node2)
|
||||||
participant BobClient as Bob's Client
|
participant BobClient as Bob's Client
|
||||||
|
|
||||||
AliceClient->>Voxa: Lookup Bob's node
|
AliceClient->>Axiom: Lookup Bob's node
|
||||||
Voxa-->>AliceClient: Respond with Bob → Node2
|
Axiom-->>AliceClient: Respond with Bob → Node2
|
||||||
AliceClient->>BobNode: Authenticate and Send DM to bob
|
AliceClient->>BobNode: Authenticate and Send DM to bob
|
||||||
BobNode-->>AliceClient: Acknowledge message stored
|
BobNode-->>AliceClient: Acknowledge message stored
|
||||||
BobNode->>BobClient: Notify new message
|
BobNode->>BobClient: Notify new message
|
||||||
@@ -78,7 +78,7 @@ flowchart TD
|
|||||||
A --> B{Is Bob connected<br>to this server?}
|
A --> B{Is Bob connected<br>to this server?}
|
||||||
B -- Yes --> C[Deliver directly<br>to Bob's client]
|
B -- Yes --> C[Deliver directly<br>to Bob's client]
|
||||||
C --> D[Ack received]
|
C --> D[Ack received]
|
||||||
B -- No --> E[Lookup Bob's selected node<br>via Voxa Cloud]
|
B -- No --> E[Lookup Bob's selected node<br>via Axiom Cloud]
|
||||||
E --> F[Forward message<br>to Bob's node]
|
E --> F[Forward message<br>to Bob's node]
|
||||||
F --> G[Bob's node notifies<br>Bob's client]
|
F --> G[Bob's node notifies<br>Bob's client]
|
||||||
G --> H[Ack flows back]
|
G --> H[Ack flows back]
|
||||||
@@ -91,14 +91,14 @@ This sequence happens when you connect to a server or node.
|
|||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Alice as Alice's Client
|
participant Alice as Alice's Client
|
||||||
participant Voxa as Voxa Cloud
|
participant Axiom as Axiom Cloud
|
||||||
participant Server as Node / Server
|
participant Server as Node / Server
|
||||||
|
|
||||||
Alice->>Voxa: Register token for the (Node / Server)
|
Alice->>Axiom: Register token for the (Node / Server)
|
||||||
Voxa-->>Alice: TOKEN
|
Axiom-->>Alice: TOKEN
|
||||||
Alice->>Server: Authenticate with TOKEN
|
Alice->>Server: Authenticate with TOKEN
|
||||||
Server->>Voxa: Authenticate with TOKEN
|
Server->>Axiom: Authenticate with TOKEN
|
||||||
Voxa-->>Server: The user is Alice
|
Axiom-->>Server: The user is Alice
|
||||||
Server-->>Alice: You are Alice
|
Server-->>Alice: You are Alice
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "voxa",
|
"name": "axiom",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voxa",
|
"name": "axiom",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lexical/react": "^0.34.0",
|
"@lexical/react": "^0.34.0",
|
||||||
"@lexical/rich-text": "^0.34.0",
|
"@lexical/rich-text": "^0.34.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.75.0",
|
"@supabase/supabase-js": "^2.75.0",
|
||||||
"@types/axios": "^0.9.36",
|
"@types/axios": "^0.9.36",
|
||||||
"@types/date-fns": "^2.5.3",
|
"@types/date-fns": "^2.5.3",
|
||||||
@@ -38,11 +42,12 @@
|
|||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lexical": "^0.34.0",
|
"lexical": "^0.34.0",
|
||||||
"lucide-react": "^0.541.0",
|
"lucide-react": "^0.541.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-iframes-bridge": "^1.0.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20.19.21",
|
"@types/node": "^20.19.27",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -882,15 +887,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||||
"integrity": "sha512-sDaprBAfzCQiOgo2pO+LhnV0Wt2wBgartjrr+dpcTORYVnnXD0gwhHhiiyIih9hQbq+JnbqH4odgcFWhqCGidw==",
|
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
|
||||||
"integrity": "sha512-v7Jj9iqC6enxIRBIScD/o0lH7QKvSxq2LM8UTyqJi+S2w2QzhMYjven4vgu/RzgsdtdbpkyCxBTzHl/gN5rTRg==",
|
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -904,9 +909,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
|
||||||
"integrity": "sha512-s2Nk6ec+pmYmAb/utawuURy7uvyYKDk+TRE5aqLRsdnj3AhwC9IKUBmhfnLmY/+P+DnwqpeXEFIKe9tlG0p6CA==",
|
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -920,9 +925,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
|
||||||
"integrity": "sha512-mGlPJMZReU4yP5fSHjOxiTYvZmwPSWn/eF/dcg21pwfmiUCKS1amFvf1F1RkLHPIMPfocxLViNWFvkvDB14Isg==",
|
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -936,9 +941,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
|
||||||
"integrity": "sha512-biWqIOE17OW/6S34t1X8K/3vb1+svp5ji5QQT/IKR+VfM3B7GvlCwmz5XtlEan2ukOUf9tj2vJJBffaGH4fGRw==",
|
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -952,9 +957,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
|
||||||
"integrity": "sha512-zPisT+obYypM/l6EZ0yRkK3LEuoZqHaSoYKj+5jiD9ESHwdr6QhnabnNxYkdy34uCigNlWIaCbjFmQ8FY5AlxA==",
|
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -968,9 +973,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
|
||||||
"integrity": "sha512-+t3+7GoU9IYmk+N+FHKBNFdahaReoAktdOpXHFIPOU1ixxtdge26NgQEEkJkCw2dHT9UwwK5zw4mAsURw4E8jA==",
|
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -984,9 +989,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
|
||||||
"integrity": "sha512-d8MrXKh0A+c9DLiy1BUFwtg3Hu90Lucj3k6iKTUdPOv42Ve2UiIG8HYi3UAb8kFVluXxEfdpCoPPCSODk5fDcw==",
|
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1000,9 +1005,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
|
||||||
"integrity": "sha512-Fe1tGHxOWEyQjmygWkkXSwhFcTJuimrNu52JEuwItrKJVV4iRjbWp9I7zZjwqtiNnQmxoEvoisn8wueFLrNpvQ==",
|
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1163,6 +1168,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu": {
|
||||||
|
"version": "2.2.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
|
||||||
|
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-dialog": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
@@ -1391,6 +1424,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
@@ -1494,6 +1564,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
@@ -1888,77 +2038,95 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.75.0",
|
"version": "2.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.89.0.tgz",
|
||||||
"integrity": "sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==",
|
"integrity": "sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "2.6.15"
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.75.0",
|
"version": "2.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.89.0.tgz",
|
||||||
"integrity": "sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==",
|
"integrity": "sha512-XEueaC5gMe5NufNYfBh9kPwJlP5M2f+Ogr8rvhmRDAZNHgY6mI35RCkYDijd92pMcNM7g8pUUJov93UGUnqfyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "2.6.15"
|
"tslib": "2.8.1"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/node-fetch": {
|
|
||||||
"version": "2.6.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
|
||||||
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "4.x || >=6.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/postgrest-js": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "2.75.0",
|
"version": "2.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.89.0.tgz",
|
||||||
"integrity": "sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==",
|
"integrity": "sha512-/b0fKrxV9i7RNOEXMno/I1862RsYhuUo+Q6m6z3ar1f4ulTMXnDfv0y4YYxK2POcgrOXQOgKYQx1eArybyNvtg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "2.6.15"
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.75.0",
|
"version": "2.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.89.0.tgz",
|
||||||
"integrity": "sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==",
|
"integrity": "sha512-aMOvfDb2a52u6PX6jrrjvACHXGV3zsOlWRzZsTIOAJa0hOVvRp01AwC1+nLTGUzxzezejrYeCX+KnnM1xHdl+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "2.6.15",
|
|
||||||
"@types/phoenix": "^1.6.6",
|
"@types/phoenix": "^1.6.6",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
"ws": "^8.18.2"
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.76.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/storage-js": {
|
"node_modules/@supabase/storage-js": {
|
||||||
"version": "2.75.0",
|
"version": "2.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.89.0.tgz",
|
||||||
"integrity": "sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==",
|
"integrity": "sha512-6zKcXofk/M/4Eato7iqpRh+B+vnxeiTumCIP+Tz26xEqIiywzD9JxHq+udRrDuv6hXE+pmetvJd8n5wcf4MFRQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "2.6.15"
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/supabase-js": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.75.0",
|
"version": "2.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.89.0.tgz",
|
||||||
"integrity": "sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==",
|
"integrity": "sha512-KlaRwSfFA0fD73PYVMHj5/iXFtQGCcX7PSx0FdQwYEEw9b2wqM7GxadY+5YwcmuEhalmjFB/YvqaoNVF+sWUlg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.75.0",
|
"@supabase/auth-js": "2.89.0",
|
||||||
"@supabase/functions-js": "2.75.0",
|
"@supabase/functions-js": "2.89.0",
|
||||||
"@supabase/node-fetch": "2.6.15",
|
"@supabase/postgrest-js": "2.89.0",
|
||||||
"@supabase/postgrest-js": "2.75.0",
|
"@supabase/realtime-js": "2.89.0",
|
||||||
"@supabase/realtime-js": "2.75.0",
|
"@supabase/storage-js": "2.89.0"
|
||||||
"@supabase/storage-js": "2.75.0"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
@@ -2323,9 +2491,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.21",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2344,9 +2512,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/phoenix": {
|
"node_modules/@types/phoenix": {
|
||||||
"version": "1.6.6",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
@@ -2657,6 +2825,19 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
@@ -3110,6 +3291,15 @@
|
|||||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inline-style-parser": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
|
||||||
@@ -4457,12 +4647,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.0",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||||
"integrity": "sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ==",
|
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||||
|
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.0",
|
"@next/env": "15.5.7",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -4475,14 +4666,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.0",
|
"@next/swc-darwin-arm64": "15.5.7",
|
||||||
"@next/swc-darwin-x64": "15.5.0",
|
"@next/swc-darwin-x64": "15.5.7",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.0",
|
"@next/swc-linux-arm64-gnu": "15.5.7",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.0",
|
"@next/swc-linux-arm64-musl": "15.5.7",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.0",
|
"@next/swc-linux-x64-gnu": "15.5.7",
|
||||||
"@next/swc-linux-x64-musl": "15.5.0",
|
"@next/swc-linux-x64-musl": "15.5.7",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.0",
|
"@next/swc-win32-arm64-msvc": "15.5.7",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.0",
|
"@next/swc-win32-x64-msvc": "15.5.7",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -4802,6 +4993,16 @@
|
|||||||
"react": ">=16.13.1"
|
"react": ">=16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-iframes-bridge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-iframes-bridge/-/react-iframes-bridge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sjUbHOF7x23nZCek+i3okMasTBYKhvlJXYZe3IBvF/8GAspEG/uHxeoVvLmTj2IZKdUTcKBPNCqCB8lz6LFxTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||||
@@ -5180,12 +5381,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/trim-lines": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
@@ -5381,6 +5576,19 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
@@ -5409,22 +5617,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
@@ -5483,6 +5675,15 @@
|
|||||||
"url": "https://github.com/sponsors/dmonad"
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "voxa",
|
"name": "axiom",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,15 +15,19 @@
|
|||||||
"@lexical/rich-text": "^0.34.0",
|
"@lexical/rich-text": "^0.34.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.75.0",
|
"@supabase/supabase-js": "^2.75.0",
|
||||||
"@types/axios": "^0.9.36",
|
"@types/axios": "^0.9.36",
|
||||||
"@types/date-fns": "^2.5.3",
|
"@types/date-fns": "^2.5.3",
|
||||||
@@ -41,11 +45,12 @@
|
|||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lexical": "^0.34.0",
|
"lexical": "^0.34.0",
|
||||||
"lucide-react": "^0.541.0",
|
"lucide-react": "^0.541.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-iframes-bridge": "^1.0.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -54,7 +59,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20.19.21",
|
"@types/node": "^20.19.27",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
class MicProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.buffer = new Float32Array(960);
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (!input || !input[0]) return true;
|
||||||
|
|
||||||
|
const channel = input[0];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < channel.length) {
|
||||||
|
const remaining = this.buffer.length - this.offset;
|
||||||
|
const toCopy = Math.min(remaining, channel.length - i);
|
||||||
|
|
||||||
|
this.buffer.set(channel.subarray(i, i + toCopy), this.offset);
|
||||||
|
|
||||||
|
this.offset += toCopy;
|
||||||
|
i += toCopy;
|
||||||
|
|
||||||
|
if (this.offset === this.buffer.length) {
|
||||||
|
// COPY before sending
|
||||||
|
this.port.postMessage(this.buffer.slice());
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor("mic-processor", MicProcessor);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="96" height="96" fill="black"/>
|
||||||
|
<path d="M23.585 43.82C20.105 49.7 18.33 52.695 18.26 52.805C18.19 52.935 18.08 53 17.93 53H16.595C19.305 48.35 21.255 44.995 22.445 42.935C22.545 42.745 22.69 42.595 22.88 42.485C23.08 42.365 23.295 42.305 23.525 42.305H24.365C25.225 43.915 27.13 47.48 30.08 53H28.985C28.655 53 28.41 52.855 28.25 52.565C27.83 51.765 27.245 50.67 26.495 49.28L23.585 43.82ZM43.5091 42.305L38.3941 47.645L43.5091 53H42.2941C41.9841 53 41.7141 52.88 41.4841 52.64L37.5541 48.53L33.6241 52.64C33.3941 52.88 33.1241 53 32.8141 53H31.4791C32.7091 51.71 36.0041 48.265 41.3641 42.665C41.5941 42.425 41.8641 42.305 42.1741 42.305H43.5091ZM35.6791 45.71C35.5591 45.83 35.4191 45.89 35.2591 45.89C35.0991 45.89 34.9591 45.83 34.8391 45.71L31.5991 42.305H32.8141C33.1241 42.305 33.3941 42.425 33.6241 42.665L36.0991 45.26L35.6791 45.71ZM46.8406 53H46.1206C45.9206 53 45.7506 52.935 45.6106 52.805C45.4706 52.665 45.4006 52.5 45.4006 52.31V42.305H46.1206C46.3206 42.305 46.4906 42.375 46.6306 42.515C46.7706 42.645 46.8406 42.805 46.8406 42.995V53ZM59.8957 44.975C60.3957 45.795 60.6457 46.69 60.6457 47.66C60.6457 48.62 60.3957 49.515 59.8957 50.345C59.3957 51.165 58.7157 51.815 57.8557 52.295C57.0057 52.765 56.0757 53 55.0657 53C54.0557 53 53.1207 52.765 52.2607 52.295C51.4007 51.815 50.7207 51.165 50.2207 50.345C49.7207 49.515 49.4707 48.62 49.4707 47.66C49.4707 46.69 49.7207 45.795 50.2207 44.975C50.7207 44.155 51.4007 43.505 52.2607 43.025C53.1207 42.545 54.0557 42.305 55.0657 42.305C56.0757 42.305 57.0057 42.545 57.8557 43.025C58.7157 43.505 59.3957 44.155 59.8957 44.975ZM58.0357 44.825C57.2157 44.035 56.2207 43.64 55.0507 43.64C53.8907 43.64 52.9007 44.035 52.0807 44.825C51.2607 45.605 50.8507 46.55 50.8507 47.66C50.8507 48.76 51.2607 49.705 52.0807 50.495C52.9007 51.275 53.8907 51.665 55.0507 51.665C56.2207 51.665 57.2157 51.275 58.0357 50.495C58.8557 49.705 59.2657 48.76 59.2657 47.66C59.2657 46.55 58.8557 45.605 58.0357 44.825ZM76.8799 52.94C76.6399 52.94 76.4349 52.875 76.2649 52.745C76.0949 52.605 76.0099 52.44 76.0099 52.25V45.185L71.5099 52.535C71.3199 52.845 70.9849 53 70.5049 53C70.0349 53.01 69.7049 52.855 69.5149 52.535L65.0149 45.185V52.25C65.0149 52.44 64.9299 52.605 64.7599 52.745C64.5899 52.875 64.3849 52.94 64.1449 52.94H63.6199L63.6049 42.305H64.3699C64.6999 42.305 64.9349 42.42 65.0749 42.65L70.5199 51.74L75.9499 42.65C76.0899 42.42 76.3249 42.305 76.6549 42.305H77.4199L77.4049 52.94H76.8799Z" fill="#C084FC"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="61" height="11" viewBox="0 0 61 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6.99 1.515C3.51 7.395 1.735 10.39 1.665 10.5C1.595 10.63 1.485 10.695 1.335 10.695H-1.10269e-06C2.71 6.045 4.66 2.69 5.85 0.63C5.95 0.439999 6.095 0.289999 6.285 0.179999C6.485 0.0599989 6.7 -1.19209e-06 6.93 -1.19209e-06H7.77C8.63 1.61 10.535 5.175 13.485 10.695H12.39C12.06 10.695 11.815 10.55 11.655 10.26C11.235 9.46 10.65 8.365 9.9 6.975L6.99 1.515ZM26.9141 -1.19209e-06L21.7991 5.34L26.9141 10.695H25.6991C25.3891 10.695 25.1191 10.575 24.8891 10.335L20.9591 6.225L17.0291 10.335C16.7991 10.575 16.5291 10.695 16.2191 10.695H14.8841C16.1141 9.405 19.4091 5.96 24.7691 0.36C24.9991 0.119999 25.2691 -1.19209e-06 25.5791 -1.19209e-06H26.9141ZM19.0841 3.405C18.9641 3.525 18.8241 3.585 18.6641 3.585C18.5041 3.585 18.3641 3.525 18.2441 3.405L15.0041 -1.19209e-06H16.2191C16.5291 -1.19209e-06 16.7991 0.119999 17.0291 0.36L19.5041 2.955L19.0841 3.405ZM30.2456 10.695H29.5256C29.3256 10.695 29.1556 10.63 29.0156 10.5C28.8756 10.36 28.8056 10.195 28.8056 10.005V-1.19209e-06H29.5256C29.7256 -1.19209e-06 29.8956 0.0699988 30.0356 0.209999C30.1756 0.339999 30.2456 0.499999 30.2456 0.689999V10.695ZM43.3007 2.67C43.8007 3.49 44.0507 4.385 44.0507 5.355C44.0507 6.315 43.8007 7.21 43.3007 8.04C42.8007 8.86 42.1207 9.51 41.2607 9.99C40.4107 10.46 39.4807 10.695 38.4707 10.695C37.4607 10.695 36.5257 10.46 35.6657 9.99C34.8057 9.51 34.1257 8.86 33.6257 8.04C33.1257 7.21 32.8757 6.315 32.8757 5.355C32.8757 4.385 33.1257 3.49 33.6257 2.67C34.1257 1.85 34.8057 1.2 35.6657 0.719999C36.5257 0.239999 37.4607 -1.19209e-06 38.4707 -1.19209e-06C39.4807 -1.19209e-06 40.4107 0.239999 41.2607 0.719999C42.1207 1.2 42.8007 1.85 43.3007 2.67ZM41.4407 2.52C40.6207 1.73 39.6257 1.335 38.4557 1.335C37.2957 1.335 36.3057 1.73 35.4857 2.52C34.6657 3.3 34.2557 4.245 34.2557 5.355C34.2557 6.455 34.6657 7.4 35.4857 8.19C36.3057 8.97 37.2957 9.36 38.4557 9.36C39.6257 9.36 40.6207 8.97 41.4407 8.19C42.2607 7.4 42.6707 6.455 42.6707 5.355C42.6707 4.245 42.2607 3.3 41.4407 2.52ZM60.2849 10.635C60.0449 10.635 59.8399 10.57 59.6699 10.44C59.4999 10.3 59.4149 10.135 59.4149 9.945V2.88L54.9149 10.23C54.7249 10.54 54.3899 10.695 53.9099 10.695C53.4399 10.705 53.1099 10.55 52.9199 10.23L48.4199 2.88V9.945C48.4199 10.135 48.3349 10.3 48.1649 10.44C47.9949 10.57 47.7899 10.635 47.5499 10.635H47.0249L47.0099 -1.19209e-06H47.7749C48.1049 -1.19209e-06 48.3399 0.114999 48.4799 0.344999L53.9249 9.435L59.3549 0.344999C59.4949 0.114999 59.7299 -1.19209e-06 60.0599 -1.19209e-06H60.8249L60.8099 10.635H60.2849Z" fill="#C084FC"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 304 B |
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 328 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import createClient from "@/actions/supa";
|
||||||
|
import getSession from "./get-session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a temporary relay token for a DM node or server.
|
||||||
|
*/
|
||||||
|
export async function authRelay(server_id: string) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const user_id = await getSession();
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
throw new Error("Invalid user id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: relayToken, error: errorRelay } = await supabase
|
||||||
|
.from("server_auth")
|
||||||
|
.insert({ user_id: user_id, server_id })
|
||||||
|
.select("key")
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!relayToken || errorRelay) {
|
||||||
|
throw new Error("Failed to create relay token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return relayToken.key as string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import createClient from "@/actions/supa";
|
||||||
|
import { ProfileSettings } from "@/types/settings";
|
||||||
|
import getSession from "./get-session";
|
||||||
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
|
|
||||||
|
export async function getProfileById(id: string): Promise<UserProfile | null> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { error, data } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id, username, display_name, avatar_url, node_address")
|
||||||
|
.eq("id", id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfileByUsername(
|
||||||
|
username: string
|
||||||
|
): Promise<UserProfile | null> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { error, data } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id, username, display_name, avatar_url, node_address")
|
||||||
|
.eq("username", username)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(profile: ProfileSettings) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const user_id = await getSession();
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
throw new Error("User id invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update(profile)
|
||||||
|
.eq("id", user_id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import createClient from "@/actions/supa";
|
||||||
|
|
||||||
|
export async function getServerById(id: string) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { error, data: server } = await supabase
|
||||||
|
.from("servers")
|
||||||
|
.select("id, name, created_at, owner, address, icon_url")
|
||||||
|
.eq("id", id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (!server) throw "Server is null";
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import createClient from "@/actions/supa";
|
||||||
|
|
||||||
|
export default async function getSessionUserId() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const user = data.user;
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
export default async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
get(name) {
|
||||||
|
return cookieStore.get(name)?.value;
|
||||||
|
},
|
||||||
|
set(name, value) {
|
||||||
|
cookieStore.set(name, value);
|
||||||
|
},
|
||||||
|
remove(name) {
|
||||||
|
cookieStore.delete(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import createClient from "@/actions/supa";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
export async function registerUser(
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
) {
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
throw new Error("Missing required fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// Check if username is already taken
|
||||||
|
const { data: existing } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("username")
|
||||||
|
.eq("username", username)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Username already used");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign up user
|
||||||
|
const { data: authData, error: authError } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError || !authData.user) {
|
||||||
|
throw new Error(authError?.message ?? "User registration failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert profile
|
||||||
|
await supabase.from("profiles").insert({
|
||||||
|
id: authData.user.id,
|
||||||
|
username,
|
||||||
|
display_name: username,
|
||||||
|
avatar_url: "",
|
||||||
|
node_address: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "User registered. Please check your email to verify your account.",
|
||||||
|
user_id: authData.user.id,
|
||||||
|
email_sent: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login a user
|
||||||
|
*/
|
||||||
|
export async function loginUser(email: string, password: string) {
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error("Email and password are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data.session) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session } = data;
|
||||||
|
|
||||||
|
return session.user?.id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export default function Shine() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[2px] mx-auto lg:w-5xl relative z-10">
|
||||||
|
{/* Primary Deep Glow */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute left-1/2 -translate-x-1/2
|
||||||
|
w-[99%] h-[29px] rounded-full
|
||||||
|
filter blur-[24.5px] mix-blend-lighten opacity-30
|
||||||
|
bg-[radial-gradient(50%_50%,_#C084FC_0%,_#C084FC66_100%)]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Secondary Soft Glow */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute left-1/2 -translate-x-1/2
|
||||||
|
w-[64%] h-[17px] rounded-full
|
||||||
|
filter blur-[40.5px] mix-blend-lighten opacity-80
|
||||||
|
bg-[radial-gradient(50%_50%,_#C084FC_0%,_#C084FC33_100%)]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Inner Purple Tint */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute left-1/2 -translate-x-1/2
|
||||||
|
w-[60.8%] h-[17px] rounded-full
|
||||||
|
filter blur-[7px] mix-blend-lighten opacity-20
|
||||||
|
bg-[radial-gradient(50%_50%,_#F3E8FF_0%,_#C084FC66_100%)]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Core Highlight Line (Brightest) */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute left-1/2 -translate-x-1/2
|
||||||
|
w-[43.5%] h-[5px] rounded-full
|
||||||
|
filter blur-md mix-blend-lighten opacity-80
|
||||||
|
bg-[radial-gradient(50%_50%,_#FFF_0%,_#C084FC66_100%)]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Sharp Center Line */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute left-1/2 -translate-x-1/2
|
||||||
|
w-[99%] h-[2px] rounded-full
|
||||||
|
filter
|
||||||
|
bg-[radial-gradient(50%_50%,_#F3E8FF_0%,_#C084FC00_100%)]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Large Ambient Background Blur */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute left-1/2 -translate-x-1/2
|
||||||
|
w-full h-[398px] rounded-full
|
||||||
|
filter blur-[135px] mix-blend-lighten opacity-10
|
||||||
|
bg-[#C084FC]
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
import { supabase } from "../supa";
|
||||||
|
/**
|
||||||
|
* GET /api/auth-test?id=<serverId>?key=<serverKey>
|
||||||
|
* Called by a (DM node / Server) to check if the auth config is correct.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const serverId = url.searchParams.get("id");
|
||||||
|
const key = url.searchParams.get("key");
|
||||||
|
|
||||||
|
if (!serverId || !key)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "There should be a id and a key parameter" },
|
||||||
|
{ status: StatusCodes.BAD_REQUEST }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: server } = await supabase
|
||||||
|
.from("servers")
|
||||||
|
.select("id, created_at, owner, address, name")
|
||||||
|
.eq("key", key)
|
||||||
|
.eq("id", serverId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!server)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid key or id" },
|
||||||
|
{ status: StatusCodes.UNAUTHORIZED }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "ok", ...server });
|
||||||
|
}
|
||||||
@@ -49,56 +49,3 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ message: "ok", ...auth });
|
return NextResponse.json({ message: "ok", ...auth });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth
|
|
||||||
* Called by the client to request a temporary relay token for a DM server.
|
|
||||||
*
|
|
||||||
* Body: { server_id: string }
|
|
||||||
* Header: Authorization: Bearer <supabase_jwt>
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const { server_id } = await req.json();
|
|
||||||
const authHeader = req.cookies.get("token");
|
|
||||||
|
|
||||||
if (!server_id)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing server_id in the json body" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!authHeader)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing Supabase Authorization header" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
error,
|
|
||||||
} = await supabase.auth.getUser(authHeader.value);
|
|
||||||
|
|
||||||
if (error || !user) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Invalid Supabase token" },
|
|
||||||
{ status: StatusCodes.UNAUTHORIZED }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: relayToken, error: errorRelay } = await supabase
|
|
||||||
.from("server_auth")
|
|
||||||
.insert({
|
|
||||||
user_id: user.id,
|
|
||||||
server_id,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (!relayToken || errorRelay)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Failed to create relay token" },
|
|
||||||
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "ok", token: relayToken.key });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { supabase } from "../supa";
|
|
||||||
import { ProfileSettings } from "@/types/settings";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/profile
|
|
||||||
* body { username, display_name, avatar_url }
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const token = req.cookies.get("token")?.value;
|
|
||||||
const { username, display_name, avatar_url, node_address } =
|
|
||||||
(await req.json()) as ProfileSettings;
|
|
||||||
|
|
||||||
if (!token)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Token cookie not found" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!username ||
|
|
||||||
!display_name ||
|
|
||||||
avatar_url === undefined ||
|
|
||||||
avatar_url === null ||
|
|
||||||
node_address === undefined ||
|
|
||||||
node_address === null
|
|
||||||
)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid body, requires username, display_name, avatar_url, node_address",
|
|
||||||
},
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser(token);
|
|
||||||
|
|
||||||
if (!user)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Invalid token" },
|
|
||||||
{ status: StatusCodes.NOT_FOUND }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.update({
|
|
||||||
username,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
node_address,
|
|
||||||
})
|
|
||||||
.eq("id", user.id);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: error.message },
|
|
||||||
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "ok" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/profile?id=<id>
|
|
||||||
* or cookie-based lookup
|
|
||||||
*/
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const id = url.searchParams.get("id");
|
|
||||||
const username = url.searchParams.get("username");
|
|
||||||
const token = req.cookies.get("token")?.value;
|
|
||||||
|
|
||||||
if (!token && !id && !username)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Query arg should be token or id or username" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: profile, error } = id
|
|
||||||
? await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("id, username, display_name, avatar_url, node_address")
|
|
||||||
.eq("id", id)
|
|
||||||
.maybeSingle()
|
|
||||||
: username
|
|
||||||
? await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("id, username, display_name, avatar_url, node_address")
|
|
||||||
.eq("username", username)
|
|
||||||
.maybeSingle()
|
|
||||||
: await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("id, username, display_name, avatar_url, node_address")
|
|
||||||
.eq("id", (await supabase.auth.getUser(token)).data.user?.id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: error.message },
|
|
||||||
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!profile)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Profile not found" },
|
|
||||||
{ status: StatusCodes.NOT_FOUND }
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "ok", ...profile });
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { supabase } from "../../supa";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/server/<id>
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from("servers")
|
|
||||||
.select("id, created_at, owner, address")
|
|
||||||
.eq("id", id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (!profile)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "not found" },
|
|
||||||
{ status: StatusCodes.NOT_FOUND }
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "ok", ...profile });
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
import { supabase } from "../supa";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/user
|
|
||||||
* Register new user
|
|
||||||
* Body: { username, email, password }
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const { username, email, password } = await req.json();
|
|
||||||
|
|
||||||
if (!email || !password || !username)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing required fields" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: existing } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("username")
|
|
||||||
.eq("username", username)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existing)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Username already used" },
|
|
||||||
{ status: StatusCodes.CONFLICT }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: authData, error: authError } = await supabase.auth.signUp({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (authError)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: authError.message },
|
|
||||||
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!authData.user)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "User is null" },
|
|
||||||
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
|
|
||||||
);
|
|
||||||
|
|
||||||
await supabase.from("profiles").insert({
|
|
||||||
id: authData.user.id,
|
|
||||||
username,
|
|
||||||
display_name: username,
|
|
||||||
avatar_url: "",
|
|
||||||
node_address: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "User registered. Please check your email to verify your account.",
|
|
||||||
user_id: authData.user?.id,
|
|
||||||
email_sent: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/user
|
|
||||||
* Login user
|
|
||||||
* Params: { username, password }
|
|
||||||
*/
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const params = new URL(req.url).searchParams;
|
|
||||||
const [email, password] = [params.get("email"), params.get("password")];
|
|
||||||
|
|
||||||
if (!email || !password)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Params email and password are required" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Invalid credentials" },
|
|
||||||
{ status: StatusCodes.UNAUTHORIZED }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { session } = data;
|
|
||||||
const res = NextResponse.json({
|
|
||||||
message: "ok",
|
|
||||||
access_token: session?.access_token,
|
|
||||||
refresh_token: session?.refresh_token,
|
|
||||||
user_id: session?.user?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.cookies.set("token", session?.access_token ?? "", { httpOnly: true });
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/auth
|
|
||||||
* Body: { username }
|
|
||||||
*/
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
const { username } = await req.json();
|
|
||||||
|
|
||||||
// Delete both profile + auth user
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("id")
|
|
||||||
.eq("username", username)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (profile) {
|
|
||||||
await supabase.auth.admin.deleteUser(profile.id);
|
|
||||||
await supabase.from("profiles").delete().eq("id", profile.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "ok" });
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CardContent, CardHeader, CardTitle } from "../ui/card";
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
import { registerUser } from "@/actions/user";
|
||||||
|
|
||||||
export default function CreateAccount() {
|
export default function CreateAccount() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -58,8 +59,6 @@ export default function CreateAccount() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const signup = async () => {
|
const signup = async () => {
|
||||||
localStorage.removeItem("chat-messages");
|
|
||||||
localStorage.removeItem("server-messages");
|
|
||||||
setFeedback(undefined);
|
setFeedback(undefined);
|
||||||
const validationError = validateInput();
|
const validationError = validateInput();
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -69,16 +68,8 @@ export default function CreateAccount() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data } = await axios.post("/api/user", {
|
const response = await registerUser(username, email, password);
|
||||||
username,
|
|
||||||
password,
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
const response = data as {
|
|
||||||
message: string;
|
|
||||||
email_sent: boolean;
|
|
||||||
user_id: string;
|
|
||||||
};
|
|
||||||
if (response.email_sent) {
|
if (response.email_sent) {
|
||||||
toast.info(
|
toast.info(
|
||||||
"We sent you a verification email. Please verify to continue."
|
"We sent you a verification email. Please verify to continue."
|
||||||
@@ -91,6 +82,8 @@ export default function CreateAccount() {
|
|||||||
Cookies.set("verify_temp_email", email);
|
Cookies.set("verify_temp_email", email);
|
||||||
Cookies.set("verify_temp_password", password);
|
Cookies.set("verify_temp_password", password);
|
||||||
router.push(`/verify`);
|
router.push(`/verify`);
|
||||||
|
Cookies.set("servers", "");
|
||||||
|
localStorage.clear();
|
||||||
} else {
|
} else {
|
||||||
setFeedback({ message: response.message, kind: "error" });
|
setFeedback({ message: response.message, kind: "error" });
|
||||||
}
|
}
|
||||||
@@ -115,6 +108,7 @@ export default function CreateAccount() {
|
|||||||
<span>
|
<span>
|
||||||
Email
|
Email
|
||||||
<Input
|
<Input
|
||||||
|
autoComplete="email"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -123,6 +117,7 @@ export default function CreateAccount() {
|
|||||||
<span>
|
<span>
|
||||||
Username
|
Username
|
||||||
<Input
|
<Input
|
||||||
|
autoComplete="username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="john_doe"
|
placeholder="john_doe"
|
||||||
/>
|
/>
|
||||||
@@ -130,6 +125,7 @@ export default function CreateAccount() {
|
|||||||
<span>
|
<span>
|
||||||
Password
|
Password
|
||||||
<Input
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
@@ -137,6 +133,7 @@ export default function CreateAccount() {
|
|||||||
<span>
|
<span>
|
||||||
Confirm Password
|
Confirm Password
|
||||||
<Input
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") signup();
|
if (e.key === "Enter") signup();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { loginUser } from "@/actions/user";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type Feedback = { kind: "error" | "info"; message: string };
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<Feedback>();
|
||||||
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
setFeedback(undefined);
|
||||||
|
try {
|
||||||
|
await loginUser(email, password);
|
||||||
|
|
||||||
|
setFeedback({
|
||||||
|
kind: "info",
|
||||||
|
message: "Login successful! Redirecting...",
|
||||||
|
});
|
||||||
|
|
||||||
|
Cookies.set("servers", "");
|
||||||
|
localStorage.clear();
|
||||||
|
router.push("/chat");
|
||||||
|
} catch (err: any) {
|
||||||
|
setFeedback({
|
||||||
|
kind: "error",
|
||||||
|
message:
|
||||||
|
err.response?.data?.message || "Login failed. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl mx-auto">Login</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="gap-3 flex flex-col">
|
||||||
|
<span>
|
||||||
|
Email
|
||||||
|
<Input
|
||||||
|
autoComplete="username"
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Password
|
||||||
|
<div className="relative flex">
|
||||||
|
<Input
|
||||||
|
className="relative"
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") login();
|
||||||
|
}}
|
||||||
|
type={passwordVisible ? "text" : "password"}
|
||||||
|
/>
|
||||||
|
<div className="absolute w-full h-full flex items-center justify-end pr-3 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto cursor-pointer hover:text-primary transition-colors duration-500"
|
||||||
|
onClick={() => setPasswordVisible((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{passwordVisible ? (
|
||||||
|
<EyeOffIcon className="w-4.5 left-full" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="w-4.5 left-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<div
|
||||||
|
className={`text-sm rounded-md p-2 ${
|
||||||
|
feedback.kind === "error"
|
||||||
|
? "bg-red-500/20 text-red-400"
|
||||||
|
: "bg-green-500/20 text-green-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button className="mt-2 select-none" onClick={login}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import CreateAccount from "./CreateAccount";
|
||||||
|
import Login from "./Login";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Auth() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const pt = params.get("t");
|
||||||
|
const tab = pt === "create" || pt === "login" ? pt : "create";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-svh w-svw flex items-center">
|
||||||
|
<Tabs defaultValue={tab} className="mx-auto">
|
||||||
|
<TabsList className="mx-auto">
|
||||||
|
<TabsTrigger value="login">Login</TabsTrigger>
|
||||||
|
<TabsTrigger value="create">Create Account</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="login" className="w-svw p-5 md:p-0 md:w-max">
|
||||||
|
<Card className="w-full md:w-md">
|
||||||
|
<Login />
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="create" className="w-svw p-5 md:p-0 md:w-max">
|
||||||
|
<Card className="w-full md:w-md">
|
||||||
|
<CreateAccount />
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PlusCircle } from "lucide-react";
|
||||||
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import axios from "axios";
|
||||||
|
import { getProfileByUsername } from "@/actions/get-profile";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function StartNewDM() {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleStartDM = async () => {
|
||||||
|
const user = await getProfileByUsername(username);
|
||||||
|
if (!user) {
|
||||||
|
toast.error("User not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/chat/${user.id}`);
|
||||||
|
setUsername("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<PlusCircle className="w-4 h-4" />
|
||||||
|
Start New DM
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56 p-3 space-y-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleStartDM} className="w-full">
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +1,94 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import MessageBox from "@/components/MessageBox";
|
import MessageBox from "@/components/channel/MessageBox";
|
||||||
import AppSidebar from "@/components/Sidebar";
|
import AppLayout from "@/components/app/AppLayout";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import useApp from "@/hooks/use-app";
|
import useApp from "@/hooks/use-app";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import auth from "@/lib/auth";
|
import useAsync from "@/hooks/use-async";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import useIndicators from "@/hooks/use-indicators";
|
||||||
|
import { ClientMessage } from "@/types/protocol";
|
||||||
|
import { getNode, useNullNode } from "@/hooks/use-node";
|
||||||
|
import { useEffectOnceWhenReady } from "@/hooks/use-once";
|
||||||
|
|
||||||
export default function DMs() {
|
export default function DMs() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const targetNode = useRef<WebSocket | null>(null);
|
const { value: target, loading } = useAsync(() => app.getUserById(id));
|
||||||
|
const router = useRouter();
|
||||||
|
const { indicators, addIndicator } = useIndicators();
|
||||||
|
|
||||||
|
const sendRequest = async (req: ClientMessage) => {
|
||||||
|
console.log("Sending request:", req);
|
||||||
|
|
||||||
|
await targetNode.current?.waitUntilReady();
|
||||||
|
await app.node.current?.waitUntilReady();
|
||||||
|
|
||||||
|
targetNode.current?.socket?.send(JSON.stringify(req));
|
||||||
|
if (target?.node_address !== app.profile?.node_address)
|
||||||
|
app.node.current?.socket?.send(JSON.stringify(req));
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetNode = useNullNode();
|
||||||
|
|
||||||
|
useEffectOnceWhenReady(
|
||||||
|
() => {
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
return getNode(
|
||||||
|
target?.node_address,
|
||||||
|
app.privateMessages,
|
||||||
|
{
|
||||||
|
onIndicator: addIndicator,
|
||||||
|
setServer: () => targetNode.current?.loadInitialMessages(id),
|
||||||
|
},
|
||||||
|
targetNode
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[target],
|
||||||
|
[(v) => v]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function connectTargetNode() {
|
app.setCurrentChannel({
|
||||||
const target = await app.getUserById(id);
|
id: id,
|
||||||
if (target) {
|
name: target?.display_name || id,
|
||||||
auth(
|
kind: "text",
|
||||||
target.node_address,
|
});
|
||||||
targetNode,
|
}, [targetNode]);
|
||||||
() => {},
|
|
||||||
app.addMessage,
|
|
||||||
() => {}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectTargetNode().then(() => {});
|
return loading || target ? (
|
||||||
}, [id]);
|
<AppLayout app={app}>
|
||||||
|
|
||||||
return (
|
|
||||||
<AppSidebar app={app}>
|
|
||||||
<MessageBox
|
<MessageBox
|
||||||
app={app}
|
app={app}
|
||||||
channelName={app.profiles[id]?.display_name || id}
|
channelName={target?.display_name || id}
|
||||||
messages={app.messages.filter(
|
channelIcon={target?.avatar_url || "_"}
|
||||||
|
messages={app.privateMessages.messages.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
(m.channel_id === id && m.from === app.profile?.id) ||
|
(m.channel_id === id && m.from === app.profile?.id) ||
|
||||||
(m.from === id && m.channel_id === app.profile?.id)
|
(m.from === id && m.channel_id === app.profile?.id)
|
||||||
)}
|
)}
|
||||||
sendMessage={(message) => {
|
sendRequest={sendRequest}
|
||||||
targetNode.current?.send(
|
indicators={indicators.filter(
|
||||||
JSON.stringify({
|
(indicator) => indicator.indicator.params.user_id !== app.profile?.id
|
||||||
type: "send_message",
|
)}
|
||||||
params: {
|
|
||||||
channel_id: id,
|
|
||||||
contents: message,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</AppSidebar>
|
</AppLayout>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="text-9xl font-extrabold text-gray-500 tracking-widest">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<div className="border border-border text-white px-2 text-sm rounded rotate-12 absolute backdrop-blur-sm">
|
||||||
|
User Not Found
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 text-gray-600">
|
||||||
|
Sorry, we couldn't find the user you're looking for.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" onClick={() => router.push("/chat")}>
|
||||||
|
Go home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ProfilePicture from "@/components/ProfilePicture";
|
import ProfilePreview, { ProfileImage } from "@/components/ProfilePicture";
|
||||||
import SettingsDialog from "@/components/settings/SettingsDialog";
|
import SettingsDialog from "@/components/settings/SettingsDialog";
|
||||||
import AppSidebar from "@/components/Sidebar";
|
import AppLayout from "@/components/app/AppLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -12,63 +12,89 @@ import { Server } from "@/types/types";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
MessageCircle,
|
|
||||||
Users,
|
|
||||||
PlusCircle,
|
|
||||||
Settings,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
TrendingUp,
|
||||||
|
NetworkIcon,
|
||||||
|
MessagesSquare,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import StartNewDM from "./StartNewDM";
|
||||||
|
import AsyncValue from "@/components/AsyncValue";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
export default function ChatHub() {
|
export default function ChatHub() {
|
||||||
const app = useApp();
|
const router = useRouter();
|
||||||
|
|
||||||
const recentDMs = app.dms?.slice(0, 5) || [];
|
const app = useApp();
|
||||||
// const activeServers = app.servers?.slice(0, 5) || [];
|
const recentPeople = app.dms.slice(0, 3);
|
||||||
const activeServers: Server[] = [];
|
const trendingServers: Server[] = [
|
||||||
|
{
|
||||||
|
id: "7de3138f-e0b6-4d96-8158-8c7e866ba0ff",
|
||||||
|
name: "ORUS",
|
||||||
|
icon_url: "https://github.com/orus-dev.png",
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cee6b20f-7e6a-4071-8df1-aab37f52bbb9",
|
||||||
|
name: "Saturn Client",
|
||||||
|
icon_url: "https://github.com/saturnclientmc.png",
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "osui",
|
||||||
|
name: "OSUI Rust Hub",
|
||||||
|
icon_url: "https://github.com/osui-rs.png",
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const recentActivity = app.privateMessages.messages.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSidebar app={app}>
|
<AppLayout app={app}>
|
||||||
<div className="h-svh w-full max-h-svh flex flex-col py-5 px-5 gap-5">
|
<div className="h-svh w-full max-h-svh flex flex-col py-6 px-5 md:px-10 gap-6 overflow-y-auto">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="space-y-8"
|
className="space-y-10"
|
||||||
>
|
>
|
||||||
{/* Greeting */}
|
{/* Greeting */}
|
||||||
<div onClick={() => app.setSidebarOpen(true)}>
|
<div onClick={() => app.setSidebarOpen(true)}>
|
||||||
<h1 className="text-3xl font-bold flex cursor-pointer gap-2">
|
<h1 className="text-3xl font-bold flex cursor-pointer gap-2">
|
||||||
<ChevronLeft className="my-auto w-5" />
|
<ChevronLeft className="my-auto w-5 md:hidden" />
|
||||||
Hey {app.profile?.display_name || "there"}
|
Hey {app.profile?.display_name || "there"}!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Here's what's happening in your network today.
|
Here's what's happening in your network today.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent DMs + Active Servers */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Recent DMs */}
|
{/* Recent DMs */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<MessageCircle className="w-5 h-5" />
|
<Users className="w-5 h-5" />
|
||||||
Recent DMs
|
Recently chatted with
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{recentDMs.length ? (
|
{recentPeople.length ? (
|
||||||
recentDMs.map((dm) => (
|
recentPeople.map((dm) => (
|
||||||
<Link
|
<Link
|
||||||
href={`/chat/${dm.id}`}
|
href={`/chat/${dm.id}`}
|
||||||
key={dm.id}
|
key={dm.id}
|
||||||
className="flex justify-between items-center hover:bg-accent hover:text-accent-foreground rounded p-1"
|
className="flex justify-between items-center hover:bg-accent hover:text-accent-foreground rounded p-2 transition"
|
||||||
>
|
>
|
||||||
<span className="flex gap-2">
|
<span className="flex gap-2">
|
||||||
<ProfilePicture
|
<ProfilePreview profile={dm} />
|
||||||
name={dm.display_name}
|
|
||||||
url={dm.avatar_url}
|
|
||||||
/>
|
|
||||||
<span className="truncate my-auto">
|
<span className="truncate my-auto">
|
||||||
{dm.display_name || dm.id}
|
{dm.display_name || dm.id}
|
||||||
</span>
|
</span>
|
||||||
@@ -88,44 +114,129 @@ export default function ChatHub() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Users className="w-5 h-5" />
|
<TrendingUp className="w-5 h-5" />
|
||||||
Active Servers
|
Your Activity ⋅ 150 XP
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{activeServers.length ? (
|
<CardContent className="space-y-2">
|
||||||
activeServers.map((srv) => (
|
<div>
|
||||||
<Link
|
<span className="flex gap-1 items-center">
|
||||||
href={`/server/${srv.id}`}
|
<p>ORUS community</p>
|
||||||
key={srv.id}
|
<Link href={"#"}>
|
||||||
className="flex justify-between items-center hover:bg-accent hover:text-accent-foreground rounded p-1"
|
<ExternalLink className="w-4 text-muted-foreground" />
|
||||||
>
|
|
||||||
<span className="flex gap-2">
|
|
||||||
<ProfilePicture name={srv.name} url="" />
|
|
||||||
<span className="truncate my-auto">{srv.name}</span>
|
|
||||||
</span>
|
|
||||||
<ChevronRight />
|
|
||||||
</Link>
|
</Link>
|
||||||
))
|
</span>
|
||||||
) : (
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Progress value={(150 / 150) * 100} />
|
||||||
No servers joined yet.
|
<span className="text-sm text-muted-foreground w-16 flex justify-end">
|
||||||
</p>
|
<p>150</p>
|
||||||
)}
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity Feed */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessagesSquare className="w-5 h-5" />
|
||||||
|
Recent Activity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{recentActivity.length ? (
|
||||||
|
recentActivity.map((msg) => (
|
||||||
|
<AsyncValue
|
||||||
|
key={msg.id + msg.channel_id}
|
||||||
|
func={() => app.getUserById(msg.from)}
|
||||||
|
render={({ value: user }) => (
|
||||||
|
<Link
|
||||||
|
href={`/chat/${msg.from}`}
|
||||||
|
className="flex items-center justify-between hover:bg-accent rounded p-2 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProfilePreview profile={user || undefined} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{user?.display_name || ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate w-[200px]">
|
||||||
|
{msg.contents}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight className="opacity-70 w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recent messages yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Server Discovery */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
Trending Servers
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{trendingServers.map((srv) => (
|
||||||
|
<motion.div
|
||||||
|
key={srv.id}
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="hover:bg-accent transition"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
Cookies.set(
|
||||||
|
"servers",
|
||||||
|
Object.keys({
|
||||||
|
...app.servers,
|
||||||
|
[srv.id]: srv,
|
||||||
|
}).join(",")
|
||||||
|
);
|
||||||
|
|
||||||
|
app.setServers((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[srv.id]: srv,
|
||||||
|
}));
|
||||||
|
router.push(`/server/${srv.id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 flex flex-col items-center text-center gap-2">
|
||||||
|
<ProfileImage name={srv.name} url={srv.icon_url} />
|
||||||
|
<h3 className="font-medium truncate">{srv.name}</h3>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div>
|
<div className="w-full flex items-center justify-center">
|
||||||
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button variant="outline" className="gap-2">
|
<StartNewDM />
|
||||||
<PlusCircle className="w-4 h-4" />
|
|
||||||
Start New DM
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
Create Server
|
Create Server
|
||||||
@@ -140,6 +251,6 @@ export default function ChatHub() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</AppSidebar>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 103 KiB |
@@ -51,7 +51,7 @@
|
|||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(56.008% 0.28326 298.234);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(63.261% 0.23681 303.14);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.922 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.145 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
@@ -112,6 +112,40 @@
|
|||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.black {
|
||||||
|
--background: oklch(0.050 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.150 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.150 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(63.261% 0.23681 303.14);
|
||||||
|
--primary-foreground: oklch(0.922 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.215 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.200 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(0.3 0 0 / 70%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.150 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.150 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -139,3 +173,15 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #4b5563;
|
background-color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { parseClientSettings } from "@/lib/clientSettings";
|
import { parseClientSettings } from "@/lib/clientSettings";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -16,9 +16,17 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Voxa | Chat. Connect. Collaborate.",
|
title: "Axiom | Your Conversations, Your Privacy, Your Community.",
|
||||||
description:
|
description:
|
||||||
"Voxa is a modern chat app built for speed, simplicity, and collaboration. Experience real-time messaging with powerful tools for teams and communities.",
|
"Axiom is a modern chat app built for speed, simplicity, and collaboration. Experience real-time messaging with powerful tools for teams and communities.",
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.ico" },
|
||||||
|
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||||
|
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
],
|
||||||
|
apple: "/apple-touch-icon.png",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
@@ -26,9 +34,8 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const cookieStore = await cookies();
|
const c = await cookies();
|
||||||
|
const settings = parseClientSettings(c.get("settings")?.value);
|
||||||
const settings = parseClientSettings(cookieStore.get("settings")?.value);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Auth from "@/components/auth/Auth";
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
return <Auth kind="login" />;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { MessageCircle, Users, Shield } from "lucide-react";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import Shine from "./Shine";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -14,18 +13,21 @@ export default function LandingPage() {
|
|||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between px-8 py-6 border-b bg-background/60 backdrop-blur-md sticky top-0 z-50">
|
<header className="flex items-center justify-between px-8 py-6 border-b bg-background/60 backdrop-blur-md sticky top-0 z-50">
|
||||||
<motion.img
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-2xl font-bold"
|
className="text-2xl font-bold"
|
||||||
src="/voxa.svg"
|
>
|
||||||
/>
|
<Logo />
|
||||||
|
</motion.div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button variant="ghost" onClick={() => router.push("/login")}>
|
<Button variant="ghost" onClick={() => router.push("/auth?t=login")}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => router.push("/signup")}>Get Started</Button>
|
<Button onClick={() => router.push("/auth?t=create")}>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -35,16 +37,16 @@ export default function LandingPage() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
className="text-4xl md:text-6xl font-extrabold tracking-tight mb-6"
|
className="text-4xl md:text-4xl font-extrabold tracking-tight mb-6"
|
||||||
>
|
>
|
||||||
Chat. Connect. Collaborate.
|
Your Conversations, Your Privacy, Your Community.
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<p className="text-lg text-muted-foreground max-w-xl mb-8">
|
<p className="text-lg text-muted-foreground max-w-xl mb-8">
|
||||||
A modern chat app for teams and friends. Stay connected with real-time
|
A modern chat app for teams, communities and friends. Stay connected
|
||||||
messaging, secure channels, and seamless collaboration.
|
with real-time messaging, secure channels, and seamless collaboration.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button size="lg" onClick={() => router.push("/signup")}>
|
<Button size="lg" onClick={() => router.push("/auth?t=create")}>
|
||||||
Get Started
|
Get Started
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -58,62 +60,44 @@ export default function LandingPage() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<section className="grid md:grid-cols-3 gap-6 px-6 py-16 max-w-6xl mx-auto">
|
{/* <section className="grid md:grid-cols-3 gap-6 px-6 py-16 max-w-6xl mx-auto">
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={<MessageCircle className="h-10 w-10 text-primary" />}
|
icon={<MessageCircle className="h-10 w-10 text-primary" />}
|
||||||
title="Real-Time Chat"
|
title="Real-Time Chat"
|
||||||
description="Experience instant messaging with typing indicators, read receipts, and more with our self-hostable node system."
|
description="Experience instant messaging with our self-hostable node / server system."
|
||||||
/>
|
/>
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={<Users className="h-10 w-10 text-primary" />}
|
icon={<Users className="h-10 w-10 text-primary" />}
|
||||||
title="Group Channels"
|
title="Group Channels"
|
||||||
description="Organize conversations into channels for projects, teams, or friends by hosting your own server."
|
description="Organize conversations into channels for projects, teams, or friends by hosting your own server with plugins."
|
||||||
/>
|
/>
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={<Shield className="h-10 w-10 text-primary" />}
|
icon={<Shield className="h-10 w-10 text-primary" />}
|
||||||
title="Secure & Private"
|
title="Secure & Private"
|
||||||
description="Your data is encrypted and protected, ensuring safe communication."
|
description="Your messages are managed by your chosen node server, including your own self hosted node."
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Call to Action */}
|
|
||||||
{/* <section className="bg-muted py-20 px-6 text-center">
|
|
||||||
<h3 className="text-3xl font-semibold mb-4">
|
|
||||||
Ready to start chatting?
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Create your free account today and bring your conversations together.
|
|
||||||
</p>
|
|
||||||
<div className="max-w-md mx-auto flex gap-2">
|
|
||||||
<Input placeholder="Enter your email" />
|
|
||||||
<Button>Sign Up</Button>
|
|
||||||
</div>
|
|
||||||
</section> */}
|
</section> */}
|
||||||
|
|
||||||
|
{/* Showcase */}
|
||||||
|
<motion.section
|
||||||
|
className="w-full lg:w-6xl mx-auto px-8 pb-10"
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Shine />
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="/ss.png"
|
||||||
|
alt="Showcase"
|
||||||
|
className="w-full mx-auto relative border border-accent rounded-lg"
|
||||||
|
/>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="px-6 py-8 border-t text-center text-sm text-muted-foreground">
|
<footer className="px-6 py-8 border-t text-center text-sm text-muted-foreground">
|
||||||
© {new Date().getFullYear()} Voxa. All rights reserved.
|
© {new Date().getFullYear()} Axiom. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureCard({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
|
||||||
{icon}
|
|
||||||
<h4 className="mt-4 font-semibold text-xl">{title}</h4>
|
|
||||||
<p className="mt-2 text-muted-foreground">{description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
|
import AppLayout from "@/components/app/AppLayout";
|
||||||
|
import useMessages from "@/hooks/use-messages";
|
||||||
|
import useApp from "@/hooks/use-app";
|
||||||
|
import ChannelView from "@/components/channel/ChannelView";
|
||||||
|
import useIndicators from "@/hooks/use-indicators";
|
||||||
|
import useNode from "@/hooks/use-node";
|
||||||
|
|
||||||
|
export default function Server() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const app = useApp();
|
||||||
|
const messageStore = useMessages();
|
||||||
|
const { indicators, addIndicator } = useIndicators();
|
||||||
|
|
||||||
|
const serverNode = useNode(id, messageStore, {
|
||||||
|
setServer: app.setServer,
|
||||||
|
onIndicator: addIndicator,
|
||||||
|
onVoiceJoin: (userId, channelId, voiceId) =>
|
||||||
|
app.setVoiceConns((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: { ...prev[channelId], [userId]: voiceId },
|
||||||
|
})),
|
||||||
|
onVoiceLeave: (userId, channelId) =>
|
||||||
|
app.setVoiceConns((prev) => {
|
||||||
|
const { [userId]: _, ...remainingUsers } = prev[channelId] || {};
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[channelId]: remainingUsers,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
onSpeaking: (voiceId) =>
|
||||||
|
app.setSpeaking((speaking) => ({
|
||||||
|
...speaking,
|
||||||
|
[voiceId]: Date.now(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const channelId = searchParams.get("ch");
|
||||||
|
if (channelId) {
|
||||||
|
app.setCurrentChannel(
|
||||||
|
app.server?.channels.find((v) => v.id === channelId) ||
|
||||||
|
app.server?.channels[0] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.setCurrentChannel(app.server?.channels[0] || null);
|
||||||
|
}
|
||||||
|
}, [app.server]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout app={app}>
|
||||||
|
<ChannelView
|
||||||
|
app={app}
|
||||||
|
node={serverNode}
|
||||||
|
messages={messageStore.messages.filter(
|
||||||
|
(m) => m.channel_id === app.currentChannel?.id
|
||||||
|
)}
|
||||||
|
ip={id}
|
||||||
|
indicators={indicators}
|
||||||
|
/>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import MessageBox from "@/components/MessageBox";
|
|
||||||
import AppSidebar from "@/components/Sidebar";
|
|
||||||
import { Server as ServerType } from "@/types/types";
|
|
||||||
import auth from "@/lib/auth";
|
|
||||||
import { useServerMessages } from "@/hooks/use-messages";
|
|
||||||
import useApp from "@/hooks/use-app";
|
|
||||||
import { useEffectOnceWhenReady } from "@/hooks/use-once";
|
|
||||||
|
|
||||||
export default function Server() {
|
|
||||||
const { ip } = useParams<{ ip: string }>();
|
|
||||||
const serverRef = useRef<WebSocket | null>(null);
|
|
||||||
const [server, setServer] = useState<undefined | ServerType>();
|
|
||||||
const app = useApp();
|
|
||||||
const { messages, addMessage } = useServerMessages();
|
|
||||||
|
|
||||||
useEffectOnceWhenReady(
|
|
||||||
() => {
|
|
||||||
if (!ip) return;
|
|
||||||
const msgs = messages[ip];
|
|
||||||
auth(
|
|
||||||
ip,
|
|
||||||
serverRef,
|
|
||||||
setServer,
|
|
||||||
(m) => addMessage(ip, m),
|
|
||||||
() => {},
|
|
||||||
msgs ? msgs[msgs.length - 1]?.id : undefined
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[ip, messages],
|
|
||||||
[undefined, (v) => v[ip]]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppSidebar app={app} server={server}>
|
|
||||||
<MessageBox
|
|
||||||
app={app}
|
|
||||||
channelName="General"
|
|
||||||
messages={messages[ip]?.filter((m) => m.channel_id === "general") || []}
|
|
||||||
sendMessage={(message) => {
|
|
||||||
serverRef.current?.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "send_message",
|
|
||||||
params: {
|
|
||||||
channel_id: "general",
|
|
||||||
contents: message,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AppSidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Auth from "@/components/auth/Auth";
|
|
||||||
|
|
||||||
export default function SignUp() {
|
|
||||||
return <Auth kind="create" />;
|
|
||||||
}
|
|
||||||
@@ -35,10 +35,11 @@ export default function Verify() {
|
|||||||
};
|
};
|
||||||
if (response.verified) {
|
if (response.verified) {
|
||||||
clearInterval(i);
|
clearInterval(i);
|
||||||
if (response.access_token) {
|
if (response.access_token && response.refresh_token) {
|
||||||
setVerified(true);
|
setVerified(true);
|
||||||
toast.info("Email verified, redirecting...");
|
toast.info("Email verified, redirecting...");
|
||||||
Cookies.set("token", response.access_token);
|
Cookies.set("token", response.access_token);
|
||||||
|
Cookies.set("refresh_token", response.refresh_token);
|
||||||
Cookies.remove("verify_temp_email");
|
Cookies.remove("verify_temp_email");
|
||||||
Cookies.remove("verify_temp_password");
|
Cookies.remove("verify_temp_password");
|
||||||
setTimeout(() => router.push("/chat"), 500);
|
setTimeout(() => router.push("/chat"), 500);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useAsync from "@/hooks/use-async";
|
||||||
|
|
||||||
|
export default function AsyncValue<T, E = any>({
|
||||||
|
func,
|
||||||
|
render,
|
||||||
|
}: Readonly<{
|
||||||
|
func: () => Promise<T>;
|
||||||
|
render: ({
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
value: T | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
error: E | undefined;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return render(useAsync(func));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = React.SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
export default function Logo(props: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={159}
|
||||||
|
height={29}
|
||||||
|
viewBox="0 0 61 11"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.99 1.515C3.51 7.395 1.735 10.39 1.665 10.5C1.595 10.63 1.485 10.695 1.335 10.695H-1.10269e-06C2.71 6.045 4.66 2.69 5.85 0.63C5.95 0.439999 6.095 0.289999 6.285 0.179999C6.485 0.0599989 6.7 -1.19209e-06 6.93 -1.19209e-06H7.77C8.63 1.61 10.535 5.175 13.485 10.695H12.39C12.06 10.695 11.815 10.55 11.655 10.26C11.235 9.46 10.65 8.365 9.9 6.975L6.99 1.515ZM26.9141 -1.19209e-06L21.7991 5.34L26.9141 10.695H25.6991C25.3891 10.695 25.1191 10.575 24.8891 10.335L20.9591 6.225L17.0291 10.335C16.7991 10.575 16.5291 10.695 16.2191 10.695H14.8841C16.1141 9.405 19.4091 5.96 24.7691 0.36C24.9991 0.119999 25.2691 -1.19209e-06 25.5791 -1.19209e-06H26.9141ZM19.0841 3.405C18.9641 3.525 18.8241 3.585 18.6641 3.585C18.5041 3.585 18.3641 3.525 18.2441 3.405L15.0041 -1.19209e-06H16.2191C16.5291 -1.19209e-06 16.7991 0.119999 17.0291 0.36L19.5041 2.955L19.0841 3.405ZM30.2456 10.695H29.5256C29.3256 10.695 29.1556 10.63 29.0156 10.5C28.8756 10.36 28.8056 10.195 28.8056 10.005V-1.19209e-06H29.5256C29.7256 -1.19209e-06 29.8956 0.0699988 30.0356 0.209999C30.1756 0.339999 30.2456 0.499999 30.2456 0.689999V10.695ZM43.3007 2.67C43.8007 3.49 44.0507 4.385 44.0507 5.355C44.0507 6.315 43.8007 7.21 43.3007 8.04C42.8007 8.86 42.1207 9.51 41.2607 9.99C40.4107 10.46 39.4807 10.695 38.4707 10.695C37.4607 10.695 36.5257 10.46 35.6657 9.99C34.8057 9.51 34.1257 8.86 33.6257 8.04C33.1257 7.21 32.8757 6.315 32.8757 5.355C32.8757 4.385 33.1257 3.49 33.6257 2.67C34.1257 1.85 34.8057 1.2 35.6657 0.719999C36.5257 0.239999 37.4607 -1.19209e-06 38.4707 -1.19209e-06C39.4807 -1.19209e-06 40.4107 0.239999 41.2607 0.719999C42.1207 1.2 42.8007 1.85 43.3007 2.67ZM41.4407 2.52C40.6207 1.73 39.6257 1.335 38.4557 1.335C37.2957 1.335 36.3057 1.73 35.4857 2.52C34.6657 3.3 34.2557 4.245 34.2557 5.355C34.2557 6.455 34.6657 7.4 35.4857 8.19C36.3057 8.97 37.2957 9.36 38.4557 9.36C39.6257 9.36 40.6207 8.97 41.4407 8.19C42.2607 7.4 42.6707 6.455 42.6707 5.355C42.6707 4.245 42.2607 3.3 41.4407 2.52ZM60.2849 10.635C60.0449 10.635 59.8399 10.57 59.6699 10.44C59.4999 10.3 59.4149 10.135 59.4149 9.945V2.88L54.9149 10.23C54.7249 10.54 54.3899 10.695 53.9099 10.695C53.4399 10.705 53.1099 10.55 52.9199 10.23L48.4199 2.88V9.945C48.4199 10.135 48.3349 10.3 48.1649 10.44C47.9949 10.57 47.7899 10.635 47.5499 10.635H47.0249L47.0099 -1.19209e-06H47.7749C48.1049 -1.19209e-06 48.3399 0.114999 48.4799 0.344999L53.9249 9.435L59.3549 0.344999C59.4949 0.114999 59.7299 -1.19209e-06 60.0599 -1.19209e-06H60.8249L60.8099 10.635H60.2849Z"
|
||||||
|
fill="#C084FC"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Message } from "@/types/types";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import * as emoji from "emoji-picker-react";
|
|
||||||
import EmojiPicker from "emoji-picker-react";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "./ui/dropdown-menu";
|
|
||||||
import { ChevronLeftIcon, SendIcon, SmilePlusIcon } from "lucide-react";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { format, isToday, isYesterday } from "date-fns";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { UserProfile } from "@/hooks/get-user";
|
|
||||||
import ProfilePicture from "./ProfilePicture";
|
|
||||||
import App from "@/types/app";
|
|
||||||
import { Textarea } from "./ui/textarea";
|
|
||||||
|
|
||||||
function MessageContainer({ message, app }: { message: Message; app: App }) {
|
|
||||||
const [profile, setProfile] = useState<UserProfile | null>();
|
|
||||||
|
|
||||||
app.getUserById(message.from).then(setProfile);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg p-4 hover:bg-accent">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{/* <div className="w-8 h-8 rounded-full bg-gray-400" /> */}
|
|
||||||
<ProfilePicture
|
|
||||||
url={profile?.avatar_url}
|
|
||||||
name={profile?.display_name || message.from}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<span className="font-bold flex gap-1 items-center">
|
|
||||||
{profile?.display_name || message.from}
|
|
||||||
|
|
||||||
<p className="text-neutral-500 text-xs">
|
|
||||||
{isToday(message.timestamp * 1000)
|
|
||||||
? format(message.timestamp * 1000, "p")
|
|
||||||
: isYesterday(message.timestamp * 1000)
|
|
||||||
? "Yesterday, " + format(message.timestamp * 1000, "p")
|
|
||||||
: format(message.timestamp * 1000, "PP, p")}
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="text-foreground/85 flex flex-col break-words whitespace-pre-wrap">
|
|
||||||
{message.contents.split("\n\n").map((line, index) => (
|
|
||||||
<div key={index} className="h-max break-words">
|
|
||||||
{index > 0 && <br />}
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
h1: ({ node, ...props }) => (
|
|
||||||
<h1
|
|
||||||
className="text-3xl font-bold my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h2: ({ node, ...props }) => (
|
|
||||||
<h2
|
|
||||||
className="text-2xl font-bold my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h3: ({ node, ...props }) => (
|
|
||||||
<h3
|
|
||||||
className="text-xl font-bold my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h4: ({ node, ...props }) => (
|
|
||||||
<h4
|
|
||||||
className="text-lg font-bold my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h5: ({ node, ...props }) => (
|
|
||||||
<h5
|
|
||||||
className="text-base font-bold my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
ul: ({ node, ...props }) => (
|
|
||||||
<ul
|
|
||||||
className="list-disc list-inside my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
ol: ({ node, ...props }) => (
|
|
||||||
<ol
|
|
||||||
className="list-decimal list-inside my-2 break-words"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
a: ({ node, ...props }) => (
|
|
||||||
<a
|
|
||||||
className="text-accent-foreground underline underline-offset-2 break-all"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MessageBox({
|
|
||||||
app,
|
|
||||||
sendMessage,
|
|
||||||
channelName,
|
|
||||||
messages,
|
|
||||||
}: {
|
|
||||||
app: App;
|
|
||||||
sendMessage: (m: string) => void;
|
|
||||||
channelName?: string;
|
|
||||||
messages: Message[];
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const inputRef = useRef<any>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 gap-5">
|
|
||||||
{channelName && (
|
|
||||||
<header className="h-12 py-4 flex items-center border-b text-sm font-semibold">
|
|
||||||
<span
|
|
||||||
onClick={() => app.setSidebarOpen(true)}
|
|
||||||
className="cursor-pointer flex"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-5 h-5" />
|
|
||||||
{channelName}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
<div className="mt-auto flex flex-col-reverse overflow-y-scroll">
|
|
||||||
{messages.toReversed().map((msg) => (
|
|
||||||
<MessageContainer key={msg.id} message={msg} app={app} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<footer className="flex gap-3 pr-5 w-full h-max">
|
|
||||||
{text.includes("\n") ? (
|
|
||||||
<Textarea
|
|
||||||
ref={inputRef}
|
|
||||||
className="resize-none"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!e.target.value.includes("\n") && text.includes("\n")) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.selectionStart = text.length + 1;
|
|
||||||
inputRef.current.selectionEnd = text.length + 1;
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
setText(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage(text);
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
placeholder="Type a message..."
|
|
||||||
ref={inputRef}
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
if (e.shiftKey) {
|
|
||||||
setText((p) => p + "\n");
|
|
||||||
setTimeout(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.selectionStart = text.length + 1;
|
|
||||||
inputRef.current.selectionEnd = text.length + 1;
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, 10);
|
|
||||||
} else {
|
|
||||||
sendMessage(text.trim());
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="secondary" className="hidden md:block">
|
|
||||||
<SmilePlusIcon />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="">
|
|
||||||
<EmojiPicker
|
|
||||||
onEmojiClick={(e) => setText(text + e.emoji)}
|
|
||||||
theme={
|
|
||||||
app.clientSettings.theme === "dark"
|
|
||||||
? emoji.Theme.DARK
|
|
||||||
: emoji.Theme.LIGHT
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button variant="secondary">
|
|
||||||
<SendIcon />
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,81 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "./ui/popover";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
|
|
||||||
export default function ProfilePicture({
|
export function ProfileImage({
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
url: string | undefined;
|
url: string;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className={cn("h-8 w-8 cursor-pointer", className)}>
|
||||||
<AvatarImage src={url} alt={name} />
|
<AvatarImage src={url} alt={name} />
|
||||||
<AvatarFallback>{name[0]}</AvatarFallback>
|
<AvatarFallback>{name[0]}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ProfilePreview({
|
||||||
|
className,
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
profile?: UserProfile;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!profile)
|
||||||
|
return (
|
||||||
|
<Avatar className={cn("h-8 w-8 cursor-pointer", className)}>
|
||||||
|
<AvatarFallback>Loading</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Avatar className={cn("h-8 w-8 cursor-pointer", className)}>
|
||||||
|
<AvatarImage src={profile.avatar_url} alt={profile.display_name} />
|
||||||
|
<AvatarFallback>{profile.display_name[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-64 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className={cn("h-8 w-8 cursor-pointer", className)}>
|
||||||
|
<AvatarImage src={profile.avatar_url} alt={profile.display_name} />
|
||||||
|
<AvatarFallback>{profile.display_name[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium leading-none">
|
||||||
|
{profile.display_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
@{profile.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-1">
|
||||||
|
<span className="text-lg font-bold">{1130} XP</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button size="sm" className="flex-1">
|
||||||
|
Message
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
HashIcon,
|
|
||||||
MessageCircle,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Volume2Icon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Server, Channel, Message } from "@/types/types";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "./ui/dialog";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Label } from "./ui/label";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Card } from "./ui/card";
|
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { UserProfile } from "@/hooks/get-user";
|
|
||||||
import ProfilePicture from "./ProfilePicture";
|
|
||||||
import auth from "@/lib/auth";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import SettingsDialog from "./settings/SettingsDialog";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import App from "@/types/app";
|
|
||||||
import { ProfileSettings } from "@/types/settings";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useIsMobile } from "@/hooks/is-mobile";
|
|
||||||
|
|
||||||
export default function AppSidebar({
|
|
||||||
children,
|
|
||||||
chatWith,
|
|
||||||
onNewMessage,
|
|
||||||
server,
|
|
||||||
app,
|
|
||||||
}: Readonly<{
|
|
||||||
onNewMessage?: (msg: Message) => void;
|
|
||||||
chatWith?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
server?: Server | undefined;
|
|
||||||
app: App;
|
|
||||||
}>) {
|
|
||||||
const [servers, setServers] = useState<[string, string][]>([]);
|
|
||||||
const [newServer, setNewServer] = useState({ name: "", ip: "" });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const router = useRouter();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const dms = useMemo(() => {
|
|
||||||
return Array.from(
|
|
||||||
new Set(
|
|
||||||
app.messages
|
|
||||||
?.flatMap((item) => [item.from, item.channel_id])
|
|
||||||
.filter((item) => item !== app.profile?.id) || []
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [app.messages, app.profile?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
const users = await Promise.all(dms.map((id) => app.getUserById(id)));
|
|
||||||
app.setDms(
|
|
||||||
users.filter(
|
|
||||||
(user) =>
|
|
||||||
user !== null &&
|
|
||||||
(user?.username.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
user?.id.toLowerCase().includes(query.toLowerCase()))
|
|
||||||
) as UserProfile[]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [dms, query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = Cookies.get("servers");
|
|
||||||
if (!stored) return;
|
|
||||||
const parsed = stored
|
|
||||||
.split(",")
|
|
||||||
.map((x) => x.trim().split("@"))
|
|
||||||
.filter(([ip, name]) => ip && name) as [string, string][];
|
|
||||||
setServers(parsed);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!app.profile?.node_address) return;
|
|
||||||
auth(
|
|
||||||
app.profile?.node_address,
|
|
||||||
app.node,
|
|
||||||
() => {},
|
|
||||||
app.addMessage,
|
|
||||||
(m) => {
|
|
||||||
if (m.from !== chatWith && m.from !== app.profile?.id)
|
|
||||||
toast(
|
|
||||||
`New message from ${app.profiles[m.from]?.display_name || m.from}`,
|
|
||||||
{
|
|
||||||
description: m.contents.slice(0, 80),
|
|
||||||
action: {
|
|
||||||
label: "View",
|
|
||||||
onClick: () => router.push(`/chat/${m.from}`),
|
|
||||||
},
|
|
||||||
position: "top-right",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onNewMessage) onNewMessage(m);
|
|
||||||
},
|
|
||||||
app.messages ? app.messages[app.messages.length - 1]?.id : undefined
|
|
||||||
);
|
|
||||||
app.setProfiles((prev) => {
|
|
||||||
if (app.profile) prev[app.profile.id] = app.profile;
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, [chatWith, app.profile]);
|
|
||||||
|
|
||||||
const handleAddServer = () => {
|
|
||||||
if (!newServer.ip) return;
|
|
||||||
const updatedServers = [
|
|
||||||
...servers,
|
|
||||||
[newServer.ip, newServer.name] as [string, string],
|
|
||||||
];
|
|
||||||
|
|
||||||
setServers(updatedServers);
|
|
||||||
Cookies.set("servers", updatedServers.map((x) => x.join("@")).join(","));
|
|
||||||
setNewServer({ name: "", ip: "" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-svh md:h-screen">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"border-r transition-all duration-300 ease-in-out overflow-hidden flex",
|
|
||||||
app.sidebarOpen && isMobile ? "w-5xl md:w-md" : "w-0 md:w-md"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-16 flex flex-col items-center gap-4 py-4 bg-muted border-r">
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/chat`)}
|
|
||||||
className="bg-transparent text-accent-foreground hover:text-accent"
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{servers.map(
|
|
||||||
([ip, name]) =>
|
|
||||||
ip && (
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/server/${ip}`)}
|
|
||||||
key={ip}
|
|
||||||
className="bg-transparent text-accent-foreground hover:text-accent"
|
|
||||||
>
|
|
||||||
<HashIcon className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add server</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new server to your server list.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<Label htmlFor="name-1">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name-1"
|
|
||||||
name="name"
|
|
||||||
value={newServer.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewServer((prev) => ({
|
|
||||||
...prev,
|
|
||||||
name: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Voxa Server"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<Label htmlFor="ip-1">Server Ip</Label>
|
|
||||||
<Input
|
|
||||||
id="ip-1"
|
|
||||||
name="ip"
|
|
||||||
value={newServer.ip}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewServer((prev) => ({
|
|
||||||
...prev,
|
|
||||||
ip: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="192.168.1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" onClick={handleAddServer}>
|
|
||||||
Add server
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<footer className="mt-auto">
|
|
||||||
<SettingsDialog profile={app.profile as ProfileSettings} />
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column */}
|
|
||||||
<div className="w-full flex flex-col bg-card">
|
|
||||||
{server ? (
|
|
||||||
<div className="p-3 border-b flex items-center gap-2">
|
|
||||||
<h2>{server.name}</h2>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-3 border-b flex items-center">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
|
||||||
placeholder="Find or start a conversation"
|
|
||||||
className="pr-8 h-8"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Search className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-2 flex flex-col gap-2">
|
|
||||||
{server ? (
|
|
||||||
<>
|
|
||||||
{server.channels.map((channel) => (
|
|
||||||
<ChannelProp key={channel.id} {...channel} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{app.dms.map((user) => (
|
|
||||||
<DMItem
|
|
||||||
name={user.display_name}
|
|
||||||
id={user.id}
|
|
||||||
status="online"
|
|
||||||
avatar={user.avatar_url}
|
|
||||||
key={user.id}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{app.dms.length === 0 && query.trim() !== "" ? (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start gap-2 text-sm"
|
|
||||||
onClick={async () => {
|
|
||||||
const response = await axios.get("/api/profile", {
|
|
||||||
params: { username: query },
|
|
||||||
});
|
|
||||||
const user = response.data as UserProfile;
|
|
||||||
router.push(`/chat/${user.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Start new DM with “{query}”
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<footer className="mt-auto pb-3 pl-3 pr-3">
|
|
||||||
<DMItem
|
|
||||||
name={app.profile?.display_name || "Loading.."}
|
|
||||||
id={app.profile?.id || "me"}
|
|
||||||
avatar={app.profile?.avatar_url || ""}
|
|
||||||
status="online"
|
|
||||||
app={app}
|
|
||||||
settings
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{app.sidebarOpen && isMobile ? (
|
|
||||||
<div
|
|
||||||
className="w-full h-full overflow-x-hidden"
|
|
||||||
onClick={() => app.setSidebarOpen(false)}
|
|
||||||
>
|
|
||||||
<div className="overflow-x-hidden min-w-screen flex-shrink-0">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DMItem({
|
|
||||||
name,
|
|
||||||
id,
|
|
||||||
avatar,
|
|
||||||
status,
|
|
||||||
settings,
|
|
||||||
app,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
avatar: string;
|
|
||||||
status: string;
|
|
||||||
settings?: boolean;
|
|
||||||
app: App;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={settings ? "" : `/chat/${id}`}
|
|
||||||
className="p-2 flex flex-row items-center gap-2 cursor-pointer hover:bg-accent rounded-md"
|
|
||||||
>
|
|
||||||
<ProfilePicture name={name} url={avatar} />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">{name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{status}</span>
|
|
||||||
</div>
|
|
||||||
{settings && (
|
|
||||||
<SettingsDialog
|
|
||||||
className="ml-auto w-7 h-7 p-0 flex items-center justify-center"
|
|
||||||
tab="profile"
|
|
||||||
profile={app.profile as ProfileSettings}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelProp(channel: Channel) {
|
|
||||||
return (
|
|
||||||
<Card className="p-2 flex flex-row items-center gap-2 cursor-pointer hover:bg-accent">
|
|
||||||
<ChannelIcon kind={channel.kind} />
|
|
||||||
<span className="text-sm font-medium">{channel.name}</span>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelIcon({ kind }: { kind: "text" | "voice" }) {
|
|
||||||
switch (kind) {
|
|
||||||
case "text":
|
|
||||||
return <HashIcon className="h-4 w-4" />;
|
|
||||||
case "voice":
|
|
||||||
return <Volume2Icon className="h-4 w-4" />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useIsMobile } from "@/hooks/is-mobile";
|
||||||
|
import App from "@/types/app";
|
||||||
|
import { Message } from "@/types/types";
|
||||||
|
|
||||||
|
import SidebarServers from "./SidebarServers";
|
||||||
|
import SidebarDMs from "./SidebarDMs";
|
||||||
|
import SidebarChannels from "./SidebarChannels";
|
||||||
|
import DMItem from "./DMItem";
|
||||||
|
import { useEffectOnceWhenReady } from "@/hooks/use-once";
|
||||||
|
import { getNode, useNullNode } from "@/hooks/use-node";
|
||||||
|
import { ClientMessage } from "@/types/protocol";
|
||||||
|
|
||||||
|
export default function AppLayout({
|
||||||
|
children,
|
||||||
|
chatWith,
|
||||||
|
onNewMessage,
|
||||||
|
app,
|
||||||
|
}: {
|
||||||
|
onNewMessage?: (msg: Message) => void;
|
||||||
|
chatWith?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
app: App;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const node = useNullNode();
|
||||||
|
|
||||||
|
useEffectOnceWhenReady(
|
||||||
|
() => {
|
||||||
|
if (!app.profile?.node_address) return;
|
||||||
|
|
||||||
|
return getNode(
|
||||||
|
app.profile?.node_address,
|
||||||
|
app.privateMessages,
|
||||||
|
{
|
||||||
|
onNewMessage: (m) => {
|
||||||
|
if (m.from !== chatWith && m.from !== app.profile?.id)
|
||||||
|
toast(
|
||||||
|
`New message from ${
|
||||||
|
app.profiles[m.from]?.display_name || m.from
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
description: m.contents.slice(0, 80),
|
||||||
|
action: {
|
||||||
|
label: "View",
|
||||||
|
onClick: () => router.push(`/chat/${m.from}`),
|
||||||
|
},
|
||||||
|
position: "top-right",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onNewMessage) onNewMessage(m);
|
||||||
|
},
|
||||||
|
setServer: () =>
|
||||||
|
chatWith && app.node.current?.loadInitialMessages(chatWith),
|
||||||
|
},
|
||||||
|
node
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[chatWith, app.profile],
|
||||||
|
[undefined, undefined]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
app.node.current = node.current;
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
useEffect(() => app.setSidebarOpen(!isMobile), [isMobile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-svh md:h-screen">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-r transition-all duration-300 ease-in-out overflow-hidden flex",
|
||||||
|
app.sidebarOpen && isMobile ? "w-5xl md:w-md" : "w-0 md:w-md"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<SidebarServers app={app} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col bg-sidebar">
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{app.server ? (
|
||||||
|
<SidebarChannels app={app} />
|
||||||
|
) : (
|
||||||
|
<SidebarDMs app={app} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="mt-auto pb-3 px-3">
|
||||||
|
<DMItem
|
||||||
|
profile={app.profile}
|
||||||
|
id={app.profile?.id || "me"}
|
||||||
|
status="online"
|
||||||
|
app={app}
|
||||||
|
settings
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{app.sidebarOpen && isMobile ? (
|
||||||
|
<div
|
||||||
|
className="w-full h-full overflow-x-hidden"
|
||||||
|
onClick={() => app.setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-hidden min-w-screen flex-shrink-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import ProfilePreview from "../ProfilePicture";
|
||||||
|
import SettingsDialog from "../settings/SettingsDialog";
|
||||||
|
import App from "@/types/app";
|
||||||
|
import { ProfileSettings } from "@/types/settings";
|
||||||
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
|
|
||||||
|
export default function DMItem({
|
||||||
|
status,
|
||||||
|
settings,
|
||||||
|
app,
|
||||||
|
profile,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
profile?: UserProfile | null;
|
||||||
|
settings?: boolean;
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
app: App;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={settings ? "" : `/chat/${id}`}
|
||||||
|
className="p-2 flex items-center gap-2 cursor-pointer hover:bg-accent rounded-md"
|
||||||
|
>
|
||||||
|
{profile && (
|
||||||
|
<>
|
||||||
|
<ProfilePreview profile={profile} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{profile.display_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{status}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{settings && (
|
||||||
|
<SettingsDialog
|
||||||
|
className="ml-auto w-7 h-7 p-0 flex items-center justify-center"
|
||||||
|
tab="profile"
|
||||||
|
profile={app.profile as ProfileSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
|
import { Channel } from "@/types/types";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { HashIcon, Volume2Icon } from "lucide-react";
|
||||||
|
import App from "@/types/app";
|
||||||
|
|
||||||
|
export function ChannelIcon({ kind }: { kind: "text" | "voice" }) {
|
||||||
|
switch (kind) {
|
||||||
|
case "text":
|
||||||
|
return <HashIcon className="h-4 w-4" />;
|
||||||
|
case "voice":
|
||||||
|
return <Volume2Icon className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelItem({ channel, app }: { channel: Channel; app: App }) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="p-2 flex flex-row px-3 items-center gap-2 cursor-pointer hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`/server/${app.server?.id}?ch=${channel.id}`
|
||||||
|
);
|
||||||
|
app.setCurrentChannel(channel);
|
||||||
|
app.setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChannelIcon kind={channel.kind} />
|
||||||
|
<span className="text-sm font-medium">{channel.name}</span>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarChannels({ app }: { app: App }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col bg-sidebar">
|
||||||
|
<div className="p-3 border-b flex items-center gap-2">
|
||||||
|
<h2>{app.server?.name}</h2>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2 flex flex-col gap-2">
|
||||||
|
{app.server?.channels.map((channel: Channel) => (
|
||||||
|
<ChannelItem key={channel.id} channel={channel} app={app} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Search, Plus } from "lucide-react";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import axios from "axios";
|
||||||
|
import DMItem from "./DMItem";
|
||||||
|
import App from "@/types/app";
|
||||||
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getProfileByUsername } from "@/actions/get-profile";
|
||||||
|
|
||||||
|
export default function SidebarDMs({ app }: { app: App }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [dms, setDms] = useState<UserProfile[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!app.profile) return;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const users = (
|
||||||
|
await Promise.all(
|
||||||
|
[
|
||||||
|
...new Set(
|
||||||
|
app.privateMessages.messages
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
.flatMap((m) => [m.from, m.channel_id])
|
||||||
|
.filter((id) => id !== app.profile?.id)
|
||||||
|
),
|
||||||
|
].map((id) => app.getUserById(id))
|
||||||
|
)
|
||||||
|
).filter((v) => !!v);
|
||||||
|
|
||||||
|
app.setDms(users);
|
||||||
|
|
||||||
|
setDms(
|
||||||
|
users.filter(
|
||||||
|
(user) =>
|
||||||
|
user?.username.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
user?.display_name.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [query, app.privateMessages.messages, app.profile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col bg-sidebar">
|
||||||
|
<div className="p-3 border-b flex items-center">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Find or start a conversation"
|
||||||
|
className="pr-8 h-8"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2 flex flex-col gap-2">
|
||||||
|
{dms.map((user) => (
|
||||||
|
<DMItem
|
||||||
|
key={user.id}
|
||||||
|
profile={user}
|
||||||
|
id={user.id}
|
||||||
|
status="online"
|
||||||
|
app={app}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dms.length === 0 && query.trim() !== "" && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start gap-2 text-sm truncate"
|
||||||
|
onClick={async () => {
|
||||||
|
const user = await getProfileByUsername(query);
|
||||||
|
if (!user) {
|
||||||
|
toast.error("User not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/chat/${user.id}`);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Start new DM with “{query}”
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, MessageCircle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useState } from "react";
|
||||||
|
import App from "@/types/app";
|
||||||
|
import ProfilePreview, { ProfileImage } from "../ProfilePicture";
|
||||||
|
import SettingsDialog from "../settings/SettingsDialog";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { getServerById } from "@/actions/get-server";
|
||||||
|
|
||||||
|
export default function SidebarServers({ app }: { app: App }) {
|
||||||
|
const [newServer, setNewServer] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleAddServer = async () => {
|
||||||
|
if (!newServer || Object.hasOwn(app.servers, newServer)) return;
|
||||||
|
try {
|
||||||
|
const server = await getServerById(newServer);
|
||||||
|
app.setServers((prev) => ({ ...prev, [server.id]: server }));
|
||||||
|
Cookies.set("servers", Object.keys(app.servers).join(","));
|
||||||
|
setNewServer("");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-16 flex flex-col items-center gap-4 py-4 border-r h-full overflow-y-scroll no-scrollbar">
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/chat`)}
|
||||||
|
className="bg-transparent text-foreground"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{Object.values(app.servers).map(
|
||||||
|
(srv, i) =>
|
||||||
|
srv && (
|
||||||
|
<Link href={`/server/${srv.id}`} key={i}>
|
||||||
|
<ProfileImage name={srv.name} url={srv.icon_url} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Join a server</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Label htmlFor="server-id">Server ID</Label>
|
||||||
|
<Input
|
||||||
|
id="server-id"
|
||||||
|
value={newServer}
|
||||||
|
onChange={(e) => setNewServer(e.target.value)}
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button onClick={handleAddServer}>Join server</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<footer className="mt-auto">
|
||||||
|
<SettingsDialog profile={app.profile!} />
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Card } from "../ui/card";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
||||||
import CreateAccount from "./CreateAccount";
|
|
||||||
import Login from "./Login";
|
|
||||||
|
|
||||||
export default function Auth({ kind }: { kind: "login" | "create" }) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex items-center">
|
|
||||||
<Tabs defaultValue={kind} className="mx-auto">
|
|
||||||
<TabsList className="mx-auto">
|
|
||||||
<TabsTrigger value="login">Login</TabsTrigger>
|
|
||||||
<TabsTrigger value="create">Create Account</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="login">
|
|
||||||
<Card className="w-md">
|
|
||||||
<Login />
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="create">
|
|
||||||
<Card className="w-md">
|
|
||||||
<CreateAccount />
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useState } from "react";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { CardContent, CardHeader, CardTitle } from "../ui/card";
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [feedback, setFeedback] = useState<
|
|
||||||
undefined | { kind: "error" | "info"; message: string }
|
|
||||||
>();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
localStorage.removeItem("chat-messages");
|
|
||||||
localStorage.removeItem("server-messages");
|
|
||||||
setFeedback(undefined);
|
|
||||||
try {
|
|
||||||
const res = await axios.get("/api/user", {
|
|
||||||
params: { email, password },
|
|
||||||
});
|
|
||||||
|
|
||||||
Cookies.set("token", (res.data as any).token);
|
|
||||||
setFeedback({
|
|
||||||
kind: "info",
|
|
||||||
message: "Login successful! Redirecting...",
|
|
||||||
});
|
|
||||||
|
|
||||||
// small delay to show feedback before redirect
|
|
||||||
setTimeout(() => router.push("/chat"), 800);
|
|
||||||
} catch (err: any) {
|
|
||||||
setFeedback({
|
|
||||||
kind: "error",
|
|
||||||
message:
|
|
||||||
err.response?.data?.message || "Login failed. Please try again.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-3xl mx-auto">Login</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="gap-3 flex flex-col">
|
|
||||||
<span>
|
|
||||||
Email
|
|
||||||
<Input onChange={(e) => setEmail(e.target.value)} type="email" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Password
|
|
||||||
<Input
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") login();
|
|
||||||
}}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{feedback && (
|
|
||||||
<div
|
|
||||||
className={`text-sm rounded-md p-2 ${
|
|
||||||
feedback.kind === "error"
|
|
||||||
? "bg-red-500/20 text-red-400"
|
|
||||||
: "bg-green-500/20 text-green-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{feedback.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button className="mt-2" onClick={login}>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Message } from "@/types/types";
|
||||||
|
import MessageBox from "./MessageBox";
|
||||||
|
import App from "@/types/app";
|
||||||
|
import React, { RefObject } from "react";
|
||||||
|
import PluginChannel from "./PluginChannel";
|
||||||
|
import { ClientMessage, IndicatorContext } from "@/types/protocol";
|
||||||
|
import VoiceBox from "./voice/VoiceBox";
|
||||||
|
import { sendVoice } from "@/lib/audio";
|
||||||
|
import { NodeClient } from "@/lib/node";
|
||||||
|
|
||||||
|
export default function ChannelView({
|
||||||
|
app,
|
||||||
|
messages,
|
||||||
|
ip,
|
||||||
|
node,
|
||||||
|
indicators,
|
||||||
|
}: {
|
||||||
|
app: App;
|
||||||
|
messages: Message[];
|
||||||
|
ip: string;
|
||||||
|
indicators: IndicatorContext[];
|
||||||
|
node: RefObject<NodeClient | null>;
|
||||||
|
}) {
|
||||||
|
if (!app.currentChannel)
|
||||||
|
return (
|
||||||
|
<div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 gap-5" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendRequest = async (req: ClientMessage) => {
|
||||||
|
await node.current?.waitUntilReady();
|
||||||
|
node.current?.socket?.send(JSON.stringify(req));
|
||||||
|
console.log(node.current?.socket);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (app.currentChannel.kind) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<MessageBox
|
||||||
|
app={app}
|
||||||
|
channelName={app.currentChannel.name}
|
||||||
|
messages={messages}
|
||||||
|
sendRequest={sendRequest}
|
||||||
|
indicators={indicators.filter(
|
||||||
|
(indicator) =>
|
||||||
|
indicator.indicator.params.user_id !== app.profile?.id
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "voice":
|
||||||
|
return (
|
||||||
|
<VoiceBox
|
||||||
|
app={app}
|
||||||
|
channelName={app.currentChannel.name}
|
||||||
|
channelId={app.currentChannel.id}
|
||||||
|
messages={messages}
|
||||||
|
sendRequest={sendRequest}
|
||||||
|
sendVoice={(data) => {
|
||||||
|
if (!node.current?.socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendVoice(node.current?.socket, data);
|
||||||
|
}}
|
||||||
|
indicators={indicators.filter(
|
||||||
|
(indicator) =>
|
||||||
|
indicator.indicator.params.user_id !== app.profile?.id
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<PluginChannel app={app} messages={messages} ip={ip} node={node} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Message } from "@/types/types";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import * as emoji from "emoji-picker-react";
|
||||||
|
import EmojiPicker from "emoji-picker-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { ChevronLeftIcon, SendIcon, SmilePlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { format, isToday, isYesterday } from "date-fns";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
|
import ProfilePreview, { ProfileImage } from "../ProfilePicture";
|
||||||
|
import App, { SetState } from "@/types/app";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { ClientMessage, IndicatorContext } from "@/types/protocol";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import AsyncValue from "../AsyncValue";
|
||||||
|
|
||||||
|
function MessageContainer({
|
||||||
|
message,
|
||||||
|
app,
|
||||||
|
sendRequest,
|
||||||
|
}: {
|
||||||
|
message: Message;
|
||||||
|
app: App;
|
||||||
|
sendRequest: (content: ClientMessage) => void;
|
||||||
|
}) {
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState(message.contents);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
app.getUserById(message.from).then((p) => {
|
||||||
|
if (mounted) setProfile(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [app, message.from]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg p-4 hover:bg-accent">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* <div className="w-8 h-8 rounded-full bg-gray-400" /> */}
|
||||||
|
<ProfilePreview profile={profile || undefined} />
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<span className="font-bold flex gap-1 items-center select-none">
|
||||||
|
{profile?.display_name || message.from}
|
||||||
|
|
||||||
|
<p className="text-neutral-500 text-xs">
|
||||||
|
{isToday(message.timestamp * 1000)
|
||||||
|
? format(message.timestamp * 1000, "p")
|
||||||
|
: isYesterday(message.timestamp * 1000)
|
||||||
|
? "Yesterday, " + format(message.timestamp * 1000, "p")
|
||||||
|
: format(message.timestamp * 1000, "PP, p")}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="text-foreground/85 flex flex-col break-words whitespace-pre-wrap"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape" && isEditing) {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditText(message.contents);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<MessageInput
|
||||||
|
text={editText}
|
||||||
|
setText={setEditText}
|
||||||
|
onEnter={() => {
|
||||||
|
sendRequest({
|
||||||
|
type: "edit_message",
|
||||||
|
params: {
|
||||||
|
message_id: message.id,
|
||||||
|
new_contents: editText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
message.contents.split("\n\n").map((line, index) => (
|
||||||
|
<div key={index} className="h-max break-words select-none">
|
||||||
|
{index > 0 && <br />}
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ node, ...props }) => (
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h2: ({ node, ...props }) => (
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h3: ({ node, ...props }) => (
|
||||||
|
<h3
|
||||||
|
className="text-xl font-bold my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h4: ({ node, ...props }) => (
|
||||||
|
<h4
|
||||||
|
className="text-lg font-bold my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h5: ({ node, ...props }) => (
|
||||||
|
<h5
|
||||||
|
className="text-base font-bold my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ul: ({ node, ...props }) => (
|
||||||
|
<ul
|
||||||
|
className="list-disc list-inside my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ol: ({ node, ...props }) => (
|
||||||
|
<ol
|
||||||
|
className="list-decimal list-inside my-2 break-words"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ node, ...props }) => (
|
||||||
|
<a
|
||||||
|
className="text-accent-foreground underline underline-offset-2 break-all"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-52">
|
||||||
|
<ContextMenuItem
|
||||||
|
inset
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(message.contents);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy message
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
inset
|
||||||
|
onClick={() => {
|
||||||
|
setEditText(message.contents);
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
disabled={message.from !== app.profile?.id}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
inset
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
sendRequest({
|
||||||
|
type: "delete_message",
|
||||||
|
params: {
|
||||||
|
message_id: message.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={message.from !== app.profile?.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageInput({
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
onEnter,
|
||||||
|
onTyping,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
setText: SetState<string>;
|
||||||
|
onEnter: () => void;
|
||||||
|
onTyping?: () => void;
|
||||||
|
}) {
|
||||||
|
const TYPING_INTERVAL = 1000;
|
||||||
|
const inputRef = useRef<any>(null);
|
||||||
|
const lastTypingSent = useRef(0);
|
||||||
|
const stopTypingTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [inputRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onTyping || !text) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now - lastTypingSent.current > TYPING_INTERVAL) {
|
||||||
|
lastTypingSent.current = now;
|
||||||
|
onTyping();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopTypingTimeout.current) {
|
||||||
|
clearTimeout(stopTypingTimeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTypingTimeout.current = setTimeout(() => {
|
||||||
|
lastTypingSent.current = 0;
|
||||||
|
}, TYPING_INTERVAL);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return text.includes("\n") ? (
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className="resize-none"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value.includes("\n") && text.includes("\n")) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.selectionStart = text.length + 1;
|
||||||
|
inputRef.current.selectionEnd = text.length + 1;
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onEnter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
placeholder="Type a message..."
|
||||||
|
ref={inputRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
setText((p) => p + "\n");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.selectionStart = text.length + 1;
|
||||||
|
inputRef.current.selectionEnd = text.length + 1;
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
} else {
|
||||||
|
onEnter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBox({
|
||||||
|
app,
|
||||||
|
sendRequest,
|
||||||
|
channelName,
|
||||||
|
channelIcon,
|
||||||
|
messages,
|
||||||
|
indicators,
|
||||||
|
}: {
|
||||||
|
app: App;
|
||||||
|
sendRequest: (req: ClientMessage) => void;
|
||||||
|
channelName?: string;
|
||||||
|
channelIcon?: string;
|
||||||
|
messages: Message[];
|
||||||
|
indicators: IndicatorContext[];
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const chunkRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
console.log("Element is in view!");
|
||||||
|
if (!app.currentChannel) return;
|
||||||
|
sendRequest({
|
||||||
|
type: "load_chunk",
|
||||||
|
params: {
|
||||||
|
channel_id: app.currentChannel.id,
|
||||||
|
chunk_id: chunkRef.current,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
chunkRef.current += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0.5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(ref.current);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (!app.currentChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRequest({
|
||||||
|
type: "send_message",
|
||||||
|
params: {
|
||||||
|
channel_id: app.currentChannel?.id,
|
||||||
|
contents: text.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 gap-5">
|
||||||
|
{channelName && (
|
||||||
|
<header className="h-12 py-4 flex items-center border-b text-sm font-semibold">
|
||||||
|
<span
|
||||||
|
onClick={() => app.setSidebarOpen(true)}
|
||||||
|
className="cursor-pointer flex gap-2"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-5 h-5 my-auto md:hidden" />
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{channelIcon && (
|
||||||
|
<ProfileImage
|
||||||
|
name={channelName}
|
||||||
|
url={channelIcon}
|
||||||
|
className="size-7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{channelName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex flex-col-reverse overflow-y-scroll">
|
||||||
|
<AnimatePresence>
|
||||||
|
{messages.toReversed().map((msg, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
height: 0,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<MessageContainer
|
||||||
|
message={msg}
|
||||||
|
app={app}
|
||||||
|
sendRequest={sendRequest}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
<div ref={ref} />
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<div className="relative h-1">
|
||||||
|
<AnimatePresence>
|
||||||
|
{indicators.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
key={indicators
|
||||||
|
.map((i) => i.indicator.params.user_id)
|
||||||
|
.join("-")}
|
||||||
|
className="absolute inset-0 text-muted-foreground text-xs top-[-26px]"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<AsyncValue
|
||||||
|
func={async () =>
|
||||||
|
await Promise.all(
|
||||||
|
indicators.map((indicator) =>
|
||||||
|
app.getUserById(indicator.indicator.params.user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
render={({ value: indicators }) =>
|
||||||
|
indicators
|
||||||
|
?.map((indicator) => indicator?.display_name)
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{indicators.length > 0 && " is typing"}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pr-5 w-full h-max">
|
||||||
|
<MessageInput
|
||||||
|
text={text}
|
||||||
|
setText={setText}
|
||||||
|
onEnter={sendMessage}
|
||||||
|
onTyping={() => {
|
||||||
|
if (!app.currentChannel) return;
|
||||||
|
sendRequest({
|
||||||
|
type: "typing",
|
||||||
|
params: {
|
||||||
|
channel_id: app.currentChannel.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="secondary" className="hidden md:block">
|
||||||
|
<SmilePlusIcon />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="">
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiClick={(e) => setText(text + e.emoji)}
|
||||||
|
theme={
|
||||||
|
app.clientSettings.theme === "dark"
|
||||||
|
? emoji.Theme.DARK
|
||||||
|
: emoji.Theme.LIGHT
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button variant="secondary" onClick={sendMessage}>
|
||||||
|
<SendIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Message } from "@/types/types";
|
||||||
|
import App from "@/types/app";
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useParentCommunication } from "react-iframes-bridge";
|
||||||
|
import { NodeClient } from "@/lib/node";
|
||||||
|
|
||||||
|
export default function PluginChannel({
|
||||||
|
app,
|
||||||
|
messages,
|
||||||
|
ip,
|
||||||
|
node,
|
||||||
|
}: {
|
||||||
|
app: App;
|
||||||
|
messages: Message[];
|
||||||
|
ip: string;
|
||||||
|
node: React.RefObject<NodeClient | null>;
|
||||||
|
}) {
|
||||||
|
if (!app.currentChannel)
|
||||||
|
return (
|
||||||
|
<div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 gap-5" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
const parentComm = useParentCommunication(iframeRef, {
|
||||||
|
allowedOrigins: [new URL(app.currentChannel.kind).origin],
|
||||||
|
communication: { debug: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
parentComm.onMessage("GET_MESSAGES", () => {
|
||||||
|
parentComm.sendToChild("MESSAGES", messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
parentComm.onMessage("SEND_MESSAGE", async (msg: string) => {
|
||||||
|
await node.current?.waitUntilReady();
|
||||||
|
node.current?.socket?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "send_message",
|
||||||
|
params: {
|
||||||
|
channel_id: app.currentChannel?.id,
|
||||||
|
contents: msg,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [parentComm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe src={app.currentChannel.kind} className="w-full" ref={iframeRef} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import App from "@/types/app";
|
||||||
|
import { ClientMessage, IndicatorContext } from "@/types/protocol";
|
||||||
|
import { Message } from "@/types/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { rms, startMic } from "@/lib/audio";
|
||||||
|
import { Button } from "../../ui/button";
|
||||||
|
import { ChevronLeftIcon, Phone, Volume2Icon } from "lucide-react";
|
||||||
|
import VoiceGrid from "./VoiceGrid";
|
||||||
|
|
||||||
|
export default function VoiceBox({
|
||||||
|
sendVoice,
|
||||||
|
app,
|
||||||
|
channelName,
|
||||||
|
sendRequest,
|
||||||
|
channelId,
|
||||||
|
}: {
|
||||||
|
channelName?: string;
|
||||||
|
app: App;
|
||||||
|
sendRequest: (req: ClientMessage) => void;
|
||||||
|
channelId: string;
|
||||||
|
messages: Message[];
|
||||||
|
indicators: IndicatorContext[];
|
||||||
|
sendVoice: (data: Int16Array) => void;
|
||||||
|
}) {
|
||||||
|
const [voiceConn, setVoiceConn] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!voiceConn) return;
|
||||||
|
if (!app.profile) return;
|
||||||
|
|
||||||
|
const stop = startMic((data) => {
|
||||||
|
if (rms(data) > 0.005) {
|
||||||
|
sendVoice(data);
|
||||||
|
|
||||||
|
if (!app.profile) return;
|
||||||
|
|
||||||
|
const voiceId = app.voiceConns[channelId]?.[app.profile.id];
|
||||||
|
if (!voiceId) return;
|
||||||
|
|
||||||
|
app.setSpeaking((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[voiceId]: Date.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop.then((s) => s());
|
||||||
|
};
|
||||||
|
}, [voiceConn, channelId, app.profile?.id, app.voiceConns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const inv = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const TIMEOUT = 800;
|
||||||
|
|
||||||
|
app.setSpeaking((prev) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(prev).filter(([_, ts]) => now - ts < TIMEOUT)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => clearInterval(inv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-svh w-full max-h-svh flex flex-col pb-5 pl-5 pr-5 gap-5">
|
||||||
|
{channelName && (
|
||||||
|
<header className="h-12 py-4 flex items-center border-b text-sm font-semibold w-full">
|
||||||
|
<span
|
||||||
|
onClick={() => app.setSidebarOpen(true)}
|
||||||
|
className="cursor-pointer flex gap-2"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-5 h-5 my-auto md:hidden" />
|
||||||
|
<Volume2Icon className="w-4 h-4 my-auto" />
|
||||||
|
{channelName}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
{app.voiceConns[channelId] && (
|
||||||
|
<VoiceGrid
|
||||||
|
users={Object.entries(app.voiceConns[channelId]).map(
|
||||||
|
([userId, voiceId]) => ({
|
||||||
|
id: userId,
|
||||||
|
speaking: Boolean(app.speaking[voiceId]),
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
app={app}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<footer className="mt-auto h-max w-max mx-auto">
|
||||||
|
{voiceConn ? (
|
||||||
|
<Button
|
||||||
|
className="mx-auto my-auto h-max bg-transparent text-destructive border border-destructive hover:bg-destructive/20 rounded-full aspect-square w-12"
|
||||||
|
onClick={() => {
|
||||||
|
setVoiceConn(false);
|
||||||
|
sendRequest({
|
||||||
|
type: "leave_voice",
|
||||||
|
params: { channel_id: channelId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Phone className="rotate-135" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="mx-auto my-auto h-max bg-transparent text-chart-2 border border-chart-2 hover:bg-chart-2/20 rounded-full aspect-square w-12"
|
||||||
|
onClick={() => {
|
||||||
|
setVoiceConn(true);
|
||||||
|
sendRequest({
|
||||||
|
type: "join_voice",
|
||||||
|
params: { channel_id: channelId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Phone />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import App from "@/types/app";
|
||||||
|
import { VoiceTile, VoiceUser } from "./VoiceTile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function VoiceGrid({
|
||||||
|
app,
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
app: App;
|
||||||
|
users: VoiceUser[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-4 grid-cols-1",
|
||||||
|
app.sidebarOpen
|
||||||
|
? "min-[930px]:grid-cols-2 min-[1200px]:grid-cols-3 min-[1600px]:grid-cols-4"
|
||||||
|
: "min-[560px]:grid-cols-2 min-[1200px]:grid-cols-3 min-[1600px]:grid-cols-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{users.map((user) => (
|
||||||
|
<VoiceTile key={user.id} user={user} app={app} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import AsyncValue from "@/components/AsyncValue";
|
||||||
|
import App from "@/types/app";
|
||||||
|
|
||||||
|
export type VoiceUser = {
|
||||||
|
id: string;
|
||||||
|
speaking: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VoiceTile({ user, app }: { app: App; user: VoiceUser }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative flex flex-col items-center justify-center
|
||||||
|
aspect-[16/9] rounded-xl
|
||||||
|
bg-muted/40
|
||||||
|
transition-all
|
||||||
|
${
|
||||||
|
user.speaking ? "border-4 border-green-500" : "border-1 border-border"
|
||||||
|
}
|
||||||
|
${user.muted ? "opacity-50" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Avatar and name */}
|
||||||
|
<AsyncValue
|
||||||
|
func={() => app.getUserById(user.id)}
|
||||||
|
render={({ value }) => (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={value?.avatar_url}
|
||||||
|
alt="Pfp"
|
||||||
|
className="h-20 w-20 rounded-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 text-sm font-medium">
|
||||||
|
{value?.display_name}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export default function SettingSchema<T>({
|
|||||||
if (!regexSetting.pattern.test(value)) {
|
if (!regexSetting.pattern.test(value)) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: `Value does not match the pattern!`,
|
message: `Value does not match the pattern`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFeedback(undefined);
|
setFeedback(undefined);
|
||||||
@@ -124,7 +124,7 @@ export default function SettingSchema<T>({
|
|||||||
<>
|
<>
|
||||||
{Object.entries(schema).map(([key, type]) => (
|
{Object.entries(schema).map(([key, type]) => (
|
||||||
<div key={key} className="flex items-center justify-between space-x-4">
|
<div key={key} className="flex items-center justify-between space-x-4">
|
||||||
<label className="capitalize">{key}</label>
|
<label className="capitalize">{key.replaceAll("_", "_")}</label>
|
||||||
{renderInput(key, type)}
|
{renderInput(key, type)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useClientSettings } from "@/hooks/use-settings";
|
import { useClientSettings } from "@/hooks/use-settings";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { updateProfile } from "@/actions/get-profile";
|
||||||
|
|
||||||
export default function Settings({
|
export default function Settings({
|
||||||
tab,
|
tab,
|
||||||
@@ -41,7 +42,7 @@ export default function Settings({
|
|||||||
schema={CLIENT_SCHEMA}
|
schema={CLIENT_SCHEMA}
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
setClientSettings(settings);
|
setClientSettings(settings);
|
||||||
router.refresh();
|
setTimeout(() => router.refresh(), 300);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -55,7 +56,7 @@ export default function Settings({
|
|||||||
setSettings={setProfileSettings}
|
setSettings={setProfileSettings}
|
||||||
schema={PROFILE_SCHEMA}
|
schema={PROFILE_SCHEMA}
|
||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
await axios.post("/api/profile", profileSettings);
|
await updateProfile(profileSettings);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Settings from "./Settings";
|
import Settings from "./Settings";
|
||||||
import { ProfileSettings } from "@/types/settings";
|
import { ProfileSettings } from "@/types/settings";
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export default function SettingsDialog({
|
export default function SettingsDialog({
|
||||||
className,
|
className,
|
||||||
@@ -29,12 +29,7 @@ export default function SettingsDialog({
|
|||||||
{children ? (
|
{children ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button className={cn("bg-transparent text-foreground", className)}>
|
||||||
className={cn(
|
|
||||||
"bg-transparent text-accent-foreground hover:text-accent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -45,7 +40,7 @@ export default function SettingsDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Settings tab={tab} profileSettings={profile} />
|
{profile && <Settings tab={tab} profileSettings={profile} />}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -6,6 +6,9 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { get } from "@/lib/request";
|
import { get } from "@/lib/request";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import axios from "axios";
|
||||||
|
import getSession from "@/actions/get-session";
|
||||||
|
import { getProfileById } from "@/actions/get-profile";
|
||||||
|
|
||||||
export interface UserProfile extends ProfileSettings {
|
export interface UserProfile extends ProfileSettings {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,15 +21,20 @@ export default function useUser() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchUser() {
|
async function fetchUser() {
|
||||||
try {
|
try {
|
||||||
setUser((await get("/api/profile")).data);
|
const user_id = await getSession();
|
||||||
|
if (!user_id) {
|
||||||
|
throw new Error("Session user id is not valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(await getProfileById(user_id));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
router.push("/login");
|
router.push("/auth?t=login");
|
||||||
toast.error(`Error: ${e}`);
|
toast.error(`Error: ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUser();
|
fetchUser();
|
||||||
}, [router]);
|
}, []);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,59 @@
|
|||||||
import App from "@/types/app";
|
import App from "@/types/app";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useMessages } from "./use-messages";
|
import useMessages from "./use-messages";
|
||||||
import { StringMap } from "@/types/typeUtils";
|
import { StringMap } from "@/types/typeUtils";
|
||||||
import useUser, { UserProfile } from "./get-user";
|
import useUser, { UserProfile } from "./get-user";
|
||||||
import { useClientSettings } from "./use-settings";
|
import { useClientSettings } from "./use-settings";
|
||||||
import { get, Response } from "@/lib/request";
|
import { Channel, Server } from "@/types/types";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getProfileById } from "@/actions/get-profile";
|
||||||
|
import { getServerById } from "@/actions/get-server";
|
||||||
|
import { NodeClient } from "@/lib/node";
|
||||||
|
|
||||||
export default function useApp(): App {
|
export default function useApp(): App {
|
||||||
const node = useRef<WebSocket | null>(null);
|
const node = useRef<NodeClient | null>(null);
|
||||||
const [profiles, setProfiles] = useState<StringMap<UserProfile | null>>({});
|
const [profiles, setProfiles] = useState<StringMap<UserProfile | null>>({});
|
||||||
const { messages, addMessage } = useMessages();
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const profile = useUser();
|
const profile = useUser();
|
||||||
const [clientSettings, setClientSettings] = useClientSettings();
|
const [clientSettings, setClientSettings] = useClientSettings();
|
||||||
const [dms, setDms] = useState<UserProfile[]>([]);
|
const [dms, setDms] = useState<UserProfile[]>([]);
|
||||||
|
const [servers, setServers] = useState<StringMap<Server>>({});
|
||||||
|
const [currentChannel, setCurrentChannel] = useState<Channel | null>(null);
|
||||||
|
const [server, setServer] = useState<Server>();
|
||||||
|
const privateMessages = useMessages();
|
||||||
|
const [voiceConns, setVoiceConns] = useState<
|
||||||
|
Record<string, Record<string, number>>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const [speaking, setSpeaking] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = Cookies.get("servers");
|
||||||
|
if (!stored) return;
|
||||||
|
Promise.all(
|
||||||
|
stored
|
||||||
|
.split(",")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter((id) => id)
|
||||||
|
.map((id) => getServerById(id))
|
||||||
|
)
|
||||||
|
.then((o) => setServers(Object.fromEntries(o.map((i) => [i.id, i]))))
|
||||||
|
.catch(toast.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
speaking,
|
||||||
|
setSpeaking,
|
||||||
|
voiceConns,
|
||||||
|
setVoiceConns,
|
||||||
|
privateMessages,
|
||||||
|
currentChannel,
|
||||||
|
setCurrentChannel,
|
||||||
|
server,
|
||||||
|
setServer,
|
||||||
|
servers,
|
||||||
|
setServers,
|
||||||
dms,
|
dms,
|
||||||
setDms,
|
setDms,
|
||||||
node,
|
node,
|
||||||
@@ -24,24 +62,18 @@ export default function useApp(): App {
|
|||||||
profile,
|
profile,
|
||||||
profiles,
|
profiles,
|
||||||
setProfiles,
|
setProfiles,
|
||||||
messages,
|
|
||||||
addMessage,
|
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
getUserById: async (id: string) => {
|
getUserById: async (id: string) => {
|
||||||
if (profiles[id] !== undefined) return profiles[id];
|
if (profiles[id] !== undefined) return profiles[id];
|
||||||
|
|
||||||
const onResponse = (res: Response<UserProfile>) => {
|
try {
|
||||||
|
const user = await getProfileById(id);
|
||||||
setProfiles((prev) => {
|
setProfiles((prev) => {
|
||||||
prev[id] = res.data as UserProfile;
|
prev[id] = user;
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
};
|
return user;
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await get(`/api/profile/?id=${id}`, onResponse);
|
|
||||||
onResponse(user);
|
|
||||||
return user.data;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useAsync<T, E = any>(func: () => Promise<T>) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [value, setValue] = useState<T>();
|
||||||
|
const [error, setError] = useState<E>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
func()
|
||||||
|
.then(setValue)
|
||||||
|
.catch(setError)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { value, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IndicatorContext } from "@/types/protocol";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useIndicators() {
|
||||||
|
const [indicators, setIndicators] = useState<IndicatorContext[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setIndicators((prev) =>
|
||||||
|
prev
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
expires: item.expires - 1,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.expires > 0)
|
||||||
|
);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
indicators,
|
||||||
|
setIndicators,
|
||||||
|
addIndicator: (i: IndicatorContext) => {
|
||||||
|
setIndicators((prev) => {
|
||||||
|
const combined = [...prev, { ...i }];
|
||||||
|
const uniqueByUser = combined.reduce<Record<string, typeof i>>(
|
||||||
|
(acc, curr) => {
|
||||||
|
const userId = curr.indicator.params.user_id;
|
||||||
|
if (!acc[userId] || curr.expires > acc[userId].expires) {
|
||||||
|
acc[userId] = curr;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(uniqueByUser);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,75 +1,54 @@
|
|||||||
import { Message } from "@/types/types";
|
import { Message } from "@/types/types";
|
||||||
import { create } from "zustand";
|
import { useState } from "react";
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
|
|
||||||
interface MessageStore {
|
export interface MessageStore {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
setMessages: (msgs: Message[]) => void;
|
setMessages: (msgs: Message[]) => void;
|
||||||
addMessage: (msg: Message) => void;
|
addMessage: (msg: Message) => void;
|
||||||
|
deleteMessage: (id: number) => void;
|
||||||
|
editMessage: (id: number, contents: string) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
|
insertMessages: (msgs: Message[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerMessageStore = {
|
export default function useMessages(): MessageStore {
|
||||||
messages: Record<string, Message[]>; // server_id → messages
|
const [messages, setMessagesState] = useState<Message[]>([]);
|
||||||
setMessages: (serverId: string, msgs: Message[]) => void;
|
|
||||||
addMessage: (serverId: string, msg: Message) => void;
|
|
||||||
clearMessages: (serverId?: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMessages = create<MessageStore>()(
|
// Helper to ensure unique messages by id
|
||||||
persist(
|
const uniqueMessages = (msgs: Message[]) => {
|
||||||
(set) => ({
|
const map = new Map<number, Message>();
|
||||||
messages: [],
|
msgs.forEach((m) => map.set(m.id, m)); // duplicates overwrite
|
||||||
setMessages: (msgs) => set({ messages: msgs }),
|
return Array.from(map.values());
|
||||||
addMessage: (msg) =>
|
};
|
||||||
set((state) =>
|
|
||||||
state.messages.some((m) => m.id === msg.id)
|
|
||||||
? state
|
|
||||||
: { messages: [...state.messages, msg] }
|
|
||||||
),
|
|
||||||
clearMessages: () => set({ messages: [] }),
|
|
||||||
}),
|
|
||||||
{ name: "chat-messages" }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useServerMessages = create<ServerMessageStore>()(
|
const setMessages = (msgs: Message[]) =>
|
||||||
persist(
|
setMessagesState(uniqueMessages(msgs));
|
||||||
(set) => ({
|
|
||||||
messages: {},
|
|
||||||
|
|
||||||
setMessages: (serverId, msgs) =>
|
const addMessage = (msg: Message) =>
|
||||||
set((state) => ({
|
setMessagesState((messages) =>
|
||||||
messages: { ...state.messages, [serverId]: msgs },
|
messages.some((m) => m.id === msg.id) ? messages : [...messages, msg]
|
||||||
})),
|
);
|
||||||
|
|
||||||
addMessage: (serverId, msg) =>
|
const insertMessages = (msgs: Message[]) =>
|
||||||
set((state) => {
|
setMessagesState((messages) => uniqueMessages([...msgs, ...messages]));
|
||||||
const serverMsgs = state.messages[serverId] || [];
|
|
||||||
|
|
||||||
// avoid duplicates
|
const deleteMessage = (id: number) =>
|
||||||
if (serverMsgs.some((m) => m.id === msg.id)) return state;
|
setMessagesState((messages) => messages.filter((m) => m.id !== id));
|
||||||
|
|
||||||
|
const editMessage = (id: number, contents: string) =>
|
||||||
|
setMessagesState((messages) =>
|
||||||
|
messages.map((m) => (m.id === id ? { ...m, contents } : m))
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearMessages = () => setMessagesState([]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: {
|
messages,
|
||||||
...state.messages,
|
setMessages,
|
||||||
[serverId]: [...serverMsgs, msg],
|
addMessage,
|
||||||
},
|
deleteMessage,
|
||||||
|
editMessage,
|
||||||
|
clearMessages,
|
||||||
|
insertMessages,
|
||||||
};
|
};
|
||||||
}),
|
}
|
||||||
|
|
||||||
clearMessages: (serverId) =>
|
|
||||||
set((state) =>
|
|
||||||
serverId
|
|
||||||
? {
|
|
||||||
messages: {
|
|
||||||
...state.messages,
|
|
||||||
[serverId]: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { messages: {} }
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
{ name: "server-messages" }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { RefObject, useEffect, useRef } from "react";
|
||||||
|
import { MessageStore } from "./use-messages";
|
||||||
|
import { NodeCallbacks, NodeClient } from "@/lib/node";
|
||||||
|
import { asyncWrapProviders } from "async_hooks";
|
||||||
|
|
||||||
|
export default function useNode(
|
||||||
|
nodeId: string,
|
||||||
|
messageStore: MessageStore,
|
||||||
|
callbacks: NodeCallbacks
|
||||||
|
) {
|
||||||
|
const wsClientRef = useRef<NodeClient | null>(null);
|
||||||
|
useEffect(() => getNode(nodeId, messageStore, callbacks, wsClientRef), []);
|
||||||
|
return wsClientRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDynNode(
|
||||||
|
nodeId: string | undefined | null,
|
||||||
|
messageStore: MessageStore,
|
||||||
|
callbacks: NodeCallbacks
|
||||||
|
) {
|
||||||
|
const wsClientRef = useRef<NodeClient | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!nodeId) return;
|
||||||
|
return getNode(nodeId, messageStore, callbacks, wsClientRef);
|
||||||
|
}, [nodeId]);
|
||||||
|
|
||||||
|
return wsClientRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNode(
|
||||||
|
nodeId: string,
|
||||||
|
messageStore: MessageStore,
|
||||||
|
callbacks: NodeCallbacks,
|
||||||
|
ref: RefObject<NodeClient | null>
|
||||||
|
) {
|
||||||
|
const client = new NodeClient(nodeId, messageStore, callbacks);
|
||||||
|
|
||||||
|
ref.current = client;
|
||||||
|
client.connect();
|
||||||
|
|
||||||
|
return () => client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNullNode() {
|
||||||
|
return useRef<NodeClient | null>(null);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export function useEffectOnceWhenReady(
|
export function useEffectOnceWhenReady(
|
||||||
effect: () => void,
|
effect: () => any,
|
||||||
deps: any[],
|
deps: any[],
|
||||||
testers: (undefined | ((m: any) => boolean))[]
|
testers: (undefined | ((m: any) => boolean))[]
|
||||||
) {
|
) {
|
||||||
@@ -13,6 +13,7 @@ export function useEffectOnceWhenReady(
|
|||||||
);
|
);
|
||||||
if (!allReady || hasRun.current) return;
|
if (!allReady || hasRun.current) return;
|
||||||
hasRun.current = true;
|
hasRun.current = true;
|
||||||
effect();
|
|
||||||
|
return effect();
|
||||||
}, deps);
|
}, deps);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
async function getMicStream() {
|
||||||
|
return await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startMic(onAudio: (data: Int16Array) => void) {
|
||||||
|
const stream = await getMicStream();
|
||||||
|
const sampleRate = 48000;
|
||||||
|
const audioCtx = new AudioContext({ sampleRate });
|
||||||
|
await audioCtx.resume();
|
||||||
|
await audioCtx.audioWorklet.addModule("/audio-worklet.js");
|
||||||
|
|
||||||
|
const source = audioCtx.createMediaStreamSource(stream);
|
||||||
|
const worklet = new AudioWorkletNode(audioCtx, "mic-processor");
|
||||||
|
|
||||||
|
source.connect(worklet);
|
||||||
|
worklet.connect(audioCtx.destination);
|
||||||
|
|
||||||
|
const buffer: number[] = [];
|
||||||
|
const chunkSize = sampleRate;
|
||||||
|
|
||||||
|
worklet.port.onmessage = (e) => {
|
||||||
|
const float32 = e.data as Float32Array;
|
||||||
|
const pcm16 = float32ToPCM16(float32);
|
||||||
|
buffer.push(...pcm16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (buffer.length === 0) return;
|
||||||
|
let chunk: Int16Array;
|
||||||
|
if (buffer.length >= chunkSize) {
|
||||||
|
chunk = new Int16Array(buffer.splice(0, chunkSize));
|
||||||
|
} else {
|
||||||
|
chunk = new Int16Array(chunkSize);
|
||||||
|
chunk.set(buffer.splice(0, buffer.length));
|
||||||
|
}
|
||||||
|
onAudio(chunk);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
worklet.disconnect();
|
||||||
|
source.disconnect();
|
||||||
|
audioCtx.close();
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function float32ToPCM16(float32: Float32Array): Int16Array {
|
||||||
|
const pcm16 = new Int16Array(float32.length);
|
||||||
|
for (let i = 0; i < float32.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, float32[i]));
|
||||||
|
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||||
|
}
|
||||||
|
return pcm16;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendVoice(ws: WebSocket, pcm: Int16Array) {
|
||||||
|
ws.send(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null;
|
||||||
|
|
||||||
|
function getAudioContext(sampleRate: number) {
|
||||||
|
if (!ctx) {
|
||||||
|
ctx = new AudioContext({ sampleRate });
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playPCM16(u8: Uint8Array) {
|
||||||
|
const audioCtx = getAudioContext(48000);
|
||||||
|
|
||||||
|
// Remove the first two bytes
|
||||||
|
const trimmed = u8.slice(2);
|
||||||
|
|
||||||
|
const pcm16 = new Int16Array(
|
||||||
|
trimmed.buffer,
|
||||||
|
trimmed.byteOffset,
|
||||||
|
trimmed.byteLength / 2
|
||||||
|
);
|
||||||
|
|
||||||
|
const float32 = new Float32Array(pcm16.length);
|
||||||
|
for (let i = 0; i < pcm16.length; i++) {
|
||||||
|
float32[i] = pcm16[i] / 0x8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
|
||||||
|
buffer.getChannelData(0).set(float32);
|
||||||
|
|
||||||
|
const src = audioCtx.createBufferSource();
|
||||||
|
src.buffer = buffer;
|
||||||
|
src.connect(audioCtx.destination);
|
||||||
|
src.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rms(samples: Int16Array): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const s = samples[i] / 32768; // normalize to [-1, 1]
|
||||||
|
sum += s * s;
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum / samples.length);
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Message, Server } from "@/types/types";
|
|
||||||
import axios from "axios";
|
|
||||||
import { Dispatch, RefObject, SetStateAction } from "react";
|
|
||||||
import { get } from "./request";
|
|
||||||
|
|
||||||
export default async function auth(
|
|
||||||
id: string,
|
|
||||||
wsRef: RefObject<WebSocket | null>,
|
|
||||||
setServer: Dispatch<SetStateAction<Server | undefined>>,
|
|
||||||
addMessage: (m: Message) => void,
|
|
||||||
onNewMessage?: (m: Message) => void,
|
|
||||||
lastMessage?: number
|
|
||||||
) {
|
|
||||||
console.log("Authenticating with server id:", id);
|
|
||||||
|
|
||||||
const ip = ((await get(`/api/server/${id}`)).data as any).address as string;
|
|
||||||
|
|
||||||
console.log("Authenticating with server at:", ip);
|
|
||||||
const server_auth = (
|
|
||||||
(await axios.post("/api/auth", { server_id: id })).data as any
|
|
||||||
).token;
|
|
||||||
|
|
||||||
// Open the WebSocket connection
|
|
||||||
const ws = new WebSocket(`wss://${ip}`);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("Connected to WebSocket:", ip);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (m) => {
|
|
||||||
const data = JSON.parse(m.data);
|
|
||||||
console.log("Message received:", data);
|
|
||||||
|
|
||||||
if (data.version) {
|
|
||||||
setServer({ channels: [], ...data });
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
version: "0.0.1",
|
|
||||||
auth_token: server_auth,
|
|
||||||
last_message: lastMessage || 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (data.type) {
|
|
||||||
case "authenticated":
|
|
||||||
data.params.messages.forEach(addMessage);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "message_create":
|
|
||||||
if (onNewMessage) onNewMessage(data.params as Message);
|
|
||||||
addMessage(data.params as Message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
|
||||||
console.error("WebSocket error:", err);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log("WebSocket closed:", ip);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup on unmount or id change
|
|
||||||
return () => {
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Message, Server } from "@/types/types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MessageStore } from "@/hooks/use-messages";
|
||||||
|
import { IndicatorContext, ServerMessage } from "@/types/protocol";
|
||||||
|
import { authRelay } from "@/actions/auth";
|
||||||
|
import { getServerById } from "@/actions/get-server";
|
||||||
|
import { playPCM16 } from "./audio";
|
||||||
|
|
||||||
|
export interface NodeCallbacks {
|
||||||
|
setServer?: (s: Server) => void;
|
||||||
|
onNewMessage?: (m: Message) => void;
|
||||||
|
onIndicator?: (i: IndicatorContext) => void;
|
||||||
|
onVoiceJoin?: (userId: string, channelId: string, voiceId: number) => void;
|
||||||
|
onVoiceLeave?: (userId: string, channelId: string, voiceId: number) => void;
|
||||||
|
onSpeaking?: (voiceId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NodeClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private name: string;
|
||||||
|
private readonly serverId: string;
|
||||||
|
private readonly messageStore: MessageStore;
|
||||||
|
private readonly callbacks: NodeCallbacks;
|
||||||
|
private ready: boolean;
|
||||||
|
private readyPromise: Promise<void>;
|
||||||
|
private resolveReady!: () => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
serverId: string,
|
||||||
|
messageStore: MessageStore,
|
||||||
|
callbacks: NodeCallbacks = {}
|
||||||
|
) {
|
||||||
|
this.serverId = serverId;
|
||||||
|
this.messageStore = messageStore;
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.name = serverId;
|
||||||
|
this.ready = false;
|
||||||
|
this.readyPromise = new Promise((resolve) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get socket() {
|
||||||
|
return this.ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (!this.serverId) return;
|
||||||
|
|
||||||
|
const server = await getServerById(this.serverId);
|
||||||
|
const authToken = await authRelay(this.serverId);
|
||||||
|
|
||||||
|
this.ws = new WebSocket(`wss://${server.address}`);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log("WebSocket connected:", server.address);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (e) => this.handleMessage(e, authToken);
|
||||||
|
this.ws.onerror = (e) => this.handleError(e);
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.log("WebSocket closed:", server.address);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
console.log("Disconnecting");
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitUntilReady() {
|
||||||
|
if (this.ready) return;
|
||||||
|
await this.readyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialMessages(channelId: string) {
|
||||||
|
this.waitUntilReady().then(() => {
|
||||||
|
this.socket?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "load_chunk",
|
||||||
|
params: {
|
||||||
|
channel_id: channelId,
|
||||||
|
chunk_id: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(e: MessageEvent, authToken: string) {
|
||||||
|
// Voice packet
|
||||||
|
if (e.data instanceof Blob) {
|
||||||
|
const buffer = await e.data.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
playPCM16(bytes);
|
||||||
|
|
||||||
|
if (this.callbacks.onSpeaking) {
|
||||||
|
const voiceId = new DataView(buffer).getUint16(0, true);
|
||||||
|
this.callbacks.onSpeaking(voiceId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
|
||||||
|
console.log("Message received:", data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
toast.error(`Error from ${this.name} (${data.error})`, {
|
||||||
|
description: data.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handshake
|
||||||
|
if (data.version) {
|
||||||
|
this.callbacks.setServer?.({ channels: [], ...data });
|
||||||
|
|
||||||
|
this.ws?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
version: "0.0.1",
|
||||||
|
auth_token: authToken,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.name = data.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleServerMessage(data as ServerMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerMessage(msg: ServerMessage) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "authenticated":
|
||||||
|
msg.params.indicators.forEach((i) => this.callbacks.onIndicator?.(i));
|
||||||
|
|
||||||
|
Object.entries(msg.params.voice_chat).forEach(([channelId, users]) =>
|
||||||
|
Object.entries(users).forEach(([userId, voiceId]) =>
|
||||||
|
this.callbacks.onVoiceJoin?.(userId, channelId, voiceId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.ready = true;
|
||||||
|
this.resolveReady();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_create":
|
||||||
|
this.callbacks.onNewMessage?.(msg.params);
|
||||||
|
this.messageStore.addMessage(msg.params);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_delete":
|
||||||
|
this.messageStore.deleteMessage(msg.params.message_id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_update":
|
||||||
|
this.messageStore.editMessage(
|
||||||
|
msg.params.message_id,
|
||||||
|
msg.params.contents
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "chunk":
|
||||||
|
this.messageStore.insertMessages(msg.params);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "indicator":
|
||||||
|
this.callbacks.onIndicator?.(msg.params);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "voice_join":
|
||||||
|
this.callbacks.onVoiceJoin?.(
|
||||||
|
msg.params.user_id,
|
||||||
|
msg.params.channel_id,
|
||||||
|
msg.params.voice_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "voice_leave":
|
||||||
|
this.callbacks.onVoiceLeave?.(
|
||||||
|
msg.params.user_id,
|
||||||
|
msg.params.channel_id,
|
||||||
|
msg.params.voice_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(err: Event) {
|
||||||
|
console.error("WebSocket error:", err);
|
||||||
|
|
||||||
|
let description = "WebSocket connection failed.";
|
||||||
|
|
||||||
|
if (err instanceof ErrorEvent) {
|
||||||
|
description = err.message || description;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(`WebSocket error (${this.name})`, { description });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import { Dispatch, RefObject, SetStateAction } from "react";
|
import { Dispatch, RefObject, SetStateAction } from "react";
|
||||||
import { StringMap } from "./typeUtils";
|
import { StringMap } from "./typeUtils";
|
||||||
import { UserProfile } from "@/hooks/get-user";
|
import { UserProfile } from "@/hooks/get-user";
|
||||||
import { Message } from "./types";
|
import { Channel, Server } from "./types";
|
||||||
import { ClientSettings } from "./settings";
|
import { ClientSettings } from "./settings";
|
||||||
|
import { MessageStore } from "@/hooks/use-messages";
|
||||||
|
import { NodeClient } from "@/lib/node";
|
||||||
|
|
||||||
export type SetState<T> = Dispatch<SetStateAction<T>>;
|
export type SetState<T> = Dispatch<SetStateAction<T>>;
|
||||||
|
|
||||||
export default interface App {
|
export default interface App {
|
||||||
node: RefObject<WebSocket | null>;
|
node: RefObject<NodeClient | null>;
|
||||||
profile: UserProfile | null;
|
profile: UserProfile | null;
|
||||||
|
|
||||||
|
server: Server | undefined;
|
||||||
|
setServer: SetState<Server | undefined>;
|
||||||
|
|
||||||
clientSettings: ClientSettings;
|
clientSettings: ClientSettings;
|
||||||
setClientSettings: SetState<ClientSettings>;
|
setClientSettings: SetState<ClientSettings>;
|
||||||
|
|
||||||
|
servers: StringMap<Server>;
|
||||||
|
setServers: SetState<StringMap<Server>>;
|
||||||
|
|
||||||
profiles: StringMap<UserProfile | null>;
|
profiles: StringMap<UserProfile | null>;
|
||||||
setProfiles: SetState<StringMap<UserProfile | null>>;
|
setProfiles: SetState<StringMap<UserProfile | null>>;
|
||||||
|
|
||||||
messages: Message[];
|
privateMessages: MessageStore;
|
||||||
addMessage: (v: Message) => void;
|
|
||||||
|
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
setSidebarOpen: SetState<boolean>;
|
setSidebarOpen: SetState<boolean>;
|
||||||
@@ -26,4 +33,13 @@ export default interface App {
|
|||||||
|
|
||||||
dms: UserProfile[];
|
dms: UserProfile[];
|
||||||
setDms: SetState<UserProfile[]>;
|
setDms: SetState<UserProfile[]>;
|
||||||
|
|
||||||
|
currentChannel: Channel | null;
|
||||||
|
setCurrentChannel: SetState<Channel | null>;
|
||||||
|
|
||||||
|
voiceConns: Record<string, Record<string, number>>;
|
||||||
|
setVoiceConns: SetState<Record<string, Record<string, number>>>;
|
||||||
|
|
||||||
|
speaking: Record<number, number>;
|
||||||
|
setSpeaking: SetState<Record<number, number>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Message } from "./types";
|
||||||
|
|
||||||
|
export interface EnumType<T = string, P = object> {
|
||||||
|
type: T;
|
||||||
|
params: P;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Indicator = EnumType<
|
||||||
|
"typing",
|
||||||
|
{ user_id: string; channel_id: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface IndicatorContext {
|
||||||
|
indicator: Indicator;
|
||||||
|
expires: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| EnumType<"send_message", { channel_id: string; contents: string }>
|
||||||
|
| EnumType<"delete_message", { message_id: number }>
|
||||||
|
| EnumType<"edit_message", { message_id: number; new_contents: string }>
|
||||||
|
| EnumType<"load_chunk", { channel_id: string; chunk_id: number }>
|
||||||
|
| EnumType<"typing", { channel_id: string }>
|
||||||
|
| EnumType<"join_voice", { channel_id: string }>
|
||||||
|
| EnumType<"leave_voice", { channel_id: string }>;
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| EnumType<
|
||||||
|
"authenticated",
|
||||||
|
{
|
||||||
|
uuid: string;
|
||||||
|
indicators: IndicatorContext[];
|
||||||
|
voice_chat: Record<string, Record<string, number>>;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
| EnumType<"temp_message", { message: string }>
|
||||||
|
| EnumType<"message_create", Message>
|
||||||
|
| EnumType<"message_update", { message_id: number; contents: string }>
|
||||||
|
| EnumType<"message_delete", { message_id: number }>
|
||||||
|
| EnumType<"presence_update", { user_id: String; status: String }>
|
||||||
|
| EnumType<"indicator", IndicatorContext>
|
||||||
|
| EnumType<"shutdown", { message: string }>
|
||||||
|
| EnumType<"chunk", Message[]>
|
||||||
|
| EnumType<
|
||||||
|
"voice_join",
|
||||||
|
{ channel_id: string; user_id: string; voice_id: number }
|
||||||
|
>
|
||||||
|
| EnumType<
|
||||||
|
"voice_leave",
|
||||||
|
{ channel_id: string; user_id: string; voice_id: number }
|
||||||
|
>
|
||||||
|
| EnumType<"chunk", Message[]>;
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from "@/components/settings/SettingSchema";
|
} from "@/components/settings/SettingSchema";
|
||||||
|
|
||||||
export interface ClientSettings {
|
export interface ClientSettings {
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark" | "black";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileSettings {
|
export interface ProfileSettings {
|
||||||
@@ -15,7 +15,7 @@ export interface ProfileSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CLIENT_SCHEMA: SettingsSchema = {
|
export const CLIENT_SCHEMA: SettingsSchema = {
|
||||||
theme: ["light", "dark"],
|
theme: ["light", "dark", "black"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROFILE_SCHEMA: SettingsSchema = {
|
export const PROFILE_SCHEMA: SettingsSchema = {
|
||||||
@@ -23,7 +23,7 @@ export const PROFILE_SCHEMA: SettingsSchema = {
|
|||||||
display_name: "string",
|
display_name: "string",
|
||||||
avatar_url: "string",
|
avatar_url: "string",
|
||||||
node_address: new RegexSetting(
|
node_address: new RegexSetting(
|
||||||
"node2.voxa.org",
|
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
/^(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|(?:\d{1,3}\.){3}\d{1,3})$/
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export interface Message {
|
|||||||
export interface Server {
|
export interface Server {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
icon_url: string;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
}
|
}
|
||||||
|
|||||||