Merge pull request #58 from trails-cool/implement-multiplayer-awareness
This commit is contained in:
commit
71dec3f2f8
7 changed files with 292 additions and 24 deletions
136
apps/planner/app/components/ParticipantList.tsx
Normal file
136
apps/planner/app/components/ParticipantList.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { electHost } from "~/lib/host-election";
|
||||
|
||||
interface Participant {
|
||||
clientId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
isHost: boolean;
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
interface ParticipantListProps {
|
||||
yjs: YjsState;
|
||||
}
|
||||
|
||||
export function ParticipantList({ yjs }: ParticipantListProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const updateParticipants = useCallback(() => {
|
||||
const states = yjs.awareness.getStates() as Map<number, Record<string, unknown>>;
|
||||
const localId = yjs.awareness.clientID;
|
||||
|
||||
const list: Participant[] = [];
|
||||
states.forEach((state, clientId) => {
|
||||
const user = state.user as { color: string; name: string } | undefined;
|
||||
if (!user) return;
|
||||
|
||||
const { isHost } = electHost(states, clientId);
|
||||
|
||||
list.push({
|
||||
clientId,
|
||||
name: user.name,
|
||||
color: user.color,
|
||||
isHost,
|
||||
isLocal: clientId === localId,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort: local user first, then host, then alphabetical
|
||||
list.sort((a, b) => {
|
||||
if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1;
|
||||
if (a.isHost !== b.isHost) return a.isHost ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setParticipants(list);
|
||||
}, [yjs.awareness]);
|
||||
|
||||
useEffect(() => {
|
||||
yjs.awareness.on("change", updateParticipants);
|
||||
updateParticipants();
|
||||
|
||||
return () => {
|
||||
yjs.awareness.off("change", updateParticipants);
|
||||
};
|
||||
}, [yjs.awareness, updateParticipants]);
|
||||
|
||||
const startEditing = () => {
|
||||
const localParticipant = participants.find((p) => p.isLocal);
|
||||
if (localParticipant) {
|
||||
setEditValue(localParticipant.name);
|
||||
setEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const commitEdit = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed.length <= 20) {
|
||||
yjs.setUserName(trimmed);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{participants.map((p) => (
|
||||
<div key={p.clientId} className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
{p.isLocal && editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={20}
|
||||
className="w-20 rounded border border-gray-300 px-1 py-0 text-xs text-gray-700 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.isLocal ? startEditing : undefined}
|
||||
className={`text-xs text-gray-700 ${p.isLocal ? "cursor-pointer hover:underline" : "cursor-default"}`}
|
||||
title={p.isLocal ? t("participants.editName") : p.name}
|
||||
>
|
||||
{p.name}
|
||||
{p.isLocal && (
|
||||
<span className="ml-0.5 text-gray-400">{t("participants.you")}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{p.isHost && (
|
||||
<span className="rounded-full bg-green-100 px-1.5 py-0 text-[10px] text-green-700">
|
||||
{t("participants.host")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -102,9 +102,22 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
<Marker
|
||||
key={clientId}
|
||||
position={[cursor.lat, cursor.lng]}
|
||||
zIndexOffset={-1000}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="background:${cursor.color};color:white;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;transform:translate(10px,-50%)">${cursor.name}</div>`,
|
||||
html: `<div style="position:relative;z-index:400;pointer-events:none">
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))">
|
||||
<path d="M0 0L16 12L8 12L4 20Z" fill="${cursor.color}" />
|
||||
</svg>
|
||||
<span style="
|
||||
position:absolute;left:16px;top:2px;
|
||||
background:${cursor.color};color:white;
|
||||
padding:1px 6px;border-radius:6px;
|
||||
font-size:11px;font-weight:500;white-space:nowrap;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.2);
|
||||
line-height:1.4;
|
||||
">${cursor.name}</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { Suspense, lazy, useState, useCallback, useEffect } from "react";
|
||||
import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { TFunction } from "i18next";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { useYjs } from "~/lib/use-yjs";
|
||||
import { useYjs, type YjsState } from "~/lib/use-yjs";
|
||||
import { useRouting } from "~/lib/use-routing";
|
||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||
import { ExportButton } from "~/components/ExportButton";
|
||||
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
|
||||
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
|
||||
import { ParticipantList } from "~/components/ParticipantList";
|
||||
|
||||
const PlannerMap = lazy(() =>
|
||||
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
|
||||
|
|
@ -19,6 +21,89 @@ const ElevationChart = lazy(() =>
|
|||
import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })),
|
||||
);
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
let toastIdCounter = 0;
|
||||
|
||||
function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const namesCacheRef = useRef<Map<number, string>>(new Map());
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!yjs) return;
|
||||
|
||||
const addToast = (message: string) => {
|
||||
const id = ++toastIdCounter;
|
||||
setToasts((prev) => [...prev, { id, message }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
changes: { added: number[]; updated: number[]; removed: number[] },
|
||||
) => {
|
||||
const states = yjs.awareness.getStates();
|
||||
const localId = yjs.awareness.clientID;
|
||||
|
||||
// On first change, seed the cache with current participants and skip toasts
|
||||
if (!initializedRef.current) {
|
||||
states.forEach((state, clientId) => {
|
||||
const user = (state as Record<string, unknown>).user as { name: string } | undefined;
|
||||
if (user?.name) {
|
||||
namesCacheRef.current.set(clientId, user.name);
|
||||
}
|
||||
});
|
||||
initializedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cache for any updated clients
|
||||
for (const clientId of changes.updated) {
|
||||
const state = states.get(clientId) as Record<string, unknown> | undefined;
|
||||
const user = state?.user as { name: string } | undefined;
|
||||
if (user?.name) {
|
||||
namesCacheRef.current.set(clientId, user.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientId of changes.added) {
|
||||
if (clientId === localId) continue;
|
||||
const state = states.get(clientId) as Record<string, unknown> | undefined;
|
||||
const user = state?.user as { name: string } | undefined;
|
||||
const name = user?.name;
|
||||
if (name) {
|
||||
namesCacheRef.current.set(clientId, name);
|
||||
addToast(t("participants.joined", { name }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientId of changes.removed) {
|
||||
if (clientId === localId) continue;
|
||||
const name = namesCacheRef.current.get(clientId);
|
||||
namesCacheRef.current.delete(clientId);
|
||||
if (name) {
|
||||
addToast(t("participants.left", { name }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
yjs.awareness.on("change", handleChange);
|
||||
|
||||
return () => {
|
||||
yjs.awareness.off("change", handleChange);
|
||||
initializedRef.current = false;
|
||||
namesCacheRef.current.clear();
|
||||
};
|
||||
}, [yjs, t]);
|
||||
|
||||
return toasts;
|
||||
}
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string;
|
||||
callbackUrl?: string;
|
||||
|
|
@ -31,8 +116,9 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
const { t } = useTranslation("planner");
|
||||
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
|
||||
const yjs = useYjs(sessionId, initialWaypoints);
|
||||
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
|
||||
const { computing, routeStats, requestRoute } = useRouting(yjs);
|
||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||
const toasts = useAwarenessToasts(yjs, t);
|
||||
|
||||
const handleElevationHover = useCallback((pos: [number, number] | null) => {
|
||||
setHighlightPosition(pos);
|
||||
|
|
@ -54,6 +140,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
{t("title")}
|
||||
</Link>
|
||||
<ProfileSelector yjs={yjs} />
|
||||
<ParticipantList yjs={yjs} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{callbackUrl && callbackToken && (
|
||||
|
|
@ -68,11 +155,6 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
{computing && (
|
||||
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
|
||||
)}
|
||||
{isHost && (
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
|
||||
{t("host")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">
|
||||
{yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
|
||||
</span>
|
||||
|
|
@ -109,6 +191,18 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
</aside>
|
||||
</div>
|
||||
<YjsDebugPanel yjs={yjs} />
|
||||
{toasts.length > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="rounded-lg bg-gray-800 px-4 py-2 text-sm text-white shadow-lg animate-[fadeIn_0.2s_ease-out]"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface YjsState {
|
|||
routeData: Y.Map<unknown>;
|
||||
awareness: WebsocketProvider["awareness"];
|
||||
connected: boolean;
|
||||
setUserName: (name: string) => void;
|
||||
}
|
||||
|
||||
export function useYjs(
|
||||
|
|
@ -44,6 +45,7 @@ export function useYjs(
|
|||
const providerRef = useRef<WebsocketProvider | null>(null);
|
||||
const docRef = useRef<Y.Doc | null>(null);
|
||||
const initializedWaypoints = useRef(false);
|
||||
const identityRef = useRef<{ color: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const doc = new Y.Doc();
|
||||
|
|
@ -56,8 +58,16 @@ export function useYjs(
|
|||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||
const routeData = doc.getMap("routeData");
|
||||
|
||||
const { color, name } = getOrCreateUserIdentity();
|
||||
provider.awareness.setLocalStateField("user", { color, name });
|
||||
const identity = getOrCreateUserIdentity();
|
||||
identityRef.current = identity;
|
||||
provider.awareness.setLocalStateField("user", { color: identity.color, name: identity.name });
|
||||
|
||||
const setUserName = (newName: string) => {
|
||||
if (!identityRef.current) return;
|
||||
identityRef.current = { ...identityRef.current, name: newName };
|
||||
try { localStorage.setItem("trails:user", JSON.stringify(identityRef.current)); } catch { /* localStorage unavailable */ }
|
||||
provider.awareness.setLocalStateField("user", { ...identityRef.current });
|
||||
};
|
||||
|
||||
// Initialize waypoints once after first sync
|
||||
if (initialWaypoints?.length && !initializedWaypoints.current) {
|
||||
|
|
@ -86,6 +96,7 @@ export function useYjs(
|
|||
routeData,
|
||||
awareness: provider.awareness,
|
||||
connected,
|
||||
setUserName,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
## 1. Name Editing
|
||||
|
||||
- [ ] 1.1 Update use-yjs.ts: allow updating user name via a returned setter function
|
||||
- [ ] 1.2 Save updated name to localStorage and sync via awareness.setLocalStateField
|
||||
- [ ] 1.3 Add i18n keys for participant UI (en + de)
|
||||
- [x] 1.1 Update use-yjs.ts: allow updating user name via a returned setter function
|
||||
- [x] 1.2 Save updated name to localStorage and sync via awareness.setLocalStateField
|
||||
- [x] 1.3 Add i18n keys for participant UI (en + de)
|
||||
|
||||
## 2. Participant List
|
||||
|
||||
- [ ] 2.1 Create ParticipantList component showing all awareness states (name, color dot, host badge)
|
||||
- [ ] 2.2 Add inline name edit when clicking own name in the list
|
||||
- [ ] 2.3 Integrate ParticipantList into SessionView header
|
||||
- [x] 2.1 Create ParticipantList component showing all awareness states (name, color dot, host badge)
|
||||
- [x] 2.2 Add inline name edit when clicking own name in the list
|
||||
- [x] 2.3 Integrate ParticipantList into SessionView header
|
||||
|
||||
## 3. Cursor Styling
|
||||
|
||||
- [ ] 3.1 Replace current divIcon cursor with SVG pointer arrow + styled name tag (background, shadow, rounded)
|
||||
- [ ] 3.2 Set cursor z-index below map controls to avoid overlap
|
||||
- [x] 3.1 Replace current divIcon cursor with SVG pointer arrow + styled name tag (background, shadow, rounded)
|
||||
- [x] 3.2 Set cursor z-index below map controls to avoid overlap
|
||||
|
||||
## 4. Join/Leave Toasts
|
||||
|
||||
- [ ] 4.1 Track awareness changes (added/removed clients) in SessionView
|
||||
- [ ] 4.2 Show auto-dismissing toast when participant joins or leaves (3 second duration)
|
||||
- [x] 4.1 Track awareness changes (added/removed clients) in SessionView
|
||||
- [x] 4.2 Show auto-dismissing toast when participant joins or leaves (3 second duration)
|
||||
|
||||
## 5. Verify
|
||||
|
||||
- [ ] 5.1 Test with two browser windows: verify participant list, name editing, cursor rendering
|
||||
- [ ] 5.2 Verify join/leave toasts appear correctly
|
||||
- [ ] 5.3 Verify name persists across page refresh (localStorage)
|
||||
- [x] 5.1 Test with two browser windows: verify participant list, name editing, cursor rendering
|
||||
- [x] 5.2 Verify join/leave toasts appear correctly
|
||||
- [x] 5.3 Verify name persists across page refresh (localStorage)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ export default {
|
|||
shortest: "Kürzeste",
|
||||
car: "Auto",
|
||||
},
|
||||
participants: {
|
||||
you: "(du)",
|
||||
host: "Host",
|
||||
editName: "Klicke, um deinen Namen zu ändern",
|
||||
joined: "{{name}} ist beigetreten",
|
||||
left: "{{name}} hat die Sitzung verlassen",
|
||||
},
|
||||
elevation: {
|
||||
gain: "Höhenmeter aufwärts",
|
||||
loss: "Höhenmeter abwärts",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ export default {
|
|||
shortest: "Shortest",
|
||||
car: "Car",
|
||||
},
|
||||
participants: {
|
||||
you: "(you)",
|
||||
host: "Host",
|
||||
editName: "Click to edit your name",
|
||||
joined: "{{name}} joined",
|
||||
left: "{{name}} left",
|
||||
},
|
||||
elevation: {
|
||||
gain: "Elevation Gain",
|
||||
loss: "Elevation Loss",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue