pretty bad and unworking code

This commit is contained in:
Jeff Emmett 2024-11-23 15:27:17 +07:00
parent 202971f343
commit e04efac1cc
19 changed files with 702 additions and 215 deletions

3
.env.production Normal file
View File

@ -0,0 +1,3 @@
# Production environment variables
VITE_GOOGLE_CLIENT_ID=your_production_client_id_here
VITE_GOOGLE_API_KEY=your_production_api_key_here

View File

@ -1,42 +1,58 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head>
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<!-- Social Meta Tags --> <head>
<meta name="description" <title>Jeff Emmett</title>
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<meta property="og:url" content="https://jeffemmett.com"> <!-- Social Meta Tags -->
<meta property="og:type" content="website"> <meta name="description"
<meta property="og:title" content="Jeff Emmett"> content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image"> <meta property="og:url" content="https://jeffemmett.com">
<meta property="twitter:domain" content="jeffemmett.com"> <meta property="og:type" content="website">
<meta property="twitter:url" content="https://jeffemmett.com"> <meta property="og:title" content="Jeff Emmett">
<meta name="twitter:title" content="Jeff Emmett"> <meta property="og:description"
<meta name="twitter:description" content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation."> <meta property="og:image" content="/website-embed.png">
<meta name="twitter:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="jeffemmett.com">
<meta property="twitter:url" content="https://jeffemmett.com">
<meta name="twitter:title" content="Jeff Emmett">
<meta name="twitter:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta name="twitter:image" content="/website-embed.png">
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<!-- Add CSP meta tag here, after other meta tags -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://gc.zgo.at https://va.vercel-scripts.com https://apis.google.com https://www.google.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://accounts.google.com https://www.google.com;
frame-src 'self' https://docs.google.com https://accounts.google.com https://*.google.com https://drive.google.com;
connect-src 'self' http://localhost:5172 ws://localhost:5172 wss://localhost:5172 https://www.googleapis.com https://*.vercel-scripts.com wss://jeffemmett-canvas.jeffemmett.workers.dev https://jeffemmett-canvas.jeffemmett.workers.dev https://cdn.tldraw.com https://oauth2.googleapis.com https://www.google.com;
font-src 'self' data: https://fonts.gstatic.com https://cdn.tldraw.com;
img-src 'self' data: https: blob:;
worker-src 'self' blob:;
">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html> </html>

13
instructions.md Normal file
View File

@ -0,0 +1,13 @@
https://docs.google.com/presentation/d/11tdbD5vw5y0IPhP3ygb8pXoVMaSzew-650Zr2Zvw2Yw/edit?pli=1#slide=id.p
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy
<meta Content-Security-Policy-Report-Only: script-src
https://accounts.google.com/gsi/client; frame-src
https://accounts.google.com/gsi/; connect-src
https://accounts.google.com/gsi/; />
https://github.com/vercel/next.js/discussions/51135

View File

@ -9,13 +9,15 @@
"dev:worker": "wrangler dev", "dev:worker": "wrangler dev",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview",
"watch": "tsc --noEmit --watch"
}, },
"keywords": [], "keywords": [],
"author": "Jeff Emmett", "author": "Jeff Emmett",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dimforge/rapier2d": "^0.11.2", "@dimforge/rapier2d": "^0.11.2",
"@react-oauth/google": "^0.12.1",
"@tldraw/sync": "^2.4.6", "@tldraw/sync": "^2.4.6",
"@tldraw/sync-core": "^2.4.6", "@tldraw/sync-core": "^2.4.6",
"@tldraw/tlschema": "^2.4.6", "@tldraw/tlschema": "^2.4.6",
@ -25,13 +27,19 @@
"cloudflare-workers-unfurl": "^0.0.7", "cloudflare-workers-unfurl": "^0.0.7",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"itty-router": "^5.0.17", "itty-router": "^5.0.17",
"jwt-decode": "^4.0.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-latex2img": "^0.0.6", "markdown-it-latex2img": "^0.0.6",
"prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-google-drive-picker": "^1.2.2",
"react-google-picker": "^0.1.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"tldraw": "^2.4.6" "socket.io-client": "^4.8.1",
"tldraw": "^2.4.6",
"use-local-storage-state": "^19.5.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.4.1", "@biomejs/biome": "1.4.1",
@ -40,6 +48,7 @@
"@types/lodash.throttle": "^4", "@types/lodash.throttle": "^4",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-google-picker": "^0.1.4",
"@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0", "@typescript-eslint/parser": "^5.59.0",
"@vitejs/plugin-react": "^4.0.3", "@vitejs/plugin-react": "^4.0.3",
@ -55,4 +64,4 @@
"vite-plugin-wasm": "^3.2.2", "vite-plugin-wasm": "^3.2.2",
"wrangler": "^3.88.0" "wrangler": "^3.88.0"
} }
} }

View File

@ -1,16 +1,16 @@
import { inject } from '@vercel/analytics'; import { inject } from '@vercel/analytics';
import "tldraw/tldraw.css"; import "tldraw/tldraw.css";
import "@/css/style.css" import "./css/style.css"
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Default } from "@/components/Default"; import { Default } from "./components/Default";
import { Canvas } from "@/components/Canvas"; import { Canvas } from "./components/Canvas";
import { Toggle } from "@/components/Toggle"; import { Toggle } from "./components/Toggle";
import { useCanvas } from "@/hooks/useCanvas" import { useCanvas } from "./hooks/useCanvas"
import { createShapes } from "@/utils/utils"; import { createShapes } from "./utils/utils";
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Contact } from "@/components/Contact"; import { Contact } from "./components/Contact";
import { Post } from '@/components/Post'; import { Post } from "./components/Post";
import { Board } from './components/Board'; import { Board } from './components/Board';
import { Inbox } from './components/Inbox'; import { Inbox } from './components/Inbox';
import { Books } from './components/Books'; import { Books } from './components/Books';
@ -26,6 +26,11 @@ import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
import { VideoChatShape } from './shapes/VideoChatShapeUtil'; import { VideoChatShape } from './shapes/VideoChatShapeUtil';
import { ChatBoxTool } from './tools/ChatBoxTool'; import { ChatBoxTool } from './tools/ChatBoxTool';
import { VideoChatTool } from './tools/VideoChatTool'; import { VideoChatTool } from './tools/VideoChatTool';
import { GoogleAuthProvider } from './context/GoogleAuthContext';
import EnvCheck from './test/EnvCheck';
import { EmbedTest } from './test/EmbedTest';
//import { Callback } from './components/callback';
import AuthCallback from './pages/auth/callback';
inject(); inject();
@ -76,22 +81,24 @@ export default function InteractiveShapeExample() {
ReactDOM.createRoot(document.getElementById("root")!).render(<App />); ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
function App() { function App() {
return ( return (
// <React.StrictMode> <GoogleAuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/card/contact" element={<Contact />} /> <Route path="/card/contact" element={<Contact />} />
<Route path="/posts/:slug" element={<Post />} /> <Route path="/posts/:slug" element={<Post />} />
<Route path="/board/:slug" element={<Board />} /> <Route path="/board/:slug" element={<Board />} />
<Route path="/inbox" element={<Inbox />} /> <Route path="/inbox" element={<Inbox />} />
<Route path="/books" element={<Books />} /> <Route path="/books" element={<Books />} />
</Routes> <Route path="/test" element={<EnvCheck />} />
</BrowserRouter> <Route path="/embed-test" element={<EmbedTest />} />
// </React.StrictMode> <Route path="/auth/callback" element={<AuthCallback />} />
</Routes>
</BrowserRouter>
</GoogleAuthProvider>
); );
}; }
function Home() { function Home() {
const { isCanvasEnabled, elementsInfo } = useCanvas(); const { isCanvasEnabled, elementsInfo } = useCanvas();

View File

@ -0,0 +1 @@

View File

@ -14,12 +14,15 @@ import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
import { customSchema } from '../../worker/TldrawDurableObject' import { customSchema } from '../../worker/TldrawDurableObject'
import { EmbedShape } from '@/shapes/EmbedShapeUtil' import { EmbedShape } from '@/shapes/EmbedShapeUtil'
import { EmbedTool } from '@/tools/EmbedTool' import { EmbedTool } from '@/tools/EmbedTool'
import { useGoogleAuth } from '@/context/GoogleAuthContext';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ChatBox } from '@/shapes/ChatBoxShapeUtil'; import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
import { components, uiOverrides } from '@/ui-overrides' import { components, uiOverrides } from '@/ui-overrides'
const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` const WORKER_URL = process.env.NODE_ENV === 'development'
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.hostname}:5172`
: 'wss://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
@ -38,6 +41,7 @@ export function Board() {
const [isChatBoxVisible, setChatBoxVisible] = useState(false); const [isChatBoxVisible, setChatBoxVisible] = useState(false);
const [userName, setUserName] = useState(''); const [userName, setUserName] = useState('');
const [isVideoChatVisible, setVideoChatVisible] = useState(false); // Added state for video chat visibility const [isVideoChatVisible, setVideoChatVisible] = useState(false); // Added state for video chat visibility
const { isAuthenticated, user } = useGoogleAuth();
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value); setUserName(event.target.value);
@ -45,6 +49,29 @@ export function Board() {
return ( return (
<div style={{ position: 'fixed', inset: 0 }}> <div style={{ position: 'fixed', inset: 0 }}>
{isAuthenticated && user?.picture && (
<div style={{
position: 'fixed',
top: '10px',
right: '10px',
zIndex: 10000,
borderRadius: '50%',
overflow: 'hidden',
width: '32px',
height: '32px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<img
src={user.picture}
alt="User Profile"
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
</div>
)}
<Tldraw <Tldraw
store={store} store={store}
shapeUtils={shapeUtils} shapeUtils={shapeUtils}

View File

@ -0,0 +1,50 @@
import { createContext, useContext, ReactNode, useState } from 'react';
import { GoogleOAuthProvider } from '@react-oauth/google';
interface GoogleAuthContextType {
isAuthenticated: boolean;
setIsAuthenticated: (value: boolean) => void;
user: any;
setUser: (user: any) => void;
accessToken: string | null;
setAccessToken: (token: string | null) => void;
logout: () => void;
}
const GoogleAuthContext = createContext<GoogleAuthContextType | undefined>(undefined);
export function GoogleAuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const logout = () => {
setIsAuthenticated(false);
setUser(null);
setAccessToken(null);
};
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<GoogleAuthContext.Provider value={{
isAuthenticated,
setIsAuthenticated,
user,
setUser,
accessToken,
setAccessToken,
logout,
}}>
{children}
</GoogleAuthContext.Provider>
</GoogleOAuthProvider>
);
}
export function useGoogleAuth() {
const context = useContext(GoogleAuthContext);
if (context === undefined) {
throw new Error('useGoogleAuth must be used within a GoogleAuthProvider');
}
return context;
}

View File

@ -0,0 +1,40 @@
import { useEffect } from 'react';
export default function AuthCallback() {
useEffect(() => {
console.log('🎯 Callback page loaded');
const hashParams = new URLSearchParams(window.location.hash.replace('#', ''));
const queryParams = new URLSearchParams(window.location.search);
console.log('📝 URL params:', {
hash: window.location.hash,
search: window.location.search
});
const state = hashParams.get('state') || queryParams.get('state');
const accessToken = hashParams.get('access_token') || queryParams.get('access_token');
const docId = state ? decodeURIComponent(state) : null;
console.log('🔑 Extracted values:', {
state,
accessToken: accessToken ? 'present' : 'missing',
docId
});
if (window.opener && docId) {
console.log('📤 Sending message to opener');
window.opener.postMessage({
type: 'auth-complete',
docId,
accessToken
}, window.location.origin);
console.log('🚪 Closing callback window');
window.close();
} else {
console.warn('⚠️ Missing window.opener or docId');
}
}, []);
return <div>Processing authentication...</div>;
}

View File

@ -1,5 +1,8 @@
/// <reference path="../types/google.accounts.d.ts" />
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"; import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
import { useCallback, useState } from "react"; import { useState, useCallback } from "react";
import { useGoogleAuth } from '@/context/GoogleAuthContext';
export type IEmbedShape = TLBaseShape< export type IEmbedShape = TLBaseShape<
'Embed', 'Embed',
@ -15,124 +18,214 @@ export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
getDefaultProps(): IEmbedShape['props'] { getDefaultProps(): IEmbedShape['props'] {
return { return {
w: 400,
h: 300,
url: null, url: null,
w: 640, }
h: 480,
};
} }
indicator(shape: IEmbedShape) { indicator(shape: IEmbedShape) {
return ( return <rect width={shape.props.w} height={shape.props.h} />
<g>
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
</g>
);
} }
component(shape: IEmbedShape) { component(shape: IEmbedShape) {
const [inputUrl, setInputUrl] = useState(shape.props.url || ''); const [inputUrl, setInputUrl] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const { isAuthenticated, setIsAuthenticated, accessToken, setAccessToken } = useGoogleAuth();
const handleGoogleAuth = useCallback((docId: string) => {
// Create message handler before opening window
const messageHandler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'auth-complete') {
console.log('Auth complete received', event.data);
setAccessToken(event.data.accessToken);
setIsAuthenticated(true);
const embedUrl = `https://docs.google.com/document/d/${docId}/preview?embedded=true&access_token=${event.data.accessToken}`;
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: 'Embed',
props: { ...shape.props, url: embedUrl }
});
// Clean up
window.removeEventListener('message', messageHandler);
}
};
// Add message listener
window.addEventListener('message', messageHandler);
// Open auth window with additional parameters
const authWindow = window.open(
`https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${import.meta.env.VITE_GOOGLE_CLIENT_ID}` +
`&redirect_uri=${window.location.origin}/auth/callback` +
`&response_type=token` +
`&scope=https://www.googleapis.com/auth/drive.readonly` +
`&prompt=consent` +
`&access_type=online` +
`&state=${encodeURIComponent(docId)}`,
'googleAuth',
'width=500,height=600'
);
if (!authWindow) {
setError('Popup blocked. Please allow popups and try again.');
return;
}
// Simplified window check
const checkWindow = setInterval(() => {
try {
if (!authWindow || authWindow.closed) {
clearInterval(checkWindow);
window.removeEventListener('message', messageHandler);
}
} catch (e) {
// Ignore COOP errors
}
}, 500);
// Cleanup after 5 minutes
setTimeout(() => {
clearInterval(checkWindow);
window.removeEventListener('message', messageHandler);
}, 300000);
}, []);
const handleSubmit = useCallback((e: React.FormEvent) => { const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let completedUrl = inputUrl.startsWith('http://') || inputUrl.startsWith('https://') ? inputUrl : `https://${inputUrl}`; setError('');
// Handle YouTube links try {
if (completedUrl.includes('youtube.com') || completedUrl.includes('youtu.be')) { // Check if it's a Google Docs URL
const videoId = extractYouTubeVideoId(completedUrl); if (inputUrl.includes('docs.google.com')) {
if (videoId) { const docId = inputUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1];
completedUrl = `https://www.youtube.com/embed/${videoId}`; if (!docId) {
setError('Invalid Google Docs URL');
return;
}
if (!isAuthenticated) {
handleGoogleAuth(docId);
return;
}
// If already authenticated, use preview URL
const embedUrl = `https://docs.google.com/document/d/${docId}/preview`;
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: 'Embed',
props: { ...shape.props, url: embedUrl }
});
} else { } else {
setError('Invalid YouTube URL'); // For non-Google URLs
return; this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: 'Embed',
props: { ...shape.props, url: inputUrl }
});
} }
} catch (err) {
setError('Error processing URL');
console.error(err);
} }
}, [inputUrl, isAuthenticated]);
// 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) { if (!shape.props.url) {
return ( return (
<div style={wrapperStyle}> <div
<div style={contentStyle} onClick={() => document.querySelector('input')?.focus()}> style={{
<form onSubmit={handleSubmit} style={{ width: '100%', height: '100%', padding: '10px' }}> width: '100%',
<input height: '100%',
type="text" display: 'flex',
value={inputUrl} flexDirection: 'column',
onChange={(e) => setInputUrl(e.target.value)} alignItems: 'center',
placeholder="Enter URL" justifyContent: 'center',
style={{ width: '100%', height: '100%', border: 'none', padding: '10px' }} padding: '20px',
onKeyDown={(e) => { pointerEvents: 'all'
if (e.key === 'Enter') { }}
handleSubmit(e); onPointerDown={(e) => e.stopPropagation()}
} >
}} <form
/> onSubmit={handleSubmit}
{error && <div style={{ color: 'red', marginTop: '10px' }}>{error}</div>} style={{ width: '100%' }}
</form> onPointerDown={(e) => e.stopPropagation()}
</div> >
<div
contentEditable
suppressContentEditableWarning
style={{
width: '100%',
padding: '8px',
marginBottom: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
minHeight: '36px',
cursor: 'text',
background: 'white'
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
}}
onInput={(e) => {
setInputUrl(e.currentTarget.textContent || '');
}}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
}}
/>
<button
type="submit"
style={{
pointerEvents: 'all',
touchAction: 'manipulation',
padding: '8px 16px',
cursor: 'pointer'
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onClick={(e) => e.stopPropagation()}
>
Embed Content
</button>
</form>
{error && <p style={{ color: 'red', marginTop: '10px' }}>{error}</p>}
</div> </div>
); );
} }
return ( return (
<div style={wrapperStyle}> <div style={{
<div style={contentStyle}> width: `${shape.props.w}px`,
<iframe height: `${shape.props.h}px`,
src={shape.props.url} overflow: 'hidden',
width="100%" }}>
height="100%" <iframe
style={{ border: 'none' }} src={shape.props.url}
allowFullScreen width="100%"
/> height="100%"
</div> style={{ border: 'none' }}
allowFullScreen
/>
</div> </div>
); );
} }
// ... rest of your utility methods ...
} }

