diff --git a/frontend/.gitkeep b/frontend/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..019aca7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + PodMan + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0ae9d92 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "@podman/frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@podman/shared": "workspace:*", + "livekit-client": "^2.20.0", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "tailwindcss": "^4.3.1", + "vite": "^8.1.0", + "vite-plugin-pwa": "^1.3.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f8adf28 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import type { Room } from 'livekit-client'; +import { joinPod } from './lib/pod.js'; + +export default function App() { + const [podId, setPodId] = useState('demo-pod'); + const [name, setName] = useState(''); + const [room, setRoom] = useState(null); + const [error, setError] = useState(null); + const [connecting, setConnecting] = useState(false); + + async function handleJoin() { + setError(null); + setConnecting(true); + try { + const identity = `${name || 'engineer'}-${Math.random().toString(36).slice(2, 7)}`; + setRoom(await joinPod(podId, identity, name || identity)); + } catch (err) { + setError((err as Error).message); + } finally { + setConnecting(false); + } + } + + return ( +
+
+

🛰️ PodMan

+

+ Join a pod and share your screen — PodMan watches for collisions before you push. +

+
+ + {room ? ( +
+

Connected to “{podId}”.

+

Sharing screen + mic. PodMan is watching.

+
+ ) : ( +
+ setName(e.target.value)} + /> + setPodId(e.target.value)} + /> + + {error &&

{error}

} +
+ )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7993442 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,16 @@ +@import 'tailwindcss'; + +:root { + color-scheme: dark; +} + +body { + margin: 0; + background: #0b0f17; + color: #e7eaf0; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; +} diff --git a/frontend/src/lib/pod.ts b/frontend/src/lib/pod.ts new file mode 100644 index 0000000..22f714e --- /dev/null +++ b/frontend/src/lib/pod.ts @@ -0,0 +1,38 @@ +import { Room, RoomEvent } from 'livekit-client'; + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787'; + +/** Ask the backend for a LiveKit token to join a pod. */ +export async function fetchPodToken( + podId: string, + identity: string, + name: string, +): Promise<{ token: string; url: string }> { + const res = await fetch(`${BACKEND_URL}/pods/${encodeURIComponent(podId)}/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ identity, name }), + }); + if (!res.ok) throw new Error(`token request failed: ${res.status}`); + return res.json(); +} + +/** + * Join a pod: connect to the LiveKit room and publish screen + mic so PodMan + * can watch. Returns the connected Room. + */ +export async function joinPod(podId: string, identity: string, name: string): Promise { + const { token, url } = await fetchPodToken(podId, identity, name); + const room = new Room({ adaptiveStream: true, dynacast: true }); + room.on(RoomEvent.Disconnected, () => console.log('[podman] disconnected')); + + await room.connect(url, token); + + const screen = await navigator.mediaDevices.getDisplayMedia({ video: true }); + for (const track of screen.getTracks()) { + await room.localParticipant.publishTrack(track); + } + await room.localParticipant.setMicrophoneEnabled(true); + + return room; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..251d265 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.js'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..2ce65b7 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// +/// + +interface ImportMetaEnv { + readonly VITE_BACKEND_URL: string; + readonly VITE_LIVEKIT_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..66a33a9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f953048 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { VitePWA } from 'vite-plugin-pwa'; + +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + manifest: { + name: 'PodMan', + short_name: 'PodMan', + description: 'Ambient AI teammate that prevents merge collisions before push', + theme_color: '#0b0f17', + background_color: '#0b0f17', + display: 'standalone', + }, + }), + ], + server: { + port: 5173, + }, +});