From a8c8d62e6328b7edb73f215c64faa8331d1b1a63 Mon Sep 17 00:00:00 2001 From: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> Date: Sat, 31 Aug 2024 13:06:13 +0200 Subject: [PATCH] fix image/asset handling --- {client => src/client}/getBookmarkPreview.tsx | 2 +- .../client}/multiplayerAssetStore.tsx | 0 src/components/Board.tsx | 52 +++---- src/shapes/ChatBoxShape.tsx | 141 ++++++++++++++++++ src/tools/CardShapeTool.tsx | 20 +++ src/tools/ChatBoxTool.ts | 6 + sw.ts | 11 ++ ui-overrides.tsx | 67 +++++++++ worker/TldrawDurableObject.ts | 7 +- 9 files changed, 273 insertions(+), 33 deletions(-) rename {client => src/client}/getBookmarkPreview.tsx (89%) rename {client => src/client}/multiplayerAssetStore.tsx (100%) create mode 100644 src/shapes/ChatBoxShape.tsx create mode 100644 src/tools/CardShapeTool.tsx create mode 100644 src/tools/ChatBoxTool.ts create mode 100644 sw.ts create mode 100644 ui-overrides.tsx diff --git a/client/getBookmarkPreview.tsx b/src/client/getBookmarkPreview.tsx similarity index 89% rename from client/getBookmarkPreview.tsx rename to src/client/getBookmarkPreview.tsx index 7faf032..b26bc28 100644 --- a/client/getBookmarkPreview.tsx +++ b/src/client/getBookmarkPreview.tsx @@ -22,7 +22,7 @@ export async function getBookmarkPreview({ url }: { url: string }): Promise() @@ -23,7 +30,9 @@ export function Board() { // Use the dynamic roomId in the URI uri: `${WORKER_URL}/connect/${roomId}`, // ...and how to handle static assets like images & videos - assets: multiplayerAssets, + assets: multiplayerAssetStore, + shapeUtils: shapeUtils, + schema: customSchema }) return ( @@ -31,43 +40,28 @@ export function Board() { { // when the editor is ready, we need to register out bookmark unfurling service editor.registerExternalAssetHandler('url', unfurlBookmarkUrl) + editor.createShape({ + type: 'chatBox', + x: 0, + y: 0, + props: { + w: 200, + h: 200, + roomId: roomId, + }, + }) }} /> ) } -// How does our server handle assets like images and videos? -const multiplayerAssets: TLAssetStore = { - // to upload an asset, we prefix it with a unique id, POST it to our worker, and return the URL - async upload(_asset, file) { - const id = uniqueId() - - const objectName = `${id}-${file.name}` - const url = `${WORKER_URL}/uploads/${encodeURIComponent(objectName)}` - - const response = await fetch(url, { - method: 'PUT', - body: file, - }) - - if (!response.ok) { - throw new Error(`Failed to upload asset: ${response.statusText}`) - } - - return url - }, - // to retrieve an asset, we can just use the same URL. you could customize this to add extra - // auth, or to serve optimized versions / sizes of the asset. - resolve(asset) { - return asset.props.src - }, -} - // How does our server handle bookmark unfurling? async function unfurlBookmarkUrl({ url }: { url: string }): Promise { const asset: TLBookmarkAsset = { diff --git a/src/shapes/ChatBoxShape.tsx b/src/shapes/ChatBoxShape.tsx new file mode 100644 index 0000000..d56acce --- /dev/null +++ b/src/shapes/ChatBoxShape.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from "react"; +import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape, TldrawBaseProps } from "tldraw"; + +export type IChatBoxShape = TLBaseShape< + 'chatBox', + { + w: number + h: number + roomId: string + } +> + +export class ChatBoxShape extends BaseBoxShapeUtil { + static override type = 'chatBox' + + getDefaultProps(): IChatBoxShape['props'] { + return { + roomId: 'default-room', + w: 100, + h: 100, + } + } + + indicator(shape: IChatBoxShape) { + return + } + + component(shape: IChatBoxShape) { + return ( + + ) + } +} + +interface Message { + id: string; + username: string; + content: string; + timestamp: Date; +} + +// Add this new component after the ChatBoxShape class +function ChatBox({ width, height }: { roomId: string, width: number, height: number }) { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(""); + const [username, setUsername] = useState("jeff"); + const messagesEndRef = useRef(null); + + useEffect(() => { + const storedUsername = localStorage.getItem("chatUsername"); + if (storedUsername) { + setUsername(storedUsername); + } else { + const newUsername = `User${Math.floor(Math.random() * 1000)}`; + setUsername(newUsername); + localStorage.setItem("chatUsername", newUsername); + } + + fetchMessages(); + const interval = setInterval(fetchMessages, 2000); + + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (messagesEndRef.current) { + (messagesEndRef.current as HTMLElement).scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + + const fetchMessages = async () => { + try { + const response = await fetch("https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const newMessages = await response.json() as Message[]; + setMessages(newMessages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp) }))); + } catch (error) { + console.error('Error fetching messages:', error); + } + }; + + const sendMessage = async (e: any) => { + e.preventDefault(); + if (!inputMessage.trim()) return; + await sendMessageToChat(username, inputMessage); + setInputMessage(""); + fetchMessages(); + }; + + return ( +
+
+ {messages.map((msg) => ( +
+
+ {msg.username} + {new Date(msg.timestamp).toLocaleTimeString()} +
+
{msg.content}
+
+ ))} +
+
+
+ setInputMessage(e.target.value)} + placeholder="Type a message..." + className="message-input" + /> + +
+
+ ); +} + +async function sendMessageToChat(username: string, content: string): Promise { + const apiUrl = 'https://jeffemmett-realtimechatappwithpolling.web.val.run'; // Replace with your actual Val Town URL + + try { + const response = await fetch(`${apiUrl}?action=sendMessage`, { + method: 'POST', + mode: 'no-cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + content, + }), + }); + + const result = await response.text(); + console.log('Message sent successfully:', result); + } catch (error) { + console.error('Error sending message:', error); + } + } \ No newline at end of file diff --git a/src/tools/CardShapeTool.tsx b/src/tools/CardShapeTool.tsx new file mode 100644 index 0000000..dcd0b72 --- /dev/null +++ b/src/tools/CardShapeTool.tsx @@ -0,0 +1,20 @@ +import { BaseBoxShapeTool, TLClickEvent } from 'tldraw' +export class CardShapeTool extends BaseBoxShapeTool { + static override id = 'card' + static override initial = 'idle' + override shapeType = 'card' + + override onDoubleClick: TLClickEvent = (_info) => { + // you can handle events in handlers like this one; + // check the BaseBoxShapeTool source as an example + } +} + +/* +This file contains our custom tool. The tool is a StateNode with the `id` "card". + +We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can +handle events in out own way by overriding methods like onDoubleClick. For an example +of a tool with more custom functionality, check out the screenshot-tool example. + +*/ \ No newline at end of file diff --git a/src/tools/ChatBoxTool.ts b/src/tools/ChatBoxTool.ts new file mode 100644 index 0000000..ce64403 --- /dev/null +++ b/src/tools/ChatBoxTool.ts @@ -0,0 +1,6 @@ +import { BaseBoxShapeTool } from "tldraw"; + +export class ChatBoxTool extends BaseBoxShapeTool { + shapeType = 'chatBox' + override initial = 'idle' +} \ No newline at end of file diff --git a/sw.ts b/sw.ts new file mode 100644 index 0000000..a144876 --- /dev/null +++ b/sw.ts @@ -0,0 +1,11 @@ +self.addEventListener('push', function(event) { + const data = event.data.json(); + const options = { + body: data.message, + icon: 'path/to/icon.png', + badge: 'path/to/badge.png' + }; + event.waitUntil( + self.registration.showNotification('New Message', options) + ); +}); \ No newline at end of file diff --git a/ui-overrides.tsx b/ui-overrides.tsx new file mode 100644 index 0000000..b709669 --- /dev/null +++ b/ui-overrides.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { + DefaultKeyboardShortcutsDialog, + DefaultKeyboardShortcutsDialogContent, + DefaultToolbar, + DefaultToolbarContent, + TLComponents, + TLUiOverrides, + TldrawUiMenuItem, + useIsToolSelected, + useTools, +} from 'tldraw' + +// There's a guide at the bottom of this file! + +export const uiOverrides: TLUiOverrides = { + tools(editor, tools) { + // Create a tool item in the ui's context. + tools.card = { + id: 'card', + icon: 'color', + label: 'Card', + kbd: 'c', + onSelect: () => { + editor.setCurrentTool('card') + }, + } + return tools + }, +} + +export const components: TLComponents = { + Toolbar: (props) => { + const tools = useTools() + const isCardSelected = useIsToolSelected(tools['card']) + return ( + + + + + ) + }, + KeyboardShortcutsDialog: (props) => { + const tools = useTools() + return ( + + + + + ) + }, +} + +/* + +This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools to +the toolbar and the keyboard shortcuts menu. + +First we have to add our new tool to the tools object in the tools override. This is where we define +all the basic information about our new tool - its icon, label, keyboard shortcut, what happens when +we select it, etc. + +Then, we replace the UI components for the toolbar and keyboard shortcut dialog with our own, that +add our new tool to the existing default content. Ideally, we'd interleave our new tool into the +ideal place among the default tools, but for now we're just adding it at the start to keep things +simple. +*/ \ No newline at end of file diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index 5889a2b..f878c83 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -10,10 +10,11 @@ import { import { AutoRouter, IRequest, error } from 'itty-router' import throttle from 'lodash.throttle' import { Environment } from './types' +import { ChatBoxShape } from '@/shapes/ChatBoxShape' // add custom shapes and bindings here if needed: -const schema = createTLSchema({ - shapes: { ...defaultShapeSchemas }, +export const customSchema = createTLSchema({ + shapes: { ...defaultShapeSchemas, chatBox: ChatBoxShape }, // bindings: { ...defaultBindingSchemas }, }) @@ -101,7 +102,7 @@ export class TldrawDurableObject { // create a new TLSocketRoom. This handles all the sync protocol & websocket connections. // it's up to us to persist the room state to R2 when needed though. return new TLSocketRoom({ - schema, + schema: customSchema, initialSnapshot, onDataChange: () => { // and persist whenever the data in the room changes