feat: room list

This commit is contained in:
Ryan Di
2025-05-29 21:33:22 +10:00
parent 48ec3716ca
commit 23175654b8
4 changed files with 560 additions and 1 deletions

View File

@@ -0,0 +1,249 @@
@import "../../packages/excalidraw/css/variables.module.scss";
.RoomList {
display: flex;
flex-direction: column;
max-height: 400px;
padding: 0 1rem;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.2rem;
flex-shrink: 0;
padding-top: 1rem;
h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
}
}
&__description {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
flex-shrink: 0;
}
&__clearAll {
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 0.85rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: var(--color-surface-lowest);
color: var(--color-text);
}
}
&__loading {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
&__empty {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
p {
margin: 0.5rem 0;
}
p:first-child {
font-weight: 500;
}
}
&__items {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
flex: 1;
min-height: 0;
padding-bottom: 1rem;
}
}
.RoomItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
// background-color: var(--color-surface-low);
border-radius: 8px;
border: 1px solid var(--color-border);
transition: all 0.2s ease;
&:hover {
background-color: var(--color-surface-high);
border-color: var(--color-border-accent);
}
&__info {
flex: 1;
min-width: 0; // Allow text truncation
}
&__name {
margin-bottom: 0.25rem;
height: 1.5rem; // Fixed height for consistent layout
display: flex;
align-items: center;
}
&__nameText {
font-weight: 500;
color: var(--color-text);
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
height: 1.5rem;
line-height: 1.5rem;
padding: 0 0.25rem;
margin: 0;
box-sizing: border-box;
}
&__nameInput {
font-weight: 500;
color: var(--color-text);
background: var(--color-surface-lowest);
border: 1px solid var(--color-border-accent);
border-radius: 4px;
font-size: inherit;
font-family: inherit;
max-width: 200px;
height: 1.5rem;
line-height: 1.2;
padding: 0 0.25rem;
margin: 0;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-accent);
}
}
&__meta {
display: flex;
align-items: center;
flex-direction: row;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
&__date,
&__lastAccessed {
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: 1rem;
}
&__action {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-secondary);
transition: all 0.2s ease;
svg {
width: 1rem;
height: 1rem;
}
&:hover {
background-color: var(--color-surface-lowest);
color: var(--color-text);
}
&--danger {
&:hover {
background-color: var(--color-error-low);
color: var(--color-error);
}
}
}
}
// Mobile responsiveness
@media (max-width: 640px) {
.RoomList {
&__header {
padding: 0 0.5rem;
}
&__items {
padding: 0 0.5rem;
}
}
.RoomItem {
padding: 0.5rem;
&__nameText {
max-width: 150px;
}
&__nameInput {
max-width: 150px;
}
&__meta {
font-size: 0.75rem;
flex-wrap: wrap;
}
&__actions {
margin-left: 0.5rem;
}
&__action {
width: 1.75rem;
height: 1.75rem;
svg {
width: 0.875rem;
height: 0.875rem;
}
}
}
}

View File

