- Participant list: hidden on mobile (sm:block) - Undo/redo buttons: hidden on mobile (sm:flex) - Connection status: hidden on mobile (sm:inline) - Header: no-wrap, tighter padding on mobile - Username input: text-base on mobile to prevent iOS auto-zoom Keeps profile selector and export button visible on all sizes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
136 lines
4 KiB
TypeScript
136 lines
4 KiB
TypeScript
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-base sm: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>
|
|
);
|
|
}
|