Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { type Request } from "./api/types";

type AppProps = {
socket: WebSocket | null;
userName: string;
};

export function App({ socket }: AppProps) {
export function App({ socket, userName }: AppProps) {
const [roomID, setRoomID] = useState<string>("");
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [joinedRooms, setJoinedRooms] = useState<Set<string>>(new Set());
Expand All @@ -30,7 +31,7 @@ export function App({ socket }: AppProps) {
const sendMessageRequest = useWsRequest(socket, undefined);

const onMessageSent = (content: string) => {
const chat: Chat = { content, user: { name: "You" } };
const chat: Chat = { content, user: { name: userName } };

if (!roomID) {
return;
Expand Down
90 changes: 88 additions & 2 deletions frontend/src/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,100 @@
export function TopBar() {
import { useRef, useState, type FormEvent } from "react";

type TopBarProps = {
displayName: string;
onRename: (name: string) => void;
};

export function TopBar({ displayName, onRename }: TopBarProps) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [draftName, setDraftName] = useState(displayName);
const initials = getInitials(displayName);

const openDialog = () => {
setDraftName(displayName);
dialogRef.current?.showModal();
};

const closeDialog = () => {
dialogRef.current?.close();
};

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = draftName.trim();
if (!trimmed) {
return;
}
onRename(trimmed);
closeDialog();
};

return (
<div className="navbar border-b border-base-300 bg-base-100 px-4 shadow-sm">
<div className="navbar-start">
<a className="btn btn-ghost text-lg font-semibold">echx</a>
</div>
<div className="navbar-end">
<div className="navbar-end gap-2">
<div className="hidden flex-col items-end text-right sm:flex">
<span className="text-[0.7rem] uppercase tracking-[0.2em] text-base-content/50">Signed in as</span>
<span className="text-sm font-semibold text-base-content">{displayName}</span>
</div>
<div className="avatar placeholder">
<div className="w-9 rounded-full bg-primary text-primary-content">
<span className="text-xs font-semibold">{initials}</span>
</div>
</div>
<button type="button" className="btn btn-ghost btn-sm" onClick={openDialog}>
Rename
</button>
<button type="button" className="btn btn-ghost btn-sm">
New room
</button>
</div>
<dialog ref={dialogRef} className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">Rename profile</h3>
<p className="mt-1 text-sm text-base-content/70">
Your new name will be used the next time you connect.
</p>
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
<label className="form-control">
<div className="label">
<span className="label-text">Display name</span>
</div>
<input
className="input input-bordered"
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
maxLength={32}
placeholder="Enter a new name"
/>
</label>
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={closeDialog}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={!draftName.trim()}>
Save
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button">close</button>
</form>
</dialog>
</div>
);
}

function getInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "?";
}
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return (parts[0][0] + parts[1][0]).toUpperCase();
}
70 changes: 68 additions & 2 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { TopBar } from "./TopBar.tsx";
import { App } from "./App.tsx";
import { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useWsRequest } from "./hooks/useWsRequest";
import { type Request } from "./api/types";

createRoot(document.getElementById("root")!).render(
<StrictMode>
Expand All @@ -16,6 +18,8 @@ const queryClient = new QueryClient();

export function Main() {
const [socket, setSocket] = useState<WebSocket | null>(null);
const [identity, setIdentity] = useState(() => loadIdentity());
const connectRequest = useWsRequest(socket, undefined);

useEffect(() => {
const nextSocket = new WebSocket(resolveWebSocketUrl());
Expand All @@ -33,12 +37,29 @@ export function Main() {
};
}, []);

useEffect(() => {
saveIdentity(identity);
}, [identity]);

useEffect(() => {
if (!identity.name.trim()) {
return;
}

const request: Request = { type: "connect", token: identity.token, name: identity.name };
connectRequest(request);
}, [connectRequest, identity]);

const handleRename = (nextName: string) => {
setIdentity((prev) => ({ ...prev, name: nextName }));
};

return (
<div className="h-screen flex flex-col overflow-hidden">
<TopBar />
<TopBar displayName={identity.name} onRename={handleRename} />
<div className="flex flex-1 min-h-0 overflow-hidden">
<QueryClientProvider client={queryClient}>
<App socket={socket} />
<App socket={socket} userName={identity.name} />
</QueryClientProvider>
</div>
</div>
Expand All @@ -60,3 +81,48 @@ function resolveWebSocketUrl(): string {

return url.toString();
}

const IDENTITY_STORAGE_KEY = "echx.identity";
const DEFAULT_NAME = "Guest";

type Identity = {
token: string;
name: string;
};

function loadIdentity(): Identity {
if (typeof window === "undefined") {
return { token: createToken(), name: DEFAULT_NAME };
}

const stored = window.localStorage.getItem(IDENTITY_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored) as Partial<Identity>;
if (parsed?.token && parsed?.name) {
return { token: parsed.token, name: parsed.name };
}
} catch {
// ignore invalid storage
}
}

const identity = { token: createToken(), name: DEFAULT_NAME };
window.localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(identity));
return identity;
}

function saveIdentity(identity: Identity): void {
if (typeof window === "undefined") {
return;
}

window.localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(identity));
}

function createToken(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `token-${Date.now()}`;
}