diff --git a/bsg-frontend/.gitignore b/bsg-frontend/.gitignore index 228f3fa..7096335 100644 --- a/bsg-frontend/.gitignore +++ b/bsg-frontend/.gitignore @@ -5,3 +5,4 @@ /**/package-lock.json **/node_modules/ **/next-env.d.ts +out/ \ No newline at end of file diff --git a/bsg-frontend/app/extension/defaultPopup/contentScript.js b/bsg-frontend/app/extension/defaultPopup/contentScript.js new file mode 100644 index 0000000..07b7034 --- /dev/null +++ b/bsg-frontend/app/extension/defaultPopup/contentScript.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import DefaultPopup from './page.tsx'; + +const container = document.createElement('div'); +container.id = 'my-extension-root'; +document.body.appendChild(container); + +Object.assign(container.style, { + position: 'fixed', + top: '0', + right: '0', + width: '30%', + height: '100%', + backgroundColor: '#000', + zIndex: 9999, + overflow: 'auto' +}); + +const root = createRoot(container); +root.render(); \ No newline at end of file diff --git a/bsg-frontend/apps/extension/.env.example b/bsg-frontend/apps/extension/.env.example index 1a54794..eb71c8f 100644 --- a/bsg-frontend/apps/extension/.env.example +++ b/bsg-frontend/apps/extension/.env.example @@ -2,10 +2,10 @@ # Copy this file to .env.local and fill in your Firebase project values # Get these values from Firebase Console > Project Settings -NEXT_PUBLIC_FIREBASE_API_KEY= -NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= -NEXT_PUBLIC_FIREBASE_PROJECT_ID= -NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= -NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= -NEXT_PUBLIC_FIREBASE_APP_ID= -NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= +NEXT_PUBLIC_FIREBASE_API_KEY="" +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="" +NEXT_PUBLIC_FIREBASE_PROJECT_ID="" +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="" +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID="" +NEXT_PUBLIC_FIREBASE_APP_ID="" +NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID="" diff --git a/bsg-frontend/apps/extension/.gitignore b/bsg-frontend/apps/extension/.gitignore index 693eb9d..f3f6c47 100644 --- a/bsg-frontend/apps/extension/.gitignore +++ b/bsg-frontend/apps/extension/.gitignore @@ -3,3 +3,4 @@ !.env.example .env.local .env +out/ \ No newline at end of file diff --git a/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/chromeExtensionAuth.ts b/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/chromeExtensionAuth.ts index e9bae46..1924e37 100644 --- a/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/chromeExtensionAuth.ts +++ b/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/chromeExtensionAuth.ts @@ -1,7 +1,5 @@ -import { app } from "../../../config"; -import { getAuth, signInWithCredential, GoogleAuthProvider, User } from "firebase/auth"; - -const auth = getAuth(app); +import { getFirebaseAuth } from "../../../config"; +import { signInWithCredential, GoogleAuthProvider, User } from "firebase/auth"; export async function SignInWithChromeIdentity(): Promise { return new Promise((resolve, reject) => { @@ -22,8 +20,12 @@ export async function SignInWithChromeIdentity(): Promise { } try { - // Use the token to get user info from Google API - //const userInfo = await getUserInfoFromToken(token); + // get auth lazily (may be null during server/build) + const auth = getFirebaseAuth(); + if (!auth) { + reject(new Error('Firebase auth not available in this environment')); + return; + } // Create a Firebase credential using the token const credential = GoogleAuthProvider.credential(null, token); @@ -40,7 +42,7 @@ export async function SignInWithChromeIdentity(): Promise { }); } -async function getUserInfoFromToken(token: string) { +export async function getUserInfoFromToken(token: string) { const response = await fetch(`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${token}`); if (!response.ok) { throw new Error('Failed to get user info'); @@ -59,9 +61,7 @@ export async function SignOutFromChrome(): Promise { chrome.identity.getAuthToken({ interactive: false }, (token) => { const tokenToRevoke = token.toString(); - const requestBody = new URLSearchParams({ - token: tokenToRevoke, - }).toString(); + const requestBody = new URLSearchParams({ token: tokenToRevoke }).toString(); fetch('https://oauth2.googleapis.com/revoke', { method:'POST', @@ -74,10 +74,17 @@ export async function SignOutFromChrome(): Promise { if(response.ok){ console.log("Reached google servers") - chrome.identity.clearAllCachedAuthTokens(() => { - auth.signOut().then(resolve).catch(reject); - } - ) + chrome.identity.clearAllCachedAuthTokens(async () => { + const auth = getFirebaseAuth(); + if (auth) { + try { + await auth.signOut(); + } catch (e) { + // ignore sign out error + } + } + resolve(); + }); } else { diff --git a/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/googleAuth.ts b/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/googleAuth.ts index 012945d..52b0425 100644 --- a/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/googleAuth.ts +++ b/bsg-frontend/apps/extension/firebase/auth/signIn/googleImplementation/googleAuth.ts @@ -1,20 +1,19 @@ -import { app } from "../../../config"; -import { getAuth, - signInWithRedirect, - signInWithPopup, - getRedirectResult, - User, - } from "firebase/auth"; +import { getFirebaseAuth } from "../../../config"; +import { + signInWithRedirect, + signInWithPopup, + getRedirectResult, + User, +} from "firebase/auth"; import { provider } from './googleSignIn'; -const auth = getAuth(app); - - export async function SignInWithGoogleRedirect(): Promise { try{ + const auth = getFirebaseAuth(); + if (!auth) throw new Error('Firebase auth not available in this environment'); await signInWithRedirect(auth, provider); } catch(error){ @@ -26,6 +25,8 @@ export async function SignInWithGoogleRedirect(): Promise { export async function SignInWithGooglePopup(): Promise { try { + const auth = getFirebaseAuth(); + if (!auth) throw new Error('Firebase auth not available in this environment'); const result = await signInWithPopup(auth, provider); return result.user; } catch (error) { @@ -38,6 +39,9 @@ export async function HandleAuthRedirectedResult(): Promise{ try{ + const auth = getFirebaseAuth(); + if (!auth) throw new Error('Firebase auth not available in this environment'); + const result = await getRedirectResult(auth, provider); if(result){ diff --git a/bsg-frontend/apps/extension/firebase/auth/signOut.ts b/bsg-frontend/apps/extension/firebase/auth/signOut.ts index 65b8366..a8ae5d3 100644 --- a/bsg-frontend/apps/extension/firebase/auth/signOut.ts +++ b/bsg-frontend/apps/extension/firebase/auth/signOut.ts @@ -1,19 +1,18 @@ -import { signOut, getAuth } from "firebase/auth"; -import { app } from "../config"; +import { signOut } from "firebase/auth"; +import { getFirebaseAuth } from "../config"; +export async function signOutOfAccount(): Promise { + const auth = getFirebaseAuth(); + if (!auth) { + // Nothing to do when auth isn't available (server/build environment) + return; + } -export async function signOutOfAccount(): Promise{ - - const auth = getAuth(app); - - try{ + try { return await signOut(auth); - - }catch(error){ - - console.log(error.code) - console.log(error.message) + } catch (error: any) { + console.log(error?.code); + console.log(error?.message); throw error; } - } \ No newline at end of file diff --git a/bsg-frontend/apps/extension/firebase/config.ts b/bsg-frontend/apps/extension/firebase/config.ts index 7b329b5..23a89c4 100644 --- a/bsg-frontend/apps/extension/firebase/config.ts +++ b/bsg-frontend/apps/extension/firebase/config.ts @@ -1,8 +1,6 @@ // Import the functions you need from the SDKs you need -import { initializeApp } from "firebase/app"; -import { getAuth, setPersistence, browserSessionPersistence } from "firebase/auth"; - - +import { initializeApp, FirebaseApp } from "firebase/app"; +import { getAuth, setPersistence, browserSessionPersistence, Auth } from "firebase/auth"; const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -14,11 +12,39 @@ const firebaseConfig = { measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }; -// Initialize Firebase -export const app = initializeApp(firebaseConfig); +let firebaseApp: FirebaseApp | null = null; + +/** + * Lazily initialize and return the Firebase app instance. + * Returns null when run on the server or if the required env is missing. + */ +export function getFirebaseApp(): FirebaseApp | null { + if (typeof window === 'undefined') return null; + if (firebaseApp) return firebaseApp; + + if (!firebaseConfig.apiKey) { + // Do not initialize Firebase in environments without an API key. + // This prevents build-time/server-side initialization which causes + // auth/invalid-api-key errors during next build when env files aren't loaded. + return null; + } + + firebaseApp = initializeApp(firebaseConfig); + return firebaseApp; +} -// Initialize Auth with session persistence -export const auth = getAuth(app); -setPersistence(auth, browserSessionPersistence); +/** + * Lazily return an Auth instance, or null if unavailable (server or missing config). + */ +export function getFirebaseAuth(): Auth | null { + const app = getFirebaseApp(); + if (!app) return null; + const auth = getAuth(app); + // Only set browser persistence in a real browser environment + if (typeof window !== 'undefined') { + setPersistence(auth, browserSessionPersistence).catch(() => {}); + } + return auth; +} diff --git a/bsg-frontend/apps/extension/hooks/useChatSocket.ts b/bsg-frontend/apps/extension/hooks/useChatSocket.ts new file mode 100644 index 0000000..60f2322 --- /dev/null +++ b/bsg-frontend/apps/extension/hooks/useChatSocket.ts @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +const RTC_SERVICE_URL = 'ws://localhost:8080/ws'; + +export type Message = { + userHandle: string; + data: string; + roomID: string; + isSystem?: boolean; +} + +export const useChatSocket = (userEmail: string | null | undefined) => { + const socketRef = useRef(null); + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + if (!userEmail) return; + + const ws = new WebSocket(RTC_SERVICE_URL); + socketRef.current = ws; + + ws.onopen = () => { + console.log('Connected to RTC service'); + setIsConnected(true); + }; + + ws.onmessage = (event) => { + try { + const response = JSON.parse(event.data); + + if (response.status === 'ok') { + const { message, responseType } = response; + + if (responseType === 'chat-message') { + setMessages(prev => [...prev, { + userHandle: message.userHandle, + data: message.data, + roomID: message.roomID, + isSystem: false + }]); + } else if (responseType === 'system-announcement') { + setMessages(prev => [...prev, { + userHandle: 'System', + data: message.data, + roomID: message.roomID, + isSystem: true + }]); + } + } else if (response.status === 'error') { + console.error('RTC Error:', response.message); + } + } catch (e) { + console.error('Failed to parse WS message', e); + } + }; + + ws.onclose = () => { + console.log('Disconnected from RTC service'); + setIsConnected(false); + }; + + return () => { + ws.close(); + }; + }, [userEmail]); + + const joinRoom = useCallback((roomID: string) => { + // Clear messages when joining a new room so we don't see chat history from previous rooms + setMessages([]); + + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN && userEmail) { + const payload = { + name: userEmail, + "request-type": "join-room", + data: JSON.stringify({ + userHandle: userEmail, + roomID: roomID + }) + }; + socketRef.current.send(JSON.stringify(payload)); + } + }, [userEmail]); + + const sendChatMessage = useCallback((roomID: string, message: string) => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN && userEmail) { + const payload = { + name: userEmail, + "request-type": "chat-message", + data: JSON.stringify({ + userHandle: userEmail, + roomID: roomID, + message: message + }) + }; + socketRef.current.send(JSON.stringify(payload)); + } + }, [userEmail]); + + return { + messages, + isConnected, + joinRoom, + sendChatMessage + }; +}; \ No newline at end of file diff --git a/bsg-frontend/apps/extension/out/404.html b/bsg-frontend/apps/extension/out/404.html index a2a205a..9bdddf2 100644 --- a/bsg-frontend/apps/extension/out/404.html +++ b/bsg-frontend/apps/extension/out/404.html @@ -1 +1 @@ -404: This page could not be found

404

This page could not be found.

\ No newline at end of file +
BSG_
\ No newline at end of file diff --git a/bsg-frontend/apps/extension/out/defaultPopup.html b/bsg-frontend/apps/extension/out/defaultPopup.html index cdf98f4..e61c0f9 100644 --- a/bsg-frontend/apps/extension/out/defaultPopup.html +++ b/bsg-frontend/apps/extension/out/defaultPopup.html @@ -1 +1 @@ -

BSG_

You are not on LeetCode. Once you go to the website you can open up the side panel to start solving!

\ No newline at end of file +
BSG_
\ No newline at end of file diff --git a/bsg-frontend/apps/extension/out/manifest.json b/bsg-frontend/apps/extension/out/manifest.json index 878a68e..0bbf310 100644 --- a/bsg-frontend/apps/extension/out/manifest.json +++ b/bsg-frontend/apps/extension/out/manifest.json @@ -2,6 +2,9 @@ "manifest_version": 3, "name": "BSG", "version": "1.0.0", + "background": { + "service_worker": "background.js" + }, "permissions": [ "tabs", "identity" @@ -25,7 +28,7 @@ "default_popup": "defaultPopup.html" }, "content_security_policy": { - "extension_pages": "script-src 'self'; script-src-elem 'self' https://apis.google.com https://accounts.google.com https://www.gstatic.com; connect-src 'self' https://*.googleapis.com https://*.firebaseapp.com https://accounts.google.com; object-src 'self';" + "extension_pages": "script-src 'self'; script-src-elem 'self' https://apis.google.com https://accounts.google.com https://www.gstatic.com; connect-src 'self' ws://localhost:8080 https://*.googleapis.com https://*.firebaseapp.com https://accounts.google.com; object-src 'self';" }, "web_accessible_resources": [ { diff --git a/bsg-frontend/apps/extension/pages/_app.tsx b/bsg-frontend/apps/extension/pages/_app.tsx index 4db9a90..74fa05e 100644 --- a/bsg-frontend/apps/extension/pages/_app.tsx +++ b/bsg-frontend/apps/extension/pages/_app.tsx @@ -1,22 +1,262 @@ import '../../../packages/ui-styles/global.css' -import Logo from '@bsg/components/Logo' import { useState, useRef, useEffect } from 'react' import type { AppProps } from 'next/app' import '@bsg/ui-styles/global.css'; import {Poppins} from 'next/font/google' import { Button } from '@bsg/ui/button' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faDiscord, faGithub, faGoogle } from '@fortawesome/free-brands-svg-icons' -import { faPaperPlane, faSmile } from '@fortawesome/free-solid-svg-icons' +import { faPaperPlane, faSmile, faCopy } from '@fortawesome/free-solid-svg-icons' +import { faGoogle } from '@fortawesome/free-brands-svg-icons' +import RoomChoice from './room-choice' +import { SignInWithChromeIdentity, getUserInfoFromToken } from '../firebase/auth/signIn/googleImplementation/chromeExtensionAuth' +import { useChatSocket } from '../hooks/useChatSocket' -const poppins = Poppins({weight: '400', subsets: ['latin'], variable: '--poppins'}) +const poppins = Poppins({ weight: '400', subsets: ['latin'] }) + +type Participant = { id: string; name?: string; avatarUrl?: string } export default function App({ Component, pageProps }: AppProps) { + const [loggedIn, setLoggedIn] = useState(false) + const [currentRoom, setCurrentRoom] = useState<{ code: string, options?: any } | null>(null) + const [copied, setCopied] = useState(false) + const [userProfile, setUserProfile] = useState(null) + + const inputRef = useRef(null) + const containerRef = useRef(null) + + // Initialize WebSocket Hook + const { messages, isConnected, joinRoom, sendChatMessage } = useChatSocket(userProfile?.id); + + // copy room code to clipboard (works in extension and locally) + function copyRoomCode(roomCode: string) { + if (!roomCode) return + try { + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { + chrome.runtime.sendMessage({ type: 'COPY_TO_CLIPBOARD', text: roomCode }, (resp) => { + const ok = resp && resp.ok + if (ok) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + return + } + doLocalCopy(roomCode) + }) + return + } + } catch (e) { + // fallback + } + doLocalCopy(roomCode) + } + + function doLocalCopy(roomCode: string) { + const ta = document.createElement('textarea') + ta.value = roomCode + ta.style.position = 'fixed' + ta.style.left = '-9999px' + document.body.appendChild(ta) + ta.select() + try { document.execCommand('copy') } catch {} + ta.remove() + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + function sendMessage() { + const text = inputRef.current?.value.trim() + if (!text || !currentRoom) return + + // Send message via WebSocket + sendChatMessage(currentRoom.code, text); + + // REMOVED: Optimistic update. + // The server will echo the message back to us, so we don't need to add it manually here. + // This prevents the "double message" issue for the sender. + + inputRef.current!.value = '' + } + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight + } + }, [messages]) + + // join/create handlers + const handleJoin = (roomCode: string) => { + setCurrentRoom({ code: roomCode, options: {} }) + joinRoom(roomCode); + } + + const handleCreate = (roomCode: string, options: any) => { + setCurrentRoom({ code: roomCode, options: { ...options } }) + // Currently just joins the room code generated. + // Future: Send 'create-room' request if backend distinguishes it. + joinRoom(roomCode); + } + + // --- RENDER LOGIC --- + if (!loggedIn) { + return ( +
+
+
+ BSG_ +
+
+ +
+
+
+ ) + } + + // Show RoomChoice if not yet in a room + if (!currentRoom) { + return ( + + ) + } + + const participants: Participant[] = currentRoom.options?.participants || [] return ( -
- +
+
+
+
+
Room Code:
+
+
{currentRoom.code}
+ + {copied &&
copied
} + {!isConnected &&
Disconnected
} +
+
+ + {/* Participant avatars (lobby) */} +
+ {participants.map((p) => ( + {p.name + ))} +
+
+ +
+ {/* show current user's avatar */} + {userProfile && ( + {userProfile.name} + )} +
+
+ +
+ {messages.map((msg, i) => ( +
+ {msg.isSystem ? ( +
+ {msg.data} +
+ ) : ( +
+
{msg.userHandle === userProfile?.id ? 'You' : msg.userHandle}
+ {msg.data} +
+ )} +
+ ))} +
+
+ + { + if (e.key === 'Enter') { + sendMessage() + } + }} + /> + +
+
) -} +} \ No newline at end of file diff --git a/bsg-frontend/apps/extension/pages/logIn.tsx b/bsg-frontend/apps/extension/pages/logIn.tsx index ddf8469..d99706a 100644 --- a/bsg-frontend/apps/extension/pages/logIn.tsx +++ b/bsg-frontend/apps/extension/pages/logIn.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'; import { User, onAuthStateChanged } from 'firebase/auth'; import { useNewTab } from '../hooks/useNewTab'; import { SignInWithChromeIdentity, SignOutFromChrome } from '../firebase/auth/signIn/googleImplementation/chromeExtensionAuth'; -import { auth } from '../firebase/config'; +import { getFirebaseAuth } from '../firebase/config'; export default function LogIn() { @@ -27,7 +27,10 @@ export default function LogIn() { }); } - // Listen for auth state changes + // Listen for auth state changes (only in browser when auth available) + const auth = getFirebaseAuth(); + if (!auth) return; + const unsubscribe = onAuthStateChanged(auth, (currentUser) => { setUser(currentUser); setLoading(false); diff --git a/bsg-frontend/apps/extension/pages/room-choice.tsx b/bsg-frontend/apps/extension/pages/room-choice.tsx new file mode 100644 index 0000000..d9d102f --- /dev/null +++ b/bsg-frontend/apps/extension/pages/room-choice.tsx @@ -0,0 +1,234 @@ +import { useState } from 'react' +import { Poppins } from 'next/font/google' +import { Button } from '@bsg/ui/button' +// not used +// import { +// Dialog, +// DialogContent, +// DialogDescription, +// DialogFooter, +// DialogHeader, +// DialogTitle, +// DialogTrigger, +// } from "@bsg/ui/dialog" +import { Label } from "@bsg/ui/label" +import { Slider } from "@bsg/ui/slider" +import { ScrollArea } from "@bsg/ui/scroll-area" +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus, faDoorOpen, faMinus } from '@fortawesome/free-solid-svg-icons' + +const poppins = Poppins({ weight: '400', subsets: ['latin'] }) + +interface Topic { + name: string + numberOfProblems: number + isSelected: boolean +} + +enum Difficulty { + Easy = 'Easy', + Medium = 'Medium', + Hard = 'Hard' +} + +const IncDecButtons = ({ decrementOnClick, incrementOnClick }: { decrementOnClick: () => void; incrementOnClick: () => void }) => ( +
+ + +
+) + +const NumberOfProblemsWithDifficultyLabel = ({ difficulty, num }: { difficulty: Difficulty; num: number }) => { + const getColorClass = (diff: Difficulty) => { + switch (diff) { + case Difficulty.Easy: return 'text-green-500' + case Difficulty.Medium: return 'text-yellow-500' + case Difficulty.Hard: return 'text-red-500' + default: return 'text-gray-500' + } + } + return {difficulty}: {num} +} + +const TopicComponent = ({ topic, toggle }: { topic: Topic; toggle: () => void }) => { + return ( + + ) +} + +interface RoomChoiceProps { + onJoin: (roomCode: string) => void + onCreate: (roomCode: string, options: { easy: number; medium: number; hard: number; duration: number }) => void +} + +export default function RoomChoice({ onJoin, onCreate }: RoomChoiceProps) { + const [joinCode, setJoinCode] = useState('') + const [showCreateOptions, setShowCreateOptions] = useState(false) + + const [numberOfEasyProblems, setNumberOfEasyProblems] = useState(1) + const [numberOfMediumProblems, setNumberOfMediumProblems] = useState(0) + const [numberOfHardProblems, setNumberOfHardProblems] = useState(0) + const [duration, setDuration] = useState(30) + const [total, setTotal] = useState(1) + const minNumberOfProblems = 0 + const maxNumberOfProblems = 10 + + const [topics, setTopics] = useState([ + {name: "Arrays", numberOfProblems: 214, isSelected: false}, + {name: "Strings", numberOfProblems: 180, isSelected: false}, + {name: "Hash Tables", numberOfProblems: 156, isSelected: false}, + {name: "Dynamic Programming", numberOfProblems: 203, isSelected: false}, + {name: "Trees", numberOfProblems: 175, isSelected: false}, + {name: "Graphs", numberOfProblems: 142, isSelected: false}, + {name: "Linked Lists", numberOfProblems: 98, isSelected: false}, + {name: "Binary Search", numberOfProblems: 87, isSelected: false}, + {name: "Two Pointers", numberOfProblems: 125, isSelected: false}, + {name: "Sliding Window", numberOfProblems: 76, isSelected: false}, + {name: "Backtracking", numberOfProblems: 91, isSelected: false}, + {name: "Greedy", numberOfProblems: 134, isSelected: false}, + ]) + + const decrement = (setter: (v: number) => void, val: number) => { + if (total <= 1 || val <= minNumberOfProblems) return + setter(val - 1) + setTotal(total - 1) + } + + const increment = (setter: (v: number) => void, val: number) => { + if (total >= maxNumberOfProblems) return + setter(val + 1) + setTotal(total + 1) + } + + const handleCreateRoom = () => { + const roomSettings = { easy: numberOfEasyProblems, medium: numberOfMediumProblems, hard: numberOfHardProblems, duration } + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let code = '' + for (let i = 0; i < 5; i++) code += chars.charAt(Math.floor(Math.random() * chars.length)) + onCreate(code, roomSettings) + } + + const handleJoinRoom = () => { + if (!joinCode.trim()) return + onJoin(joinCode.trim()) + } + + const toggleTopic = (index: number) => { + setTopics(prev => { + const copy = [...prev] + copy[index].isSelected = !copy[index].isSelected + return copy + }) + } + + return ( +
+
+

Create a room or join one

+ +
+ {/* Create Room - opens a focused modal dialog (create only) */} + + + + {/* Modal for create options only */} + {showCreateOptions && ( +
+
+
+

Create Room

+ +
+ +
+
+ + decrement(setNumberOfEasyProblems, numberOfEasyProblems)} + incrementOnClick={() => increment(setNumberOfEasyProblems, numberOfEasyProblems)}/> +
+
+ + decrement(setNumberOfMediumProblems, numberOfMediumProblems)} + incrementOnClick={() => increment(setNumberOfMediumProblems, numberOfMediumProblems)}/> +
+
+ + decrement(setNumberOfHardProblems, numberOfHardProblems)} + incrementOnClick={() => increment(setNumberOfHardProblems, numberOfHardProblems)}/> +
+ +
+ + +
+ {topics.map((t, i) => toggleTopic(i)}/>)} +
+
+
+ +
+ + setDuration(v[0])} className={'pt-2'}/> +
+ +
+ + + +
+
+
+
+ )} + + {/* Join Room */} +
+ setJoinCode(e.target.value)} + placeholder="Enter room code" + className="flex-1 px-3 py-2 rounded-lg bg-[#121214] border border-gray-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"/> + + +
+
+
+
+ ) +} diff --git a/bsg-frontend/apps/extension/tsconfig.json b/bsg-frontend/apps/extension/tsconfig.json index 89fa789..67b1b59 100644 --- a/bsg-frontend/apps/extension/tsconfig.json +++ b/bsg-frontend/apps/extension/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": [ - "chrome" + "chrome", + "node" ], "lib": [ "dom", diff --git a/bsg-frontend/extension/background.js b/bsg-frontend/extension/background.js new file mode 100644 index 0000000..a32a09f --- /dev/null +++ b/bsg-frontend/extension/background.js @@ -0,0 +1,61 @@ +let offscreenCreated = false; + +async function ensureOffscreen() { + if (offscreenCreated) return true; + if (!chrome.offscreen) return false; + + try { + const exists = await chrome.offscreen.hasDocument(); + if (!exists) { + await chrome.offscreen.createDocument({ + url: 'offscreen.html', + reasons: ['CLIPBOARD'], + justification: 'Required to write to clipboard from content scripts' + }); + } + offscreenCreated = true; + return true; + } catch (e) { + console.error('ensureOffscreen error', e); + return false; + } +} + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (!msg || msg.type !== 'COPY_TO_CLIPBOARD') return; + const text = msg.text || ''; + + (async () => { + const ok = await doCopy(text); + sendResponse({ ok }); + })(); + + // return true to indicate we'll call sendResponse asynchronously + return true; +}); + +async function doCopy(text) { + // try to use the offscreen document if available + const hasOffscreen = await ensureOffscreen().catch(() => false); + if (hasOffscreen) { + try { + const res = await chrome.runtime.sendMessage({ type: 'OFFSCREEN_COPY', text }); + return res && res.ok; + } catch (e) { + console.error('sendMessage to offscreen failed', e); + } + } + + // try the clipboard API in the service worker context + try { + if (navigator && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch (e) { + console.warn('navigator.clipboard.writeText in service worker failed', e); + } + + // give up + return false; +} diff --git a/bsg-frontend/extension/contentScript.js b/bsg-frontend/extension/contentScript.js index 7e97d3f..fce82c0 100644 --- a/bsg-frontend/extension/contentScript.js +++ b/bsg-frontend/extension/contentScript.js @@ -222,4 +222,4 @@ }); }); })(); - \ No newline at end of file + diff --git a/bsg-frontend/extension/manifest.json b/bsg-frontend/extension/manifest.json index 878a68e..0bbf310 100644 --- a/bsg-frontend/extension/manifest.json +++ b/bsg-frontend/extension/manifest.json @@ -2,6 +2,9 @@ "manifest_version": 3, "name": "BSG", "version": "1.0.0", + "background": { + "service_worker": "background.js" + }, "permissions": [ "tabs", "identity" @@ -25,7 +28,7 @@ "default_popup": "defaultPopup.html" }, "content_security_policy": { - "extension_pages": "script-src 'self'; script-src-elem 'self' https://apis.google.com https://accounts.google.com https://www.gstatic.com; connect-src 'self' https://*.googleapis.com https://*.firebaseapp.com https://accounts.google.com; object-src 'self';" + "extension_pages": "script-src 'self'; script-src-elem 'self' https://apis.google.com https://accounts.google.com https://www.gstatic.com; connect-src 'self' ws://localhost:8080 https://*.googleapis.com https://*.firebaseapp.com https://accounts.google.com; object-src 'self';" }, "web_accessible_resources": [ { diff --git a/bsg-frontend/extension/offscreen.html b/bsg-frontend/extension/offscreen.html new file mode 100644 index 0000000..89370a2 --- /dev/null +++ b/bsg-frontend/extension/offscreen.html @@ -0,0 +1,12 @@ + + + + + + + Offscreen Clipboard + + + + + diff --git a/bsg-frontend/extension/offscreen.js b/bsg-frontend/extension/offscreen.js new file mode 100644 index 0000000..d44096f --- /dev/null +++ b/bsg-frontend/extension/offscreen.js @@ -0,0 +1,35 @@ +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (!msg || msg.type !== 'OFFSCREEN_COPY') return; + const text = msg.text || ''; + + (async () => { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + sendResponse({ ok: true }); + return; + } + } catch (e) { + console.warn('navigator.clipboard failed in offscreen:', e); + } + + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + sendResponse({ ok: !!ok }); + return; + } catch (e) { + console.error('offscreen execCommand failed', e); + sendResponse({ ok: false, err: String(e) }); + return; + } + })(); + + return true; // indicate async response +}); diff --git a/bsg-frontend/public/tailwind.css b/bsg-frontend/public/tailwind.css new file mode 100644 index 0000000..31860ac --- /dev/null +++ b/bsg-frontend/public/tailwind.css @@ -0,0 +1 @@ +/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}:root{--background:0 1% 13%;--foreground:0 0% 100%;--card:0 0% 100%;--card-foreground:224 71.4% 4.1%;--popover:0 0% 100%;--popover-foreground:224 71.4% 4.1%;--primary:90 72% 39%;--primary-foreground:0 0% 100%;--secondary:220 14.3% 95.9%;--secondary-foreground:220.9 39.3% 11%;--muted:220 14.3% 95.9%;--muted-foreground:220 8.9% 46.1%;--accent:220 14.3% 95.9%;--accent-foreground:220.9 39.3% 11%;--destructive:0 84.2% 60.2%;--destructive-foreground:210 20% 98%;--border:220 13% 91%;--input:220 13% 91%;--inputBackground:0 0% 22%;--ring:0 0% 100%;--brand:0 0% 68%;--radius:0.9rem}.dark{--background:224 71.4% 4.1%;--foreground:210 20% 98%;--card:224 71.4% 4.1%;--card-foreground:210 20% 98%;--popover:224 71.4% 4.1%;--popover-foreground:210 20% 98%;--primary:210 20% 98%;--primary-foreground:220.9 39.3% 11%;--secondary:215 27.9% 16.9%;--secondary-foreground:210 20% 98%;--muted:215 27.9% 16.9%;--muted-foreground:217.9 10.6% 64.9%;--accent:215 27.9% 16.9%;--accent-foreground:210 20% 98%;--destructive:0 62.8% 30.6%;--destructive-foreground:210 20% 98%;--border:215 27.9% 16.9%;--input:215 27.9% 16.9%;--ring:216 12.2% 83.9%}*{border-color:hsl(var(--border));outline-color:hsl(var(--ring)/.5)}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%;margin-right:auto;margin-left:auto;padding-right:2rem;padding-left:2rem}@media (min-width:1400px){.container{max-width:1400px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.not-sr-only{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-1{left:.25rem}.left-2{left:.5rem}.left-\[50\%\]{left:50%}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-4{top:1rem}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.top-\[60\%\]{top:60%}.top-full{top:100%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.col-span-2{grid-column:span 2/span 2}.col-span-5{grid-column:span 5/span 5}.row-auto{grid-row:auto}.m-10{margin:2.5rem}.m-5{margin:1.25rem}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem}.mb-2,.my-2{margin-bottom:.5rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mr-3{margin-right:.75rem}.mr-5{margin-right:1.25rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.\!table{display:table!important}.table{display:table}.inline-table{display:inline-table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row-group{display:table-row-group}.table-row{display:table-row}.flow-root{display:flow-root}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.list-item{display:list-item}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-9{height:2.25rem}.h-\[1px\]{height:1px}.h-\[var\(--radix-navigation-menu-viewport-height\)\]{height:var(--radix-navigation-menu-viewport-height)}.h-full{height:100%}.h-px{height:1px}.max-h-32{max-height:8rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-5\/12{width:41.666667%}.w-56{width:14rem}.w-80{width:20rem}.w-96{width:24rem}.w-\[1px\]{width:1px}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.w-max{width:-moz-max-content;width:max-content}.w-px{width:1px}.min-w-\[8rem\]{min-width:8rem}.max-w-\[650px\]{max-width:650px}.max-w-lg{max-width:32rem}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.translate-x-\[-50\%\]{--tw-translate-x:-50%}.translate-x-\[-50\%\],.translate-y-\[-50\%\]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.rotate-45{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-45{--tw-rotate:45deg}.rotate-90{--tw-rotate:90deg}.rotate-90,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.touch-none{touch-action:none}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x) var(--tw-pan-y) var(--tw-pinch-zoom)}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-9{grid-template-columns:repeat(9,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.space-y-reverse>:not([hidden])~:not([hidden]){--tw-space-y-reverse:1}.space-x-reverse>:not([hidden])~:not([hidden]){--tw-space-x-reverse:1}.divide-x>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;border-right-width:calc(1px*var(--tw-divide-x-reverse));border-left-width:calc(1px*(1 - var(--tw-divide-x-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-y-reverse>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:1}.divide-x-reverse>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:1}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.text-clip{text-overflow:clip}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.rounded-br{border-bottom-right-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-tl-sm{border-top-left-radius:calc(var(--radius) - 4px)}.rounded-tr{border-top-right-radius:.25rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-x{border-left-width:1px;border-right-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-e{border-inline-end-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-s{border-inline-start-width:1px}.border-t{border-top-width:1px}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity))}.border-input{border-color:hsl(var(--input))}.border-inputBackground{border-color:hsl(var(--inputBackground))}.border-primary{border-color:hsl(var(--primary))}.border-l-transparent{border-left-color:#0000}.border-t-transparent{border-top-color:#0000}.bg-accent{background-color:hsl(var(--accent))}.bg-background{background-color:hsl(var(--background))}.bg-background\/70{background-color:hsl(var(--background)/.7)}.bg-black\/80{background-color:#000c}.bg-border{background-color:hsl(var(--border))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/90{background-color:hsl(var(--destructive)/.9)}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-inputBackground{background-color:hsl(var(--inputBackground))}.bg-muted{background-color:hsl(var(--muted))}.bg-muted\/50{background-color:hsl(var(--muted)/.5)}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/90{background-color:hsl(var(--primary)/.9)}.bg-secondary{background-color:hsl(var(--secondary))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-repeat{background-repeat:repeat}.fill-accent{fill:hsl(var(--accent))}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-\[1px\]{padding:1px}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pl-1{padding-left:.25rem}.pl-2{padding-left:.5rem}.pl-2\.5{padding-left:.625rem}.pl-5{padding-left:1.25rem}.pl-8{padding-left:2rem}.pr-0{padding-right:0}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-5{padding-right:1.25rem}.pt-2{padding-top:.5rem}.pt-\[64px\]{padding-top:64px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.not-italic{font-style:normal}.normal-nums{font-variant-numeric:normal}.ordinal{--tw-ordinal:ordinal}.ordinal,.slashed-zero{font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.slashed-zero{--tw-slashed-zero:slashed-zero}.lining-nums{--tw-numeric-figure:lining-nums}.lining-nums,.oldstyle-nums{font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums}.proportional-nums{--tw-numeric-spacing:proportional-nums}.proportional-nums,.tabular-nums{font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.tabular-nums{--tw-numeric-spacing:tabular-nums}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.tracking-widest{letter-spacing:.1em}.text-accent-foreground{color:hsl(var(--accent-foreground))}.text-brand{color:hsl(var(--brand))}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.overline{text-decoration-line:overline}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid #0000;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-2{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-inset{--tw-ring-inset:inset}.ring-ring{--tw-ring-color:hsl(var(--ring))}.ring-offset-2{--tw-ring-offset-width:2px}.ring-offset-background{--tw-ring-offset-color:hsl(var(--background))}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f)}.grayscale{--tw-grayscale:grayscale(100%)}.grayscale,.invert{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.invert{--tw-invert:invert(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-grayscale{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%)}.backdrop-invert{--tw-backdrop-invert:invert(100%)}.backdrop-invert,.backdrop-sepia{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0) scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0) scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}.animate-in{animation-name:enter;animation-duration:.15s;--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial}.fade-in-0{--tw-enter-opacity:0}.zoom-in{--tw-enter-scale:0}.zoom-in-95{--tw-enter-scale:.95}.zoom-out{--tw-exit-scale:0}.duration-200{animation-duration:.2s}.ease-in-out{animation-timing-function:cubic-bezier(.4,0,.2,1)}.running{animation-play-state:running}.paused{animation-play-state:paused}.file\:border-0::file-selector-button{border-width:0}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-y-0:after{content:var(--tw-content);top:0;bottom:0}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-1:after{content:var(--tw-content);width:.25rem}.after\:-translate-x-1\/2:after{content:var(--tw-content);--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-background\/70:hover{background-color:hsl(var(--background)/.7)}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive)/.9)}.hover\:bg-inputBackground:hover{background-color:hsl(var(--inputBackground))}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted)/.5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary)/.9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary)/.8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-75:hover{opacity:.75}.hover\:brightness-125:hover{--tw-brightness:brightness(1.25);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:bg-inputBackground:focus{background-color:hsl(var(--inputBackground))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color:hsl(var(--ring))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid #0000;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus-visible\:ring-1:focus-visible,.focus-visible\:ring-2:focus-visible{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[panel-group-direction\=vertical\]\:h-px[data-panel-group-direction=vertical]{height:1px}.data-\[panel-group-direction\=vertical\]\:w-full[data-panel-group-direction=vertical]{width:100%}.data-\[panel-group-direction\=vertical\]\:flex-col[data-panel-group-direction=vertical]{flex-direction:column}.data-\[active\]\:bg-accent\/50[data-active]{background-color:hsl(var(--accent)/.5)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:hsl(var(--background))}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:hsl(var(--accent))}.data-\[state\=open\]\:bg-accent\/50[data-state=open]{background-color:hsl(var(--accent)/.5)}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:hsl(var(--muted))}.data-\[state\=active\]\:text-foreground[data-state=active]{color:hsl(var(--foreground))}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:hsl(var(--muted-foreground))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow-sm[data-state=active]{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.data-\[motion\^\=from-\]\:animate-in[data-motion^=from-],.data-\[state\=open\]\:animate-in[data-state=open],.data-\[state\=visible\]\:animate-in[data-state=visible]{animation-name:enter;animation-duration:.15s;--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial}.data-\[motion\^\=to-\]\:animate-out[data-motion^=to-],.data-\[state\=closed\]\:animate-out[data-state=closed],.data-\[state\=hidden\]\:animate-out[data-state=hidden]{animation-name:exit;animation-duration:.15s;--tw-exit-opacity:initial;--tw-exit-scale:initial;--tw-exit-rotate:initial;--tw-exit-translate-x:initial;--tw-exit-translate-y:initial}.data-\[motion\^\=from-\]\:fade-in[data-motion^=from-]{--tw-enter-opacity:0}.data-\[motion\^\=to-\]\:fade-out[data-motion^=to-],.data-\[state\=closed\]\:fade-out-0[data-state=closed],.data-\[state\=hidden\]\:fade-out[data-state=hidden]{--tw-exit-opacity:0}.data-\[state\=open\]\:fade-in-0[data-state=open],.data-\[state\=visible\]\:fade-in[data-state=visible]{--tw-enter-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:zoom-in-90[data-state=open]{--tw-enter-scale:.9}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[motion\=from-end\]\:slide-in-from-right-52[data-motion=from-end]{--tw-enter-translate-x:13rem}.data-\[motion\=from-start\]\:slide-in-from-left-52[data-motion=from-start]{--tw-enter-translate-x:-13rem}.data-\[motion\=to-end\]\:slide-out-to-right-52[data-motion=to-end]{--tw-exit-translate-x:13rem}.data-\[motion\=to-start\]\:slide-out-to-left-52[data-motion=to-start]{--tw-exit-translate-x:-13rem}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:-0.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:0.5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:-0.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:0.5rem}.data-\[state\=closed\]\:slide-out-to-left-1\/2[data-state=closed]{--tw-exit-translate-x:-50%}.data-\[state\=closed\]\:slide-out-to-top-\[48\%\][data-state=closed]{--tw-exit-translate-y:-48%}.data-\[state\=open\]\:slide-in-from-left-1\/2[data-state=open]{--tw-enter-translate-x:-50%}.data-\[state\=open\]\:slide-in-from-top-\[48\%\][data-state=open]{--tw-enter-translate-y:-48%}.data-\[panel-group-direction\=vertical\]\:after\:left-0[data-panel-group-direction=vertical]:after{content:var(--tw-content);left:0}.data-\[panel-group-direction\=vertical\]\:after\:h-1[data-panel-group-direction=vertical]:after{content:var(--tw-content);height:.25rem}.data-\[panel-group-direction\=vertical\]\:after\:w-full[data-panel-group-direction=vertical]:after{content:var(--tw-content);width:100%}.data-\[panel-group-direction\=vertical\]\:after\:-translate-y-1\/2[data-panel-group-direction=vertical]:after{content:var(--tw-content);--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[panel-group-direction\=vertical\]\:after\:translate-x-0[data-panel-group-direction=vertical]:after{content:var(--tw-content);--tw-translate-x:0px}.data-\[panel-group-direction\=vertical\]\:after\:translate-x-0[data-panel-group-direction=vertical]:after,.group[data-state=open] .group-data-\[state\=open\]\:rotate-180{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group[data-state=open] .group-data-\[state\=open\]\:rotate-180{--tw-rotate:180deg}@media (min-width:640px){.sm\:max-w-\[425px\]{max-width:425px}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:var(--radius)}.sm\:text-left{text-align:left}}@media (min-width:768px){.md\:absolute{position:absolute}.md\:w-\[var\(--radix-navigation-menu-viewport-width\)\]{width:var(--radix-navigation-menu-viewport-width)}.md\:w-auto{width:auto}}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\&\>tr\]\:last\:border-b-0:last-child>tr{border-bottom-width:0}.\[\&\[data-panel-group-direction\=vertical\]\>div\]\:rotate-90[data-panel-group-direction=vertical]>div{--tw-rotate:90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0}.\[\&_tr\]\:border-b tr{border-bottom-width:1px} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3011e70..5084d9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: build: ./rtc-service ports: - 5001:8080 + - 8080:8080 worker-service: build: ./worker-service environment: diff --git a/rtc-service/main.go b/rtc-service/main.go index 15e4a87..88819f4 100644 --- a/rtc-service/main.go +++ b/rtc-service/main.go @@ -28,12 +28,17 @@ var ( var upgrader = websocket.Upgrader{ ReadBufferSize: readBufferSize, WriteBufferSize: writeBufferSize, + // IMPORTANT: Allow all origins. Chrome extensions send "chrome-extension://..." + // which differs from "localhost", causing the default check to fail. + CheckOrigin: func(r *http.Request) bool { + return true + }, } var serviceManager = servicesmanager.NewServiceManager() func main() { - logging.Info("Starting RTC Service") + logging.Info("Starting RTC Service on " + port) http.HandleFunc(path, wsHandler) log.Fatal(http.ListenAndServe(port, nil)) @@ -45,7 +50,6 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { logging.Error("Failed to upgrade connection: ", err) - conn.Close() return } @@ -63,4 +67,4 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { // Start writing messages to the service. go client.WriteMessages() -} +} \ No newline at end of file diff --git a/rtc-service/requests/chat-message-request.go b/rtc-service/requests/chat-message-request.go index 1c4dc3c..2d3211b 100644 --- a/rtc-service/requests/chat-message-request.go +++ b/rtc-service/requests/chat-message-request.go @@ -3,8 +3,7 @@ package requests import ( "encoding/json" "errors" - "strings" - + "github.com/acmutd/bsg/rtc-service/chatmanager" "github.com/acmutd/bsg/rtc-service/response" "github.com/go-playground/validator/v10" @@ -67,6 +66,12 @@ func (r *ChatMessageRequest) Handle(m *Message) (response.ResponseType, string, return r.responseType(), "", r.RoomID, errors.New("user doesn't exist in the room") } - chat_message := []string{r.UserHandle, r.Message} - return r.responseType(), strings.Join(chat_message, " - "), r.RoomID, nil -} + // Return data as JSON so response handler can parse it easily + responseData := map[string]string{ + "userHandle": r.UserHandle, + "message": r.Message, + } + jsonBytes, _ := json.Marshal(responseData) + + return r.responseType(), string(jsonBytes), r.RoomID, nil +} \ No newline at end of file diff --git a/rtc-service/response/response.go b/rtc-service/response/response.go index 5d53b52..035a925 100644 --- a/rtc-service/response/response.go +++ b/rtc-service/response/response.go @@ -55,10 +55,22 @@ func NewErrorResponse(responseType ResponseType, message string, roomID string) func NewOkResponse(responseType ResponseType, message string, roomID string) *Response { userHandle := "" data := message + if responseType == CHAT_MESSAGE && message != "" { - message := strings.Split(message, " - ") - userHandle = message[0] - data = message[1] + // Try to unmarshal JSON first + var chatData map[string]string + err := json.Unmarshal([]byte(message), &chatData) + if err == nil { + userHandle = chatData["userHandle"] + data = chatData["message"] + } else { + // Fallback to old format for compatibility if needed + parts := strings.Split(message, " - ") + if len(parts) >= 2 { + userHandle = parts[0] + data = parts[1] + } + } } respMessage := responseMessage{ @@ -89,4 +101,4 @@ func (r *Response) IsError() bool { func (r *Response) Message() string { resp, _ := json.Marshal(r) return string(resp) -} +} \ No newline at end of file diff --git a/rtc-service/servicesmanager/service.go b/rtc-service/servicesmanager/service.go index d092120..9653590 100644 --- a/rtc-service/servicesmanager/service.go +++ b/rtc-service/servicesmanager/service.go @@ -4,6 +4,7 @@ import ( "encoding/json" "time" + "github.com/acmutd/bsg/rtc-service/chatmanager" "github.com/acmutd/bsg/rtc-service/logging" "github.com/acmutd/bsg/rtc-service/requests" "github.com/acmutd/bsg/rtc-service/response" @@ -108,13 +109,35 @@ func (s *Service) ReadMessages() { logging.Error("Failed to handle message: ", err) s.Egress <- *response.NewErrorResponse(respType, err.Error(), roomID) } else { - // Send the response back to the client. - s.Egress <- *response.NewOkResponse(respType, resp, roomID) + respObj := *response.NewOkResponse(respType, resp, roomID) + + // Broadcast Logic + // If it's a chat message or announcement, send to everyone in the room + if respType == response.CHAT_MESSAGE || respType == response.SYSTEM_ANNOUNCEMENT { + room := chatmanager.RTCChatManager.GetRoom(roomID) + if room != nil { + // Send to all users in the room + for user := range room.Users { + // Find the service associated with the user handle + // Note: This assumes the Service Name matches the User Handle + userService := s.ServiceManager.FindService(user.Handle) + if userService != nil { + userService.Egress <- respObj + } + } + } else { + // Fallback if room not found (shouldn't happen if logic is correct) + s.Egress <- respObj + } + } else { + // Default: Send the response back to the sender only + s.Egress <- respObj + } - // Send the requests to the front-end + // Send the requests to the front-end (Central Service) frontEnd := s.ServiceManager.FindService(FRONT_END_SERVICE) if frontEnd != nil { - frontEnd.Egress <- *response.NewOkResponse(respType, resp, roomID) + frontEnd.Egress <- respObj } } } @@ -172,4 +195,4 @@ func (s *Service) pongHandler(pongMsg string) error { // Current time + Pong Wait time logging.Info("Pong: ", s.Name) return s.Connection.SetReadDeadline(time.Now().Add(PONG_WAIT)) -} +} \ No newline at end of file