51
src/test/EmbedTest.tsx Normal file
View File

@ -0,0 +1,51 @@
import { useState } from 'react';
export const EmbedTest = () => {
const [docUrl, setDocUrl] = useState('');
const [embedUrl, setEmbedUrl] = useState('');
const [error, setError] = useState('');
const handleEmbed = () => {
const docId = docUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1];
if (!docId) {
setError('Invalid Google Docs URL');
return;
}
const newEmbedUrl = `https://docs.google.com/document/d/${docId}/preview`;
setEmbedUrl(newEmbedUrl);
setError('');
};
return (
<div style={{ padding: '20px' }}>
<h2>Embed Test</h2>
<div style={{ marginBottom: '20px' }}>
<input
type="text"
value={docUrl}
onChange={(e) => setDocUrl(e.target.value)}
placeholder="Paste Google Doc URL"
style={{ width: '100%', padding: '8px' }}
/>
<button onClick={handleEmbed} style={{ marginTop: '10px' }}>
Embed Document
</button>
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
{embedUrl && (
<div>
<h3>Embedded Document:</h3>
<iframe
src={embedUrl}
style={{ width: '100%', height: '500px', border: '1px solid #ccc' }}
allowFullScreen
/>
</div>
)}
</div>
);
};

8
src/test/EnvCheck.tsx Normal file
View File

@ -0,0 +1,8 @@
import React from 'react';
export default function EnvCheck() {
console.log('Client ID:', process.env.VITE_GOOGLE_CLIENT_ID);
console.log('API Key:', process.env.VITE_GOOGLE_API_KEY);
console.log(process.env)
return <div>Check console for environment variables</div>;
}

52
src/types/google.accounts.d.ts vendored Normal file
View File

@ -0,0 +1,52 @@
declare namespace google {
namespace accounts {
namespace oauth2 {
interface TokenClient {
callback: (response: { access_token?: string }) => void;
client_id: string;
scope: string;
}
function initTokenClient(config: {
client_id: string;
scope: string;
callback: (response: { access_token?: string }) => void;
error_callback?: (error: any) => void;
}): {
requestAccessToken(options?: { prompt?: string }): void;
};
}
}
namespace picker {
class PickerBuilder {
addView(view: any): PickerBuilder;
setOAuthToken(token: string): PickerBuilder;
setDeveloperKey(key: string): PickerBuilder;
setCallback(callback: (data: PickerResponse) => void): PickerBuilder;
build(): Picker;
}
interface PickerResponse {
action: string;
docs: Array<{
id: string;
name: string;
url: string;
mimeType: string;
}>;
}
class Picker {
setVisible(visible: boolean): void;
}
const ViewId: {
DOCS: string;
DOCUMENTS: string;
PRESENTATIONS: string;
SPREADSHEETS: string;
FOLDERS: string;
};
}
}

