Fixed asset upload CORS for broken links, updated markdown tool, changed keyboard shortcuts & menu ordering
This commit is contained in:
parent
4b5ba9eab3
commit
b11aecffa4
File diff suppressed because it is too large
Load Diff
|
|
@ -26,6 +26,7 @@
|
||||||
"@tldraw/tlschema": "^3.6.0",
|
"@tldraw/tlschema": "^3.6.0",
|
||||||
"@types/markdown-it": "^14.1.1",
|
"@types/markdown-it": "^14.1.1",
|
||||||
"@types/marked": "^5.0.2",
|
"@types/marked": "^5.0.2",
|
||||||
|
"@uiw/react-md-editor": "^4.0.5",
|
||||||
"@vercel/analytics": "^1.2.2",
|
"@vercel/analytics": "^1.2.2",
|
||||||
"ai": "^4.1.0",
|
"ai": "^4.1.0",
|
||||||
"cherry-markdown": "^0.8.57",
|
"cherry-markdown": "^0.8.57",
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
"rbush": "^4.0.1",
|
"rbush": "^4.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.0.2",
|
"react-router-dom": "^7.0.2",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"tldraw": "^3.6.0",
|
"tldraw": "^3.6.0",
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
|
||||||
|
|
||||||
getDefaultProps(): IMarkdownShape['props'] {
|
getDefaultProps(): IMarkdownShape['props'] {
|
||||||
return {
|
return {
|
||||||
w: 300,
|
w: 500,
|
||||||
h: 200,
|
h: 400,
|
||||||
text: '',
|
text: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,20 +110,28 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
preview='edit'
|
preview='live'
|
||||||
hideToolbar={true}
|
visibleDragbar={false}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: 'auto',
|
||||||
|
minHeight: '100%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
previewOptions={{
|
||||||
|
style: {
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
textareaProps={{
|
textareaProps={{
|
||||||
placeholder: "Enter markdown text...",
|
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: 'transparent',
|
padding: '8px',
|
||||||
height: '100%',
|
|
||||||
padding: '12px',
|
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
fontSize: '14px',
|
height: 'auto',
|
||||||
|
minHeight: '100%',
|
||||||
|
resize: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,29 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
return (
|
return (
|
||||||
<DefaultContextMenu {...props}>
|
<DefaultContextMenu {...props}>
|
||||||
<DefaultContextMenuContent />
|
<DefaultContextMenuContent />
|
||||||
|
|
||||||
|
{/* Frames List - Moved to top */}
|
||||||
|
<TldrawUiMenuGroup id="frames-list">
|
||||||
|
<TldrawUiMenuSubmenu id="frames-dropdown" label="Shortcut to Frames">
|
||||||
|
{getAllFrames(editor).map((frame) => (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
key={frame.id}
|
||||||
|
id={`frame-${frame.id}`}
|
||||||
|
label={frame.title}
|
||||||
|
onSelect={() => {
|
||||||
|
const shape = editor.getShape(frame.id)
|
||||||
|
if (shape) {
|
||||||
|
editor.zoomToBounds(editor.getShapePageBounds(shape)!, {
|
||||||
|
animation: { duration: 400, easing: (t) => t * (2 - t) },
|
||||||
|
})
|
||||||
|
editor.select(frame.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TldrawUiMenuSubmenu>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
{/* Camera Controls Group */}
|
{/* Camera Controls Group */}
|
||||||
<TldrawUiMenuGroup id="camera-controls">
|
<TldrawUiMenuGroup id="camera-controls">
|
||||||
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
|
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
|
||||||
|
|
@ -90,27 +113,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
|
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
|
||||||
</TldrawUiMenuGroup>
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
{/* Frame Controls */}
|
|
||||||
<TldrawUiMenuGroup id="frames-list">
|
|
||||||
<TldrawUiMenuSubmenu id="frames-dropdown" label="Shortcut to Frames">
|
|
||||||
{getAllFrames(editor).map((frame) => (
|
|
||||||
<TldrawUiMenuItem
|
|
||||||
key={frame.id}
|
|
||||||
id={`frame-${frame.id}`}
|
|
||||||
label={frame.title}
|
|
||||||
onSelect={() => {
|
|
||||||
const shape = editor.getShape(frame.id)
|
|
||||||
if (shape) {
|
|
||||||
editor.zoomToBounds(editor.getShapePageBounds(shape)!, {
|
|
||||||
animation: { duration: 400, easing: (t) => t * (2 - t) },
|
|
||||||
})
|
|
||||||
editor.select(frame.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TldrawUiMenuSubmenu>
|
|
||||||
</TldrawUiMenuGroup>
|
|
||||||
{/* TODO: FIX & IMPLEMENT BROADCASTING*/}
|
{/* TODO: FIX & IMPLEMENT BROADCASTING*/}
|
||||||
{/* <TldrawUiMenuGroup id="broadcast-controls">
|
{/* <TldrawUiMenuGroup id="broadcast-controls">
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export const overrides: TLUiOverrides = {
|
||||||
icon: "prompt",
|
icon: "prompt",
|
||||||
label: "Prompt",
|
label: "Prompt",
|
||||||
type: "Prompt",
|
type: "Prompt",
|
||||||
kbd: "alt+p",
|
kbd: "alt+l",
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect: () => editor.setCurrentTool("Prompt"),
|
onSelect: () => editor.setCurrentTool("Prompt"),
|
||||||
},
|
},
|
||||||
|
|
@ -222,7 +222,7 @@ export const overrides: TLUiOverrides = {
|
||||||
saveToPdf: {
|
saveToPdf: {
|
||||||
id: "save-to-pdf",
|
id: "save-to-pdf",
|
||||||
label: "Save Selection as PDF",
|
label: "Save Selection as PDF",
|
||||||
kbd: "alt+s",
|
kbd: "alt+p",
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (editor.getSelectedShapeIds().length > 0) {
|
if (editor.getSelectedShapeIds().length > 0) {
|
||||||
saveToPdf(editor)
|
saveToPdf(editor)
|
||||||
|
|
@ -348,66 +348,67 @@ export const overrides: TLUiOverrides = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"next-slide": {
|
//TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS
|
||||||
id: "next-slide",
|
// "next-slide": {
|
||||||
label: "Next slide",
|
// id: "next-slide",
|
||||||
kbd: "right",
|
// label: "Next slide",
|
||||||
onSelect() {
|
// kbd: "right",
|
||||||
const slides = editor
|
// onSelect() {
|
||||||
.getCurrentPageShapes()
|
// const slides = editor
|
||||||
.filter((shape) => shape.type === "Slide")
|
// .getCurrentPageShapes()
|
||||||
if (slides.length === 0) return
|
// .filter((shape) => shape.type === "Slide")
|
||||||
|
// if (slides.length === 0) return
|
||||||
|
|
||||||
const currentSlide = editor
|
// const currentSlide = editor
|
||||||
.getSelectedShapes()
|
// .getSelectedShapes()
|
||||||
.find((shape) => shape.type === "Slide")
|
// .find((shape) => shape.type === "Slide")
|
||||||
const currentIndex = currentSlide
|
// const currentIndex = currentSlide
|
||||||
? slides.findIndex((slide) => slide.id === currentSlide.id)
|
// ? slides.findIndex((slide) => slide.id === currentSlide.id)
|
||||||
: -1
|
// : -1
|
||||||
|
|
||||||
// Calculate next index with wraparound
|
// // Calculate next index with wraparound
|
||||||
const nextIndex =
|
// const nextIndex =
|
||||||
currentIndex === -1
|
// currentIndex === -1
|
||||||
? 0
|
// ? 0
|
||||||
: currentIndex >= slides.length - 1
|
// : currentIndex >= slides.length - 1
|
||||||
? 0
|
// ? 0
|
||||||
: currentIndex + 1
|
// : currentIndex + 1
|
||||||
|
|
||||||
const nextSlide = slides[nextIndex]
|
// const nextSlide = slides[nextIndex]
|
||||||
|
|
||||||
editor.select(nextSlide.id)
|
// editor.select(nextSlide.id)
|
||||||
editor.stopCameraAnimation()
|
// editor.stopCameraAnimation()
|
||||||
moveToSlide(editor, nextSlide as ISlideShape)
|
// moveToSlide(editor, nextSlide as ISlideShape)
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
"previous-slide": {
|
// "previous-slide": {
|
||||||
id: "previous-slide",
|
// id: "previous-slide",
|
||||||
label: "Previous slide",
|
// label: "Previous slide",
|
||||||
kbd: "left",
|
// kbd: "left",
|
||||||
onSelect() {
|
// onSelect() {
|
||||||
const slides = editor
|
// const slides = editor
|
||||||
.getCurrentPageShapes()
|
// .getCurrentPageShapes()
|
||||||
.filter((shape) => shape.type === "Slide")
|
// .filter((shape) => shape.type === "Slide")
|
||||||
if (slides.length === 0) return
|
// if (slides.length === 0) return
|
||||||
|
|
||||||
const currentSlide = editor
|
// const currentSlide = editor
|
||||||
.getSelectedShapes()
|
// .getSelectedShapes()
|
||||||
.find((shape) => shape.type === "Slide")
|
// .find((shape) => shape.type === "Slide")
|
||||||
const currentIndex = currentSlide
|
// const currentIndex = currentSlide
|
||||||
? slides.findIndex((slide) => slide.id === currentSlide.id)
|
// ? slides.findIndex((slide) => slide.id === currentSlide.id)
|
||||||
: -1
|
// : -1
|
||||||
|
|
||||||
// Calculate previous index with wraparound
|
// // Calculate previous index with wraparound
|
||||||
const previousIndex =
|
// const previousIndex =
|
||||||
currentIndex <= 0 ? slides.length - 1 : currentIndex - 1
|
// currentIndex <= 0 ? slides.length - 1 : currentIndex - 1
|
||||||
|
|
||||||
const previousSlide = slides[previousIndex]
|
// const previousSlide = slides[previousIndex]
|
||||||
|
|
||||||
editor.select(previousSlide.id)
|
// editor.select(previousSlide.id)
|
||||||
editor.stopCameraAnimation()
|
// editor.stopCameraAnimation()
|
||||||
moveToSlide(editor, previousSlide as ISlideShape)
|
// moveToSlide(editor, previousSlide as ISlideShape)
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ function getAssetObjectName(uploadId: string) {
|
||||||
|
|
||||||
// when a user uploads an asset, we store it in the bucket. we only allow image and video assets.
|
// when a user uploads an asset, we store it in the bucket. we only allow image and video assets.
|
||||||
export async function handleAssetUpload(request: IRequest, env: Environment) {
|
export async function handleAssetUpload(request: IRequest, env: Environment) {
|
||||||
|
// If this is a preflight request, return appropriate CORS headers
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
const headers = new Headers({
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'access-control-allow-methods': 'POST, OPTIONS',
|
||||||
|
'access-control-allow-headers': 'content-type',
|
||||||
|
'access-control-max-age': '86400',
|
||||||
|
})
|
||||||
|
return new Response(null, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const objectName = getAssetObjectName(request.params.uploadId)
|
const objectName = getAssetObjectName(request.params.uploadId)
|
||||||
|
|
||||||
|
|
@ -67,10 +78,14 @@ export async function handleAssetDownload(
|
||||||
headers.set('cache-control', 'public, max-age=31536000, immutable')
|
headers.set('cache-control', 'public, max-age=31536000, immutable')
|
||||||
headers.set('etag', object.httpEtag)
|
headers.set('etag', object.httpEtag)
|
||||||
|
|
||||||
// we set CORS headers so all clients can access assets. we do this here so our `cors` helper in
|
// Set comprehensive CORS headers for asset access
|
||||||
// worker.ts doesn't try to set extra cors headers on responses that have been read from the
|
|
||||||
// cache, which isn't allowed by cloudflare.
|
|
||||||
headers.set('access-control-allow-origin', '*')
|
headers.set('access-control-allow-origin', '*')
|
||||||
|
headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS')
|
||||||
|
headers.set('access-control-allow-headers', '*')
|
||||||
|
headers.set('access-control-expose-headers', 'content-length, content-range')
|
||||||
|
headers.set('cross-origin-resource-policy', 'cross-origin')
|
||||||
|
headers.set('cross-origin-opener-policy', 'same-origin')
|
||||||
|
headers.set('cross-origin-embedder-policy', 'require-corp')
|
||||||
|
|
||||||
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
|
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
|
||||||
// need to do it ourselves.
|
// need to do it ourselves.
|
||||||
|
|
|
||||||
168
worker/worker.ts
168
worker/worker.ts
|
|
@ -58,6 +58,11 @@ const { preflight, corsify } = cors({
|
||||||
"Sec-WebSocket-Version",
|
"Sec-WebSocket-Version",
|
||||||
"Sec-WebSocket-Extensions",
|
"Sec-WebSocket-Extensions",
|
||||||
"Sec-WebSocket-Protocol",
|
"Sec-WebSocket-Protocol",
|
||||||
|
"Content-Length",
|
||||||
|
"Content-Range",
|
||||||
|
"Range",
|
||||||
|
"If-None-Match",
|
||||||
|
"If-Modified-Since"
|
||||||
],
|
],
|
||||||
maxAge: 86400,
|
maxAge: 86400,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|
@ -155,6 +160,169 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add new transcription endpoints
|
||||||
|
.post("/daily/rooms/:roomName/start-transcription", async (req) => {
|
||||||
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||||
|
const { roomName } = req.params
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/transcription/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
return new Response(JSON.stringify(error), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/daily/rooms/:roomName/stop-transcription", async (req) => {
|
||||||
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||||
|
const { roomName } = req.params
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/transcription/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
return new Response(JSON.stringify(error), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add endpoint to get transcript access link
|
||||||
|
.get("/daily/transcript/:transcriptId/access-link", async (req) => {
|
||||||
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||||
|
const { transcriptId } = req.params
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.daily.co/v1/transcript/${transcriptId}/access-link`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
return new Response(JSON.stringify(error), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add endpoint to get transcript text
|
||||||
|
.get("/daily/transcript/:transcriptId", async (req) => {
|
||||||
|
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||||
|
const { transcriptId } = req.params
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.daily.co/v1/transcripts/${transcriptId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
return new Response(JSON.stringify(error), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function backupAllBoards(env: Environment) {
|
async function backupAllBoards(env: Environment) {
|
||||||
try {
|
try {
|
||||||
// List all room files from TLDRAW_BUCKET
|
// List all room files from TLDRAW_BUCKET
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue