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 { useEffect } from "react"
|
||||||
import { Editor, TLEventMap, TLFrameShape, TLParentId } from 'tldraw';
|
import { Editor, TLEventMap, TLFrameShape, TLParentId } from "tldraw"
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
// Define camera state interface
|
// Define camera state interface
|
||||||
interface CameraState {
|
interface CameraState {
|
||||||
x: number;
|
x: number
|
||||||
y: number;
|
y: number
|
||||||
z: number;
|
z: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_HISTORY = 10;
|
const MAX_HISTORY = 10
|
||||||
let cameraHistory: CameraState[] = [];
|
let cameraHistory: CameraState[] = []
|
||||||
|
|
||||||
|
// TODO: use this
|
||||||
|
|
||||||
// Improved camera change tracking with debouncing
|
// Improved camera change tracking with debouncing
|
||||||
const trackCameraChange = (editor: Editor) => {
|
const trackCameraChange = (editor: Editor) => {
|
||||||
const currentCamera = editor.getCamera();
|
const currentCamera = editor.getCamera()
|
||||||
const lastPosition = cameraHistory[cameraHistory.length - 1];
|
const lastPosition = cameraHistory[cameraHistory.length - 1]
|
||||||
|
|
||||||
// Store any viewport change that's not from a revert operation
|
// Store any viewport change that's not from a revert operation
|
||||||
if (!lastPosition ||
|
if (
|
||||||
currentCamera.x !== lastPosition.x ||
|
!lastPosition ||
|
||||||
currentCamera.y !== lastPosition.y ||
|
currentCamera.x !== lastPosition.x ||
|
||||||
currentCamera.z !== lastPosition.z) {
|
currentCamera.y !== lastPosition.y ||
|
||||||
cameraHistory.push({ ...currentCamera });
|
currentCamera.z !== lastPosition.z
|
||||||
if (cameraHistory.length > MAX_HISTORY) {
|
) {
|
||||||
cameraHistory.shift();
|
cameraHistory.push({ ...currentCamera })
|
||||||
}
|
if (cameraHistory.length > MAX_HISTORY) {
|
||||||
|
cameraHistory.shift()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useCameraControls(editor: Editor | null) {
|
export function useCameraControls(editor: Editor | null) {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
// Handle URL-based camera positioning
|
// Handle URL-based camera positioning
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return
|
||||||
|
|
||||||
const frameId = searchParams.get('frameId');
|
const frameId = searchParams.get("frameId")
|
||||||
const x = searchParams.get('x');
|
const x = searchParams.get("x")
|
||||||
const y = searchParams.get('y');
|
const y = searchParams.get("y")
|
||||||
const zoom = searchParams.get('zoom');
|
const zoom = searchParams.get("zoom")
|
||||||
|
|
||||||
if (x && y && zoom) {
|
if (x && y && zoom) {
|
||||||
editor.setCamera({
|
editor.setCamera({
|
||||||
x: parseFloat(x),
|
x: parseFloat(x),
|
||||||
y: parseFloat(y),
|
y: parseFloat(y),
|
||||||
z: parseFloat(zoom)
|
z: parseFloat(zoom),
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frameId) {
|
if (frameId) {
|
||||||
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
console.warn('Frame not found:', frameId);
|
console.warn("Frame not found:", frameId)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use editor's built-in zoomToBounds with animation
|
// Use editor's built-in zoomToBounds with animation
|
||||||
editor.zoomToBounds(
|
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
|
||||||
editor.getShapePageBounds(frame)!,
|
inset: 32,
|
||||||
{
|
animation: { duration: 500 },
|
||||||
inset: 32,
|
})
|
||||||
animation: { duration: 500 }
|
}
|
||||||
}
|
}, [editor, searchParams])
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [editor, searchParams]);
|
|
||||||
|
|
||||||
// Track camera changes
|
// Track camera changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return
|
||||||
|
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
trackCameraChange(editor);
|
trackCameraChange(editor)
|
||||||
};
|
}
|
||||||
|
|
||||||
// Track both viewport changes and user interaction end
|
// Track both viewport changes and user interaction end
|
||||||
editor.on('viewportChange' as keyof TLEventMap, handler);
|
editor.on("viewportChange" as keyof TLEventMap, handler)
|
||||||
editor.on('userChangeEnd' as keyof TLEventMap, handler);
|
editor.on("userChangeEnd" as keyof TLEventMap, handler)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
editor.off('viewportChange' as keyof TLEventMap, handler);
|
editor.off("viewportChange" as keyof TLEventMap, handler)
|
||||||
editor.off('userChangeEnd' as keyof TLEventMap, handler);
|
editor.off("userChangeEnd" as keyof TLEventMap, handler)
|
||||||
};
|
}
|
||||||
}, [editor]);
|
}, [editor])
|
||||||
|
|
||||||
// Enhanced camera control functions
|
// Enhanced camera control functions
|
||||||
return {
|
return {
|
||||||
zoomToFrame: (frameId: string) => {
|
zoomToFrame: (frameId: string) => {
|
||||||
if (!editor) return;
|
if (!editor) return
|
||||||
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
|
||||||
if (!frame) return;
|
if (!frame) return
|
||||||
|
|
||||||
editor.zoomToBounds(
|
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
|
||||||
editor.getShapePageBounds(frame)!,
|
inset: 32,
|
||||||
{
|
animation: { duration: 500 },
|
||||||
inset: 32,
|
})
|
||||||
animation: { duration: 500 }
|
},
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
copyFrameLink: (frameId: string) => {
|
copyFrameLink: (frameId: string) => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('frameId', frameId);
|
url.searchParams.set("frameId", frameId)
|
||||||
navigator.clipboard.writeText(url.toString());
|
navigator.clipboard.writeText(url.toString())
|
||||||
},
|
},
|
||||||
|
|
||||||
copyLocationLink: () => {
|
copyLocationLink: () => {
|
||||||
if (!editor) return;
|
if (!editor) return
|
||||||
const camera = editor.getCamera();
|
const camera = editor.getCamera()
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('x', camera.x.toString());
|
url.searchParams.set("x", camera.x.toString())
|
||||||
url.searchParams.set('y', camera.y.toString());
|
url.searchParams.set("y", camera.y.toString())
|
||||||
url.searchParams.set('zoom', camera.z.toString());
|
url.searchParams.set("zoom", camera.z.toString())
|
||||||
navigator.clipboard.writeText(url.toString());
|
navigator.clipboard.writeText(url.toString())
|
||||||
},
|
},
|
||||||
|
|
||||||
revertCamera: () => {
|
revertCamera: () => {
|
||||||
if (!editor || cameraHistory.length === 0) return;
|
if (!editor || cameraHistory.length === 0) return
|
||||||
const previousCamera = cameraHistory.pop();
|
const previousCamera = cameraHistory.pop()
|
||||||
if (previousCamera) {
|
if (previousCamera) {
|
||||||
editor.setCamera(previousCamera, { animation: { duration: 200 } });
|
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 { useSync } from "@tldraw/sync"
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
AssetRecordType,
|
AssetRecordType,
|
||||||
getHashForString,
|
getHashForString,
|
||||||
TLBookmarkAsset,
|
TLBookmarkAsset,
|
||||||
Tldraw,
|
Tldraw,
|
||||||
Editor,
|
Editor,
|
||||||
} from 'tldraw'
|
} from "tldraw"
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from "react-router-dom"
|
||||||
import { ChatBoxTool } from '@/tools/ChatBoxTool'
|
import { ChatBoxTool } from "@/tools/ChatBoxTool"
|
||||||
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
|
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
|
||||||
import { VideoChatTool } from '@/tools/VideoChatTool'
|
import { VideoChatTool } from "@/tools/VideoChatTool"
|
||||||
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
|
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
|
||||||
import { multiplayerAssetStore } from '../utils/multiplayerAssetStore'
|
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
|
||||||
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||||
import { EmbedTool } from '@/tools/EmbedTool'
|
import { EmbedTool } from "@/tools/EmbedTool"
|
||||||
import { defaultShapeUtils, defaultBindingUtils } from 'tldraw'
|
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
|
||||||
|
import { useState } from "react"
|
||||||
import { useState } from 'react';
|
import { components, overrides } from "@/ui-overrides"
|
||||||
import { components, overrides } from '@/ui-overrides'
|
|
||||||
|
|
||||||
// Default to production URL if env var isn't available
|
// 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 shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
|
||||||
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools
|
const tools = [ChatBoxTool, VideoChatTool, EmbedTool] // Array of tools
|
||||||
|
|
||||||
|
|
||||||
export function Board() {
|
export function Board() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const roomId = slug || 'default-room';
|
const roomId = slug || "default-room"
|
||||||
|
|
||||||
const storeConfig = useMemo(() => ({
|
const storeConfig = useMemo(
|
||||||
uri: `${WORKER_URL}/connect/${roomId}`,
|
() => ({
|
||||||
assets: multiplayerAssetStore,
|
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||||
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
|
assets: multiplayerAssetStore,
|
||||||
bindingUtils: [...defaultBindingUtils],
|
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
|
||||||
}), [roomId]);
|
bindingUtils: [...defaultBindingUtils],
|
||||||
|
}),
|
||||||
|
[roomId],
|
||||||
|
)
|
||||||
|
|
||||||
const store = useSync(storeConfig);
|
const store = useSync(storeConfig)
|
||||||
const [editor, setEditor] = useState<Editor | null>(null)
|
const [editor, setEditor] = useState<Editor | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0 }}>
|
<div style={{ position: "fixed", inset: 0 }}>
|
||||||
<Tldraw
|
<Tldraw
|
||||||
store={store.store}
|
store={store.store}
|
||||||
shapeUtils={shapeUtils}
|
shapeUtils={shapeUtils}
|
||||||
tools={tools}
|
tools={tools}
|
||||||
components={components}
|
components={components}
|
||||||
overrides={overrides}
|
overrides={overrides}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
setEditor(editor)
|
setEditor(editor)
|
||||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||||
editor.setCurrentTool('hand')
|
editor.setCurrentTool("hand")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// How does our server handle bookmark unfurling?
|
// How does our server handle bookmark unfurling?
|
||||||
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
|
async function unfurlBookmarkUrl({
|
||||||
const asset: TLBookmarkAsset = {
|
url,
|
||||||
id: AssetRecordType.createId(getHashForString(url)),
|
}: {
|
||||||
typeName: 'asset',
|
url: string
|
||||||
type: 'bookmark',
|
}): Promise<TLBookmarkAsset> {
|
||||||
meta: {},
|
const asset: TLBookmarkAsset = {
|
||||||
props: {
|
id: AssetRecordType.createId(getHashForString(url)),
|
||||||
src: url,
|
typeName: "asset",
|
||||||
description: '',
|
type: "bookmark",
|
||||||
image: '',
|
meta: {},
|
||||||
favicon: '',
|
props: {
|
||||||
title: '',
|
src: url,
|
||||||
},
|
description: "",
|
||||||
}
|
image: "",
|
||||||
|
favicon: "",
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`)
|
const response = await fetch(
|
||||||
const data = await response.json() as { description: string, image: string, favicon: string, title: string }
|
`${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.description = data?.description ?? ""
|
||||||
asset.props.image = data?.image ?? ''
|
asset.props.image = data?.image ?? ""
|
||||||
asset.props.favicon = data?.favicon ?? ''
|
asset.props.favicon = data?.favicon ?? ""
|
||||||
asset.props.title = data?.title ?? ''
|
asset.props.title = data?.title ?? ""
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return asset
|
return asset
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,28 @@ export function Contact() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
<a href="/">
|
<a href="/">Jeff Emmett</a>
|
||||||
Jeff Emmett
|
|
||||||
</a>
|
|
||||||
</header>
|
</header>
|
||||||
<h1>Contact</h1>
|
<h1>Contact</h1>
|
||||||
<p>Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a></p>
|
<p>
|
||||||
<p>BlueSky: <a href="https://bsky.app/profile/jeffemmett.bsky.social">@jeffemnmett.bsky.social</a></p>
|
Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
|
||||||
<p>Mastodon: <a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a></p>
|
</p>
|
||||||
<p>Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a></p>
|
<p>
|
||||||
<p>GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a></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>
|
</main>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,106 @@
|
||||||
export function Default() {
|
export function Default() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>Jeff Emmett</header>
|
||||||
Jeff Emmett
|
|
||||||
</header>
|
|
||||||
<h2>Hello! 👋🍄</h2>
|
<h2>Hello! 👋🍄</h2>
|
||||||
<p>
|
<p>
|
||||||
My research investigates the intersection of mycelium and emancipatory technologies.
|
My research investigates the intersection of mycelium and emancipatory
|
||||||
I am interested in the potential of new convivial tooling as a medium for group
|
technologies. I am interested in the potential of new convivial tooling
|
||||||
consensus building and collective action, in order to empower communities of practice to address their own challenges.
|
as a medium for group consensus building and collective action, in order
|
||||||
|
to empower communities of practice to address their own challenges.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
My current focus is basic research into the nature of digital
|
My current focus is basic research into the nature of digital
|
||||||
organisation, developing prototype toolkits to improve shared
|
organisation, developing prototype toolkits to improve shared
|
||||||
infrastructure, and applying this research to the design of new
|
infrastructure, and applying this research to the design of new systems
|
||||||
systems and protocols which support the self-organisation of knowledge
|
and protocols which support the self-organisation of knowledge and
|
||||||
and emergent response to local needs.
|
emergent response to local needs.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>My work</h2>
|
<h2>My work</h2>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
|
|
||||||
<h2>Get in touch</h2>
|
<h2>Get in touch</h2>
|
||||||
<p>
|
<p>
|
||||||
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</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>.
|
, Mastodon{" "}
|
||||||
|
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
|
||||||
|
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<span className="dinkus">***</span>
|
<span className="dinkus">***</span>
|
||||||
|
|
||||||
<h2>Talks</h2>
|
<h2>Talks</h2>
|
||||||
<ol reversed>
|
<ol reversed>
|
||||||
<li><a
|
<li>
|
||||||
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>)
|
<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>
|
||||||
<li><a
|
<li>
|
||||||
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>)
|
<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>
|
||||||
<li><a
|
<li>
|
||||||
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>)
|
<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>
|
||||||
<li><a
|
<li>
|
||||||
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>)
|
<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>
|
||||||
<li><a
|
<li>
|
||||||
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>)
|
<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>
|
||||||
<li><a
|
|
||||||
href="https://youtu.be/kxcat-XBWas">A Discussion on Warm Data with Nora Bateson on Systems Innovation</a></li>
|
|
||||||
</ol>
|
</ol>
|
||||||
<h2>Writing</h2>
|
<h2>Writing</h2>
|
||||||
<ol reversed>
|
<ol reversed>
|
||||||
<li><a
|
<li>
|
||||||
href="https://www.mycofi.art">Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond</a></li>
|
<a href="https://www.mycofi.art">
|
||||||
<li><a
|
Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond
|
||||||
href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">Challenges & Approaches to Scaling the Global Commons</a></li>
|
</a>
|
||||||
<li><a
|
</li>
|
||||||
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>
|
||||||
<li><a
|
<a href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">
|
||||||
href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">Rewriting the Story of Human Collaboration</a></li>
|
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>
|
</ol>
|
||||||
</main>
|
</main>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,59 @@
|
||||||
import { createShapeId, Editor, Tldraw, TLGeoShape, TLShapePartial } from "tldraw";
|
import {
|
||||||
import { useEffect, useRef } from "react";
|
createShapeId,
|
||||||
|
Editor,
|
||||||
|
Tldraw,
|
||||||
|
TLGeoShape,
|
||||||
|
TLShapePartial,
|
||||||
|
} from "tldraw"
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
export function Inbox() {
|
export function Inbox() {
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
|
||||||
const updateEmails = async (editor: Editor) => {
|
const updateEmails = async (editor: Editor) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://jeffemmett-canvas.web.val.run', {
|
const response = await fetch("https://jeffemmett-canvas.web.val.run", {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
});
|
})
|
||||||
const messages = await response.json() as { id: string, from: string, subject: string, text: string }[];
|
const messages = (await response.json()) as {
|
||||||
|
id: string
|
||||||
|
from: string
|
||||||
|
subject: string
|
||||||
|
text: string
|
||||||
|
}[]
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i];
|
const message = messages[i]
|
||||||
const messageId = message.id;
|
const messageId = message.id
|
||||||
const parsedEmailName = message.from.match(/^([^<]+)/)?.[1]?.trim() || message.from.match(/[^<@]+(?=@)/)?.[0] || message.from;
|
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 messageText = `from: ${parsedEmailName}\nsubject: ${message.subject}\n\n${message.text}`
|
||||||
const shapeWidth = 500
|
const shapeWidth = 500
|
||||||
const shapeHeight = 300
|
const shapeHeight = 300
|
||||||
const spacing = 50
|
const spacing = 50
|
||||||
const shape: TLShapePartial<TLGeoShape> = {
|
const shape: TLShapePartial<TLGeoShape> = {
|
||||||
id: createShapeId(),
|
id: createShapeId(),
|
||||||
type: 'geo',
|
type: "geo",
|
||||||
x: shapeWidth * (i % 5) + spacing * (i % 5),
|
x: shapeWidth * (i % 5) + spacing * (i % 5),
|
||||||
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
|
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
|
||||||
props: {
|
props: {
|
||||||
w: shapeWidth,
|
w: shapeWidth,
|
||||||
h: shapeHeight,
|
h: shapeHeight,
|
||||||
text: messageText,
|
text: messageText,
|
||||||
align:'start',
|
align: "start",
|
||||||
verticalAlign:'start'
|
verticalAlign: "start",
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
id: messageId
|
id: messageId,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
let found = false;
|
let found = false
|
||||||
for (const s of editor.getCurrentPageShapes()) {
|
for (const s of editor.getCurrentPageShapes()) {
|
||||||
if (s.meta.id === messageId) {
|
if (s.meta.id === messageId) {
|
||||||
found = true;
|
found = true
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!found) {
|
if (!found) {
|
||||||
|
|
@ -47,28 +61,28 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error("Error fetching data:", error)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
updateEmails(editorRef.current);
|
updateEmails(editorRef.current)
|
||||||
}
|
}
|
||||||
}, 5*1000);
|
}, 5 * 1000)
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tldraw__editor">
|
<div className="tldraw__editor">
|
||||||
<Tldraw
|
<Tldraw
|
||||||
onMount={(editor: Editor) => {
|
onMount={(editor: Editor) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor
|
||||||
updateEmails(editor);
|
updateEmails(editor)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,191 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||||
|
|
||||||
export type IChatBoxShape = TLBaseShape<
|
export type IChatBoxShape = TLBaseShape<
|
||||||
'ChatBox',
|
"ChatBox",
|
||||||
{
|
{
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
roomId: string
|
roomId: string
|
||||||
userName: string
|
userName: string
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
||||||
static override type = 'ChatBox'
|
static override type = "ChatBox"
|
||||||
|
|
||||||
getDefaultProps(): IChatBoxShape['props'] {
|
getDefaultProps(): IChatBoxShape["props"] {
|
||||||
return {
|
return {
|
||||||
roomId: 'default-room',
|
roomId: "default-room",
|
||||||
w: 100,
|
w: 100,
|
||||||
h: 100,
|
h: 100,
|
||||||
userName: '',
|
userName: "",
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
indicator(shape: IChatBoxShape) {
|
indicator(shape: IChatBoxShape) {
|
||||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||||
}
|
}
|
||||||
|
|
||||||
component(shape: IChatBoxShape) {
|
component(shape: IChatBoxShape) {
|
||||||
return (
|
return (
|
||||||
<ChatBox roomId={shape.props.roomId} w={shape.props.w} h={shape.props.h} userName="" />
|
<ChatBox
|
||||||
)
|
roomId={shape.props.roomId}
|
||||||
}
|
w={shape.props.w}
|
||||||
|
h={shape.props.h}
|
||||||
|
userName=""
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string
|
||||||
username: string;
|
username: string
|
||||||
content: string;
|
content: string
|
||||||
timestamp: Date;
|
timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Update the ChatBox component to accept userName
|
// Update the ChatBox component to accept userName
|
||||||
export const ChatBox: React.FC<IChatBoxShape['props']> = ({ roomId, w, h, userName }) => {
|
export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
roomId,
|
||||||
const [inputMessage, setInputMessage] = useState("");
|
w,
|
||||||
const [username, setUsername] = useState(userName);
|
h,
|
||||||
const messagesEndRef = useRef(null);
|
userName,
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [inputMessage, setInputMessage] = useState("")
|
||||||
|
const [username, setUsername] = useState(userName)
|
||||||
|
const messagesEndRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUsername = localStorage.getItem("chatUsername");
|
const storedUsername = localStorage.getItem("chatUsername")
|
||||||
if (storedUsername) {
|
if (storedUsername) {
|
||||||
setUsername(storedUsername);
|
setUsername(storedUsername)
|
||||||
} else {
|
} else {
|
||||||
const newUsername = `User${Math.floor(Math.random() * 1000)}`;
|
const newUsername = `User${Math.floor(Math.random() * 1000)}`
|
||||||
setUsername(newUsername);
|
setUsername(newUsername)
|
||||||
localStorage.setItem("chatUsername", newUsername);
|
localStorage.setItem("chatUsername", newUsername)
|
||||||
}
|
}
|
||||||
fetchMessages(roomId);
|
fetchMessages(roomId)
|
||||||
const interval = setInterval(() => fetchMessages(roomId), 2000);
|
const interval = setInterval(() => fetchMessages(roomId), 2000)
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval)
|
||||||
}, [roomId]);
|
}, [roomId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
if (messagesEndRef.current) {
|
||||||
(messagesEndRef.current as HTMLElement).scrollIntoView({ behavior: "smooth" });
|
;(messagesEndRef.current as HTMLElement).scrollIntoView({
|
||||||
}
|
behavior: "smooth",
|
||||||
}, [messages]);
|
})
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
const fetchMessages = async (roomId: string) => {
|
const fetchMessages = async (roomId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`);
|
const response = await fetch(
|
||||||
if (!response.ok) {
|
`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`,
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
)
|
||||||
}
|
if (!response.ok) {
|
||||||
const newMessages = await response.json() as Message[];
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
setMessages(newMessages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp) })));
|
}
|
||||||
} catch (error) {
|
const newMessages = (await response.json()) as Message[]
|
||||||
console.error('Error fetching messages:', error);
|
setMessages(
|
||||||
}
|
newMessages.map((msg) => ({
|
||||||
};
|
...msg,
|
||||||
|
timestamp: new Date(msg.timestamp),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching messages:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendMessage = async (e: React.FormEvent) => {
|
const sendMessage = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
if (!inputMessage.trim()) return;
|
if (!inputMessage.trim()) return
|
||||||
await sendMessageToChat(roomId, username, inputMessage);
|
await sendMessageToChat(roomId, username, inputMessage)
|
||||||
setInputMessage("");
|
setInputMessage("")
|
||||||
fetchMessages(roomId);
|
fetchMessages(roomId)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-container" style={{ pointerEvents: 'all', width: `${w}px`, height: `${h}px`, overflow: 'auto', touchAction: 'auto' }}>
|
<div
|
||||||
<div className="messages-container">
|
className="chat-container"
|
||||||
{messages.map((msg) => (
|
style={{
|
||||||
<div key={msg.id} className={`message ${msg.username === username ? 'own-message' : ''}`}>
|
pointerEvents: "all",
|
||||||
<div className="message-header">
|
width: `${w}px`,
|
||||||
<strong>{msg.username}</strong>
|
height: `${h}px`,
|
||||||
<span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
overflow: "auto",
|
||||||
</div>
|
touchAction: "auto",
|
||||||
<div className="message-content">{msg.content}</div>
|
}}
|
||||||
</div>
|
>
|
||||||
))}
|
<div className="messages-container">
|
||||||
<div ref={messagesEndRef} />
|
{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>
|
||||||
<form onSubmit={sendMessage} className="input-form">
|
<div className="message-content">{msg.content}</div>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
))}
|
||||||
value={inputMessage}
|
<div ref={messagesEndRef} />
|
||||||
onChange={(e) => setInputMessage(e.target.value)}
|
</div>
|
||||||
placeholder="Type a message..."
|
<form onSubmit={sendMessage} className="input-form">
|
||||||
className="message-input"
|
<input
|
||||||
style={{ touchAction: 'manipulation' }}
|
type="text"
|
||||||
/>
|
value={inputMessage}
|
||||||
<button
|
onChange={(e) => setInputMessage(e.target.value)}
|
||||||
type="submit"
|
placeholder="Type a message..."
|
||||||
style={{ pointerEvents: 'all', touchAction: 'manipulation' }}
|
className="message-input"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
style={{ touchAction: "manipulation" }}
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
/>
|
||||||
className="send-button"
|
<button
|
||||||
>
|
type="submit"
|
||||||
Send
|
style={{ pointerEvents: "all", touchAction: "manipulation" }}
|
||||||
</button>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
</form>
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
</div>
|
className="send-button"
|
||||||
);
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessageToChat(roomId: string, username: string, content: string): Promise<void> {
|
async function sendMessageToChat(
|
||||||
const apiUrl = 'https://jeffemmett-realtimechatappwithpolling.web.val.run'; // Replace with your actual Val Town URL
|
roomId: string,
|
||||||
|
username: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const apiUrl = "https://jeffemmett-realtimechatappwithpolling.web.val.run" // Replace with your actual Val Town URL
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}?action=sendMessage`, {
|
const response = await fetch(`${apiUrl}?action=sendMessage`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
mode: 'no-cors',
|
mode: "no-cors",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
roomId,
|
roomId,
|
||||||
username,
|
username,
|
||||||
content,
|
content,
|
||||||
}),
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
const result = await response.text();
|
const result = await response.text()
|
||||||
console.log('Message sent successfully:', result);
|
console.log("Message sent successfully:", result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error);
|
console.error("Error sending message:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,138 +1,165 @@
|
||||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react"
|
||||||
|
|
||||||
export type IEmbedShape = TLBaseShape<
|
export type IEmbedShape = TLBaseShape<
|
||||||
'Embed',
|
"Embed",
|
||||||
{
|
{
|
||||||
w: number;
|
w: number
|
||||||
h: number;
|
h: number
|
||||||
url: string | null;
|
url: string | null
|
||||||
}
|
}
|
||||||
>;
|
>
|
||||||
|
|
||||||
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
|
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
|
||||||
static override type = 'Embed';
|
static override type = "Embed"
|
||||||
|
|
||||||
getDefaultProps(): IEmbedShape['props'] {
|
getDefaultProps(): IEmbedShape["props"] {
|
||||||
return {
|
return {
|
||||||
url: null,
|
url: null,
|
||||||
w: 640,
|
w: 640,
|
||||||
h: 480,
|
h: 480,
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
indicator(shape: IEmbedShape) {
|
indicator(shape: IEmbedShape) {
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||||
</g>
|
</g>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
component(shape: IEmbedShape) {
|
component(shape: IEmbedShape) {
|
||||||
const [inputUrl, setInputUrl] = useState(shape.props.url || '');
|
const [inputUrl, setInputUrl] = useState(shape.props.url || "")
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
e.preventDefault();
|
(e: React.FormEvent) => {
|
||||||
let completedUrl = inputUrl.startsWith('http://') || inputUrl.startsWith('https://') ? inputUrl : `https://${inputUrl}`;
|
e.preventDefault()
|
||||||
|
let completedUrl =
|
||||||
|
inputUrl.startsWith("http://") || inputUrl.startsWith("https://")
|
||||||
|
? inputUrl
|
||||||
|
: `https://${inputUrl}`
|
||||||
|
|
||||||
// Handle YouTube links
|
// Handle YouTube links
|
||||||
if (completedUrl.includes('youtube.com') || completedUrl.includes('youtu.be')) {
|
if (
|
||||||
const videoId = extractYouTubeVideoId(completedUrl);
|
completedUrl.includes("youtube.com") ||
|
||||||
if (videoId) {
|
completedUrl.includes("youtu.be")
|
||||||
completedUrl = `https://www.youtube.com/embed/${videoId}`;
|
) {
|
||||||
} else {
|
const videoId = extractYouTubeVideoId(completedUrl)
|
||||||
setError('Invalid YouTube URL');
|
if (videoId) {
|
||||||
return;
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Handle Google Docs links
|
||||||
<div style={wrapperStyle}>
|
if (completedUrl.includes("docs.google.com")) {
|
||||||
<div style={contentStyle}>
|
const docId = completedUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1]
|
||||||
<iframe
|
if (docId) {
|
||||||
src={shape.props.url}
|
completedUrl = `https://docs.google.com/document/d/${docId}/preview`
|
||||||
width="100%"
|
} else {
|
||||||
height="100%"
|
setError("Invalid Google Docs URL")
|
||||||
style={{ border: 'none' }}
|
return
|
||||||
allowFullScreen
|
}
|
||||||
/>
|
}
|
||||||
</div>
|
|
||||||
</div>
|
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,
|
Box,
|
||||||
TLResizeMode,
|
TLResizeMode,
|
||||||
Rectangle2d,
|
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: {
|
props: {
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
|
|
@ -19,47 +20,48 @@ export interface HTMLShape extends TLBaseShape<'html', { w: number; h: number, h
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
|
export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
|
||||||
static override type = 'html' as const
|
static override type = "html" as const
|
||||||
override canBind = () => true
|
override canBind = () => true
|
||||||
override canEdit = () => false
|
override canEdit = () => false
|
||||||
override canResize = () => true
|
override canResize = () => true
|
||||||
override isAspectRatioLocked = () => false
|
override isAspectRatioLocked = () => false
|
||||||
|
|
||||||
getDefaultProps(): HTMLShape['props'] {
|
getDefaultProps(): HTMLShape["props"] {
|
||||||
return {
|
return {
|
||||||
w: 100,
|
w: 100,
|
||||||
h: 100,
|
h: 100,
|
||||||
html: "<div></div>"
|
html: "<div></div>",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override onBeforeUpdate = (prev: HTMLShape, next: HTMLShape): void => {
|
override onBeforeUpdate = (prev: HTMLShape, next: HTMLShape): void => {
|
||||||
if (prev.x !== next.x || prev.y !== next.y) {
|
if (prev.x !== next.x || prev.y !== next.y) {
|
||||||
this.editor.bringToFront([next.id]);
|
this.editor.bringToFront([next.id])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override onResize = (
|
override onResize = (
|
||||||
shape: HTMLShape,
|
shape: HTMLShape,
|
||||||
info: {
|
info: {
|
||||||
handle: TLResizeHandle;
|
handle: TLResizeHandle
|
||||||
mode: TLResizeMode;
|
mode: TLResizeMode
|
||||||
initialBounds: Box;
|
initialBounds: Box
|
||||||
initialShape: HTMLShape;
|
initialShape: HTMLShape
|
||||||
newPoint: VecModel;
|
newPoint: VecModel
|
||||||
scaleX: number;
|
scaleX: number
|
||||||
scaleY: number;
|
scaleY: number
|
||||||
}
|
},
|
||||||
) => {
|
) => {
|
||||||
const element = document.getElementById(shape.id);
|
const element = document.getElementById(shape.id)
|
||||||
if (!element || !element.parentElement) return resizeBox(shape, info);
|
if (!element || !element.parentElement) return resizeBox(shape, info)
|
||||||
const { width, height } = element.parentElement.getBoundingClientRect();
|
const { width, height } = element.parentElement.getBoundingClientRect()
|
||||||
if (element) {
|
if (element) {
|
||||||
const isOverflowing = element.scrollWidth > width || element.scrollHeight > height;
|
const isOverflowing =
|
||||||
|
element.scrollWidth > width || element.scrollHeight > height
|
||||||
if (isOverflowing) {
|
if (isOverflowing) {
|
||||||
element.parentElement?.classList.add('overflowing');
|
element.parentElement?.classList.add("overflowing")
|
||||||
} else {
|
} else {
|
||||||
element.parentElement?.classList.remove('overflowing');
|
element.parentElement?.classList.remove("overflowing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resizeBox(shape, info)
|
return resizeBox(shape, info)
|
||||||
|
|
@ -74,10 +76,15 @@ export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
|
||||||
}
|
}
|
||||||
|
|
||||||
override component(shape: HTMLShape): JSX.Element {
|
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 {
|
override indicator(shape: HTMLShape): JSX.Element {
|
||||||
return <rect width={shape.props.w} height={shape.props.h} />
|
return <rect width={shape.props.w} height={shape.props.h} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +1,124 @@
|
||||||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
import { WORKER_URL } from '../routes/Board';
|
import { WORKER_URL } from "../routes/Board"
|
||||||
|
|
||||||
export type IVideoChatShape = TLBaseShape<
|
export type IVideoChatShape = TLBaseShape<
|
||||||
'VideoChat',
|
"VideoChat",
|
||||||
{
|
{
|
||||||
w: number;
|
w: number
|
||||||
h: number;
|
h: number
|
||||||
roomUrl: string | null;
|
roomUrl: string | null
|
||||||
userName: string;
|
userName: string
|
||||||
}
|
}
|
||||||
>;
|
>
|
||||||
|
|
||||||
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
static override type = 'VideoChat';
|
static override type = "VideoChat"
|
||||||
|
|
||||||
indicator(_shape: IVideoChatShape) {
|
indicator(_shape: IVideoChatShape) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
getDefaultProps(): IVideoChatShape['props'] {
|
getDefaultProps(): IVideoChatShape["props"] {
|
||||||
return {
|
return {
|
||||||
roomUrl: null,
|
roomUrl: null,
|
||||||
w: 640,
|
w: 640,
|
||||||
h: 480,
|
h: 480,
|
||||||
userName: ''
|
userName: "",
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureRoomExists(shape: IVideoChatShape) {
|
async ensureRoomExists(shape: IVideoChatShape) {
|
||||||
if (shape.props.roomUrl !== null) {
|
if (shape.props.roomUrl !== null) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
|
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
properties: {
|
properties: {
|
||||||
enable_recording: true,
|
enable_recording: true,
|
||||||
max_participants: 8
|
max_participants: 8,
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json()
|
||||||
|
|
||||||
this.editor.updateShape<IVideoChatShape>({
|
this.editor.updateShape<IVideoChatShape>({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: 'VideoChat',
|
type: "VideoChat",
|
||||||
props: {
|
props: {
|
||||||
...shape.props,
|
...shape.props,
|
||||||
roomUrl: (data as any).url
|
roomUrl: (data as any).url,
|
||||||
}
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
component(shape: IVideoChatShape) {
|
component(shape: IVideoChatShape) {
|
||||||
const [isInRoom, setIsInRoom] = useState(false);
|
const [isInRoom, setIsInRoom] = useState(false)
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("")
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInRoom && shape.props.roomUrl) {
|
if (isInRoom && shape.props.roomUrl) {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script")
|
||||||
script.src = 'https://www.daily.co/static/call-machine.js';
|
script.src = "https://www.daily.co/static/call-machine.js"
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script)
|
||||||
|
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.DailyIframe.createFrame({
|
window.DailyIframe.createFrame({
|
||||||
iframeStyle: {
|
iframeStyle: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
border: '0',
|
border: "0",
|
||||||
borderRadius: '4px'
|
borderRadius: "4px",
|
||||||
},
|
},
|
||||||
showLeaveButton: true,
|
showLeaveButton: true,
|
||||||
showFullscreenButton: true
|
showFullscreenButton: true,
|
||||||
}).join({ url: shape.props.roomUrl });
|
}).join({ url: shape.props.roomUrl })
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}, [isInRoom, shape.props.roomUrl]);
|
}, [isInRoom, shape.props.roomUrl])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
pointerEvents: 'all',
|
style={{
|
||||||
width: `${shape.props.w}px`,
|
pointerEvents: "all",
|
||||||
height: `${shape.props.h}px`,
|
width: `${shape.props.w}px`,
|
||||||
position: 'absolute',
|
height: `${shape.props.h}px`,
|
||||||
top: '10px',
|
position: "absolute",
|
||||||
left: '10px',
|
top: "10px",
|
||||||
zIndex: 9999,
|
left: "10px",
|
||||||
padding: '15px',
|
zIndex: 9999,
|
||||||
backgroundColor: '#F0F0F0',
|
padding: "15px",
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
backgroundColor: "#F0F0F0",
|
||||||
borderRadius: '4px',
|
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
}}>
|
borderRadius: "4px",
|
||||||
{!isInRoom ? (
|
}}
|
||||||
<button
|
>
|
||||||
onClick={() => setIsInRoom(true)}
|
{!isInRoom ? (
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded"
|
<button
|
||||||
>
|
onClick={() => setIsInRoom(true)}
|
||||||
Join Room
|
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||||
</button>
|
>
|
||||||
) : (
|
Join Room
|
||||||
<div id="daily-call-iframe-container" style={{
|
</button>
|
||||||
width: '100%',
|
) : (
|
||||||
height: '100%'
|
<div
|
||||||
}} />
|
id="daily-call-iframe-container"
|
||||||
)}
|
style={{
|
||||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
width: "100%",
|
||||||
</div>
|
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 {
|
export class ChatBoxTool extends BaseBoxShapeTool {
|
||||||
static override id = 'ChatBox'
|
static override id = "ChatBox"
|
||||||
shapeType = 'ChatBox';
|
shapeType = "ChatBox"
|
||||||
override initial = 'idle';
|
override initial = "idle"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { BaseBoxShapeTool } from "tldraw";
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
export class EmbedTool extends BaseBoxShapeTool {
|
export class EmbedTool extends BaseBoxShapeTool {
|
||||||
static override id = 'Embed'
|
static override id = "Embed"
|
||||||
shapeType = 'Embed';
|
shapeType = "Embed"
|
||||||
override initial = 'idle';
|
override initial = "idle"
|
||||||
|
}
|
||||||
// Additional methods for handling video chat functionality can be added here
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { BaseBoxShapeTool } from "tldraw";
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
export class VideoChatTool extends BaseBoxShapeTool {
|
export class VideoChatTool extends BaseBoxShapeTool {
|
||||||
static override id = 'VideoChat'
|
static override id = "VideoChat"
|
||||||
shapeType = 'VideoChat';
|
shapeType = "VideoChat"
|
||||||
override initial = 'idle';
|
override initial = "idle"
|
||||||
|
}
|
||||||
// Additional methods for handling video chat functionality can be added here
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 } from "vite";
|
||||||
import { defineConfig, loadEnv } from 'vite'
|
import react from "@vitejs/plugin-react";
|
||||||
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';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envPrefix: ['VITE_'],
|
envPrefix: ["VITE_"],
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
react(),
|
|
||||||
wasm(),
|
|
||||||
topLevelAwait(),
|
|
||||||
markdownPlugin,
|
|
||||||
viteStaticCopy({
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
src: 'src/posts/',
|
|
||||||
dest: '.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
],
|
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
base: '/',
|
base: "/",
|
||||||
publicDir: 'src/public',
|
publicDir: "src/public",
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': '/src',
|
"@": "/src",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
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