View File

@ -0,0 +1,53 @@
import { io, Socket } from 'socket.io-client';
export const wsConfig = {
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 10000
};
class WebSocketManager {
private socket: Socket | null = null;
private retryCount = 0;
connect(url: string) {
this.socket = io(url, wsConfig);
this.socket.on('connect', () => {
console.log('WebSocket connected');
this.retryCount = 0;
});
this.socket.on('error', (error) => {
console.error('WebSocket error:', error);
this.handleRetry();
});
this.socket.on('disconnect', () => {
console.log('WebSocket disconnected');
this.handleRetry();
});
}
private handleRetry() {
if (this.retryCount < wsConfig.reconnectionAttempts) {
this.retryCount++;
setTimeout(() => {
console.log(`Attempting reconnection ${this.retryCount}/${wsConfig.reconnectionAttempts}`);
this.socket?.connect();
}, wsConfig.reconnectionDelay);
}
}
// Add methods to send/receive messages
send(event: string, data: any) {
this.socket?.emit(event, data);
}
subscribe(event: string, callback: (data: any) => void) {
this.socket?.on(event, callback);
}
}
export const webSocketManager = new WebSocketManager();

View File

@ -1,16 +1,23 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "./src", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["*"], "@/*": [
"src/*": ["./src/*"], "./src/*"
],
"src/*": [
"./src/*"
]
}, },
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@ -18,14 +25,20 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src", "worker", "src/client"], "include": [
"src",
"references": [{ "path": "./tsconfig.node.json" }] "worker",
} "src/client"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -1,37 +1,49 @@
import { markdownPlugin } from './build/markdownPlugin'; 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 wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await"; import topLevelAwait from "vite-plugin-top-level-await";
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy';
import path from 'path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
export default defineConfig({ return {
define: { define: {
'process.env.TLDRAW_WORKER_URL': JSON.stringify('https://jeffemmett-canvas.jeffemmett.workers.dev') 'process.env.TLDRAW_WORKER_URL': JSON.stringify('https://jeffemmett-canvas.jeffemmett.workers.dev'),
}, 'process.env.VITE_GOOGLE_CLIENT_ID': JSON.stringify(env.VITE_GOOGLE_CLIENT_ID),
plugins: [ 'process.env.VITE_GOOGLE_API_KEY': JSON.stringify(env.VITE_GOOGLE_API_KEY)
react(),
wasm(),
topLevelAwait(),
markdownPlugin,
viteStaticCopy({
targets: [
{
src: 'src/posts/',
dest: '.'
}
]
})
],
build: {
sourcemap: true,
},
base: '/',
publicDir: 'src/public',
resolve: {
alias: {
'@': '/src',
}, },
}, plugins: [
}) react(),
wasm(),
topLevelAwait(),
markdownPlugin,
viteStaticCopy({
targets: [
{
src: 'src/posts/',
dest: '.'
}
]
})
],
build: {
sourcemap: true,
},
base: '/',
publicDir: 'src/public',
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
},
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
'Cross-Origin-Embedder-Policy': 'require-corp'
}
},
}
});

