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 { Editor, TLEventMap, TLFrameShape, TLParentId } from 'tldraw';
import { useSearchParams } from 'react-router-dom';
import { useEffect } from "react"
import { Editor, TLEventMap, TLFrameShape, TLParentId } from "tldraw"
import { useSearchParams } from "react-router-dom"
// Define camera state interface
interface CameraState {
x: number;
y: number;
z: number;
x: number
y: number
z: number
}
const MAX_HISTORY = 10;
let cameraHistory: CameraState[] = [];
const MAX_HISTORY = 10
let cameraHistory: CameraState[] = []
// TODO: use this
// Improved camera change tracking with debouncing
const trackCameraChange = (editor: Editor) => {
const currentCamera = editor.getCamera();
const lastPosition = cameraHistory[cameraHistory.length - 1];
const currentCamera = editor.getCamera()
const lastPosition = cameraHistory[cameraHistory.length - 1]
// Store any viewport change that's not from a revert operation
if (!lastPosition ||
currentCamera.x !== lastPosition.x ||
currentCamera.y !== lastPosition.y ||
currentCamera.z !== lastPosition.z) {
cameraHistory.push({ ...currentCamera });
if (cameraHistory.length > MAX_HISTORY) {
cameraHistory.shift();
}
// Store any viewport change that's not from a revert operation
if (
!lastPosition ||
currentCamera.x !== lastPosition.x ||
currentCamera.y !== lastPosition.y ||
currentCamera.z !== lastPosition.z
) {
cameraHistory.push({ ...currentCamera })
if (cameraHistory.length > MAX_HISTORY) {
cameraHistory.shift()
}
};
}
}
export function useCameraControls(editor: Editor | null) {
const [searchParams] = useSearchParams();
const [searchParams] = useSearchParams()
// Handle URL-based camera positioning
useEffect(() => {
if (!editor) return;
// Handle URL-based camera positioning
useEffect(() => {
if (!editor) return
const frameId = searchParams.get('frameId');
const x = searchParams.get('x');
const y = searchParams.get('y');
const zoom = searchParams.get('zoom');
const frameId = searchParams.get("frameId")
const x = searchParams.get("x")
const y = searchParams.get("y")
const zoom = searchParams.get("zoom")
if (x && y && zoom) {
editor.setCamera({
x: parseFloat(x),
y: parseFloat(y),
z: parseFloat(zoom)
});
return;
}
if (x && y && zoom) {
editor.setCamera({
x: parseFloat(x),
y: parseFloat(y),
z: parseFloat(zoom),
})
return
}
if (frameId) {
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
if (!frame) {
console.warn('Frame not found:', frameId);
return;
}
if (frameId) {
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
if (!frame) {
console.warn("Frame not found:", frameId)
return
}
// Use editor's built-in zoomToBounds with animation
editor.zoomToBounds(
editor.getShapePageBounds(frame)!,
{
inset: 32,
animation: { duration: 500 }
}
);
}
}, [editor, searchParams]);
// Use editor's built-in zoomToBounds with animation
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
inset: 32,
animation: { duration: 500 },
})
}
}, [editor, searchParams])
// Track camera changes
useEffect(() => {
if (!editor) return;
// Track camera changes
useEffect(() => {
if (!editor) return
const handler = () => {
trackCameraChange(editor);
};
const handler = () => {
trackCameraChange(editor)
}
// Track both viewport changes and user interaction end
editor.on('viewportChange' as keyof TLEventMap, handler);
editor.on('userChangeEnd' as keyof TLEventMap, handler);
// Track both viewport changes and user interaction end
editor.on("viewportChange" as keyof TLEventMap, handler)
editor.on("userChangeEnd" as keyof TLEventMap, handler)
return () => {
editor.off('viewportChange' as keyof TLEventMap, handler);
editor.off('userChangeEnd' as keyof TLEventMap, handler);
};
}, [editor]);
return () => {
editor.off("viewportChange" as keyof TLEventMap, handler)
editor.off("userChangeEnd" as keyof TLEventMap, handler)
}
}, [editor])
// Enhanced camera control functions
return {
zoomToFrame: (frameId: string) => {
if (!editor) return;
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
if (!frame) return;
// Enhanced camera control functions
return {
zoomToFrame: (frameId: string) => {
if (!editor) return
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
if (!frame) return
editor.zoomToBounds(
editor.getShapePageBounds(frame)!,
{
inset: 32,
animation: { duration: 500 }
}
);
},
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
inset: 32,
animation: { duration: 500 },
})
},
copyFrameLink: (frameId: string) => {
const url = new URL(window.location.href);
url.searchParams.set('frameId', frameId);
navigator.clipboard.writeText(url.toString());
},
copyFrameLink: (frameId: string) => {
const url = new URL(window.location.href)
url.searchParams.set("frameId", frameId)
navigator.clipboard.writeText(url.toString())
},
copyLocationLink: () => {
if (!editor) return;
const camera = editor.getCamera();
const url = new URL(window.location.href);
url.searchParams.set('x', camera.x.toString());
url.searchParams.set('y', camera.y.toString());
url.searchParams.set('zoom', camera.z.toString());
navigator.clipboard.writeText(url.toString());
},
copyLocationLink: () => {
if (!editor) return
const camera = editor.getCamera()
const url = new URL(window.location.href)
url.searchParams.set("x", camera.x.toString())
url.searchParams.set("y", camera.y.toString())
url.searchParams.set("zoom", camera.z.toString())
navigator.clipboard.writeText(url.toString())
},
revertCamera: () => {
if (!editor || cameraHistory.length === 0) return;
const previousCamera = cameraHistory.pop();
if (previousCamera) {
editor.setCamera(previousCamera, { animation: { duration: 200 } });
}
}
};
}
revertCamera: () => {
if (!editor || cameraHistory.length === 0) return
const previousCamera = cameraHistory.pop()
if (previousCamera) {
editor.setCamera(previousCamera, { animation: { duration: 200 } })
}
},
}
}

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,91 +1,103 @@
import { useSync } from '@tldraw/sync'
import { useMemo } from 'react'
import { useSync } from "@tldraw/sync"
import { useMemo } from "react"
import {
AssetRecordType,
getHashForString,
TLBookmarkAsset,
Tldraw,
Editor,
} from 'tldraw'
import { useParams } from 'react-router-dom'
import { ChatBoxTool } from '@/tools/ChatBoxTool'
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
import { VideoChatTool } from '@/tools/VideoChatTool'
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
import { multiplayerAssetStore } from '../utils/multiplayerAssetStore'
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
import { EmbedTool } from '@/tools/EmbedTool'
import { defaultShapeUtils, defaultBindingUtils } from 'tldraw'
import { useState } from 'react';
import { components, overrides } from '@/ui-overrides'
AssetRecordType,
getHashForString,
TLBookmarkAsset,
Tldraw,
Editor,
} from "tldraw"
import { useParams } from "react-router-dom"
import { ChatBoxTool } from "@/tools/ChatBoxTool"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatTool } from "@/tools/VideoChatTool"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { EmbedTool } from "@/tools/EmbedTool"
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
import { useState } from "react"
import { components, overrides } from "@/ui-overrides"
// Default to production URL if env var isn't available
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools
const tools = [ChatBoxTool, VideoChatTool, EmbedTool] // Array of tools
export function Board() {
const { slug } = useParams<{ slug: string }>();
const roomId = slug || 'default-room';
const { slug } = useParams<{ slug: string }>()
const roomId = slug || "default-room"
const storeConfig = useMemo(() => ({
uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore,
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
bindingUtils: [...defaultBindingUtils],
}), [roomId]);
const storeConfig = useMemo(
() => ({
uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore,
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
bindingUtils: [...defaultBindingUtils],
}),
[roomId],
)
const store = useSync(storeConfig);
const [editor, setEditor] = useState<Editor | null>(null)
const store = useSync(storeConfig)
const [editor, setEditor] = useState<Editor | null>(null)
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
store={store.store}
shapeUtils={shapeUtils}
tools={tools}
components={components}
overrides={overrides}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
editor.setCurrentTool('hand')
}}
/>
</div>
)
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
store={store.store}
shapeUtils={shapeUtils}
tools={tools}
components={components}
overrides={overrides}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
}}
/>
</div>
)
}
// How does our server handle bookmark unfurling?
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
meta: {},
props: {
src: url,
description: '',
image: '',
favicon: '',
title: '',
},
}
async function unfurlBookmarkUrl({
url,
}: {
url: string
}): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: "asset",
type: "bookmark",
meta: {},
props: {
src: url,
description: "",
image: "",
favicon: "",
title: "",
},
}
try {
const response = await fetch(`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`)
const data = await response.json() as { description: string, image: string, favicon: string, title: string }
try {
const response = await fetch(
`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`,
)
const data = (await response.json()) as {
description: string
image: string
favicon: string
title: string
}
asset.props.description = data?.description ?? ''
asset.props.image = data?.image ?? ''
asset.props.favicon = data?.favicon ?? ''
asset.props.title = data?.title ?? ''
} catch (e) {
console.error(e)
}
asset.props.description = data?.description ?? ""
asset.props.image = data?.image ?? ""
asset.props.favicon = data?.favicon ?? ""
asset.props.title = data?.title ?? ""
} catch (e) {
console.error(e)
}
return asset
return asset
}

View File

@ -2,16 +2,28 @@ export function Contact() {
return (
<main>
<header>
<a href="/">
Jeff Emmett
</a>
<a href="/">Jeff Emmett</a>
</header>
<h1>Contact</h1>
<p>Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a></p>
<p>BlueSky: <a href="https://bsky.app/profile/jeffemmett.bsky.social">@jeffemnmett.bsky.social</a></p>
<p>Mastodon: <a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a></p>
<p>Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a></p>
<p>GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a></p>
<p>
Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
</p>
<p>
BlueSky:{" "}
<a href="https://bsky.app/profile/jeffemmett.bsky.social">
@jeffemnmett.bsky.social
</a>
</p>
<p>
Mastodon:{" "}
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>
</p>
<p>
Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a>
</p>
<p>
GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a>
</p>
</main>
);
)
}

View File

@ -1,68 +1,106 @@
export function Default() {
return (
<main>
<header>
Jeff Emmett
</header>
<header>Jeff Emmett</header>
<h2>Hello! 👋🍄</h2>
<p>
My research investigates the intersection of mycelium and emancipatory technologies.
I am interested in the potential of new convivial tooling as a medium for group
consensus building and collective action, in order to empower communities of practice to address their own challenges.
My research investigates the intersection of mycelium and emancipatory
technologies. I am interested in the potential of new convivial tooling
as a medium for group consensus building and collective action, in order
to empower communities of practice to address their own challenges.
</p>
<p>
My current focus is basic research into the nature of digital
organisation, developing prototype toolkits to improve shared
infrastructure, and applying this research to the design of new
systems and protocols which support the self-organisation of knowledge
and emergent response to local needs.
infrastructure, and applying this research to the design of new systems
and protocols which support the self-organisation of knowledge and
emergent response to local needs.
</p>
<h2>My work</h2>
<p>
Alongside my independent work, I am a researcher and engineering communicator at <a href="https://block.science/">Block Science</a>, an advisor to the Active Inference Lab, Commons Stack, and the Trusted Seed. I am also an occasional collaborator with <a href="https://economicspace.agency/">ECSA</a>.
Alongside my independent work, I am a researcher and engineering
communicator at <a href="https://block.science/">Block Science</a>, an
advisor to the Active Inference Lab, Commons Stack, and the Trusted
Seed. I am also an occasional collaborator with{" "}
<a href="https://economicspace.agency/">ECSA</a>.
</p>
<h2>Get in touch</h2>
<p>
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>,
Mastodon <a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a> and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
, Mastodon{" "}
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
</p>
<span className="dinkus">***</span>
<h2>Talks</h2>
<ol reversed>
<li><a
href="https://www.teamhuman.fm/episodes/238-jeff-emmett">MycoPunk Futures on Team Human with Douglas Rushkoff</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
<li>
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
MycoPunk Futures on Team Human with Douglas Rushkoff
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li><a
href="https://www.youtube.com/watch?v=AFJFDajuCSg">Exploring MycoFi on the Greenpill Network with Kevin Owocki</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
<li>
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
Exploring MycoFi on the Greenpill Network with Kevin Owocki
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li><a
href="https://youtu.be/9ad2EJhMbZ8">Re-imagining Human Value on the Telos Podcast with Rieki & Brandonfrom SEEDS</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
<li>
<a href="https://youtu.be/9ad2EJhMbZ8">
Re-imagining Human Value on the Telos Podcast with Rieki &
Brandonfrom SEEDS
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li><a
href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">Move Slow & Fix Things: Design Patterns from Nature</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
<li>
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
Move Slow & Fix Things: Design Patterns from Nature
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li><a
href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">Localized Democracy and Public Goods with Token Engineering on the Ownership Economy</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
<li>
<a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">
Localized Democracy and Public Goods with Token Engineering on the
Ownership Economy
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://youtu.be/kxcat-XBWas">
A Discussion on Warm Data with Nora Bateson on Systems Innovation
</a>
</li>
<li><a
href="https://youtu.be/kxcat-XBWas">A Discussion on Warm Data with Nora Bateson on Systems Innovation</a></li>
</ol>
<h2>Writing</h2>
<ol reversed>
<li><a
href="https://www.mycofi.art">Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond</a></li>
<li><a
href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">Challenges & Approaches to Scaling the Global Commons</a></li>
<li><a
href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">From Monoculture to Permaculture Currencies: A Glimpse of the Myco-Economic Future</a></li>
<li><a
href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">Rewriting the Story of Human Collaboration</a></li>
<li>
<a href="https://www.mycofi.art">
Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond
</a>
</li>
<li>
<a href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">
Challenges & Approaches to Scaling the Global Commons
</a>
</li>
<li>
<a href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">
From Monoculture to Permaculture Currencies: A Glimpse of the
Myco-Economic Future
</a>
</li>
<li>
<a href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">
Rewriting the Story of Human Collaboration
</a>
</li>
</ol>
</main>
);
)
}

View File

@ -1,45 +1,59 @@
import { createShapeId, Editor, Tldraw, TLGeoShape, TLShapePartial } from "tldraw";
import { useEffect, useRef } from "react";
import {
createShapeId,
Editor,
Tldraw,
TLGeoShape,
TLShapePartial,
} from "tldraw"
import { useEffect, useRef } from "react"
export function Inbox() {
const editorRef = useRef<Editor | null>(null);
const editorRef = useRef<Editor | null>(null)
const updateEmails = async (editor: Editor) => {
try {
const response = await fetch('https://jeffemmett-canvas.web.val.run', {
method: 'GET',
});
const messages = await response.json() as { id: string, from: string, subject: string, text: string }[];
const response = await fetch("https://jeffemmett-canvas.web.val.run", {
method: "GET",
})
const messages = (await response.json()) as {
id: string
from: string
subject: string
text: string
}[]
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const messageId = message.id;
const parsedEmailName = message.from.match(/^([^<]+)/)?.[1]?.trim() || message.from.match(/[^<@]+(?=@)/)?.[0] || message.from;
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const messageId = message.id
const parsedEmailName =
message.from.match(/^([^<]+)/)?.[1]?.trim() ||
message.from.match(/[^<@]+(?=@)/)?.[0] ||
message.from
const messageText = `from: ${parsedEmailName}\nsubject: ${message.subject}\n\n${message.text}`
const shapeWidth = 500
const shapeHeight = 300
const spacing = 50
const shape: TLShapePartial<TLGeoShape> = {
id: createShapeId(),
type: 'geo',
type: "geo",
x: shapeWidth * (i % 5) + spacing * (i % 5),
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
props: {
w: shapeWidth,
h: shapeHeight,
text: messageText,
align:'start',
verticalAlign:'start'
align: "start",
verticalAlign: "start",
},
meta: {
id: messageId
}
id: messageId,
},
}
let found = false;
let found = false
for (const s of editor.getCurrentPageShapes()) {
if (s.meta.id === messageId) {
found = true;
break;
found = true
break
}
}
if (!found) {
@ -47,28 +61,28 @@ export function Inbox() {
}
}
} catch (error) {
console.error('Error fetching data:', error);
console.error("Error fetching data:", error)
}
};
}
useEffect(() => {
const intervalId = setInterval(() => {
if (editorRef.current) {
updateEmails(editorRef.current);
updateEmails(editorRef.current)
}
}, 5*1000);
}, 5 * 1000)
return () => clearInterval(intervalId);
}, []);
return () => clearInterval(intervalId)
}, [])
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor: Editor) => {
editorRef.current = editor;
updateEmails(editor);
editorRef.current = editor
updateEmails(editor)
}}
/>
</div>
);
}
)
}

View File

@ -1,155 +1,191 @@
import { useEffect, useRef, useState } from "react";
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
import { useEffect, useRef, useState } from "react"
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
export type IChatBoxShape = TLBaseShape<
'ChatBox',
{
w: number
h: number
roomId: string
userName: string
}
"ChatBox",
{
w: number
h: number
roomId: string
userName: string
}
>
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
static override type = 'ChatBox'
static override type = "ChatBox"
getDefaultProps(): IChatBoxShape['props'] {
return {
roomId: 'default-room',
w: 100,
h: 100,
userName: '',
}
getDefaultProps(): IChatBoxShape["props"] {
return {
roomId: "default-room",
w: 100,
h: 100,
userName: "",
}
}
indicator(shape: IChatBoxShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
}
indicator(shape: IChatBoxShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
}
component(shape: IChatBoxShape) {
return (
<ChatBox roomId={shape.props.roomId} w={shape.props.w} h={shape.props.h} userName="" />
)
}
component(shape: IChatBoxShape) {
return (
<ChatBox
roomId={shape.props.roomId}
w={shape.props.w}
h={shape.props.h}
userName=""
/>
)
}
}
interface Message {
id: string;
username: string;
content: string;
timestamp: Date;
id: string
username: string
content: string
timestamp: Date
}
// Update the ChatBox component to accept userName
export const ChatBox: React.FC<IChatBoxShape['props']> = ({ roomId, w, h, userName }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState("");
const [username, setUsername] = useState(userName);
const messagesEndRef = useRef(null);
export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
roomId,
w,
h,
userName,
}) => {
const [messages, setMessages] = useState<Message[]>([])
const [inputMessage, setInputMessage] = useState("")
const [username, setUsername] = useState(userName)
const messagesEndRef = useRef(null)
useEffect(() => {
const storedUsername = localStorage.getItem("chatUsername");
if (storedUsername) {
setUsername(storedUsername);
} else {
const newUsername = `User${Math.floor(Math.random() * 1000)}`;
setUsername(newUsername);
localStorage.setItem("chatUsername", newUsername);
}
fetchMessages(roomId);
const interval = setInterval(() => fetchMessages(roomId), 2000);
useEffect(() => {
const storedUsername = localStorage.getItem("chatUsername")
if (storedUsername) {
setUsername(storedUsername)
} else {
const newUsername = `User${Math.floor(Math.random() * 1000)}`
setUsername(newUsername)
localStorage.setItem("chatUsername", newUsername)
}
fetchMessages(roomId)
const interval = setInterval(() => fetchMessages(roomId), 2000)
return () => clearInterval(interval);
}, [roomId]);
return () => clearInterval(interval)
}, [roomId])
useEffect(() => {
if (messagesEndRef.current) {
(messagesEndRef.current as HTMLElement).scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
useEffect(() => {
if (messagesEndRef.current) {
;(messagesEndRef.current as HTMLElement).scrollIntoView({
behavior: "smooth",
})
}
}, [messages])
const fetchMessages = async (roomId: string) => {
try {
const response = await fetch(`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const newMessages = await response.json() as Message[];
setMessages(newMessages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp) })));
} catch (error) {
console.error('Error fetching messages:', error);
}
};
const fetchMessages = async (roomId: string) => {
try {
const response = await fetch(
`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`,
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const newMessages = (await response.json()) as Message[]
setMessages(
newMessages.map((msg) => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
)
} catch (error) {
console.error("Error fetching messages:", error)
}
}
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputMessage.trim()) return;
await sendMessageToChat(roomId, username, inputMessage);
setInputMessage("");
fetchMessages(roomId);
};
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputMessage.trim()) return
await sendMessageToChat(roomId, username, inputMessage)
setInputMessage("")
fetchMessages(roomId)
}
return (
<div className="chat-container" style={{ pointerEvents: 'all', width: `${w}px`, height: `${h}px`, overflow: 'auto', touchAction: 'auto' }}>
<div className="messages-container">
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.username === username ? 'own-message' : ''}`}>
<div className="message-header">
<strong>{msg.username}</strong>
<span className="timestamp">{new Date(msg.timestamp).toLocaleTimeString()}</span>
</div>
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEndRef} />
return (
<div
className="chat-container"
style={{
pointerEvents: "all",
width: `${w}px`,
height: `${h}px`,
overflow: "auto",
touchAction: "auto",
}}
>
<div className="messages-container">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${
msg.username === username ? "own-message" : ""
}`}
>
<div className="message-header">
<strong>{msg.username}</strong>
<span className="timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<form onSubmit={sendMessage} className="input-form">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..."
className="message-input"
style={{ touchAction: 'manipulation' }}
/>
<button
type="submit"
style={{ pointerEvents: 'all', touchAction: 'manipulation' }}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="send-button"
>
Send
</button>
</form>
</div>
);
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="input-form">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..."
className="message-input"
style={{ touchAction: "manipulation" }}
/>
<button
type="submit"
style={{ pointerEvents: "all", touchAction: "manipulation" }}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="send-button"
>
Send
</button>
</form>
</div>
)
}
async function sendMessageToChat(roomId: string, username: string, content: string): Promise<void> {
const apiUrl = 'https://jeffemmett-realtimechatappwithpolling.web.val.run'; // Replace with your actual Val Town URL
async function sendMessageToChat(
roomId: string,
username: string,
content: string,
): Promise<void> {
const apiUrl = "https://jeffemmett-realtimechatappwithpolling.web.val.run" // Replace with your actual Val Town URL
try {
const response = await fetch(`${apiUrl}?action=sendMessage`, {
method: 'POST',
mode: 'no-cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roomId,
username,
content,
}),
});
try {
const response = await fetch(`${apiUrl}?action=sendMessage`, {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roomId,
username,
content,
}),
})
const result = await response.text();
console.log('Message sent successfully:', result);
} catch (error) {
console.error('Error sending message:', error);
}
}
const result = await response.text()
console.log("Message sent successfully:", result)
} catch (error) {
console.error("Error sending message:", error)
}
}

View File

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

View File

@ -8,9 +8,10 @@ import {
Box,
TLResizeMode,
Rectangle2d,
} from 'tldraw'
} from "tldraw"
export interface HTMLShape extends TLBaseShape<'html', { w: number; h: number, html: string }> {
export interface HTMLShape
extends TLBaseShape<"html", { w: number; h: number; html: string }> {
props: {
w: number
h: number
@ -19,47 +20,48 @@ export interface HTMLShape extends TLBaseShape<'html', { w: number; h: number, h
}
export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
static override type = 'html' as const
static override type = "html" as const
override canBind = () => true
override canEdit = () => false
override canResize = () => true
override isAspectRatioLocked = () => false
getDefaultProps(): HTMLShape['props'] {
getDefaultProps(): HTMLShape["props"] {
return {
w: 100,
h: 100,
html: "<div></div>"
html: "<div></div>",
}
}
override onBeforeUpdate = (prev: HTMLShape, next: HTMLShape): void => {
if (prev.x !== next.x || prev.y !== next.y) {
this.editor.bringToFront([next.id]);
this.editor.bringToFront([next.id])
}
}
override onResize = (
shape: HTMLShape,
info: {
handle: TLResizeHandle;
mode: TLResizeMode;
initialBounds: Box;
initialShape: HTMLShape;
newPoint: VecModel;
scaleX: number;
scaleY: number;
}
handle: TLResizeHandle
mode: TLResizeMode
initialBounds: Box
initialShape: HTMLShape
newPoint: VecModel
scaleX: number
scaleY: number
},
) => {
const element = document.getElementById(shape.id);
if (!element || !element.parentElement) return resizeBox(shape, info);
const { width, height } = element.parentElement.getBoundingClientRect();
const element = document.getElementById(shape.id)
if (!element || !element.parentElement) return resizeBox(shape, info)
const { width, height } = element.parentElement.getBoundingClientRect()
if (element) {
const isOverflowing = element.scrollWidth > width || element.scrollHeight > height;
const isOverflowing =
element.scrollWidth > width || element.scrollHeight > height
if (isOverflowing) {
element.parentElement?.classList.add('overflowing');
element.parentElement?.classList.add("overflowing")
} else {
element.parentElement?.classList.remove('overflowing');
element.parentElement?.classList.remove("overflowing")
}
}
return resizeBox(shape, info)
@ -74,10 +76,15 @@ export class HTMLShapeUtil extends BaseBoxShapeUtil<HTMLShape> {
}
override component(shape: HTMLShape): JSX.Element {
return <div id={shape.id} dangerouslySetInnerHTML={{ __html: shape.props.html }} />
return (
<div
id={shape.id}
dangerouslySetInnerHTML={{ __html: shape.props.html }}
/>
)
}
override indicator(shape: HTMLShape): JSX.Element {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
}

View File

@ -1,119 +1,124 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
import { useEffect, useState } from "react";
import { WORKER_URL } from '../routes/Board';
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useEffect, useState } from "react"
import { WORKER_URL } from "../routes/Board"
export type IVideoChatShape = TLBaseShape<
'VideoChat',
{
w: number;
h: number;
roomUrl: string | null;
userName: string;
}
>;
"VideoChat",
{
w: number
h: number
roomUrl: string | null
userName: string
}
>
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = 'VideoChat';
static override type = "VideoChat"
indicator(_shape: IVideoChatShape) {
return null;
}
indicator(_shape: IVideoChatShape) {
return null
}
getDefaultProps(): IVideoChatShape['props'] {
return {
roomUrl: null,
w: 640,
h: 480,
userName: ''
};
}
getDefaultProps(): IVideoChatShape["props"] {
return {
roomUrl: null,
w: 640,
h: 480,
userName: "",
}
}
async ensureRoomExists(shape: IVideoChatShape) {
if (shape.props.roomUrl !== null) {
return;
}
async ensureRoomExists(shape: IVideoChatShape) {
if (shape.props.roomUrl !== null) {
return
}
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
properties: {
enable_recording: true,
max_participants: 8
}
})
});
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
properties: {
enable_recording: true,
max_participants: 8,
},
}),
})
const data = await response.json();
const data = await response.json()
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: 'VideoChat',
props: {
...shape.props,
roomUrl: (data as any).url
}
});
}
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: "VideoChat",
props: {
...shape.props,
roomUrl: (data as any).url,
},
})
}
component(shape: IVideoChatShape) {
const [isInRoom, setIsInRoom] = useState(false);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
component(shape: IVideoChatShape) {
const [isInRoom, setIsInRoom] = useState(false)
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (isInRoom && shape.props.roomUrl) {
const script = document.createElement('script');
script.src = 'https://www.daily.co/static/call-machine.js';
document.body.appendChild(script);
useEffect(() => {
if (isInRoom && shape.props.roomUrl) {
const script = document.createElement("script")
script.src = "https://www.daily.co/static/call-machine.js"
document.body.appendChild(script)
script.onload = () => {
// @ts-ignore
window.DailyIframe.createFrame({
iframeStyle: {
width: '100%',
height: '100%',
border: '0',
borderRadius: '4px'
},
showLeaveButton: true,
showFullscreenButton: true
}).join({ url: shape.props.roomUrl });
};
}
}, [isInRoom, shape.props.roomUrl]);
script.onload = () => {
// @ts-ignore
window.DailyIframe.createFrame({
iframeStyle: {
width: "100%",
height: "100%",
border: "0",
borderRadius: "4px",
},
showLeaveButton: true,
showFullscreenButton: true,
}).join({ url: shape.props.roomUrl })
}
}
}, [isInRoom, shape.props.roomUrl])
return (
<div style={{
pointerEvents: 'all',
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
position: 'absolute',
top: '10px',
left: '10px',
zIndex: 9999,
padding: '15px',
backgroundColor: '#F0F0F0',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
borderRadius: '4px',
}}>
{!isInRoom ? (
<button
onClick={() => setIsInRoom(true)}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Join Room
</button>
) : (
<div id="daily-call-iframe-container" style={{
width: '100%',
height: '100%'
}} />
)}
{error && <p className="text-red-500 mt-2">{error}</p>}
</div>
);
}
return (
<div
style={{
pointerEvents: "all",
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
position: "absolute",
top: "10px",
left: "10px",
zIndex: 9999,
padding: "15px",
backgroundColor: "#F0F0F0",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
borderRadius: "4px",
}}
>
{!isInRoom ? (
<button
onClick={() => setIsInRoom(true)}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Join Room
</button>
) : (
<div
id="daily-call-iframe-container"
style={{
width: "100%",
height: "100%",
}}
/>
)}
{error && <p className="text-red-500 mt-2">{error}</p>}
</div>
)
}
}

View File

@ -1,7 +1,7 @@
import { BaseBoxShapeTool } from "tldraw";
import { BaseBoxShapeTool } from "tldraw"
export class ChatBoxTool extends BaseBoxShapeTool {
static override id = 'ChatBox'
shapeType = 'ChatBox';
override initial = 'idle';
}
static override id = "ChatBox"
shapeType = "ChatBox"
override initial = "idle"
}

View File

@ -1,9 +1,7 @@
import { BaseBoxShapeTool } from "tldraw";
import { BaseBoxShapeTool } from "tldraw"
export class EmbedTool extends BaseBoxShapeTool {
static override id = 'Embed'
shapeType = 'Embed';
override initial = 'idle';
// Additional methods for handling video chat functionality can be added here
}
static override id = "Embed"
shapeType = "Embed"
override initial = "idle"
}

View File

@ -1,9 +1,7 @@
import { BaseBoxShapeTool } from "tldraw";
import { BaseBoxShapeTool } from "tldraw"
export class VideoChatTool extends BaseBoxShapeTool {
static override id = 'VideoChat'
shapeType = 'VideoChat';
override initial = 'idle';
// Additional methods for handling video chat functionality can be added here
}
static override id = "VideoChat"
shapeType = "VideoChat"
override initial = "idle"
}

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, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
envPrefix: ['VITE_'],
plugins: [
react(),
wasm(),
topLevelAwait(),
markdownPlugin,
viteStaticCopy({
targets: [
{
src: 'src/posts/',
dest: '.'
}
]
})
],
envPrefix: ["VITE_"],
plugins: [react()],
server: {
host: '0.0.0.0',
host: "0.0.0.0",
port: 5173,
},
build: {
sourcemap: true,
},
base: '/',
publicDir: 'src/public',
base: "/",
publicDir: "src/public",
resolve: {
alias: {
'@': '/src',
"@": "/src",
},
},
define: {
'import.meta.env.VITE_WORKER_URL': JSON.stringify(process.env.VITE_WORKER_URL)
}
"import.meta.env.VITE_WORKER_URL": JSON.stringify(
process.env.VITE_WORKER_URL
),
},
});