prettify and cleanup
This commit is contained in:
parent
8817af2962
commit
73731d94f8
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"semi": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import matter from "gray-matter";
|
||||
import { markdownToHtml } from "./markdownToHtml";
|
||||
import path from "path";
|
||||
|
||||
export const markdownPlugin = {
|
||||
name: "markdown-plugin",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
if (id.endsWith(".md")) {
|
||||
const { data, content } = matter(code);
|
||||
const filename = path.basename(id, ".md");
|
||||
const html = markdownToHtml(filename, content);
|
||||
return `export const html = ${JSON.stringify(html)};
|
||||
export const data = ${JSON.stringify(data)};`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import MarkdownIt from "markdown-it";
|
||||
// import markdownItLatex from "markdown-it-latex";
|
||||
import markdownLatex from "markdown-it-latex2img";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
md.use(
|
||||
markdownLatex,
|
||||
// {style: "width: 200%; height: 200%;",}
|
||||
);
|
||||
|
||||
// const mediaSrc = (folderName, fileName) => {
|
||||
// return `/posts/${folderName}/${fileName}`;
|
||||
// };
|
||||
|
||||
md.renderer.rules.code_block = (tokens, idx, options, env, self) => {
|
||||
console.log("tokens", tokens);
|
||||
return `<code>${tokens[idx].content}</code>`;
|
||||
};
|
||||
md.renderer.rules.image = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx];
|
||||
const src = token.attrGet("src");
|
||||
const alt = token.content;
|
||||
const postName = env.postName;
|
||||
const formattedSrc = `/posts/${postName}/${src}`;
|
||||
|
||||
if (src.endsWith(".mp4") || src.endsWith(".mov")) {
|
||||
return `<video controls loop>
|
||||
<source src="${formattedSrc}" type="video/mp4">
|
||||
</video>`;
|
||||
}
|
||||
|
||||
return `<img src="${formattedSrc}" alt="${alt}" />`;
|
||||
};
|
||||
|
||||
export function markdownToHtml(postName, content) {
|
||||
return md.render(content, { postName: postName });
|
||||
}
|
||||
|
|
@ -1,129 +1,127 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Editor, TLEventMap, TLFrameShape, TLParentId } from 'tldraw';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useEffect } from "react"
|
||||
import { Editor, TLEventMap, TLFrameShape, TLParentId } from "tldraw"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
// Define camera state interface
|
||||
interface CameraState {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 10;
|
||||
let cameraHistory: CameraState[] = [];
|
||||
const MAX_HISTORY = 10
|
||||
let cameraHistory: CameraState[] = []
|
||||
|
||||
// TODO: use this
|
||||
|
||||
// Improved camera change tracking with debouncing
|
||||
const trackCameraChange = (editor: Editor) => {
|
||||
const currentCamera = editor.getCamera();
|
||||
const lastPosition = cameraHistory[cameraHistory.length - 1];
|
||||
const currentCamera = editor.getCamera()
|
||||
const lastPosition = cameraHistory[cameraHistory.length - 1]
|
||||
|
||||
// Store any viewport change that's not from a revert operation
|
||||
if (!lastPosition ||
|
||||
currentCamera.x !== lastPosition.x ||
|
||||
currentCamera.y !== lastPosition.y ||
|
||||
currentCamera.z !== lastPosition.z) {
|
||||
cameraHistory.push({ ...currentCamera });
|
||||
if (cameraHistory.length > MAX_HISTORY) {
|
||||
cameraHistory.shift();
|
||||
}
|
||||
// Store any viewport change that's not from a revert operation
|
||||
if (
|
||||
!lastPosition ||
|
||||
currentCamera.x !== lastPosition.x ||
|
||||
currentCamera.y !== lastPosition.y ||
|
||||
currentCamera.z !== lastPosition.z
|
||||
) {
|
||||
cameraHistory.push({ ...currentCamera })
|
||||
if (cameraHistory.length > MAX_HISTORY) {
|
||||
cameraHistory.shift()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useCameraControls(editor: Editor | null) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
// Handle URL-based camera positioning
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
// Handle URL-based camera positioning
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
const frameId = searchParams.get('frameId');
|
||||
const x = searchParams.get('x');
|
||||
const y = searchParams.get('y');
|
||||
const zoom = searchParams.get('zoom');
|
||||
const frameId = searchParams.get("frameId")
|
||||
const x = searchParams.get("x")
|
||||
const y = searchParams.get("y")
|
||||
const zoom = searchParams.get("zoom")
|
||||
|
||||
if (x && y && zoom) {
|
||||
editor.setCamera({
|
||||
x: parseFloat(x),
|
||||
y: parseFloat(y),
|
||||
z: parseFloat(zoom)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (x && y && zoom) {
|
||||
editor.setCamera({
|
||||
x: parseFloat(x),
|
||||
y: parseFloat(y),
|
||||
z: parseFloat(zoom),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (frameId) {
|
||||
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
||||
if (!frame) {
|
||||
console.warn('Frame not found:', frameId);
|
||||
return;
|
||||
}
|
||||
if (frameId) {
|
||||
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
|
||||
if (!frame) {
|
||||
console.warn("Frame not found:", frameId)
|
||||
return
|
||||
}
|
||||
|
||||
// Use editor's built-in zoomToBounds with animation
|
||||
editor.zoomToBounds(
|
||||
editor.getShapePageBounds(frame)!,
|
||||
{
|
||||
inset: 32,
|
||||
animation: { duration: 500 }
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [editor, searchParams]);
|
||||
// Use editor's built-in zoomToBounds with animation
|
||||
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
|
||||
inset: 32,
|
||||
animation: { duration: 500 },
|
||||
})
|
||||
}
|
||||
}, [editor, searchParams])
|
||||
|
||||
// Track camera changes
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
// Track camera changes
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
const handler = () => {
|
||||
trackCameraChange(editor);
|
||||
};
|
||||
const handler = () => {
|
||||
trackCameraChange(editor)
|
||||
}
|
||||
|
||||
// Track both viewport changes and user interaction end
|
||||
editor.on('viewportChange' as keyof TLEventMap, handler);
|
||||
editor.on('userChangeEnd' as keyof TLEventMap, handler);
|
||||
// Track both viewport changes and user interaction end
|
||||
editor.on("viewportChange" as keyof TLEventMap, handler)
|
||||
editor.on("userChangeEnd" as keyof TLEventMap, handler)
|
||||
|
||||
return () => {
|
||||
editor.off('viewportChange' as keyof TLEventMap, handler);
|
||||
editor.off('userChangeEnd' as keyof TLEventMap, handler);
|
||||
};
|
||||
}, [editor]);
|
||||
return () => {
|
||||
editor.off("viewportChange" as keyof TLEventMap, handler)
|
||||
editor.off("userChangeEnd" as keyof TLEventMap, handler)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Enhanced camera control functions
|
||||
return {
|
||||
zoomToFrame: (frameId: string) => {
|
||||
if (!editor) return;
|
||||
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
||||
if (!frame) return;
|
||||
// Enhanced camera control functions
|
||||
return {
|
||||
zoomToFrame: (frameId: string) => {
|
||||
if (!editor) return
|
||||
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
|
||||
if (!frame) return
|
||||
|
||||
editor.zoomToBounds(
|
||||
editor.getShapePageBounds(frame)!,
|
||||
{
|
||||
inset: 32,
|
||||
animation: { duration: 500 }
|
||||
}
|
||||
);
|
||||
},
|
||||
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
|
||||
inset: 32,
|
||||
animation: { duration: 500 },
|
||||
})
|
||||
},
|
||||
|
||||
copyFrameLink: (frameId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('frameId', frameId);
|
||||
navigator.clipboard.writeText(url.toString());
|
||||
},
|
||||
copyFrameLink: (frameId: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("frameId", frameId)
|
||||
navigator.clipboard.writeText(url.toString())
|
||||
},
|
||||
|
||||
copyLocationLink: () => {
|
||||
if (!editor) return;
|
||||
const camera = editor.getCamera();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('x', camera.x.toString());
|
||||
url.searchParams.set('y', camera.y.toString());
|
||||
url.searchParams.set('zoom', camera.z.toString());
|
||||
navigator.clipboard.writeText(url.toString());
|
||||
},
|
||||
copyLocationLink: () => {
|
||||
if (!editor) return
|
||||
const camera = editor.getCamera()
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("x", camera.x.toString())
|
||||
url.searchParams.set("y", camera.y.toString())
|
||||
url.searchParams.set("zoom", camera.z.toString())
|
||||
navigator.clipboard.writeText(url.toString())
|
||||
},
|
||||
|
||||
revertCamera: () => {
|
||||
if (!editor || cameraHistory.length === 0) return;
|
||||
const previousCamera = cameraHistory.pop();
|
||||
if (previousCamera) {
|
||||
editor.setCamera(previousCamera, { animation: { duration: 200 } });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
revertCamera: () => {
|
||||
if (!editor || cameraHistory.length === 0) return
|
||||
const previousCamera = cameraHistory.pop()
|
||||
if (previousCamera) {
|
||||
editor.setCamera(previousCamera, { animation: { duration: 200 } })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ElementInfo {
|
||||
tagName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export function useCanvas() {
|
||||
const [isCanvasEnabled, setIsCanvasEnabled] = useState(false);
|
||||
const [elementsInfo, setElementsInfo] = useState<ElementInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const toggleCanvas = async () => {
|
||||
if (!isCanvasEnabled) {
|
||||
const info = await gatherElementsInfo();
|
||||
setElementsInfo(info);
|
||||
setIsCanvasEnabled(true);
|
||||
document.body.classList.add('canvas-mode');
|
||||
} else {
|
||||
setElementsInfo([]);
|
||||
setIsCanvasEnabled(false);
|
||||
document.body.classList.remove('canvas-mode');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('toggleCanvasEvent', toggleCanvas);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('toggleCanvasEvent', toggleCanvas);
|
||||
};
|
||||
}, [isCanvasEnabled]);
|
||||
|
||||
return { isCanvasEnabled, elementsInfo };
|
||||
}
|
||||
|
||||
async function gatherElementsInfo() {
|
||||
const rootElement = document.getElementsByTagName('main')[0];
|
||||
const info: any[] = [];
|
||||
if (rootElement) {
|
||||
for (const child of rootElement.children) {
|
||||
if (['BUTTON'].includes(child.tagName)) continue;
|
||||
const rect = child.getBoundingClientRect();
|
||||
let w = rect.width;
|
||||
if (!['P', 'UL', 'OL'].includes(child.tagName)) {
|
||||
w = measureElementTextWidth(child as HTMLElement);
|
||||
}
|
||||
// Check if the element is centered
|
||||
const computedStyle = window.getComputedStyle(child);
|
||||
let x = rect.left; // Default x position
|
||||
if (computedStyle.display === 'block' && computedStyle.textAlign === 'center') {
|
||||
// Adjust x position for centered elements
|
||||
const parentWidth = child.parentElement ? child.parentElement.getBoundingClientRect().width : 0;
|
||||
x = (parentWidth - w) / 2 + window.scrollX + (child.parentElement ? child.parentElement.getBoundingClientRect().left : 0);
|
||||
}
|
||||
|
||||
info.push({
|
||||
tagName: child.tagName,
|
||||
x: x,
|
||||
y: rect.top,
|
||||
w: w,
|
||||
h: rect.height,
|
||||
html: child.outerHTML
|
||||
});
|
||||
};
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function measureElementTextWidth(element: HTMLElement) {
|
||||
// Create a temporary span element
|
||||
const tempElement = document.createElement('span');
|
||||
// Get the text content from the passed element
|
||||
tempElement.textContent = element.textContent || element.innerText;
|
||||
// Get the computed style of the passed element
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
// Apply relevant styles to the temporary element
|
||||
tempElement.style.font = computedStyle.font;
|
||||
tempElement.style.fontWeight = computedStyle.fontWeight;
|
||||
tempElement.style.fontSize = computedStyle.fontSize;
|
||||
tempElement.style.fontFamily = computedStyle.fontFamily;
|
||||
tempElement.style.letterSpacing = computedStyle.letterSpacing;
|
||||
// Ensure the temporary element is not visible in the viewport
|
||||
tempElement.style.position = 'absolute';
|
||||
tempElement.style.visibility = 'hidden';
|
||||
tempElement.style.whiteSpace = 'nowrap'; // Prevent text from wrapping
|
||||
// Append to the body to make measurements possible
|
||||
document.body.appendChild(tempElement);
|
||||
// Measure the width
|
||||
const width = tempElement.getBoundingClientRect().width;
|
||||
// Remove the temporary element from the document
|
||||
document.body.removeChild(tempElement);
|
||||
// Return the measured width
|
||||
return width === 0 ? 10 : width;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c8140e8b76c29fa3c828553e8a6a87651af6e7ee18d1ccb82e79125eb532194b
|
||||
size 19738445
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d63dd9f37a74b85680a7c1df823f69936619ecab5e8d4d06eeeedea4d908f1de
|
||||
size 18268955
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.5.7 -->
|
||||
<svg width="293" height="293" viewBox="0 0 293 293" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="canvas-button" fill="none" stroke="#000000" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 72.367233 280.871094 C 50.196026 280.871094 39.109756 280.870453 30.79781 276.215546 C 24.923391 272.92572 20.074295 268.07663 16.784464 262.202179 C 12.129553 253.890228 12.128906 242.80397 12.128906 220.632767 L 12.128906 195.152313 L 24.139235 195.152313 L 24.139235 229.431793 C 24.139235 243.943832 24.139042 251.199875 27.185894 256.640411 C 29.339237 260.485474 32.514515 263.660767 36.359585 265.814117 C 41.800129 268.860962 49.056156 268.860779 63.568214 268.860779 L 97.847694 268.860779 L 97.847694 280.871094 L 72.367233 280.871094 Z M 195.276031 280.871094 L 195.276031 268.860779 L 229.560684 268.860779 C 244.072723 268.860779 251.328766 268.860962 256.769318 265.814117 C 260.61441 263.660767 263.789673 260.485474 265.943024 256.640411 C 268.989868 251.199875 268.989685 243.943832 268.989685 229.431793 L 268.989685 195.152313 L 281 195.152313 L 281 220.632767 C 281 242.80397 280.999359 253.890228 276.344452 262.202179 C 273.054626 268.07663 268.205536 272.92572 262.331085 276.215546 C 254.019119 280.870453 242.932877 280.871094 220.761673 280.871094 L 195.276031 280.871094 Z M 12.128906 97.723969 L 12.128906 72.238327 C 12.128906 50.067123 12.129553 38.980881 16.784464 30.668915 C 20.074295 24.794464 24.923391 19.945404 30.79781 16.655548 C 39.109756 12.000641 50.196026 12 72.367233 12 L 97.847694 12 L 97.847694 24.010315 L 63.568214 24.010315 C 49.056156 24.010315 41.800129 24.010132 36.359585 27.056976 C 32.514519 29.210327 29.339237 32.38562 27.185894 36.230682 C 24.139044 41.671234 24.139235 48.927277 24.139235 63.439316 L 24.139235 97.723969 L 12.128906 97.723969 Z M 268.989685 97.723969 L 268.989685 63.439316 C 268.989685 48.927277 268.989868 41.671234 265.943024 36.230682 C 263.789673 32.38559 260.61438 29.210327 256.769318 27.056976 C 251.328766 24.010132 244.072723 24.010315 229.560684 24.010315 L 195.276031 24.010315 L 195.276031 12 L 220.761673 12 C 242.932877 12 254.019119 12.000641 262.331085 16.655548 C 268.205536 19.945374 273.054596 24.794464 276.344452 30.668915 C 280.999359 38.980881 281 50.067123 281 72.238327 L 281 97.723969 L 268.989685 97.723969 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.5.7 -->
|
||||
<svg width="247" height="450" viewBox="0 0 247 450" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="gravity-button">
|
||||
<path id="secondary" fill="none" stroke="#000000" stroke-width="30" stroke-linecap="round" stroke-linejoin="round" d="M 123.166664 32.750031 L 123.166664 169.500031 M 214.333313 65.541687 L 214.333313 202.291687 M 32 65.541687 L 32 202.291687"/>
|
||||
<path id="primary" fill="none" stroke="#000000" stroke-width="30" stroke-linecap="round" stroke-linejoin="round" d="M 214.333313 341.833344 C 214.333313 392.183289 173.516632 433 123.166664 433 C 72.816711 433 32 392.183289 32 341.833344 C 32 291.483398 72.816711 250.666687 123.166664 250.666687 C 173.516632 250.666687 214.333313 291.483398 214.333313 341.833344 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 846 B |
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4b55a408375712bc169bc5c987d85c71c353028e611f216817f13ca0fb284604
|
||||
size 28423
|
||||
|
|
@ -1,91 +1,103 @@
|
|||
import { useSync } from '@tldraw/sync'
|
||||
import { useMemo } from 'react'
|
||||
import { useSync } from "@tldraw/sync"
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
AssetRecordType,
|
||||
getHashForString,
|
||||
TLBookmarkAsset,
|
||||
Tldraw,
|
||||
Editor,
|
||||
} from 'tldraw'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { ChatBoxTool } from '@/tools/ChatBoxTool'
|
||||
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
|
||||
import { VideoChatTool } from '@/tools/VideoChatTool'
|
||||
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
|
||||
import { multiplayerAssetStore } from '../utils/multiplayerAssetStore'
|
||||
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
||||
import { EmbedTool } from '@/tools/EmbedTool'
|
||||
import { defaultShapeUtils, defaultBindingUtils } from 'tldraw'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { components, overrides } from '@/ui-overrides'
|
||||
AssetRecordType,
|
||||
getHashForString,
|
||||
TLBookmarkAsset,
|
||||
Tldraw,
|
||||
Editor,
|
||||
} from "tldraw"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { ChatBoxTool } from "@/tools/ChatBoxTool"
|
||||
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
|
||||
import { VideoChatTool } from "@/tools/VideoChatTool"
|
||||
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
|
||||
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
|
||||
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||
import { EmbedTool } from "@/tools/EmbedTool"
|
||||
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
|
||||
import { useState } from "react"
|
||||
import { components, overrides } from "@/ui-overrides"
|
||||
|
||||
// Default to production URL if env var isn't available
|
||||
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
|
||||
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||
|
||||
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
|
||||
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools
|
||||
|
||||
const tools = [ChatBoxTool, VideoChatTool, EmbedTool] // Array of tools
|
||||
|
||||
export function Board() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const roomId = slug || 'default-room';
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const roomId = slug || "default-room"
|
||||
|
||||
const storeConfig = useMemo(() => ({
|
||||
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||
assets: multiplayerAssetStore,
|
||||
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
|
||||
bindingUtils: [...defaultBindingUtils],
|
||||
}), [roomId]);
|
||||
const storeConfig = useMemo(
|
||||
() => ({
|
||||
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||
assets: multiplayerAssetStore,
|
||||
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
|
||||
bindingUtils: [...defaultBindingUtils],
|
||||
}),
|
||||
[roomId],
|
||||
)
|
||||
|
||||
const store = useSync(storeConfig);
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
const store = useSync(storeConfig)
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
store={store.store}
|
||||
shapeUtils={shapeUtils}
|
||||
tools={tools}
|
||||
components={components}
|
||||
overrides={overrides}
|
||||
onMount={(editor) => {
|
||||
setEditor(editor)
|
||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||
editor.setCurrentTool('hand')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
store={store.store}
|
||||
shapeUtils={shapeUtils}
|
||||
tools={tools}
|
||||
components={components}
|
||||
overrides={overrides}
|
||||
onMount={(editor) => {
|
||||
setEditor(editor)
|
||||
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||
editor.setCurrentTool("hand")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// How does our server handle bookmark unfurling?
|
||||
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
|
||||
const asset: TLBookmarkAsset = {
|
||||
id: AssetRecordType.createId(getHashForString(url)),
|
||||
typeName: 'asset',
|
||||
type: 'bookmark',
|
||||
meta: {},
|
||||
props: {
|
||||
src: url,
|
||||
description: '',
|
||||
image: '',
|
||||
favicon: '',
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
async function unfurlBookmarkUrl({
|
||||
url,
|
||||
}: {
|
||||
url: string
|
||||
}): Promise<TLBookmarkAsset> {
|
||||
const asset: TLBookmarkAsset = {
|
||||
id: AssetRecordType.createId(getHashForString(url)),
|
||||
typeName: "asset",
|
||||
type: "bookmark",
|
||||
meta: {},
|
||||
props: {
|
||||
src: url,
|
||||
description: "",
|
||||
image: "",
|
||||
favicon: "",
|
||||
title: "",
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`)
|
||||
const data = await response.json() as { description: string, image: string, favicon: string, title: string }
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`,
|
||||
)
|
||||
const data = (await response.json()) as {
|
||||
description: string
|
||||
image: string
|
||||
favicon: string
|
||||
title: string
|
||||
}
|
||||
|
||||
asset.props.description = data?.description ?? ''
|
||||
asset.props.image = data?.image ?? ''
|
||||
asset.props.favicon = data?.favicon ?? ''
|
||||
asset.props.title = data?.title ?? ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
asset.props.description = data?.description ?? ""
|
||||
asset.props.image = data?.image ?? ""
|
||||
asset.props.favicon = data?.favicon ?? ""
|
||||
asset.props.title = data?.title ?? ""
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
return asset
|
||||
return asset
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,28 @@ export function Contact() {
|
|||
return (
|
||||
<main>
|
||||
<header>
|
||||
<a href="/">
|
||||
Jeff Emmett
|
||||
</a>
|
||||
<a href="/">Jeff Emmett</a>
|
||||
</header>
|
||||
<h1>Contact</h1>
|
||||
<p>Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a></p>
|
||||
<p>BlueSky: <a href="https://bsky.app/profile/jeffemmett.bsky.social">@jeffemnmett.bsky.social</a></p>
|
||||
<p>Mastodon: <a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a></p>
|
||||
<p>Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a></p>
|
||||
<p>GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a></p>
|
||||
<p>
|
||||
Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
|
||||
</p>
|
||||
<p>
|
||||
BlueSky:{" "}
|
||||
<a href="https://bsky.app/profile/jeffemmett.bsky.social">
|
||||
@jeffemnmett.bsky.social
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Mastodon:{" "}
|
||||
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>
|
||||
</p>
|
||||
<p>
|
||||
Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a>
|
||||
</p>
|
||||
<p>
|
||||
GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,106 @@
|
|||
export function Default() {
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
Jeff Emmett
|
||||
</header>
|
||||
<header>Jeff Emmett</header>
|
||||
<h2>Hello! 👋🍄</h2>
|
||||
<p>
|
||||
My research investigates the intersection of mycelium and emancipatory technologies.
|
||||
I am interested in the potential of new convivial tooling as a medium for group
|
||||
consensus building and collective action, in order to empower communities of practice to address their own challenges.
|
||||
My research investigates the intersection of mycelium and emancipatory
|
||||
technologies. I am interested in the potential of new convivial tooling
|
||||
as a medium for group consensus building and collective action, in order
|
||||
to empower communities of practice to address their own challenges.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
My current focus is basic research into the nature of digital
|
||||
organisation, developing prototype toolkits to improve shared
|
||||
infrastructure, and applying this research to the design of new
|
||||
systems and protocols which support the self-organisation of knowledge
|
||||
and emergent response to local needs.
|
||||
infrastructure, and applying this research to the design of new systems
|
||||
and protocols which support the self-organisation of knowledge and
|
||||
emergent response to local needs.
|
||||
</p>
|
||||
|
||||
<h2>My work</h2>
|
||||
<p>
|
||||
Alongside my independent work, I am a researcher and engineering communicator at <a href="https://block.science/">Block Science</a>, an advisor to the Active Inference Lab, Commons Stack, and the Trusted Seed. I am also an occasional collaborator with <a href="https://economicspace.agency/">ECSA</a>.
|
||||
Alongside my independent work, I am a researcher and engineering
|
||||
communicator at <a href="https://block.science/">Block Science</a>, an
|
||||
advisor to the Active Inference Lab, Commons Stack, and the Trusted
|
||||
Seed. I am also an occasional collaborator with{" "}
|
||||
<a href="https://economicspace.agency/">ECSA</a>.
|
||||
</p>
|
||||
|
||||
<h2>Get in touch</h2>
|
||||
<p>
|
||||
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>,
|
||||
Mastodon <a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a> and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
|
||||
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
|
||||
, Mastodon{" "}
|
||||
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
|
||||
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
|
||||
</p>
|
||||
|
||||
<span className="dinkus">***</span>
|
||||
|
||||
<h2>Talks</h2>
|
||||
<ol reversed>
|
||||
<li><a
|
||||
href="https://www.teamhuman.fm/episodes/238-jeff-emmett">MycoPunk Futures on Team Human with Douglas Rushkoff</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
<li>
|
||||
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
|
||||
MycoPunk Futures on Team Human with Douglas Rushkoff
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</li>
|
||||
<li><a
|
||||
href="https://www.youtube.com/watch?v=AFJFDajuCSg">Exploring MycoFi on the Greenpill Network with Kevin Owocki</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
<li>
|
||||
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
|
||||
Exploring MycoFi on the Greenpill Network with Kevin Owocki
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</li>
|
||||
<li><a
|
||||
href="https://youtu.be/9ad2EJhMbZ8">Re-imagining Human Value on the Telos Podcast with Rieki & Brandonfrom SEEDS</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
<li>
|
||||
<a href="https://youtu.be/9ad2EJhMbZ8">
|
||||
Re-imagining Human Value on the Telos Podcast with Rieki &
|
||||
Brandonfrom SEEDS
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</li>
|
||||
<li><a
|
||||
href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">Move Slow & Fix Things: Design Patterns from Nature</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
<li>
|
||||
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
|
||||
Move Slow & Fix Things: Design Patterns from Nature
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</li>
|
||||
<li><a
|
||||
href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">Localized Democracy and Public Goods with Token Engineering on the Ownership Economy</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
<li>
|
||||
<a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">
|
||||
Localized Democracy and Public Goods with Token Engineering on the
|
||||
Ownership Economy
|
||||
</a>{" "}
|
||||
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://youtu.be/kxcat-XBWas">
|
||||
A Discussion on Warm Data with Nora Bateson on Systems Innovation
|
||||
</a>
|
||||
</li>
|
||||
<li><a
|
||||
href="https://youtu.be/kxcat-XBWas">A Discussion on Warm Data with Nora Bateson on Systems Innovation</a></li>
|
||||
</ol>
|
||||
<h2>Writing</h2>
|
||||
<ol reversed>
|
||||
<li><a
|
||||
href="https://www.mycofi.art">Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond</a></li>
|
||||
<li><a
|
||||
href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">Challenges & Approaches to Scaling the Global Commons</a></li>
|
||||
<li><a
|
||||
href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">From Monoculture to Permaculture Currencies: A Glimpse of the Myco-Economic Future</a></li>
|
||||
<li><a
|
||||
href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">Rewriting the Story of Human Collaboration</a></li>
|
||||
<li>
|
||||
<a href="https://www.mycofi.art">
|
||||
Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">
|
||||
Challenges & Approaches to Scaling the Global Commons
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">
|
||||
From Monoculture to Permaculture Currencies: A Glimpse of the
|
||||
Myco-Economic Future
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">
|
||||
Rewriting the Story of Human Collaboration
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,59 @@
|
|||
import { createShapeId, Editor, Tldraw, TLGeoShape, TLShapePartial } from "tldraw";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
createShapeId,
|
||||
Editor,
|
||||
Tldraw,
|
||||
TLGeoShape,
|
||||
TLShapePartial,
|
||||
} from "tldraw"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
export function Inbox() {
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
|
||||
const updateEmails = async (editor: Editor) => {
|
||||
try {
|
||||
const response = await fetch('https://jeffemmett-canvas.web.val.run', {
|
||||
method: 'GET',
|
||||
});
|
||||
const messages = await response.json() as { id: string, from: string, subject: string, text: string }[];
|
||||
const response = await fetch("https://jeffemmett-canvas.web.val.run", {
|
||||
method: "GET",
|
||||
})
|
||||
const messages = (await response.json()) as {
|
||||
id: string
|
||||
from: string
|
||||
subject: string
|
||||
text: string
|
||||
}[]
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const messageId = message.id;
|
||||
const parsedEmailName = message.from.match(/^([^<]+)/)?.[1]?.trim() || message.from.match(/[^<@]+(?=@)/)?.[0] || message.from;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const messageId = message.id
|
||||
const parsedEmailName =
|
||||
message.from.match(/^([^<]+)/)?.[1]?.trim() ||
|
||||
message.from.match(/[^<@]+(?=@)/)?.[0] ||
|
||||
message.from
|
||||
const messageText = `from: ${parsedEmailName}\nsubject: ${message.subject}\n\n${message.text}`
|
||||
const shapeWidth = 500
|
||||
const shapeHeight = 300
|
||||
const spacing = 50
|
||||
const shape: TLShapePartial<TLGeoShape> = {
|
||||
id: createShapeId(),
|
||||
type: 'geo',
|
||||
type: "geo",
|
||||
x: shapeWidth * (i % 5) + spacing * (i % 5),
|
||||
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
text: messageText,
|
||||
align:'start',
|
||||
verticalAlign:'start'
|
||||
align: "start",
|
||||
verticalAlign: "start",
|
||||
},
|
||||
meta: {
|
||||
id: messageId
|
||||
}
|
||||
id: messageId,
|
||||
},
|
||||
}
|
||||
let found = false;
|
||||
let found = false
|
||||
for (const s of editor.getCurrentPageShapes()) {
|
||||
if (s.meta.id === messageId) {
|
||||
found = true;
|
||||
break;
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
|
|
@ -47,28 +61,28 @@ export function Inbox() {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
console.error("Error fetching data:", error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (editorRef.current) {
|
||||
updateEmails(editorRef.current);
|
||||
updateEmails(editorRef.current)
|
||||
}
|
||||
}, 5*1000);
|
||||
}, 5 * 1000)
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
return () => clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
onMount={(editor: Editor) => {
|
||||
editorRef.current = editor;
|
||||
updateEmails(editor);
|
||||
editorRef.current = editor
|
||||
updateEmails(editor)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,155 +1,191 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||
|
||||
export type IChatBoxShape = TLBaseShape<
|
||||
'ChatBox',
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
roomId: string
|
||||
userName: string
|
||||
}
|
||||
"ChatBox",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
roomId: string
|
||||
userName: string
|
||||
}
|
||||
>
|
||||
|
||||
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
||||
static override type = 'ChatBox'
|
||||
static override type = "ChatBox"
|
||||
|
||||
getDefaultProps(): IChatBoxShape['props'] {
|
||||
return {
|
||||
roomId: 'default-room',
|
||||
w: 100,
|
||||
h: 100,
|
||||
userName: '',
|
||||
}
|
||||
getDefaultProps(): IChatBoxShape["props"] {
|
||||
return {
|
||||
roomId: "default-room",
|
||||
w: 100,
|
||||
h: 100,
|
||||
userName: "",
|
||||
}
|
||||
}
|
||||
|
||||
indicator(shape: IChatBoxShape) {
|
||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
indicator(shape: IChatBoxShape) {
|
||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
|
||||
component(shape: IChatBoxShape) {
|
||||
return (
|
||||
<ChatBox roomId={shape.props.roomId} w={shape.props.w} h={shape.props.h} userName="" />
|
||||
)
|
||||
}
|
||||
component(shape: IChatBoxShape) {
|
||||
return (
|
||||
<ChatBox
|
||||
roomId={shape.props.roomId}
|
||||
w={shape.props.w}
|
||||
h={shape.props.h}
|
||||
userName=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
username: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
id: string
|
||||
username: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Update the ChatBox component to accept userName
|
||||
export const ChatBox: React.FC<IChatBoxShape['props']> = ({ roomId, w, h, userName }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [username, setUsername] = useState(userName);
|
||||
const messagesEndRef = useRef(null);
|
||||
export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
|
||||
roomId,
|
||||
w,
|
||||
h,
|
||||
userName,
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [inputMessage, setInputMessage] = useState("")
|
||||
const [username, setUsername] = useState(userName)
|
||||
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(roomId);
|
||||
const interval = setInterval(() => fetchMessages(roomId), 2000);
|
||||
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(roomId)
|
||||
const interval = setInterval(() => fetchMessages(roomId), 2000)
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [roomId]);
|
||||
return () => clearInterval(interval)
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
(messagesEndRef.current as HTMLElement).scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
;(messagesEndRef.current as HTMLElement).scrollIntoView({
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const fetchMessages = async (roomId: string) => {
|
||||
try {
|
||||
const response = await fetch(`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`);
|
||||
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 fetchMessages = async (roomId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`,
|
||||
)
|
||||
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: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputMessage.trim()) return;
|
||||
await sendMessageToChat(roomId, username, inputMessage);
|
||||
setInputMessage("");
|
||||
fetchMessages(roomId);
|
||||
};
|
||||
const sendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!inputMessage.trim()) return
|
||||
await sendMessageToChat(roomId, username, inputMessage)
|
||||
setInputMessage("")
|
||||
fetchMessages(roomId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-container" style={{ pointerEvents: 'all', width: `${w}px`, height: `${h}px`, overflow: 'auto', touchAction: 'auto' }}>
|
||||
<div className="messages-container">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`message ${msg.username === username ? 'own-message' : ''}`}>
|
||||
<div className="message-header">
|
||||
<strong>{msg.username}</strong>
|
||||
<span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div className="message-content">{msg.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
return (
|
||||
<div
|
||||
className="chat-container"
|
||||
style={{
|
||||
pointerEvents: "all",
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
overflow: "auto",
|
||||
touchAction: "auto",
|
||||
}}
|
||||
>
|
||||
<div className="messages-container">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`message ${
|
||||
msg.username === username ? "own-message" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="message-header">
|
||||
<strong>{msg.username}</strong>
|
||||
<span className="timestamp">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={sendMessage} className="input-form">
|
||||
<input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
className="message-input"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ pointerEvents: 'all', touchAction: 'manipulation' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
className="send-button"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
<div className="message-content">{msg.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={sendMessage} className="input-form">
|
||||
<input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
className="message-input"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ pointerEvents: "all", touchAction: "manipulation" }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
className="send-button"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function sendMessageToChat(roomId: string, username: string, content: string): Promise<void> {
|
||||
const apiUrl = 'https://jeffemmett-realtimechatappwithpolling.web.val.run'; // Replace with your actual Val Town URL
|
||||
async function sendMessageToChat(
|
||||
roomId: string,
|
||||
username: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
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({
|
||||
roomId,
|
||||
username,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}?action=sendMessage`, {
|
||||
method: "POST",
|
||||
mode: "no-cors",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roomId,
|
||||
username,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.text();
|
||||
console.log('Message sent successfully:', result);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
}
|
||||
}
|
||||
const result = await response.text()
|
||||
console.log("Message sent successfully:", result)
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,138 +1,165 @@
|
|||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||
import { useCallback, useState } from "react";
|
||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
export type IEmbedShape = TLBaseShape<
|
||||
'Embed',
|
||||
{
|
||||
w: number;
|
||||
h: number;
|
||||
url: string | null;
|
||||
}
|
||||
>;
|
||||
"Embed",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
url: string | null
|
||||
}
|
||||
>
|
||||
|
||||
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
|
||||
static override type = 'Embed';
|
||||
static override type = "Embed"
|
||||
|
||||
getDefaultProps(): IEmbedShape['props'] {
|
||||
return {
|
||||
url: null,
|
||||
w: 640,
|
||||
h: 480,
|
||||
};
|
||||
getDefaultProps(): IEmbedShape["props"] {
|
||||
return {
|
||||
url: null,
|
||||
w: 640,
|
||||
h: 480,
|
||||
}
|
||||
}
|
||||
|
||||
indicator(shape: IEmbedShape) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
indicator(shape: IEmbedShape) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
component(shape: IEmbedShape) {
|
||||
const [inputUrl, setInputUrl] = useState(shape.props.url || '');
|
||||
const [error, setError] = useState('');
|
||||
component(shape: IEmbedShape) {
|
||||
const [inputUrl, setInputUrl] = useState(shape.props.url || "")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let completedUrl = inputUrl.startsWith('http://') || inputUrl.startsWith('https://') ? inputUrl : `https://${inputUrl}`;
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
let completedUrl =
|
||||
inputUrl.startsWith("http://") || inputUrl.startsWith("https://")
|
||||
? inputUrl
|
||||
: `https://${inputUrl}`
|
||||
|
||||
// Handle YouTube links
|
||||
if (completedUrl.includes('youtube.com') || completedUrl.includes('youtu.be')) {
|
||||
const videoId = extractYouTubeVideoId(completedUrl);
|
||||
if (videoId) {
|
||||
completedUrl = `https://www.youtube.com/embed/${videoId}`;
|
||||
} else {
|
||||
setError('Invalid YouTube URL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Google Docs links
|
||||
if (completedUrl.includes('docs.google.com')) {
|
||||
const docId = completedUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1];
|
||||
if (docId) {
|
||||
completedUrl = `https://docs.google.com/document/d/${docId}/preview`;
|
||||
} else {
|
||||
setError('Invalid Google Docs URL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.updateShape<IEmbedShape>({ id: shape.id, type: 'Embed', props: { ...shape.props, url: completedUrl } });
|
||||
|
||||
// Check if the URL is valid
|
||||
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//);
|
||||
if (!isValidUrl) {
|
||||
setError('Invalid website URL');
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
}, [inputUrl]);
|
||||
|
||||
const extractYouTubeVideoId = (url: string): string | null => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const wrapperStyle = {
|
||||
width: `${shape.props.w}px`,
|
||||
height: `${shape.props.h}px`,
|
||||
padding: '15px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: '#F0F0F0',
|
||||
borderRadius: '4px',
|
||||
};
|
||||
|
||||
const contentStyle = {
|
||||
pointerEvents: 'all' as const,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: '1px solid #D3D3D3',
|
||||
backgroundColor: '#FFFFFF',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
if (!shape.props.url) {
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={contentStyle} onClick={() => document.querySelector('input')?.focus()}>
|
||||
<form onSubmit={handleSubmit} style={{ width: '100%', height: '100%', padding: '10px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder="Enter URL"
|
||||
style={{ width: '100%', height: '100%', border: 'none', padding: '10px' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <div style={{ color: 'red', marginTop: '10px' }}>{error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Handle YouTube links
|
||||
if (
|
||||
completedUrl.includes("youtube.com") ||
|
||||
completedUrl.includes("youtu.be")
|
||||
) {
|
||||
const videoId = extractYouTubeVideoId(completedUrl)
|
||||
if (videoId) {
|
||||
completedUrl = `https://www.youtube.com/embed/${videoId}`
|
||||
} else {
|
||||
setError("Invalid YouTube URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={contentStyle}>
|
||||
<iframe
|
||||
src={shape.props.url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 'none' }}
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Handle Google Docs links
|
||||
if (completedUrl.includes("docs.google.com")) {
|
||||
const docId = completedUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1]
|
||||
if (docId) {
|
||||
completedUrl = `https://docs.google.com/document/d/${docId}/preview`
|
||||
} else {
|
||||
setError("Invalid Google Docs URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.updateShape<IEmbedShape>({
|
||||
id: shape.id,
|
||||
type: "Embed",
|
||||
props: { ...shape.props, url: completedUrl },
|
||||
})
|
||||
|
||||
// Check if the URL is valid
|
||||
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
|
||||
if (!isValidUrl) {
|
||||
setError("Invalid website URL")
|
||||
} else {
|
||||
setError("")
|
||||
}
|
||||
},
|
||||
[inputUrl],
|
||||
)
|
||||
|
||||
const extractYouTubeVideoId = (url: string): string | null => {
|
||||
const regExp =
|
||||
/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||
const match = url.match(regExp)
|
||||
return match && match[2].length === 11 ? match[2] : null
|
||||
}
|
||||
|
||||
const wrapperStyle = {
|
||||
width: `${shape.props.w}px`,
|
||||
height: `${shape.props.h}px`,
|
||||
padding: "15px",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
backgroundColor: "#F0F0F0",
|
||||
borderRadius: "4px",
|
||||
}
|
||||
|
||||
const contentStyle = {
|
||||
pointerEvents: "all" as const,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #D3D3D3",
|
||||
backgroundColor: "#FFFFFF",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
}
|
||||
|
||||
if (!shape.props.url) {
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div
|
||||
style={contentStyle}
|
||||
onClick={() => document.querySelector("input")?.focus()}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ width: "100%", height: "100%", padding: "10px" }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder="Enter URL"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
padding: "10px",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={contentStyle}>
|
||||
<iframe
|
||||
src={shape.props.url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: "none" }}
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import {
|
|||
Box,
|
||||
TLResizeMode,
|
||||
Rectangle2d,
|
||||
} from 'tldraw'
|
||||
} from "tldraw"
|
||||
|
||||
export interface HTMLShape extends TLBaseShape<'html', { w: number; h: number, html: string }> {
|
||||
export interface HTMLShape
|
||||
extends TLBaseShape<"html", { w: number; h: number; html: string }> {
|
||||
props: {
|
||||
w: number
|
||||
h: number
|
||||
|
|
@ -19,47 +20,48 @@ export interface HTMLShape extends TLBaseShape<'html', { w: number; h: number, h
|
|||
}
|
||||
|
||||
export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
|
||||
static override type = 'html' as const
|
||||
static override type = "html" as const
|
||||
override canBind = () => true
|
||||
override canEdit = () => false
|
||||
override canResize = () => true
|
||||
override isAspectRatioLocked = () => false
|
||||
|
||||
getDefaultProps(): HTMLShape['props'] {
|
||||
getDefaultProps(): HTMLShape["props"] {
|
||||
return {
|
||||
w: 100,
|
||||
h: 100,
|
||||
html: "<div></div>"
|
||||
html: "<div></div>",
|
||||
}
|
||||
}
|
||||
|
||||
override onBeforeUpdate = (prev: HTMLShape, next: HTMLShape): void => {
|
||||
if (prev.x !== next.x || prev.y !== next.y) {
|
||||
this.editor.bringToFront([next.id]);
|
||||
this.editor.bringToFront([next.id])
|
||||
}
|
||||
}
|
||||
|
||||
override onResize = (
|
||||
shape: HTMLShape,
|
||||
info: {
|
||||
handle: TLResizeHandle;
|
||||
mode: TLResizeMode;
|
||||
initialBounds: Box;
|
||||
initialShape: HTMLShape;
|
||||
newPoint: VecModel;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}
|
||||
handle: TLResizeHandle
|
||||
mode: TLResizeMode
|
||||
initialBounds: Box
|
||||
initialShape: HTMLShape
|
||||
newPoint: VecModel
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
},
|
||||
) => {
|
||||
const element = document.getElementById(shape.id);
|
||||
if (!element || !element.parentElement) return resizeBox(shape, info);
|
||||
const { width, height } = element.parentElement.getBoundingClientRect();
|
||||
const element = document.getElementById(shape.id)
|
||||
if (!element || !element.parentElement) return resizeBox(shape, info)
|
||||
const { width, height } = element.parentElement.getBoundingClientRect()
|
||||
if (element) {
|
||||
const isOverflowing = element.scrollWidth > width || element.scrollHeight > height;
|
||||
const isOverflowing =
|
||||
element.scrollWidth > width || element.scrollHeight > height
|
||||
if (isOverflowing) {
|
||||
element.parentElement?.classList.add('overflowing');
|
||||
element.parentElement?.classList.add("overflowing")
|
||||
} else {
|
||||
element.parentElement?.classList.remove('overflowing');
|
||||
element.parentElement?.classList.remove("overflowing")
|
||||
}
|
||||
}
|
||||
return resizeBox(shape, info)
|
||||
|
|
@ -74,10 +76,15 @@ export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
|
|||
}
|
||||
|
||||
override component(shape: HTMLShape): JSX.Element {
|
||||
return <div id={shape.id} dangerouslySetInnerHTML={{ __html: shape.props.html }} />
|
||||
return (
|
||||
<div
|
||||
id={shape.id}
|
||||
dangerouslySetInnerHTML={{ __html: shape.props.html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
override indicator(shape: HTMLShape): JSX.Element {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,124 @@
|
|||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||
import { useEffect, useState } from "react";
|
||||
import { WORKER_URL } from '../routes/Board';
|
||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||
import { useEffect, useState } from "react"
|
||||
import { WORKER_URL } from "../routes/Board"
|
||||
|
||||
export type IVideoChatShape = TLBaseShape<
|
||||
'VideoChat',
|
||||
{
|
||||
w: number;
|
||||
h: number;
|
||||
roomUrl: string | null;
|
||||
userName: string;
|
||||
}
|
||||
>;
|
||||
"VideoChat",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
roomUrl: string | null
|
||||
userName: string
|
||||
}
|
||||
>
|
||||
|
||||
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||
static override type = 'VideoChat';
|
||||
static override type = "VideoChat"
|
||||
|
||||
indicator(_shape: IVideoChatShape) {
|
||||
return null;
|
||||
}
|
||||
indicator(_shape: IVideoChatShape) {
|
||||
return null
|
||||
}
|
||||
|
||||
getDefaultProps(): IVideoChatShape['props'] {
|
||||
return {
|
||||
roomUrl: null,
|
||||
w: 640,
|
||||
h: 480,
|
||||
userName: ''
|
||||
};
|
||||
}
|
||||
getDefaultProps(): IVideoChatShape["props"] {
|
||||
return {
|
||||
roomUrl: null,
|
||||
w: 640,
|
||||
h: 480,
|
||||
userName: "",
|
||||
}
|
||||
}
|
||||
|
||||
async ensureRoomExists(shape: IVideoChatShape) {
|
||||
if (shape.props.roomUrl !== null) {
|
||||
return;
|
||||
}
|
||||
async ensureRoomExists(shape: IVideoChatShape) {
|
||||
if (shape.props.roomUrl !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
enable_recording: true,
|
||||
max_participants: 8
|
||||
}
|
||||
})
|
||||
});
|
||||
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
enable_recording: true,
|
||||
max_participants: 8,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
this.editor.updateShape<IVideoChatShape>({
|
||||
id: shape.id,
|
||||
type: 'VideoChat',
|
||||
props: {
|
||||
...shape.props,
|
||||
roomUrl: (data as any).url
|
||||
}
|
||||
});
|
||||
}
|
||||
this.editor.updateShape<IVideoChatShape>({
|
||||
id: shape.id,
|
||||
type: "VideoChat",
|
||||
props: {
|
||||
...shape.props,
|
||||
roomUrl: (data as any).url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
component(shape: IVideoChatShape) {
|
||||
const [isInRoom, setIsInRoom] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
component(shape: IVideoChatShape) {
|
||||
const [isInRoom, setIsInRoom] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInRoom && shape.props.roomUrl) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.daily.co/static/call-machine.js';
|
||||
document.body.appendChild(script);
|
||||
useEffect(() => {
|
||||
if (isInRoom && shape.props.roomUrl) {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://www.daily.co/static/call-machine.js"
|
||||
document.body.appendChild(script)
|
||||
|
||||
script.onload = () => {
|
||||
// @ts-ignore
|
||||
window.DailyIframe.createFrame({
|
||||
iframeStyle: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: '0',
|
||||
borderRadius: '4px'
|
||||
},
|
||||
showLeaveButton: true,
|
||||
showFullscreenButton: true
|
||||
}).join({ url: shape.props.roomUrl });
|
||||
};
|
||||
}
|
||||
}, [isInRoom, shape.props.roomUrl]);
|
||||
script.onload = () => {
|
||||
// @ts-ignore
|
||||
window.DailyIframe.createFrame({
|
||||
iframeStyle: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "0",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
showLeaveButton: true,
|
||||
showFullscreenButton: true,
|
||||
}).join({ url: shape.props.roomUrl })
|
||||
}
|
||||
}
|
||||
}, [isInRoom, shape.props.roomUrl])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
pointerEvents: 'all',
|
||||
width: `${shape.props.w}px`,
|
||||
height: `${shape.props.h}px`,
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
zIndex: 9999,
|
||||
padding: '15px',
|
||||
backgroundColor: '#F0F0F0',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '4px',
|
||||
}}>
|
||||
{!isInRoom ? (
|
||||
<button
|
||||
onClick={() => setIsInRoom(true)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
) : (
|
||||
<div id="daily-call-iframe-container" style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}} />
|
||||
)}
|
||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: "all",
|
||||
width: `${shape.props.w}px`,
|
||||
height: `${shape.props.h}px`,
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "10px",
|
||||
zIndex: 9999,
|
||||
padding: "15px",
|
||||
backgroundColor: "#F0F0F0",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{!isInRoom ? (
|
||||
<button
|
||||
onClick={() => setIsInRoom(true)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
id="daily-call-iframe-container"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { BaseBoxShapeTool } from "tldraw";
|
||||
import { BaseBoxShapeTool } from "tldraw"
|
||||
|
||||
export class ChatBoxTool extends BaseBoxShapeTool {
|
||||
static override id = 'ChatBox'
|
||||
shapeType = 'ChatBox';
|
||||
override initial = 'idle';
|
||||
}
|
||||
static override id = "ChatBox"
|
||||
shapeType = "ChatBox"
|
||||
override initial = "idle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { BaseBoxShapeTool } from "tldraw";
|
||||
import { BaseBoxShapeTool } from "tldraw"
|
||||
|
||||
export class EmbedTool extends BaseBoxShapeTool {
|
||||
static override id = 'Embed'
|
||||
shapeType = 'Embed';
|
||||
override initial = 'idle';
|
||||
|
||||
// Additional methods for handling video chat functionality can be added here
|
||||
}
|
||||
static override id = "Embed"
|
||||
shapeType = "Embed"
|
||||
override initial = "idle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { BaseBoxShapeTool } from "tldraw";
|
||||
import { BaseBoxShapeTool } from "tldraw"
|
||||
|
||||
export class VideoChatTool extends BaseBoxShapeTool {
|
||||
static override id = 'VideoChat'
|
||||
shapeType = 'VideoChat';
|
||||
override initial = 'idle';
|
||||
|
||||
// Additional methods for handling video chat functionality can be added here
|
||||
}
|
||||
static override id = "VideoChat"
|
||||
shapeType = "VideoChat"
|
||||
override initial = "idle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw'
|
||||
|
||||
const versions = createShapePropsMigrationIds(
|
||||
// this must match the shape type in the shape definition
|
||||
'card',
|
||||
{
|
||||
AddSomeProperty: 1,
|
||||
}
|
||||
)
|
||||
|
||||
// Migrations for the custom card shape (optional but very helpful)
|
||||
export const cardShapeMigrations = createShapePropsMigrationSequence({
|
||||
sequence: [
|
||||
{
|
||||
id: versions.AddSomeProperty,
|
||||
up(props) {
|
||||
// it is safe to mutate the props object here
|
||||
props.someProperty = 'some value'
|
||||
},
|
||||
down(props) {
|
||||
delete props.someProperty
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { DefaultColorStyle, RecordProps, T } from 'tldraw'
|
||||
import { ICardShape } from './card-shape-types'
|
||||
|
||||
// Validation for our custom card shape's props, using one of tldraw's default styles
|
||||
export const cardShapeProps: RecordProps<ICardShape> = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
color: DefaultColorStyle,
|
||||
}
|
||||
|
||||
// To generate your own custom styles, check out the custom styles example.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { TLBaseShape, TLDefaultColorStyle } from 'tldraw'
|
||||
|
||||
// A type for our custom card shape
|
||||
export type ICardShape = TLBaseShape<
|
||||
'card',
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
color: TLDefaultColorStyle
|
||||
}
|
||||
>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { AssetRecordType, TLAsset, TLBookmarkAsset, getHashForString } from 'tldraw'
|
||||
|
||||
// How does our server handle bookmark unfurling?
|
||||
export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAsset> {
|
||||
// we start with an empty asset record
|
||||
const asset: TLBookmarkAsset = {
|
||||
id: AssetRecordType.createId(getHashForString(url)),
|
||||
typeName: 'asset',
|
||||
type: 'bookmark',
|
||||
meta: {},
|
||||
props: {
|
||||
src: url,
|
||||
description: '',
|
||||
image: '',
|
||||
favicon: '',
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
// try to fetch the preview data from the server
|
||||
const response = await fetch(
|
||||
`${process.env.TLDRAW_WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`
|
||||
)
|
||||
const data = await response.json() as {description: string, image: string, favicon: string, title: string}
|
||||
|
||||
// fill in our asset with whatever info we found
|
||||
asset.props.description = data?.description ?? ''
|
||||
asset.props.image = data?.image ?? ''
|
||||
asset.props.favicon = data?.favicon ?? ''
|
||||
asset.props.title = data?.title ?? ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
return asset
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLBaseShape } from 'tldraw'
|
||||
|
||||
// There's a guide at the bottom of this file!
|
||||
|
||||
type IMyInteractiveShape = TLBaseShape<
|
||||
'my-interactive-shape',
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
checked: boolean
|
||||
text: string
|
||||
}
|
||||
>
|
||||
|
||||
export class myInteractiveShape extends BaseBoxShapeUtil<IMyInteractiveShape> {
|
||||
static override type = 'my-interactive-shape' as const
|
||||
static override props: RecordProps<IMyInteractiveShape> = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
checked: T.boolean,
|
||||
text: T.string,
|
||||
}
|
||||
|
||||
getDefaultProps(): IMyInteractiveShape['props'] {
|
||||
return {
|
||||
w: 230,
|
||||
h: 230,
|
||||
checked: false,
|
||||
text: '',
|
||||
}
|
||||
}
|
||||
|
||||
// [1]
|
||||
component(shape: IMyInteractiveShape) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
style={{
|
||||
padding: 16,
|
||||
height: shape.props.h,
|
||||
width: shape.props.w,
|
||||
// [a] This is where we allow pointer events on our shape
|
||||
pointerEvents: 'all',
|
||||
backgroundColor: '#efefef',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shape.props.checked}
|
||||
onChange={() =>
|
||||
this.editor.updateShape<IMyInteractiveShape>({
|
||||
id: shape.id,
|
||||
type: 'my-interactive-shape',
|
||||
props: { checked: !shape.props.checked },
|
||||
})
|
||||
}
|
||||
// [b] This is where we stop event propagation
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a todo..."
|
||||
readOnly={shape.props.checked}
|
||||
value={shape.props.text}
|
||||
onChange={(e) =>
|
||||
this.editor.updateShape<IMyInteractiveShape>({
|
||||
id: shape.id,
|
||||
type: 'my-interactive-shape',
|
||||
props: { text: e.currentTarget.value },
|
||||
})
|
||||
}
|
||||
// [c]
|
||||
onPointerDown={(e) => {
|
||||
if (!shape.props.checked) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (!shape.props.checked) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
if (!shape.props.checked) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// [5]
|
||||
indicator(shape: IMyInteractiveShape) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
This is a custom shape, for a more in-depth look at how to create a custom shape,
|
||||
see our custom shape example.
|
||||
|
||||
[1]
|
||||
This is where we describe how our shape will render
|
||||
|
||||
[a] We need to set pointer-events to all so that we can interact with our shape. This CSS property is
|
||||
set to "none" off by default. We need to manually opt-in to accepting pointer events by setting it to
|
||||
'all' or 'auto'.
|
||||
|
||||
[b] We need to stop event propagation so that the editor doesn't select the shape
|
||||
when we click on the checkbox. The 'canvas container' forwards events that it receives
|
||||
on to the editor, so stopping propagation here prevents the event from reaching the canvas.
|
||||
|
||||
[c] If the shape is not checked, we stop event propagation so that the editor doesn't
|
||||
select the shape when we click on the input. If the shape is checked then we allow that event to
|
||||
propagate to the canvas and then get sent to the editor, triggering clicks or drags as usual.
|
||||
|
||||
*/
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export const calcReadingTime = (text: string): string => {
|
||||
if (!text) return "∞ min read";
|
||||
|
||||
const wordsPerMinute = 300;
|
||||
const wordCount = text.split(/\s+/).length;
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
|
||||
return `${minutes} min read`;
|
||||
};
|
||||
|
|
@ -1,41 +1,26 @@
|
|||
import { markdownPlugin } from './build/markdownPlugin';
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
envPrefix: ['VITE_'],
|
||||
plugins: [
|
||||
react(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
markdownPlugin,
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: 'src/posts/',
|
||||
dest: '.'
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
envPrefix: ["VITE_"],
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
base: '/',
|
||||
publicDir: 'src/public',
|
||||
base: "/",
|
||||
publicDir: "src/public",
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_WORKER_URL': JSON.stringify(process.env.VITE_WORKER_URL)
|
||||
}
|
||||
"import.meta.env.VITE_WORKER_URL": JSON.stringify(
|
||||
process.env.VITE_WORKER_URL
|
||||
),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue