First commit in rust
This commit is contained in:
24
backchannel-web/README.md
Normal file
24
backchannel-web/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# BackChannel Web
|
||||
|
||||
React + TypeScript browser client for BackChannel.
|
||||
|
||||
## Development
|
||||
|
||||
```powershell
|
||||
cd backchannel-web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Default WebSocket target in the app is `ws://<host>:7777` and can be changed from the UI.
|
||||
|
||||
## Build for Embedded Server
|
||||
|
||||
```powershell
|
||||
cd backchannel-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
The Rust server embeds files from `backchannel-web/dist` at compile time.
|
||||
Rebuild the Rust server after producing a new web build.
|
||||
1
backchannel-web/dist/assets/index-C3Y0u3Sh.css
vendored
Normal file
1
backchannel-web/dist/assets/index-C3Y0u3Sh.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
:root{color-scheme:light;--bg-0: #0d1b2a;--panel: #fffaf0;--text: #1d2d44;--accent: #e76f51;--accent-2: #2a9d8f;--line: #f4a261}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#415a77,var(--bg-0) 55%);font-family:Space Grotesk,Trebuchet MS,sans-serif;color:var(--text)}.layout{width:min(1400px,100vw);margin:0 auto;padding:20px;display:grid;gap:14px}.card{background:linear-gradient(145deg,#fffaf0,#fdf0d5);border:2px solid #e9c46a;border-radius:16px;box-shadow:0 8px 24px #0003}.topbar{padding:16px;display:flex;flex-wrap:wrap;justify-content:space-between;align-items:end;gap:12px}h1{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.06em;font-size:clamp(2rem,4vw,2.8rem)}h2{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.05em}p{margin:6px 0 0}.connection{display:flex;gap:8px;min-width:min(540px,100%)}.status{padding:10px 14px;font-weight:700;border-color:var(--accent-2)}.chat-shell{position:relative;padding-left:350px}.panel{padding:14px;display:grid;gap:10px}.chat-main{min-height:calc(100vh - 220px)}.auth-sidebar{position:fixed;left:20px;bottom:20px;width:320px;z-index:10}.switch{display:flex;gap:8px}.switch .active{background:var(--accent-2);color:#fff}input{width:100%;border:2px solid #264653;border-radius:10px;padding:8px 10px;font:inherit;background:#fff}form{display:grid;gap:8px}button{border:0;background:var(--accent);color:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:700}.chat-main form{grid-template-columns:1fr auto;align-items:end}.send-button{padding:6px 10px;min-width:68px}button:disabled{opacity:.5;cursor:not-allowed}.meta{display:grid;gap:6px;font-size:.9rem}.log{background:#fff;border:1px solid var(--line);border-radius:10px;min-height:200px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;justify-content:flex-end;gap:6px}.chat-log{max-height:calc(100vh - 360px)}.line{display:grid;gap:2px;border-bottom:1px dashed #c8d4dd;padding-bottom:6px}time{font-size:.75rem;color:#526072}@media (max-width: 900px){.chat-shell{padding-left:0}.auth-sidebar{position:static;width:100%;margin-top:14px}.chat-main{min-height:auto}.chat-log{max-height:50vh}.connection{min-width:0;width:100%;flex-direction:column}}
|
||||
40
backchannel-web/dist/assets/index-DEokFOhy.js
vendored
Normal file
40
backchannel-web/dist/assets/index-DEokFOhy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
backchannel-web/dist/index.html
vendored
Normal file
13
backchannel-web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BackChannel</title>
|
||||
<script type="module" crossorigin src="/assets/index-DEokFOhy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C3Y0u3Sh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
backchannel-web/index.html
Normal file
12
backchannel-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BackChannel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1768
backchannel-web/package-lock.json
generated
Normal file
1768
backchannel-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
backchannel-web/package.json
Normal file
25
backchannel-web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "backchannel-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/curves": "^1.8.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
236
backchannel-web/src/App.tsx
Normal file
236
backchannel-web/src/App.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getOrCreateIdentityKey } from "./crypto";
|
||||
import { ClientMessage, GENERAL_CHANNEL_ID, ServerMessage } from "./protocol";
|
||||
|
||||
type ChatLine = { id: string; by: string; text: string; at: string };
|
||||
|
||||
function parseServerMessage(raw: unknown): ServerMessage | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = raw as { type?: unknown };
|
||||
if (typeof candidate.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw as ServerMessage;
|
||||
}
|
||||
|
||||
function defaultWsUrl(): string {
|
||||
const host = window.location.hostname || "127.0.0.1";
|
||||
return `ws://${host}:7777`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [wsUrl, setWsUrl] = useState(() => localStorage.getItem("bc.ws_url") ?? defaultWsUrl());
|
||||
const [socketState, setSocketState] = useState<"offline" | "connecting" | "online">("offline");
|
||||
const [authMode, setAuthMode] = useState<"login" | "register">("login");
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [me, setMe] = useState<{ id: string; username: string } | null>(null);
|
||||
const [channelInput, setChannelInput] = useState("");
|
||||
const [channelLines, setChannelLines] = useState<ChatLine[]>([]);
|
||||
|
||||
const [status, setStatus] = useState("Not connected");
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const chatLogRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const ready = useMemo(() => socketState === "online", [socketState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatLogRef.current) {
|
||||
chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight;
|
||||
}
|
||||
}, [channelLines]);
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function send(message: ClientMessage) {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
setStatus("WebSocket is not connected");
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (wsRef.current && wsRef.current.readyState <= WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSocketState("connecting");
|
||||
setStatus("Connecting...");
|
||||
localStorage.setItem("bc.ws_url", wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setSocketState("online");
|
||||
setStatus("Connected");
|
||||
|
||||
const token = localStorage.getItem("bc.session_token");
|
||||
if (token) {
|
||||
send({ type: "resume_session", token });
|
||||
} else {
|
||||
setStatus("Connected. Please login or register.");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setSocketState("offline");
|
||||
setStatus("Disconnected");
|
||||
wsRef.current = null;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("WebSocket error");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = parseServerMessage(JSON.parse(event.data));
|
||||
if (!msg) {
|
||||
setStatus("Invalid server message");
|
||||
return;
|
||||
}
|
||||
handleServer(msg);
|
||||
};
|
||||
}
|
||||
|
||||
function handleServer(msg: ServerMessage) {
|
||||
switch (msg.type) {
|
||||
case "auth_success": {
|
||||
localStorage.setItem("bc.session_token", msg.session_token);
|
||||
setMe({ id: msg.user_id, username: msg.username });
|
||||
setStatus(`Authenticated as ${msg.username}`);
|
||||
send({ type: "fetch_channel_history", channel_id: GENERAL_CHANNEL_ID, before_message_id: null, limit: 100 });
|
||||
return;
|
||||
}
|
||||
case "auth_error": {
|
||||
setStatus(`Auth error: ${msg.reason}`);
|
||||
return;
|
||||
}
|
||||
case "channel_history": {
|
||||
setChannelLines(
|
||||
[...msg.messages]
|
||||
.reverse()
|
||||
.map((m) => ({ id: m.message_id, by: m.author_username, text: m.content, at: m.timestamp })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "channel_message": {
|
||||
if (msg.channel_id === GENERAL_CHANNEL_ID) {
|
||||
setChannelLines((prev) => [
|
||||
...prev,
|
||||
{ id: msg.message_id, by: msg.author_username, text: msg.content, at: msg.timestamp },
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "error": {
|
||||
setStatus(`Error ${msg.code}: ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function submitAuth(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const identity = getOrCreateIdentityKey();
|
||||
|
||||
if (authMode === "login") {
|
||||
send({ type: "login", username, password, identity_pubkey: identity });
|
||||
return;
|
||||
}
|
||||
|
||||
send({ type: "register", username, password, identity_pubkey: identity });
|
||||
}
|
||||
|
||||
function submitChannel(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!channelInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
send({ type: "send_channel_message", channel_id: GENERAL_CHANNEL_ID, content: channelInput.trim() });
|
||||
setChannelInput("");
|
||||
}
|
||||
|
||||
function logout() {
|
||||
send({ type: "logout" });
|
||||
localStorage.removeItem("bc.session_token");
|
||||
setMe(null);
|
||||
setStatus("Logged out");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="layout">
|
||||
<section className="card topbar">
|
||||
<div>
|
||||
<h1>BackChannel</h1>
|
||||
<p>General chat room.</p>
|
||||
</div>
|
||||
|
||||
<div className="connection">
|
||||
<input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://127.0.0.1:7777" />
|
||||
<button onClick={connect} disabled={ready}>Connect</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card status">{status}</section>
|
||||
|
||||
<section className="chat-shell">
|
||||
<article className="card panel chat-main">
|
||||
<h2>#general</h2>
|
||||
<form onSubmit={submitChannel}>
|
||||
<input value={channelInput} onChange={(e) => setChannelInput(e.target.value)} placeholder="message #general" />
|
||||
<button className="send-button" type="submit" disabled={!ready}>Send</button>
|
||||
</form>
|
||||
|
||||
<div ref={chatLogRef} className="log chat-log">
|
||||
{channelLines.map((line) => (
|
||||
<div key={line.id} className="line">
|
||||
<b>{line.by}</b>
|
||||
<span>{line.text}</span>
|
||||
<time>{formatTime(line.at)}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="card panel auth-sidebar">
|
||||
<h2>Auth</h2>
|
||||
<div className="switch">
|
||||
<button className={authMode === "login" ? "active" : ""} onClick={() => setAuthMode("login")}>Login</button>
|
||||
<button className={authMode === "register" ? "active" : ""} onClick={() => setAuthMode("register")}>Register</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitAuth}>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="password" type="password" />
|
||||
<button type="submit" disabled={!ready}>{authMode}</button>
|
||||
</form>
|
||||
|
||||
<div className="meta">
|
||||
<div>User: {me?.username ?? "(not authenticated)"}</div>
|
||||
<div>ID: {me?.id ?? "-"}</div>
|
||||
<button onClick={logout} disabled={!ready}>Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
69
backchannel-web/src/crypto.ts
Normal file
69
backchannel-web/src/crypto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { chacha20poly1305 } from "@noble/ciphers/chacha";
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
import { hkdf } from "@noble/hashes/hkdf";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
|
||||
const HKDF_SALT = new TextEncoder().encode("backchannel-dm-salt-v1");
|
||||
const HKDF_INFO = new TextEncoder().encode("backchannel-dm-key-v1");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export function bytesToBase64(input: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (const b of input) {
|
||||
bin += String.fromCharCode(b);
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToBytes(input: string): Uint8Array {
|
||||
const bin = atob(input);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function getOrCreateIdentityKey(): string {
|
||||
const current = localStorage.getItem("bc.identity_key");
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const key = bytesToBase64(randomBytes(32));
|
||||
localStorage.setItem("bc.identity_key", key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function createEphemeralKeypair(): { privateKey: Uint8Array; publicKeyB64: string } {
|
||||
const privateKey = x25519.utils.randomPrivateKey();
|
||||
const publicKey = x25519.getPublicKey(privateKey);
|
||||
return { privateKey, publicKeyB64: bytesToBase64(publicKey) };
|
||||
}
|
||||
|
||||
export function deriveDmKey(privateKey: Uint8Array, theirPublicKeyB64: string): Uint8Array {
|
||||
const theirPublicKey = base64ToBytes(theirPublicKeyB64);
|
||||
const sharedSecret = x25519.getSharedSecret(privateKey, theirPublicKey);
|
||||
return hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32);
|
||||
}
|
||||
|
||||
export function encryptDm(key: Uint8Array, plaintext: string): { nonce: string; ciphertext: string } {
|
||||
const nonceBytes = randomBytes(12);
|
||||
const cipher = chacha20poly1305(key, nonceBytes);
|
||||
const ciphertext = cipher.encrypt(encoder.encode(plaintext));
|
||||
return {
|
||||
nonce: bytesToBase64(nonceBytes),
|
||||
ciphertext: bytesToBase64(ciphertext),
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptDm(key: Uint8Array, nonceB64: string, ciphertextB64: string): string {
|
||||
const nonceBytes = base64ToBytes(nonceB64);
|
||||
const ciphertext = base64ToBytes(ciphertextB64);
|
||||
const cipher = chacha20poly1305(key, nonceBytes);
|
||||
const plaintext = cipher.decrypt(ciphertext);
|
||||
return decoder.decode(plaintext);
|
||||
}
|
||||
11
backchannel-web/src/main.tsx
Normal file
11
backchannel-web/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
72
backchannel-web/src/protocol.ts
Normal file
72
backchannel-web/src/protocol.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export const GENERAL_CHANNEL_ID = "00000000-0000-0000-0000-000000000010";
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "login"; username: string; password: string; identity_pubkey: string }
|
||||
| { type: "register"; username: string; password: string; identity_pubkey: string }
|
||||
| { type: "resume_session"; token: string }
|
||||
| { type: "logout" }
|
||||
| { type: "send_channel_message"; channel_id: string; content: string }
|
||||
| { type: "fetch_channel_history"; channel_id: string; before_message_id: string | null; limit: number }
|
||||
| { type: "init_dm_key_exchange"; recipient_id: string; sender_ephemeral_pubkey: string }
|
||||
| { type: "accept_dm_key_exchange"; initiator_id: string; recipient_ephemeral_pubkey: string }
|
||||
| { type: "send_dm"; recipient_id: string; ciphertext: string; nonce: string }
|
||||
| { type: "fetch_dm_history"; peer_id: string; before_message_id: string | null; limit: number }
|
||||
| { type: "ping" };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "auth_success"; user_id: string; username: string; session_token: string }
|
||||
| { type: "auth_error"; reason: string }
|
||||
| {
|
||||
type: "channel_message";
|
||||
message_id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
author_username: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "channel_history";
|
||||
channel_id: string;
|
||||
messages: Array<{
|
||||
message_id: string;
|
||||
author_id: string;
|
||||
author_username: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "dm_key_exchange_request";
|
||||
initiator_id: string;
|
||||
initiator_username: string;
|
||||
sender_ephemeral_pubkey: string;
|
||||
}
|
||||
| {
|
||||
type: "dm_key_exchange_response";
|
||||
recipient_id: string;
|
||||
recipient_username: string;
|
||||
recipient_ephemeral_pubkey: string;
|
||||
}
|
||||
| {
|
||||
type: "direct_message";
|
||||
message_id: string;
|
||||
sender_id: string;
|
||||
sender_username: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "dm_history";
|
||||
peer_id: string;
|
||||
messages: Array<{
|
||||
message_id: string;
|
||||
sender_id: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
| { type: "error"; code: number; message: string }
|
||||
| { type: "pong" };
|
||||
207
backchannel-web/src/styles.css
Normal file
207
backchannel-web/src/styles.css
Normal file
@@ -0,0 +1,207 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-0: #0d1b2a;
|
||||
--panel: #fffaf0;
|
||||
--text: #1d2d44;
|
||||
--accent: #e76f51;
|
||||
--accent-2: #2a9d8f;
|
||||
--line: #f4a261;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #415a77, var(--bg-0) 55%);
|
||||
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: min(1400px, 100vw);
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(145deg, #fffaf0, #fdf0d5);
|
||||
border: 2px solid #e9c46a;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: "Bebas Neue", "Impact", sans-serif;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: "Bebas Neue", "Impact", sans-serif;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: min(540px, 100%);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
position: relative;
|
||||
padding-left: 350px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.auth-sidebar {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
width: 320px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch .active {
|
||||
background: var(--accent-2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 2px solid #264653;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-main form {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
padding: 6px 10px;
|
||||
min-width: 68px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
min-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
max-height: calc(100vh - 360px);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border-bottom: 1px dashed #c8d4dd;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
time {
|
||||
font-size: 0.75rem;
|
||||
color: #526072;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-shell {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.auth-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.connection {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
1
backchannel-web/src/vite-env.d.ts
vendored
Normal file
1
backchannel-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
backchannel-web/tsconfig.json
Normal file
20
backchannel-web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
backchannel-web/tsconfig.tsbuildinfo
Normal file
1
backchannel-web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/crypto.ts","./src/main.tsx","./src/protocol.ts","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||
6
backchannel-web/vite.config.ts
Normal file
6
backchannel-web/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user