prettify and cleanup

This commit is contained in:
Jeff Emmett 2024-12-07 22:01:02 -05:00
parent 8817af2962
commit 73731d94f8
31 changed files with 807 additions and 1065 deletions

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"semi": false,
"trailingComma": "all"
}

View File

@ -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)};`;
}
},
};

View File

@ -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 });
}

View File

@ -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 (
!lastPosition ||
currentCamera.x !== lastPosition.x || currentCamera.x !== lastPosition.x ||
currentCamera.y !== lastPosition.y || currentCamera.y !== lastPosition.y ||
currentCamera.z !== lastPosition.z) { currentCamera.z !== lastPosition.z
cameraHistory.push({ ...currentCamera }); ) {
cameraHistory.push({ ...currentCamera })
if (cameraHistory.length > MAX_HISTORY) { if (cameraHistory.length > MAX_HISTORY) {
cameraHistory.shift(); 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, inset: 32,
animation: { duration: 500 } 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, inset: 32,
animation: { duration: 500 } 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 } })
}
},
} }
} }
};
}

View File

@ -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;
}

View File

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8140e8b76c29fa3c828553e8a6a87651af6e7ee18d1ccb82e79125eb532194b
size 19738445

View File

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d63dd9f37a74b85680a7c1df823f69936619ecab5e8d4d06eeeedea4d908f1de
size 18268955

View File

@ -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

View File

@ -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

View File

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b55a408375712bc169bc5c987d85c71c353028e611f216817f13ca0fb284604
size 28423

View File

@ -1,48 +1,49 @@
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}`, uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore, assets: multiplayerAssetStore,
shapeUtils: [...shapeUtils, ...defaultShapeUtils], shapeUtils: [...shapeUtils, ...defaultShapeUtils],
bindingUtils: [...defaultBindingUtils], bindingUtils: [...defaultBindingUtils],
}), [roomId]); }),
[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}
@ -51,8 +52,8 @@ export function Board() {
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>
@ -60,29 +61,40 @@ export function Board() {
} }
// 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({
url,
}: {
url: string
}): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = { const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)), id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset', typeName: "asset",
type: 'bookmark', type: "bookmark",
meta: {}, meta: {},
props: { props: {
src: url, src: url,
description: '', description: "",
image: '', image: "",
favicon: '', favicon: "",
title: '', 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)
} }

View File

@ -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>
); )
} }

View File

@ -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>
); )
} }

View File

@ -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>
); )
} }

View File