View File

@ -11,9 +11,9 @@ import {
import { AutoRouter, IRequest, error } from 'itty-router' import { AutoRouter, IRequest, error } from 'itty-router'
import throttle from 'lodash.throttle' import throttle from 'lodash.throttle'
import { Environment } from './types' import { Environment } from './types'
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { ChatBoxShape } from '../src/shapes/ChatBoxShapeUtil'
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil' import { VideoChatShape } from '../src/shapes/VideoChatShapeUtil'
import { EmbedShape } from '@/shapes/EmbedShapeUtil' import { EmbedShape } from '../src/shapes/EmbedShapeUtil'
// add custom shapes and bindings here if needed: // add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({ export const customSchema = createTLSchema({
@ -74,23 +74,25 @@ export class TldrawDurableObject {
// what happens when someone tries to connect to this room? // what happens when someone tries to connect to this room?
async handleConnect(request: IRequest): Promise<Response> { async handleConnect(request: IRequest): Promise<Response> {
// extract query params from request
const sessionId = request.query.sessionId as string const sessionId = request.query.sessionId as string
if (!sessionId) return error(400, 'Missing sessionId') if (!sessionId) return error(400, 'Missing sessionId')
// Create the websocket pair for the client const webSocketPair = new WebSocketPair()
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair() const [client, server] = Object.values(webSocketPair)
// @ts-ignore
serverWebSocket.accept() server.accept()
// load the room, or retrieve it if it's already loaded
const room = await this.getRoom() const room = await this.getRoom()
room.handleSocketConnect({ sessionId, socket: server })
// connect the client to the room return new Response(null, {
room.handleSocketConnect({ sessionId, socket: serverWebSocket }) status: 101,
webSocket: client,
// return the websocket connection to the client headers: {
return new Response(null, { status: 101, webSocket: clientWebSocket }) 'Upgrade': 'websocket',
'Connection': 'Upgrade'
}
})
} }
getRoom() { getRoom() {

View File

@ -8,20 +8,53 @@ export { TldrawDurableObject } from './TldrawDurableObject'
// we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because // we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because
// we're hosting the worker separately to the client. you should restrict this to your own domain. // we're hosting the worker separately to the client. you should restrict this to your own domain.
const { preflight, corsify } = cors({ origin: '*' }) const { preflight, corsify } = cors({
origin: '*',
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
allowHeaders: {
'Access-Control-Allow-Headers': '*',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Upgrade': 'websocket',
'Connection': 'Upgrade',
'Sec-WebSocket-Key': '*',
'Sec-WebSocket-Version': '*',
'Sec-WebSocket-Protocol': '*'
}
})
const addSecurityHeaders = (response: Response): Response => {
const headers = new Headers(response.headers)
headers.set('Cross-Origin-Opener-Policy', 'same-origin')
headers.set('Cross-Origin-Embedder-Policy', 'require-corp')
return new Response(response.body, {
status: response.status,
headers
})
}
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight], before: [preflight],
finally: [corsify], finally: [(response) => corsify(addSecurityHeaders(response))],
catch: (e) => { catch: (e) => {
console.error(e) console.error(e)
return error(e) return error(e)
}, },
}) })
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get('/connect/:roomId', (request, env) => { .get('/connect/:roomId', async (request, env) => {
const upgradeHeader = request.headers.get('Upgrade')
if (!upgradeHeader || upgradeHeader.toLowerCase() !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 })
}
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id) const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, { headers: request.headers, body: request.body }) return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method
})
}) })
// assets can be uploaded to the bucket under /uploads: // assets can be uploaded to the bucket under /uploads:

View File

@ -31,4 +31,8 @@ logpush = true
# wrangler.toml (wrangler v3.79.0^) # wrangler.toml (wrangler v3.79.0^)
[observability] [observability]
enabled = true enabled = true
head_sampling_rate = 1 head_sampling_rate = 1
[build]
command = "yarn build"
watch_dir = "src"