diff --git a/excalidraw-app/components/RoomList.scss b/excalidraw-app/components/RoomList.scss new file mode 100644 index 000000000..715ca1da5 --- /dev/null +++ b/excalidraw-app/components/RoomList.scss @@ -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; + } + } + } +} diff --git a/excalidraw-app/components/RoomList.tsx b/excalidraw-app/components/RoomList.tsx new file mode 100644 index 000000000..255bbd250 --- /dev/null +++ b/excalidraw-app/components/RoomList.tsx @@ -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["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 ( +
+
+
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleRename} + onKeyDown={handleKeyDown} + placeholder={t("roomDialog.roomNamePlaceholder")} + autoFocus + className="RoomItem__nameInput" + /> + ) : ( + setIsEditing(true)} + title={t("roomDialog.roomNameTooltip")} + > + {room.name || `Room ${room.roomId}`} + + )} +
+
+ + {t("roomDialog.created")} {formatDate(room.createdAt, t)} + + {room.lastAccessed !== room.createdAt && ( + <> + + + {t("roomDialog.lastUsed")}: {formatDate(room.lastAccessed, t)} + + + )} +
+
+
+ + + +
+
+ ); +}; + +export const RoomList = ({ + collabAPI, + onRoomSelect, + handleClose, +}: RoomListProps) => { + const { t } = useI18n(); + const [rooms, setRooms] = useState([]); + 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 ( +
+
+

{t("roomDialog.roomListTitle")}

+
+
+ {t("roomDialog.roomListLoading")} +
+
+ ); + } + + return ( +
+
+

{t("roomDialog.roomListTitle")}

+ {rooms.length > 0 && ( + + )} +
+ +

+ {t("roomDialog.roomListDescription")} +

+ + {rooms.length === 0 ? ( +
+

{t("roomDialog.roomListEmpty")}

+

{t("roomDialog.roomListEmptySubtext")}

+
+ ) : ( +
+ {rooms.map((room) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 884d64680..329564af9 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -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) => { /> +
+ + { + 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" && (
{t("shareDialog.or")} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 736c41722..dec5d5803 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -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"