@ -1,8 +1,8 @@
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
@ -12,14 +12,14 @@ export type IChatBoxShape = TLBaseShape<
> >
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: "",
} }
} }
@ -29,78 +29,110 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
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(
`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`,
)
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`)
} }
const newMessages = await response.json() as Message[]; const newMessages = (await response.json()) as Message[]
setMessages(newMessages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp) }))); setMessages(
newMessages.map((msg) => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
)
} catch (error) { } catch (error) {
console.error('Error fetching messages:', 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
className="chat-container"
style={{
pointerEvents: "all",
width: `${w}px`,
height: `${h}px`,
overflow: "auto",
touchAction: "auto",
}}
>
<div className="messages-container"> <div className="messages-container">
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} className={`message ${msg.username === username ? 'own-message' : ''}`}> <div
key={msg.id}
className={`message ${
msg.username === username ? "own-message" : ""
}`}
>
<div className="message-header"> <div className="message-header">
<strong>{msg.username}</strong> <strong>{msg.username}</strong>
<span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span> <span className="timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div> </div>
<div className="message-content">{msg.content}</div> <div className="message-content">{msg.content}</div>
</div> </div>
@ -114,11 +146,11 @@ export const ChatBox: React.FC<IChatBoxShape['props']> = ({ roomId, w, h, userNa
onChange={(e) => setInputMessage(e.target.value)} onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..." placeholder="Type a message..."
className="message-input" className="message-input"
style={{ touchAction: 'manipulation' }} style={{ touchAction: "manipulation" }}
/> />
<button <button
type="submit" type="submit"
style={{ pointerEvents: 'all', touchAction: 'manipulation' }} style={{ pointerEvents: "all", touchAction: "manipulation" }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()}
className="send-button" className="send-button"
@ -127,29 +159,33 @@ export const ChatBox: React.FC<IChatBoxShape['props']> = ({ roomId, w, h, userNa
</button> </button>
</form> </form>
</div> </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)
} }
} }

View File

@ -1,24 +1,24 @@
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) {
@ -26,99 +26,126 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
<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") ||
completedUrl.includes("youtu.be")
) {
const videoId = extractYouTubeVideoId(completedUrl)
if (videoId) { if (videoId) {
completedUrl = `https://www.youtube.com/embed/${videoId}`; completedUrl = `https://www.youtube.com/embed/${videoId}`
} else { } else {
setError('Invalid YouTube URL'); setError("Invalid YouTube URL")
return; return
} }
} }
// Handle Google Docs links // Handle Google Docs links
if (completedUrl.includes('docs.google.com')) { if (completedUrl.includes("docs.google.com")) {
const docId = completedUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1]; const docId = completedUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1]
if (docId) { if (docId) {
completedUrl = `https://docs.google.com/document/d/${docId}/preview`; completedUrl = `https://docs.google.com/document/d/${docId}/preview`
} else { } else {
setError('Invalid Google Docs URL'); setError("Invalid Google Docs URL")
return; return
} }
} }
this.editor.updateShape<IEmbedShape>({ id: shape.id, type: 'Embed', props: { ...shape.props, url: completedUrl } }); this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: { ...shape.props, url: completedUrl },
})
// Check if the URL is valid // Check if the URL is valid
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//); const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
if (!isValidUrl) { if (!isValidUrl) {
setError('Invalid website URL'); setError("Invalid website URL")
} else { } else {
setError(''); setError("")
} }
}, [inputUrl]); },
[inputUrl],
)
const extractYouTubeVideoId = (url: string): string | null => { const extractYouTubeVideoId = (url: string): string | null => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; const regExp =
const match = url.match(regExp); /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
return (match && match[2].length === 11) ? match[2] : null; const match = url.match(regExp)
}; return match && match[2].length === 11 ? match[2] : null
}
const wrapperStyle = { const wrapperStyle = {
width: `${shape.props.w}px`, width: `${shape.props.w}px`,
height: `${shape.props.h}px`, height: `${shape.props.h}px`,
padding: '15px', padding: "15px",
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
backgroundColor: '#F0F0F0', backgroundColor: "#F0F0F0",
borderRadius: '4px', borderRadius: "4px",
}; }
const contentStyle = { const contentStyle = {
pointerEvents: 'all' as const, pointerEvents: "all" as const,
width: '100%', width: "100%",
height: '100%', height: "100%",
border: '1px solid #D3D3D3', border: "1px solid #D3D3D3",
backgroundColor: '#FFFFFF', backgroundColor: "#FFFFFF",
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
overflow: 'hidden', overflow: "hidden",
}; }
if (!shape.props.url) { if (!shape.props.url) {
return ( return (
<div style={wrapperStyle}> <div style={wrapperStyle}>
<div style={contentStyle} onClick={() => document.querySelector('input')?.focus()}> <div
<form onSubmit={handleSubmit} style={{ width: '100%', height: '100%', padding: '10px' }}> style={contentStyle}
onClick={() => document.querySelector("input")?.focus()}
>
<form
onSubmit={handleSubmit}
style={{ width: "100%", height: "100%", padding: "10px" }}
>
<input <input
type="text" type="text"
value={inputUrl} value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)} onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL" placeholder="Enter URL"
style={{ width: '100%', height: '100%', border: 'none', padding: '10px' }} style={{
width: "100%",
height: "100%",
border: "none",
padding: "10px",
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
handleSubmit(e); handleSubmit(e)
} }
}} }}
/> />
{error && <div style={{ color: 'red', marginTop: '10px' }}>{error}</div>} {error && (
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
)}
</form> </form>
</div> </div>
</div> </div>
); )
} }
return ( return (
@ -128,11 +155,11 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
src={shape.props.url} src={shape.props.url}
width="100%" width="100%"
height="100%" height="100%"
style={{ border: 'none' }} style={{ border: "none" }}
allowFullScreen allowFullScreen
/> />
</div> </div>
</div> </div>
); )
} }
} }

View File

@ -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,7 +76,12 @@ 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 {

View File

@ -1,104 +1,106 @@
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={{
pointerEvents: "all",
width: `${shape.props.w}px`, width: `${shape.props.w}px`,
height: `${shape.props.h}px`, height: `${shape.props.h}px`,
position: 'absolute', position: "absolute",
top: '10px', top: "10px",
left: '10px', left: "10px",
zIndex: 9999, zIndex: 9999,
padding: '15px', padding: "15px",
backgroundColor: '#F0F0F0', backgroundColor: "#F0F0F0",
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
borderRadius: '4px', borderRadius: "4px",
}}> }}
>
{!isInRoom ? ( {!isInRoom ? (
<button <button
onClick={() => setIsInRoom(true)} onClick={() => setIsInRoom(true)}
@ -107,13 +109,16 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
Join Room Join Room
</button> </button>
) : ( ) : (
<div id="daily-call-iframe-container" style={{ <div
width: '100%', id="daily-call-iframe-container"
height: '100%' style={{
}} /> width: "100%",
height: "100%",
}}
/>
)} )}
{error && <p className="text-red-500 mt-2">{error}</p>} {error && <p className="text-red-500 mt-2">{error}</p>}
</div> </div>
); )
} }
} }

View File

@ -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"
} }

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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
},
},
],
})

View File

@ -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.

View File

@ -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
}
>

View File

@ -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
}

View File

@ -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.
*/

View File

@ -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`;
};

View File

@ -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
),
},
}); });