[bemo] No public shared rooms in examples app (#4152)

As discussed in #4135 I'm doing a quick follow up to help me sleep at
night.

Sorry sorry sorry sorry

![Kapture 2024-07-12 at 12 47
45](https://github.com/user-attachments/assets/ee9babf0-6b7e-4ddb-a427-5aef9436f922)

i couldn't figure out the magic overlay css so I reverted to ugly static
toolbar. again so sorry

### Change type

- [ ] `bugfix`
- [x] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`

### Test plan

1. Create a shape...
2.

- [ ] Unit tests
- [ ] End to end tests

### Release notes

- Fixed a bug with…
This commit is contained in:
David Sheldrick 2024-07-12 14:07:07 +01:00 committed by GitHub
parent 64e1fac897
commit 1c35ed27f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 150 deletions

View file

@ -16,6 +16,12 @@ export default function ExampleCodeBlock({
const [isClientSide, setIsClientSide] = useState(false)
const { theme } = useTheme()
useEffect(() => setIsClientSide(true), [])
// This is to avoid hydration mismatch between the server and the client because of the useTheme.
if (!isClientSide) {
return null
}
const SERVER =
process.env.NODE_ENV === 'development'
? `http://${location.hostname}:5420`
@ -23,14 +29,12 @@ export default function ExampleCodeBlock({
? `https://examples-canary.tldraw.com`
: 'https://examples.tldraw.com'
// This is to avoid hydration mismatch between the server and the client because of the useTheme.
if (!isClientSide) {
return null
}
return (
<div className="code-example">
<iframe src={`${SERVER}/${articleId}/full`} />
<iframe
src={`${SERVER}/${articleId}/full`}
allow={`clipboard-read; clipboard-write self ${SERVER}`}
/>
<SandpackProvider
className="sandpack"
key={`sandpack-${theme}-${activeFile}`}

View file

@ -1,4 +1,6 @@
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { uniqueId } from 'tldraw'
import { Example } from './examples'
export function ExampleWrapper({
@ -15,7 +17,55 @@ export function ExampleWrapper({
return <MultiplayerExampleWrapper component={Component} example={example} />
}
const hour = 60 * 1000 * 1000
interface ScopedRoomId {
roomId: string
deployId: string
}
function getExampleKey(exampleSlug: string) {
return `tldraw-example-room-${exampleSlug}`
}
function getRoomId(exampleSlug: string) {
const key = getExampleKey(exampleSlug)
const stored = JSON.parse(localStorage.getItem(key) ?? 'null') as null | ScopedRoomId
if (stored && stored.deployId === process.env.TLDRAW_DEPLOY_ID) {
return stored.roomId
}
return uniqueId()
}
function storeRoomId(exampleSlug: string, slug: string) {
const key = getExampleKey(exampleSlug)
localStorage.setItem(
key,
JSON.stringify({
roomId: slug,
deployId: process.env.TLDRAW_DEPLOY_ID!,
} satisfies ScopedRoomId)
)
}
function useConfirmState() {
const [confirm, setConfirm] = useState(false)
const [confirmTimeout, setConfirmTimeout] = useState<number | null>(null)
const confirmFn = useCallback(() => {
setConfirm(true)
setConfirmTimeout(window.setTimeout(() => setConfirm(false), 3000))
}, [])
useEffect(() => {
return () => {
if (confirmTimeout) {
clearTimeout(confirmTimeout)
}
}
}, [confirmTimeout])
return [confirm, confirmFn] as const
}
function MultiplayerExampleWrapper({
component: Component,
example,
@ -23,60 +73,56 @@ function MultiplayerExampleWrapper({
example: Example
component: React.ComponentType<{ roomId?: string }>
}) {
const prefix = `tldraw-example-${example.path.replace(/\//g, '-')}-${process.env.TLDRAW_DEPLOY_ID}`
const [roomId, setRoomId] = useState(String(Math.floor(Date.now() / hour)))
const [nextRoomId, setNextRoomId] = useState(roomId)
const trimmed = nextRoomId.trim()
const canSet = trimmed && roomId !== trimmed
function setIfPossible() {
if (!canSet) return
setRoomId(trimmed)
const [params, setParams] = useSearchParams()
const [confirm, confirmFn] = useConfirmState()
const exampleSlug = example.path.replace(/^\/?/g, '')
let roomId = params.get('roomId')
if (!roomId || typeof roomId !== 'string' || encodeURIComponent(roomId) !== roomId) {
roomId = getRoomId(exampleSlug)
}
useEffect(() => {
storeRoomId(exampleSlug, roomId)
setParams({ roomId })
}, [exampleSlug, roomId, setParams])
return (
<div className="MultiplayerExampleWrapper">
<div className="MultiplayerExampleWrapper-picker">
<label>
<div>Room ID:</div>
<input
value={nextRoomId}
onChange={(e) => setNextRoomId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSet) {
e.preventDefault()
setIfPossible()
}
if (e.key === 'Escape') {
e.preventDefault()
setNextRoomId(roomId)
e.currentTarget.blur()
}
}}
/>
</label>
<button onClick={() => setIfPossible()} disabled={!canSet} aria-label="join">
<ArrowIcon />
<WifiIcon />
<div>Live Example</div>
<button
onClick={() => {
// copy current url with roomId=roomId to clipboard
navigator.clipboard.writeText(window.location.href.split('?')[0] + `?roomId=${roomId}`)
confirmFn()
}}
aria-label="join"
>
{confirm ? 'Copied!' : 'Copy Share URL'}
</button>
</div>
<div className="MultiplayerExampleWrapper-example">
<div>
<Component roomId={`${prefix}_${encodeURIComponent(roomId)}`} />
</div>
<Component roomId={roomId} />
</div>
</div>
)
}
function ArrowIcon() {
function WifiIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
width={16}
>
<path
d="M9.25012 4.75L12.5001 8M12.5001 8L9.25012 11.25M12.5001 8H3.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"
/>
</svg>
)

View file

@ -9,6 +9,4 @@ multiplayer: true
---
The `useMultiplayerDemo` hook can be used to quickly prototype multiplayer experiences in tldraw,
although rooms will be deleted after one day. Note that the board below is shared with all visitors
to this page.
The `useMultiplayerDemo` hook can be used to quickly prototype multiplayer experiences in tldraw using a demo backend that we host. Data is wiped after one day.

View file

@ -451,123 +451,35 @@ a.example__sidebar__header-link {
color-scheme: dark;
}
.MultiplayerExampleWrapper {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
container: example / size;
position: absolute;
inset: 0px;
}
.MultiplayerExampleWrapper-picker {
position: absolute;
z-index: 1;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: center;
justify-content: flex-start;
align-items: center;
padding: 8px;
padding-bottom: 7px;
}
.MultiplayerExampleWrapper-picker::before {
content: '';
position: absolute;
top: 6px;
height: 29px;
width: 232px;
margin-left: 28px;
background: var(--whiteboard);
z-index: -1;
border-radius: 6px;
}
.MultiplayerExampleWrapper-picker label {
flex: 0 1 200px;
font-size: 14px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: 1px solid var(--gray-dark);
color: var(--black-transparent-dark);
border-radius: 4px;
padding: 2px 8px;
padding: 8px 16px;
gap: 16px;
background: var(--background);
/* Add space to the left the same as the button to keep the input centered */
margin-left: 32px;
}
.MultiplayerExampleWrapper-picker label:focus-within {
border-color: var(--focus);
}
.MultiplayerExampleWrapper-picker label div {
flex: 0 0 auto;
}
.MultiplayerExampleWrapper-picker input {
flex: 0 1 auto;
width: 100%;
font: inherit;
border: none;
outline: none;
color: var(--black-transparent-darker);
background: transparent;
border-bottom: 1px solid var(--gray-dark);
color: var(--text);
font-size: 14px;
}
.MultiplayerExampleWrapper-picker button {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: transparent;
color: var(--black-transparent-darker);
cursor: pointer;
height: 32px;
width: 32px;
padding: 0;
margin: -4px 0;
position: relative;
}
.MultiplayerExampleWrapper-picker button:hover::after {
content: ' ';
position: absolute;
inset: 3px;
background: var(--black-transparent-lighter);
border: 1px solid var(--gray-dark);
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
}
.MultiplayerExampleWrapper-picker button:disabled {
opacity: 0.5;
pointer-events: none;
.MultiplayerExampleWrapper-picker button:hover {
border: 1px solid var(--black-transparent-lighter);
}
.MultiplayerExampleWrapper-example {
position: relative;
height: 100%;
}
@container example (width < 584px) {
.MultiplayerExampleWrapper-picker {
position: static;
border-bottom: 1px solid var(--gray-dark);
background: var(--background);
}
.MultiplayerExampleWrapper-picker::before {
display: none;
}
.MultiplayerExampleWrapper-picker label {
width: 100%;
flex: 1 1 auto;
margin: 0;
}
.MultiplayerExampleWrapper-picker button {
margin-right: -4px;
}
}
@container example (width <= 904px) and (width > 840px) {
.MultiplayerExampleWrapper-picker {
left: 346px;
right: 0;
justify-content: start;
}
.MultiplayerExampleWrapper-picker label {
margin-left: 0;
}
flex: 1;
}