@@ -0,0 +1,258 @@
import { useState, useEffect } from "react";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import {
FreedrawIcon,
LinkIcon,
TrashIcon,
} from "@excalidraw/excalidraw/components/icons";
import { roomManager, type CollabRoom } from "../data/roomManager";
import "./RoomList.scss";
import type { CollabAPI } from "../collab/Collab";
interface RoomListProps {
collabAPI: CollabAPI;
onRoomSelect: (roomId: string, roomKey: string) => void;
handleClose: () => void;
}
const formatDate = (timestamp: number, t: ReturnType<typeof useI18n>["t"]) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return t("roomDialog.today");
} else if (diffDays === 1) {
return t("roomDialog.yesterday");
} else if (diffDays < 7) {
return t("roomDialog.daysAgo", { days: diffDays });
}
return date.toLocaleDateString();
};
const RoomItem = ({
room,
onDelete,
onSelect,
onRename,
}: {
room: CollabRoom;
onDelete: (roomId: string) => void;
onSelect: (roomId: string, roomKey: string) => void;
onRename: (roomId: string, name: string) => void;
}) => {
const { t } = useI18n();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(room.name || "");
const handleRename = () => {
if (editName.trim()) {
onRename(room.roomId, editName.trim());
}
setIsEditing(false);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
handleRename();
} else if (event.key === "Escape") {
setEditName(room.name || "");
setIsEditing(false);
}
};
return (
<div className="RoomItem">
<div className="RoomItem__info">
<div className="RoomItem__name">
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleRename}
onKeyDown={handleKeyDown}
placeholder={t("roomDialog.roomNamePlaceholder")}
autoFocus
className="RoomItem__nameInput"
/>
) : (
<span
className="RoomItem__nameText"
onClick={() => setIsEditing(true)}
title={t("roomDialog.roomNameTooltip")}
>
{room.name || `Room ${room.roomId}`}
</span>
)}
</div>
<div className="RoomItem__meta">
<span className="RoomItem__date">
{t("roomDialog.created")} {formatDate(room.createdAt, t)}
</span>
{room.lastAccessed !== room.createdAt && (
<>
<span></span>
<span className="RoomItem__lastAccessed">
{t("roomDialog.lastUsed")}: {formatDate(room.lastAccessed, t)}
</span>
</>
)}
</div>
</div>
<div className="RoomItem__actions">
<button
className="RoomItem__action"
onClick={() => onSelect(room.roomId, room.roomKey)}
title={t("roomDialog.copyRoomLinkTooltip")}
aria-label={t("roomDialog.copyRoomLinkTooltip")}
>
{LinkIcon}
</button>
<button
className="RoomItem__action"
onClick={() => setIsEditing(true)}
title={t("roomDialog.renameRoomTooltip")}
aria-label={t("roomDialog.renameRoomTooltip")}
>
{FreedrawIcon}
</button>
<button
className="RoomItem__action RoomItem__action--danger"
onClick={() => onDelete(room.roomId)}
title={t("roomDialog.deleteRoomTooltip")}
aria-label={t("roomDialog.deleteRoomTooltip")}
>
{TrashIcon}
</button>
</div>
</div>
);
};
export const RoomList = ({
collabAPI,
onRoomSelect,
handleClose,
}: RoomListProps) => {
const { t } = useI18n();
const [rooms, setRooms] = useState<CollabRoom[]>([]);
const [loading, setLoading] = useState(true);
const loadRooms = async () => {
try {
const userRooms = await roomManager.getRooms();
setRooms(userRooms);
} catch (error) {
console.error("Failed to load rooms:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRooms();
}, []);
const handleDeleteRoom = async (roomId: string) => {
if (window.confirm(t("roomDialog.deleteRoomConfirm"))) {
try {
await roomManager.deleteRoom(roomId);
await loadRooms(); // Refresh the list
trackEvent("share", "room deleted");
} catch (error) {
console.error("Failed to delete room:", error);
}
}
};
const handleRenameRoom = async (roomId: string, name: string) => {
try {
await roomManager.updateRoomName(roomId, name);
await loadRooms(); // Refresh the list
} catch (error) {
console.error("Failed to rename room:", error);
}
};
const handleRoomSelect = async (roomId: string, roomKey: string) => {
try {
await roomManager.updateRoomAccess(roomId);
trackEvent("share", "room rejoined");
onRoomSelect(roomId, roomKey);
} catch (error) {
console.error("Failed to update room access:", error);
onRoomSelect(roomId, roomKey);
}
};
const handleClearAll = async () => {
if (window.confirm(t("roomDialog.deleteAllRoomsConfirm"))) {
try {
await roomManager.clearAllRooms();
setRooms([]);
trackEvent("share", "all rooms cleared");
} catch (error) {
console.error("Failed to clear all rooms:", error);
}
}
};
if (loading) {
return (
<div className="RoomList">
<div className="RoomList__header">
<h3>{t("roomDialog.roomListTitle")}</h3>
</div>
<div className="RoomList__loading">
{t("roomDialog.roomListLoading")}
</div>
</div>
);
}
return (
<div className="RoomList">
<div className="RoomList__header">
<h3>{t("roomDialog.roomListTitle")}</h3>
{rooms.length > 0 && (
<button
className="RoomList__clearAll"
onClick={handleClearAll}
title={t("roomDialog.deleteAllRooms")}
>
{t("roomDialog.deleteAllRooms")}
</button>
)}
</div>
<p className="RoomList__description">
{t("roomDialog.roomListDescription")}
</p>
{rooms.length === 0 ? (
<div className="RoomList__empty">
<p>{t("roomDialog.roomListEmpty")}</p>
<p>{t("roomDialog.roomListEmptySubtext")}</p>
</div>
) : (
<div className="RoomList__items">
{rooms.map((room) => (
<RoomItem
key={room.id}
room={room}
onDelete={handleDeleteRoom}
onSelect={handleRoomSelect}
onRename={handleRenameRoom}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -18,8 +18,12 @@ import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS, getFrame } from "@excalidraw/common";
import { useEffect, useRef, useState } from "react";
import { useExcalidrawSetAppState } from "@excalidraw/excalidraw/components/App";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import { RoomList } from "../components/RoomList";
import { getCollaborationLink } from "../data";
import "./ShareDialog.scss";
@@ -180,6 +184,7 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
const { t } = useI18n();
const { collabAPI } = props;
const setAppState = useExcalidrawSetAppState();
const startCollabJSX = collabAPI ? (
<>
@@ -204,6 +209,34 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
/>
</div>
<div
style={{
height: "1px",
backgroundColor: "var(--color-border)",
width: "100%",
}}
></div>
<RoomList
collabAPI={collabAPI}
onRoomSelect={async (roomId, roomKey) => {
const roomLink = getCollaborationLink({ roomId, roomKey });
try {
await copyTextToSystemClipboard(roomLink);
trackEvent("share", "room link copied from list");
// set a toast message when the link is copied
setAppState({
toast: {
message: t("roomDialog.roomLinkCopied"),
},
});
} catch (e) {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
}
}}
handleClose={props.handleClose}
/>
{props.type === "share" && (
<div className="ShareDialog__separator">
<span>{t("shareDialog.or")}</span>

View File

@@ -381,7 +381,26 @@
"desc_inProgressIntro": "Live-collaboration session is now in progress.",
"desc_shareLink": "Share this link with anyone you want to collaborate with:",
"desc_exitSession": "Stopping the session will disconnect you from the room, but you'll be able to continue working with the scene, locally. Note that this won't affect other people, and they'll still be able to collaborate on their version.",
"shareTitle": "Join a live collaboration session on Excalidraw"
"shareTitle": "Join a live collaboration session on Excalidraw",
"roomListTitle": "Your Collaboration Rooms",
"roomListDescription": "Anyone with access to your rooms can join at any time. Deleting a room is permanent and will make it inaccessible to everyone (including you). Please be sure to save anything important before deleting.",
"roomListEmpty": "No collaboration rooms yet.",
"roomListEmptySubtext": "Create a room to start collaborating with others!",
"roomListLoading": "Loading your rooms...",
"roomNamePlaceholder": "Room name",
"roomNameTooltip": "Click to rename",
"copyRoomLinkTooltip": "Copy room link",
"renameRoomTooltip": "Rename room",
"deleteRoomTooltip": "Delete room",
"deleteAllRooms": "Delete All",
"deleteRoomConfirm": "Are you sure you want to delete this room? This action cannot be undone.",
"deleteAllRoomsConfirm": "Are you sure you want to delete ALL your collaboration rooms? This action cannot be undone.",
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{{days}} days ago",
"created": "Created",
"lastUsed": "Last used",
"roomLinkCopied": "Room link copied to clipboard"
},
"errorDialog": {
"title": "Error"