init project

This commit is contained in:
root
2026-04-13 13:59:26 -04:00
parent f190e6b91a
commit e26e15f98f
54 changed files with 3280 additions and 0 deletions
+12
View 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>TLM Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@tlm/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@apollo/client": "^3.11.8",
"graphql": "^16.9.0",
"graphql-ws": "^5.16.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+360
View File
@@ -0,0 +1,360 @@
import { gql, useMutation, useQuery, useSubscription } from "@apollo/client";
import { useMemo, useState } from "react";
const LOGIN = gql`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
accessToken
refreshToken
user {
id
displayName
}
}
}
`;
const REGISTER = gql`
mutation Register(
$email: String!
$password: String!
$displayName: String!
) {
register(email: $email, password: $password, displayName: $displayName) {
accessToken
refreshToken
user {
id
displayName
}
}
}
`;
const ME = gql`
query Me {
me {
id
displayName
email
}
}
`;
const CHATS = gql`
query Chats {
chats {
__typename
id
... on DirectThread {
peer {
id
displayName
}
}
... on Group {
title
memberCount
}
... on Channel {
title
subscriberCount
}
lastMessage {
id
body
seq
}
unreadCount
}
}
`;
const MESSAGES = gql`
query Messages($chatId: ID!, $first: Int) {
messages(chatId: $chatId, first: $first) {
edges {
cursor
node {
id
body
seq
sender {
displayName
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const SEND = gql`
mutation Send($chatId: ID!, $body: String) {
sendMessage(chatId: $chatId, body: $body) {
id
seq
body
}
}
`;
const CREATE_DM = gql`
mutation CreateDm($peerUserId: ID!) {
createDirectThread(peerUserId: $peerUserId) {
id
peer {
id
displayName
}
}
}
`;
const ON_CHAT = gql`
subscription OnChat($chatId: ID!) {
onChatEvent(chatId: $chatId) {
type
chatId
message {
id
body
seq
}
}
}
`;
function chatLabel(c: {
__typename: string;
id: string;
peer?: { displayName: string };
title?: string | null;
}) {
if (c.__typename === "DirectThread") return c.peer?.displayName ?? c.id;
return c.title ?? c.id;
}
export default function App() {
const [email, setEmail] = useState("alice@example.com");
const [password, setPassword] = useState("password123");
const [displayName, setDisplayName] = useState("Alice");
const [peerId, setPeerId] = useState("");
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const token = localStorage.getItem("accessToken");
const authed = Boolean(token);
const meQ = useQuery(ME, { skip: !authed });
const chatsQ = useQuery(CHATS, { skip: !authed });
const msgsQ = useQuery(MESSAGES, {
skip: !authed || !activeChatId,
variables: { chatId: activeChatId!, first: 50 },
});
const [login, loginM] = useMutation(LOGIN);
const [register, regM] = useMutation(REGISTER);
const [send, sendM] = useMutation(SEND);
const [createDm, dmM] = useMutation(CREATE_DM);
useSubscription(ON_CHAT, {
skip: !authed || !activeChatId,
variables: { chatId: activeChatId! },
onData: () => {
void msgsQ.refetch();
void chatsQ.refetch();
},
});
const sortedEdges = useMemo(() => {
const edges = [...(msgsQ.data?.messages.edges ?? [])];
edges.sort((a, b) => a.node.seq - b.node.seq);
return edges;
}, [msgsQ.data]);
async function doLogin() {
const r = await login({ variables: { email, password } });
const t = r.data?.login.accessToken;
if (t) {
localStorage.setItem("accessToken", t);
localStorage.setItem("refreshToken", r.data!.login.refreshToken);
window.location.reload();
}
}
async function doRegister() {
const r = await register({
variables: { email, password, displayName },
});
const t = r.data?.register.accessToken;
if (t) {
localStorage.setItem("accessToken", t);
localStorage.setItem("refreshToken", r.data!.register.refreshToken);
window.location.reload();
}
}
function logout() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.reload();
}
async function doCreateDm() {
if (!peerId) return;
const r = await createDm({ variables: { peerUserId: peerId } });
const id = r.data?.createDirectThread.id;
if (id) {
setActiveChatId(id);
await chatsQ.refetch();
}
}
async function doSend() {
if (!activeChatId || !draft.trim()) return;
await send({ variables: { chatId: activeChatId, body: draft } });
setDraft("");
await msgsQ.refetch();
await chatsQ.refetch();
}
if (!authed) {
return (
<div style={{ maxWidth: 420, margin: "48px auto", fontFamily: "system-ui" }}>
<h1>TLM</h1>
<label>
Email
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ width: "100%", display: "block", marginBottom: 8 }}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: "100%", display: "block", marginBottom: 8 }}
/>
</label>
<label>
Display name (register)
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
style={{ width: "100%", display: "block", marginBottom: 8 }}
/>
</label>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => void doLogin()}>
Login
</button>
<button type="button" onClick={() => void doRegister()}>
Register
</button>
</div>
<p style={{ color: "#666" }}>
{(loginM.error ?? regM.error)?.message}
</p>
</div>
);
}
return (
<div
style={{
display: "grid",
gridTemplateColumns: "280px 1fr",
height: "100vh",
fontFamily: "system-ui",
}}
>
<aside style={{ borderRight: "1px solid #ddd", padding: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<strong>{meQ.data?.me.displayName}</strong>
<button type="button" onClick={logout}>
Logout
</button>
</div>
<hr />
<div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
<input
placeholder="Peer user id"
value={peerId}
onChange={(e) => setPeerId(e.target.value)}
style={{ flex: 1 }}
/>
<button type="button" onClick={() => void doCreateDm()}>
DM
</button>
</div>
<div style={{ fontSize: 12, color: "#666", marginBottom: 8 }}>
Your id: {meQ.data?.me.id}
</div>
<div style={{ overflow: "auto" }}>
{(chatsQ.data?.chats ?? []).map((c) => (
<button
key={c.id}
type="button"
onClick={() => setActiveChatId(c.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: 8,
marginBottom: 4,
border:
c.id === activeChatId ? "2px solid #2a6" : "1px solid #eee",
background: c.id === activeChatId ? "#f6fff8" : "#fff",
}}
>
<div>{chatLabel(c)}</div>
<div style={{ fontSize: 12, color: "#666" }}>
unread {c.unreadCount}
</div>
</button>
))}
</div>
</aside>
<main style={{ display: "flex", flexDirection: "column" }}>
<header style={{ padding: 12, borderBottom: "1px solid #ddd" }}>
{activeChatId ? `Chat ${activeChatId}` : "Select a chat"}
</header>
<div style={{ flex: 1, overflow: "auto", padding: 12 }}>
{sortedEdges.map((e) => (
<div key={e.node.id} style={{ marginBottom: 8 }}>
<strong>{e.node.sender?.displayName ?? "?"}</strong>:{" "}
{e.node.body}
</div>
))}
</div>
<footer style={{ padding: 12, borderTop: "1px solid #ddd" }}>
<div style={{ display: "flex", gap: 8 }}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
style={{ flex: 1 }}
placeholder="Message"
onKeyDown={(e) => {
if (e.key === "Enter") void doSend();
}}
/>
<button
type="button"
disabled={!activeChatId || sendM.loading}
onClick={() => void doSend()}
>
Send
</button>
</div>
</footer>
</main>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {
ApolloClient,
HttpLink,
InMemoryCache,
split,
} from "@apollo/client/core";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import { createClient } from "graphql-ws";
const httpUri = import.meta.env.VITE_GRAPHQL_HTTP ?? "/graphql";
const wsUri = import.meta.env.VITE_GRAPHQL_WS ?? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/graphql`;
function getAuthHeader() {
const t = localStorage.getItem("accessToken");
return t ? { authorization: `Bearer ${t}` } : {};
}
const httpLink = new HttpLink({
uri: httpUri,
headers: getAuthHeader,
});
const wsLink = new GraphQLWsLink(
createClient({
url: wsUri,
connectionParams: () => getAuthHeader(),
lazy: true,
}),
);
const link = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === "OperationDefinition" && def.operation === "subscription";
},
wsLink,
httpLink,
);
export const apollo = new ApolloClient({
link,
cache: new InMemoryCache(),
});
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ApolloProvider } from "@apollo/client";
import App from "./App";
import { apollo } from "./apollo";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ApolloProvider client={apollo}>
<App />
</ApolloProvider>
</React.StrictMode>,
);
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GRAPHQL_HTTP?: string;
readonly VITE_GRAPHQL_WS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/graphql": { target: "http://127.0.0.1:4000", changeOrigin: true, ws: true },
"/signal": { target: "ws://127.0.0.1:4000", ws: true },
},
},
});