From f05c190ba3cddac63379a3cc3cfcff8d1bc08693 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 15:43:17 +0000 Subject: [PATCH 001/149] chore: update @atyrode/excalidraw dependency to version 0.18.0-12 - Updated the version of @atyrode/excalidraw in package.json and yarn.lock to ensure compatibility with the latest features and fixes. - Adjusted the integrity hash in yarn.lock to reflect the new version. --- src/frontend/package.json | 2 +- src/frontend/src/utils/canvasUtils.ts | 3 +++ src/frontend/yarn.lock | 8 ++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 707a206..1a70207 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-9", + "@atyrode/excalidraw": "^0.18.0-12", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts index ca9a68b..26588bd 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/utils/canvasUtils.ts @@ -47,6 +47,9 @@ export function normalizeCanvasData(data: any) { // Reset collaborators (https://github.com/excalidraw/excalidraw/issues/8637) appState.collaborators = new Map(); + // Support new appState key default value (https://github.com/excalidraw/excalidraw/commit/a30e1b25c60a9c5c6f049daada0443df874a5266#diff-b7eb4d88c1bc5b4756a01281478e2105db6502e96c2a4b855496c508cef05397L124-R124) + appState.searchMatches = null; + return { ...data, appState }; } diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 73f29bc..83c6a24 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-9": - version "0.18.0-9" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-9.tgz#6a69b5d0b44b902c10ba9b27e46803231a628d95" - integrity sha512-Wej+UFAemSTHrLTcOOYCYxnZ4DTsYgRmE+NKMLtCOXLAI69nCQ7eyIMeKdI01rfNetjzT7OOZLHi/AIhx6GYGg== +"@atyrode/excalidraw@^0.18.0-12": + version "0.18.0-12" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-12.tgz#a3ebe48cef65f3703152d5d07510f7b5e42a3342" + integrity sha512-3Z1yi7/enXtG3rTxkfe8rdhVhEJM9pkFCCNht/uDEYtGlb6FIASdO700oh6olbOqxX93OqwpHtnW7UM4i4JSzQ== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" From ebc9dcd361edd97fdfee71b5faf0faf550857a42 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 17:09:39 +0000 Subject: [PATCH 002/149] chore: remove unused files and refactor authentication handling - Deleted the frontend-backend communication documentation as it is no longer relevant. - Removed BuildVersionCheck, apiUtils, configService, and hooks files to streamline the codebase. - Refactored AuthGate and related components to simplify authentication logic, setting isAuthenticated to a static value for now. - Updated ExcalidrawWrapper and App components to reflect changes in authentication handling and removed unnecessary imports. - Cleaned up various UI components by removing references to deleted hooks and API calls, ensuring a more maintainable code structure. --- docs/frontend-backend-communication.md | 137 ----- src/frontend/index.tsx | 13 +- src/frontend/src/App.tsx | 131 +---- src/frontend/src/AuthGate.tsx | 16 +- src/frontend/src/BuildVersionCheck.tsx | 61 --- src/frontend/src/ExcalidrawWrapper.tsx | 45 +- src/frontend/src/api/apiUtils.ts | 51 -- src/frontend/src/api/configService.ts | 39 -- src/frontend/src/api/hooks.ts | 330 ------------ src/frontend/src/api/queryClient.ts | 14 - src/frontend/src/env.d.ts | 11 - src/frontend/src/global.d.ts | 3 - src/frontend/src/pad/Dashboard.tsx | 12 +- src/frontend/src/pad/StateIndicator.tsx | 21 +- src/frontend/src/pad/Terminal.tsx | 11 +- src/frontend/src/pad/buttons/ActionButton.tsx | 10 +- .../src/pad/buttons/ControlButton.tsx | 22 +- src/frontend/src/ui/AccountDialog.tsx | 24 +- src/frontend/src/ui/AuthDialog.tsx | 2 - src/frontend/src/ui/BackupsDialog.scss | 233 -------- src/frontend/src/ui/BackupsDialog.tsx | 131 ----- src/frontend/src/ui/MainMenu.tsx | 37 +- src/frontend/src/ui/PadsDialog.scss | 215 -------- src/frontend/src/ui/PadsDialog.tsx | 320 ----------- src/frontend/src/ui/SettingsDialog.tsx | 3 +- src/frontend/src/ui/TabContextMenu.scss | 160 ------ src/frontend/src/ui/TabContextMenu.tsx | 295 ----------- src/frontend/src/ui/Tabs.scss | 123 ----- src/frontend/src/ui/Tabs.tsx | 497 ------------------ src/frontend/src/utils/canvasUtils.ts | 289 +--------- src/frontend/src/utils/posthog.ts | 18 +- 31 files changed, 118 insertions(+), 3156 deletions(-) delete mode 100644 docs/frontend-backend-communication.md delete mode 100644 src/frontend/src/BuildVersionCheck.tsx delete mode 100644 src/frontend/src/api/apiUtils.ts delete mode 100644 src/frontend/src/api/configService.ts delete mode 100644 src/frontend/src/api/hooks.ts delete mode 100644 src/frontend/src/api/queryClient.ts delete mode 100644 src/frontend/src/env.d.ts delete mode 100644 src/frontend/src/global.d.ts delete mode 100644 src/frontend/src/ui/BackupsDialog.scss delete mode 100644 src/frontend/src/ui/BackupsDialog.tsx delete mode 100644 src/frontend/src/ui/PadsDialog.scss delete mode 100644 src/frontend/src/ui/PadsDialog.tsx delete mode 100644 src/frontend/src/ui/TabContextMenu.scss delete mode 100644 src/frontend/src/ui/TabContextMenu.tsx delete mode 100644 src/frontend/src/ui/Tabs.scss delete mode 100644 src/frontend/src/ui/Tabs.tsx diff --git a/docs/frontend-backend-communication.md b/docs/frontend-backend-communication.md deleted file mode 100644 index 006318e..0000000 --- a/docs/frontend-backend-communication.md +++ /dev/null @@ -1,137 +0,0 @@ -# Frontend-Backend Communication (React Query Architecture) - -This document describes the current architecture and all communication points between the frontend and backend in the Pad.ws application, following the React Query refactor. All API interactions are now managed through React Query hooks, providing deduplication, caching, polling, and robust error handling. - ---- - -## 1. Overview of Communication Architecture - -- **All frontend-backend communication is handled via React Query hooks.** -- **API calls are centralized in `src/frontend/src/api/hooks.ts` and `apiUtils.ts`.** -- **No custom context providers for authentication or workspace state are used; hooks are called directly in components.** -- **Error and loading states are managed by React Query.** -- **Mutations (e.g., saving data, starting/stopping workspace) automatically invalidate relevant queries.** - ---- - -## 2. Authentication - -### 2.1. Authentication Status - -- **Hook:** `useAuthCheck` -- **Endpoint:** `GET /api/workspace/state` -- **Usage:** Determines if the user is authenticated. Returns `true` if authenticated, `false` if 401 Unauthorized. -- **Example:** - ```typescript - import { useAuthCheck } from "./api/hooks"; - const { data: isAuthenticated = true } = useAuthCheck(); - ``` -- **UI:** If `isAuthenticated` is `false`, the login modal (`AuthModal`) is displayed. - -### 2.2. Login/Logout - -- **Login:** Handled via OAuth redirects (e.g., `/auth/login?kc_idp_hint=google`). -- **Logout:** Handled via redirect to `/auth/logout`. -- **No direct API call from React Query; handled by browser navigation.** - ---- - -## 3. User Profile - -- **Hook:** `useUserProfile` -- **Endpoint:** `GET /api/user/me` -- **Usage:** Fetches the authenticated user's profile. -- **Example:** - ```typescript - import { useUserProfile } from "./api/hooks"; - const { data: userProfile, isLoading, error } = useUserProfile(); - ``` - ---- - -## 4. Workspace Management - -### 4.1. Workspace State - -- **Hook:** `useWorkspaceState` -- **Endpoint:** `GET /api/workspace/state` -- **Usage:** Polls workspace state every 5 seconds. -- **Example:** - ```typescript - import { useWorkspaceState } from "./api/hooks"; - const { data: workspaceState, isLoading, error } = useWorkspaceState(); - ``` - -### 4.2. Start/Stop Workspace - -- **Hooks:** `useStartWorkspace`, `useStopWorkspace` -- **Endpoints:** `POST /api/workspace/start`, `POST /api/workspace/stop` -- **Usage:** Mutations to start/stop the workspace. On success, invalidate and refetch workspace state. -- **Example:** - ```typescript - import { useStartWorkspace, useStopWorkspace } from "./api/hooks"; - const { mutate: startWorkspace } = useStartWorkspace(); - const { mutate: stopWorkspace } = useStopWorkspace(); - // Usage: startWorkspace(); stopWorkspace(); - ``` - ---- - -## 5. Canvas Data Management - -### 5.1. Load Canvas - -- **Hooks:** `useCanvas`, `useDefaultCanvas` -- **Endpoints:** `GET /api/canvas`, `GET /api/canvas/default` -- **Usage:** Loads user canvas data; falls back to default if not available or on error. -- **Example:** - ```typescript - import { useCanvas, useDefaultCanvas } from "./api/hooks"; - const { data: canvasData, isError } = useCanvas(); - const { data: defaultCanvasData } = useDefaultCanvas({ enabled: isError }); - ``` - -### 5.2. Save Canvas - -- **Hook:** `useSaveCanvas` -- **Endpoint:** `POST /api/canvas` -- **Usage:** Saves canvas data. Only called if user is authenticated. -- **Example:** - ```typescript - import { useSaveCanvas } from "./api/hooks"; - const { mutate: saveCanvas } = useSaveCanvas(); - // Usage: saveCanvas(canvasData); - ``` - ---- - -## 6. Error Handling - -- **All API errors are handled by React Query and the `fetchApi` utility.** -- **401 Unauthorized:** Triggers unauthenticated state; login modal is shown. -- **Other errors:** Exposed via `error` property in hook results; components can display error messages or fallback UI. -- **Example:** - ```typescript - const { data, error, isLoading } = useWorkspaceState(); - if (error) { /* Show error UI */ } - ``` - ---- - -## 7. API Utility Functions - -- **File:** `src/frontend/src/api/apiUtils.ts` -- **Functions:** `fetchApi`, `handleResponse` -- **Purpose:** Centralizes fetch logic, error handling, and credentials management for all API calls. - ---- - -## 8. Summary - -- **All frontend-backend communication is now declarative and managed by React Query hooks.** -- **No legacy context classes or direct fetches remain.** -- **API logic is centralized, maintainable, and testable.** -- **Error handling, caching, and polling are handled automatically.** -- **UI components react to hook state for loading, error, and data.** - -This architecture ensures robust, efficient, and maintainable communication between the frontend and backend. diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index 2d4d0b3..47a295b 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -4,10 +4,6 @@ import { createRoot } from "react-dom/client"; import posthog from "./src/utils/posthog"; import { PostHogProvider } from 'posthog-js/react'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { queryClient } from './src/api/queryClient'; - import "@atyrode/excalidraw/index.css"; import "./index.scss"; @@ -15,8 +11,6 @@ import type * as TExcalidraw from "@atyrode/excalidraw"; import App from "./src/App"; import AuthGate from "./src/AuthGate"; -import { BuildVersionCheck } from "./src/BuildVersionCheck"; - declare global { interface Window { @@ -31,18 +25,13 @@ async function initApp() { root.render( - - - + { }} excalidrawLib={window.ExcalidrawLib} > - - - , ); diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 2630c5a..04a1190 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,22 +1,7 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { useAllPads, useUserProfile } from "./api/hooks"; +import React, { useState, useEffect } from "react"; import { ExcalidrawWrapper } from "./ExcalidrawWrapper"; -import { debounce } from "./utils/debounce"; -import posthog from "./utils/posthog"; -import { - normalizeCanvasData, - getPadData, - storePadData, - setActivePad, - getActivePad, - getStoredActivePad, - loadPadData -} from "./utils/canvasUtils"; -import { useSaveCanvas } from "./api/hooks"; import type * as TExcalidraw from "@atyrode/excalidraw"; -import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; -import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; -import { useAuthCheck } from "./api/hooks"; +import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; export interface AppProps { useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; @@ -33,63 +18,13 @@ export default function App({ }: AppProps) { const { useHandleLibrary, MainMenu } = excalidrawLib; - const { data: isAuthenticated, isLoading: isAuthLoading } = useAuthCheck(); - const { data: userProfile } = useUserProfile(); - - // Only enable pad queries if authenticated and not loading - const { data: pads } = useAllPads({ - queryKey: ['allPads'], - enabled: isAuthenticated === true && !isAuthLoading, - retry: 1, - }); - - // Get the first pad's data to use as the canvas data - const canvasData = pads && pads.length > 0 ? pads[0].data : null; + const isAuthenticated = false; //TODO // Excalidraw API ref const [excalidrawAPI, setExcalidrawAPI] = useState(null); useCustom(excalidrawAPI, customArgs); useHandleLibrary({ excalidrawAPI }); - // Using imported functions from canvasUtils.ts - - useEffect(() => { - if (excalidrawAPI && pads && pads.length > 0) { - // Check if there's a stored active pad ID - const storedActivePadId = getStoredActivePad(); - - // Find the pad that matches the stored ID, or use the first pad if no match - let padToActivate = pads[0]; - - if (storedActivePadId) { - // Try to find the pad with the stored ID - const matchingPad = pads.find(pad => pad.id === storedActivePadId); - if (matchingPad) { - console.debug(`[pad.ws] Found stored active pad in App.tsx: ${storedActivePadId}`); - padToActivate = matchingPad; - } else { - console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`); - } - } - - // Set the active pad ID globally - setActivePad(padToActivate.id); - - // Load the pad data for the selected pad - loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data); - } - }, [excalidrawAPI, pads]); - - const { mutate: saveCanvas } = useSaveCanvas({ - onSuccess: () => { - console.debug("[pad.ws] Canvas saved to database successfully"); - }, - onError: (error) => { - console.error("[pad.ws] Failed to save canvas to database:", error); - } - }); - - useEffect(() => { if (excalidrawAPI) { (window as any).excalidrawAPI = excalidrawAPI; @@ -99,61 +34,27 @@ export default function App({ }; }, [excalidrawAPI]); - const lastSentCanvasDataRef = useRef(""); - - const debouncedLogChange = useCallback( - debounce( - (elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => { - if (!isAuthenticated) return; - - // Get the active pad ID using the imported function - const activePadId = getActivePad(); - if (!activePadId) return; - - const canvasData = { - elements, - appState: state, - files - }; - - const serialized = JSON.stringify(canvasData); - if (serialized !== lastSentCanvasDataRef.current) { - lastSentCanvasDataRef.current = serialized; - - // Store the canvas data in local storage - storePadData(activePadId, canvasData); - - // Save the canvas data to the server - saveCanvas(canvasData); - } - }, - 1200 - ), - [saveCanvas, isAuthenticated, storePadData] - ); - - useEffect(() => { - if (userProfile?.id) { - posthog.identify(userProfile.id); - if (posthog.people && typeof posthog.people.set === "function") { - const { - id, // do not include in properties - ...personProps - } = userProfile; - posthog.people.set(personProps); - } - } - }, [userProfile]); + /* PostHog user identification */ + // useEffect(() => { + // if (userProfile?.id) { + // posthog.identify(userProfile.id); + // if (posthog.people && typeof posthog.people.set === "function") { + // const { + // id, // do not include in properties + // ...personProps + // } = userProfile; + // posthog.people.set(personProps); + // } + // } + // }, [userProfile]); return ( <> {children} diff --git a/src/frontend/src/AuthGate.tsx b/src/frontend/src/AuthGate.tsx index 6434020..43968ce 100644 --- a/src/frontend/src/AuthGate.tsx +++ b/src/frontend/src/AuthGate.tsx @@ -1,6 +1,4 @@ import React, { useEffect, useRef, useState } from "react"; -import { useAuthCheck } from "./api/hooks"; -import { getAppConfig } from "./api/configService"; /** * If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it. @@ -11,19 +9,22 @@ import { getAppConfig } from "./api/configService"; * * The iframe is removed as soon as it loads, or after a fallback timeout. */ -export default function AuthGate({ children }: { children: React.ReactNode }) { - const { data: isAuthenticated, isLoading } = useAuthCheck(); +export default function AuthGate() { const [coderAuthDone, setCoderAuthDone] = useState(false); const iframeRef = useRef(null); const timeoutRef = useRef(null); + const isAuthenticated = false; //TODO + useEffect(() => { // Only run the Coder OIDC priming once per session, after auth is confirmed - if (isAuthenticated === true && !coderAuthDone) { + if (isAuthenticated && !coderAuthDone) { const setupIframe = async () => { try { // Get config from API - const config = await getAppConfig(); + const config = { + coderUrl: 'https://coder.pad.ws' //TODO + }; if (!config.coderUrl) { console.warn('[pad.ws] Coder URL not found, skipping OIDC priming'); @@ -69,6 +70,5 @@ export default function AuthGate({ children }: { children: React.ReactNode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, coderAuthDone]); - // Just render children - AuthModal is now handled by ExcalidrawWrapper - return <>{children}; + return null; } diff --git a/src/frontend/src/BuildVersionCheck.tsx b/src/frontend/src/BuildVersionCheck.tsx deleted file mode 100644 index 162732c..0000000 --- a/src/frontend/src/BuildVersionCheck.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { useBuildInfo, useSaveCanvas } from './api/hooks'; -import { saveCurrentCanvas } from './utils/canvasUtils'; - -/** - * Component that checks for application version changes and refreshes the page when needed. - * This component doesn't render anything visible. - */ -export function BuildVersionCheck() { - // Store the initial build hash when the component first loads - const [initialBuildHash, setInitialBuildHash] = useState(null); - - // Query for the current build info from the server - const { data: buildInfo } = useBuildInfo(); - - // Get the saveCanvas mutation - const { mutate: saveCanvas } = useSaveCanvas({ - onSuccess: () => { - console.debug("[pad.ws] Canvas saved before refresh"); - // Refresh the page immediately after saving - window.location.reload(); - }, - onError: (error) => { - console.error("[pad.ws] Failed to save canvas before refresh:", error); - // Refresh anyway even if save fails - window.location.reload(); - } - }); - - // Function to handle version update - const handleVersionUpdate = useCallback(() => { - // Save the canvas and then refresh - saveCurrentCanvas( - saveCanvas, - undefined, // No success callback needed as it's handled in the useSaveCanvas hook - () => window.location.reload() // On error, just refresh - ); - }, [saveCanvas]); - - useEffect(() => { - // On first load, store the initial build hash - if (buildInfo?.buildHash && initialBuildHash === null) { - console.debug('[pad.ws] Initial build hash:', buildInfo.buildHash); - setInitialBuildHash(buildInfo.buildHash); - } - - // If we have both values and they don't match, a new version is available - if (initialBuildHash !== null && - buildInfo?.buildHash && - initialBuildHash !== buildInfo.buildHash) { - - console.debug('[pad.ws] New version detected. Current:', initialBuildHash, 'New:', buildInfo.buildHash); - - // Save the canvas and then refresh - handleVersionUpdate(); - } - }, [buildInfo, initialBuildHash, handleVersionUpdate]); - - // This component doesn't render anything - return null; -} diff --git a/src/frontend/src/ExcalidrawWrapper.tsx b/src/frontend/src/ExcalidrawWrapper.tsx index 267e548..a27f01e 100644 --- a/src/frontend/src/ExcalidrawWrapper.tsx +++ b/src/frontend/src/ExcalidrawWrapper.tsx @@ -7,12 +7,8 @@ import type { AppState } from '@atyrode/excalidraw/types'; import { MainMenuConfig } from './ui/MainMenu'; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; -import BackupsModal from './ui/BackupsDialog'; -import PadsDialog from './ui/PadsDialog'; import SettingsDialog from './ui/SettingsDialog'; import { capture } from './utils/posthog'; -import { Footer } from '@atyrode/excalidraw'; -import Tabs from './ui/Tabs'; const defaultInitialData = { elements: [], @@ -33,8 +29,6 @@ interface ExcalidrawWrapperProps { onScrollChange: (scrollX: number, scrollY: number) => void; MainMenu: any; renderTopRightUI?: () => React.ReactNode; - isAuthenticated?: boolean | null; - isAuthLoading?: boolean; } export const ExcalidrawWrapper: React.FC = ({ @@ -46,31 +40,25 @@ export const ExcalidrawWrapper: React.FC = ({ onScrollChange, MainMenu, renderTopRightUI, - isAuthenticated = null, - isAuthLoading = false, }) => { + + const isAuthenticated = false; //TODO + // Add state for modal animation const [isExiting, setIsExiting] = useState(false); // State for modals - const [showBackupsModal, setShowBackupsModal] = useState(false); const [showPadsModal, setShowPadsModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false); // Handle auth state changes useEffect(() => { - if (isAuthenticated === true) { + if (isAuthenticated) { setIsExiting(true); capture('signed_in'); } }, [isAuthenticated]); - - // Handlers for closing modals - const handleCloseBackupsModal = () => { - setShowBackupsModal(false); - }; - const handleClosePadsModal = () => { setShowPadsModal(false); }; @@ -113,43 +101,20 @@ export const ExcalidrawWrapper: React.FC = ({ )), }, <> - {excalidrawAPI && ( -
- -
- )} - {!isAuthLoading && isAuthenticated === false && ( + {isAuthenticated === false && ( {}} /> )} - {showBackupsModal && ( - - )} - - {showPadsModal && ( - - )} - {showSettingsModal && ( { - // Return cached config if available - if (cachedConfig) { - return cachedConfig; - } - - try { - // Fetch config from API - const config = await fetchApi('/api/app/config'); - cachedConfig = config; - return config; - } catch (error) { - console.error('[pad.ws] Failed to load application configuration:', error); - // Return default values as fallback - return { - coderUrl: '', - posthogKey: '', - posthogHost: '' - }; - } -} diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts deleted file mode 100644 index a4870fa..0000000 --- a/src/frontend/src/api/hooks.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; -import { fetchApi } from './apiUtils'; -import { queryClient } from './queryClient'; - -// Types -export interface WorkspaceState { - status: 'running' | 'starting' | 'stopping' | 'stopped' | 'error'; - username: string | null; - name: string | null; - base_url: string | null; - agent: string | null; - id: string | null; - error?: string; -} - -export interface UserProfile { - id: string; - email: string; - username: string; - name: string; - given_name: string; - family_name: string; - email_verified: boolean; -} - -export interface CanvasData { - elements: any[]; - appState: any; - files: any; -} - -export interface PadData { - id: string; - owner_id: string; - display_name: string; - data: CanvasData; - created_at: string; - updated_at: string; -} - -export interface CanvasBackup { - id: number; - timestamp: string; - data: CanvasData; -} - -export interface CanvasBackupsResponse { - backups: CanvasBackup[]; - pad_name?: string; -} - -export interface BuildInfo { - buildHash: string; - timestamp: number; -} - -// API functions -export const api = { - // Authentication - checkAuth: async (): Promise => { - try { - await fetchApi('/api/workspace/state'); - return true; - } catch (error) { - if (error instanceof Error && error.message === 'Unauthorized') { - return false; - } - throw error; - } - }, - - // User profile - getUserProfile: async (): Promise => { - try { - const result = await fetchApi('/api/users/me'); - return result; - } catch (error) { - throw error; - } - }, - - // Workspace - getWorkspaceState: async (): Promise => { - try { - const result = await fetchApi('/api/workspace/state'); - // Map backend 'state' property to frontend 'status' - return { ...result, status: result.state }; - } catch (error) { - // Let the error propagate to be handled by the global error handler - throw error; - } - }, - - startWorkspace: async (): Promise => { - try { - const result = await fetchApi('/api/workspace/start', { method: 'POST' }); - return result; - } catch (error) { - throw error; - } - }, - - stopWorkspace: async (): Promise => { - try { - const result = await fetchApi('/api/workspace/stop', { method: 'POST' }); - return result; - } catch (error) { - throw error; - } - }, - - // Canvas functions are now handled through getAllPads - - getAllPads: async (): Promise => { - try { - const result = await fetchApi('/api/pad'); - return result; - } catch (error) { - throw error; - } - }, - - saveCanvas: async (data: CanvasData): Promise => { - try { - // Get the active pad ID from the global variable - const activePadId = (window as any).activePadId; - - // We must have an active pad ID to save - if (!activePadId) { - throw new Error("No active pad ID found. Cannot save canvas."); - } - - // Use the specific pad endpoint - const endpoint = `/api/pad/${activePadId}`; - - const result = await fetchApi(endpoint, { - method: 'POST', - body: JSON.stringify(data), - }); - return result; - } catch (error) { - throw error; - } - }, - - renamePad: async (padId: string, newName: string): Promise => { - try { - const endpoint = `/api/pad/${padId}`; - const result = await fetchApi(endpoint, { - method: 'PATCH', - body: JSON.stringify({ display_name: newName }), - }); - return result; - } catch (error) { - throw error; - } - }, - - deletePad: async (padId: string): Promise => { - try { - const endpoint = `/api/pad/${padId}`; - const result = await fetchApi(endpoint, { - method: 'DELETE', - }); - return result; - } catch (error) { - throw error; - } - }, - - getDefaultCanvas: async (): Promise => { - try { - const result = await fetchApi('/api/templates/default'); - return result.data; - } catch (error) { - throw error; - } - }, - - // Canvas Backups - getCanvasBackups: async (limit: number = 10): Promise => { - try { - const result = await fetchApi(`/api/pad/recent?limit=${limit}`); - return result; - } catch (error) { - throw error; - } - }, - - getPadBackups: async (padId: string, limit: number = 10): Promise => { - try { - const result = await fetchApi(`/api/pad/${padId}/backups?limit=${limit}`); - return result; - } catch (error) { - throw error; - } - }, - - // Build Info - getBuildInfo: async (): Promise => { - try { - const result = await fetchApi('/api/app/build-info'); - return result; - } catch (error) { - throw error; - } - }, -}; - -// Query hooks -export function useAuthCheck(options?: UseQueryOptions) { - return useQuery({ - queryKey: ['auth'], - queryFn: api.checkAuth, - ...options, - }); -} - -export function useUserProfile(options?: UseQueryOptions) { - return useQuery({ - queryKey: ['userProfile'], - queryFn: api.getUserProfile, - ...options, - }); -} - -export function useWorkspaceState(options?: UseQueryOptions) { - // Get the current auth state from the query cache - const authState = queryClient.getQueryData(['auth']); - - return useQuery({ - queryKey: ['workspaceState'], - queryFn: api.getWorkspaceState, - // Only poll if authenticated - refetchInterval: authState === true ? 5000 : false, // Poll every 5 seconds if authenticated, otherwise don't poll - // Don't retry on error if not authenticated - retry: authState === true ? 1 : false, - ...options, - }); -} - -export function useAllPads(options?: UseQueryOptions) { - return useQuery({ - queryKey: ['allPads'], - queryFn: api.getAllPads, - ...options, - }); -} - -export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions) { - return useQuery({ - queryKey: ['canvasBackups', limit], - queryFn: () => api.getCanvasBackups(limit), - ...options, - }); -} - -export function usePadBackups(padId: string | null, limit: number = 10, options?: UseQueryOptions) { - return useQuery({ - queryKey: ['padBackups', padId, limit], - queryFn: () => padId ? api.getPadBackups(padId, limit) : Promise.reject('No pad ID provided'), - enabled: !!padId, // Only run the query if padId is provided - ...options, - }); -} - -export function useBuildInfo(options?: UseQueryOptions) { - return useQuery({ - queryKey: ['buildInfo'], - queryFn: api.getBuildInfo, - refetchInterval: 60000, // Check every minute - ...options, - }); -} - -// Mutation hooks -export function useStartWorkspace(options?: UseMutationOptions) { - return useMutation({ - mutationFn: api.startWorkspace, - onSuccess: () => { - // Invalidate workspace state query to trigger refetch - queryClient.invalidateQueries({ queryKey: ['workspaceState'] }); - }, - ...options, - }); -} - -export function useStopWorkspace(options?: UseMutationOptions) { - return useMutation({ - mutationFn: api.stopWorkspace, - onSuccess: () => { - // Invalidate workspace state query to trigger refetch - queryClient.invalidateQueries({ queryKey: ['workspaceState'] }); - }, - ...options, - }); -} - -export function useSaveCanvas(options?: UseMutationOptions) { - return useMutation({ - mutationFn: api.saveCanvas, - onSuccess: () => { - // Get the active pad ID from the global variable - const activePadId = (window as any).activePadId; - - // Invalidate canvas backups queries to trigger refetch - queryClient.invalidateQueries({ queryKey: ['canvasBackups'] }); - if (activePadId) { - queryClient.invalidateQueries({ queryKey: ['padBackups', activePadId] }); - } - }, - ...options, - }); -} - -export function useRenamePad(options?: UseMutationOptions) { - return useMutation({ - mutationFn: ({ padId, newName }) => api.renamePad(padId, newName), - // No automatic invalidation - we'll update the cache manually - ...options, - }); -} - -export function useDeletePad(options?: UseMutationOptions) { - return useMutation({ - mutationFn: (padId) => api.deletePad(padId), - // No automatic invalidation - we'll update the cache manually - ...options, - }); -} diff --git a/src/frontend/src/api/queryClient.ts b/src/frontend/src/api/queryClient.ts deleted file mode 100644 index bc91540..0000000 --- a/src/frontend/src/api/queryClient.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { QueryClient } from '@tanstack/react-query'; - -// Create a client -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: true, - staleTime: 30000, // 30 seconds - gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime) - refetchOnMount: true, - }, - }, -}); diff --git a/src/frontend/src/env.d.ts b/src/frontend/src/env.d.ts deleted file mode 100644 index fc443ba..0000000 --- a/src/frontend/src/env.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_PUBLIC_POSTHOG_KEY: string - readonly VITE_PUBLIC_POSTHOG_HOST: string - readonly CODER_URL: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} \ No newline at end of file diff --git a/src/frontend/src/global.d.ts b/src/frontend/src/global.d.ts deleted file mode 100644 index 70713f3..0000000 --- a/src/frontend/src/global.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface Window { - ExcalidrawLib: any; -} diff --git a/src/frontend/src/pad/Dashboard.tsx b/src/frontend/src/pad/Dashboard.tsx index 0538ac9..404cfb8 100644 --- a/src/frontend/src/pad/Dashboard.tsx +++ b/src/frontend/src/pad/Dashboard.tsx @@ -4,7 +4,6 @@ import type { AppState } from '@atyrode/excalidraw/types'; import StateIndicator from './StateIndicator'; import ControlButton from './buttons/ControlButton'; import { ActionButtonGrid } from './buttons'; -import { useWorkspaceState } from '../api/hooks'; import './Dashboard.scss'; // Direct import from types @@ -32,7 +31,16 @@ export const Dashboard: React.FC = ({ appState, excalidrawAPI }) => { - const { data: workspaceState } = useWorkspaceState(); + + const workspaceState = { //TODO + status: 'running', + username: 'pad.ws', + name: 'pad.ws', + base_url: 'https://pad.ws', + agent: 'pad.ws', + id: 'pad.ws', + error: null + } const buttonConfigs: ActionButtonConfig[] = [ // First row: Terminal buttons diff --git a/src/frontend/src/pad/StateIndicator.tsx b/src/frontend/src/pad/StateIndicator.tsx index 93fe304..86e38d9 100644 --- a/src/frontend/src/pad/StateIndicator.tsx +++ b/src/frontend/src/pad/StateIndicator.tsx @@ -1,27 +1,10 @@ import React from 'react'; -import { useWorkspaceState, useAuthCheck } from '../api/hooks'; import './StateIndicator.scss'; export const StateIndicator: React.FC = () => { - const { data: isAuthenticated, isLoading: isAuthLoading } = useAuthCheck(); - - // Only fetch workspace state if authenticated - const { data: workspaceState, isLoading: isWorkspaceLoading } = useWorkspaceState({ - queryKey: ['workspaceState'], - enabled: isAuthenticated === true && !isAuthLoading, - // Explicitly set refetchInterval to false when not authenticated - refetchInterval: isAuthenticated === true ? undefined : false, - }); + const workspaceState = null; //TODO - const getState = () => { - if (isAuthLoading || isWorkspaceLoading) { - return { modifier: 'loading', text: 'Loading...' }; - } - - if (isAuthenticated === false) { - return { modifier: 'unauthenticated', text: 'Not Authenticated' }; - } - + const getState = () => { if (!workspaceState) { return { modifier: 'unknown', text: 'Unknown' }; } diff --git a/src/frontend/src/pad/Terminal.tsx b/src/frontend/src/pad/Terminal.tsx index 94d1d1a..0a6e442 100644 --- a/src/frontend/src/pad/Terminal.tsx +++ b/src/frontend/src/pad/Terminal.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { useWorkspaceState } from '../api/hooks'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; import type { AppState } from '@atyrode/excalidraw/types'; import './Terminal.scss'; @@ -24,7 +23,15 @@ export const Terminal: React.FC = ({ appState, excalidrawAPI }) => { - const { data: workspaceState } = useWorkspaceState(); + + const workspaceState = { //TODO + status: 'running', + username: 'pad.ws', + name: 'pad.ws', + base_url: 'https://pad.ws', + agent: 'pad.ws' + } + const [terminalId, setTerminalId] = useState(null); const [iframeLoaded, setIframeLoaded] = useState(false); const [shouldRenderIframe, setShouldRenderIframe] = useState(false); diff --git a/src/frontend/src/pad/buttons/ActionButton.tsx b/src/frontend/src/pad/buttons/ActionButton.tsx index c752e05..98d1b21 100644 --- a/src/frontend/src/pad/buttons/ActionButton.tsx +++ b/src/frontend/src/pad/buttons/ActionButton.tsx @@ -1,6 +1,4 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useWorkspaceState } from '../../api/hooks'; -// Import SVGs as modules - using relative paths from the action button location import { Terminal, Braces, Settings, Plus, ExternalLink, Monitor } from 'lucide-react'; import { ActionType, TargetType, CodeVariant, ActionButtonProps } from './types'; import './ActionButton.scss'; @@ -28,7 +26,13 @@ const ActionButton: React.FC = ({ settingsEnabled = true, // Default to enabled for backward compatibility backgroundColor // Custom background color }) => { - const { data: workspaceState } = useWorkspaceState(); + const workspaceState = { //TODO + status: 'running', + username: 'pad.ws', + name: 'pad.ws', + base_url: 'https://pad.ws', + agent: 'pad.ws' + } // Parse settings from parent element's customData if available const parseElementSettings = (): { diff --git a/src/frontend/src/pad/buttons/ControlButton.tsx b/src/frontend/src/pad/buttons/ControlButton.tsx index f765663..b9fb304 100644 --- a/src/frontend/src/pad/buttons/ControlButton.tsx +++ b/src/frontend/src/pad/buttons/ControlButton.tsx @@ -1,16 +1,20 @@ import React from 'react'; -import { useWorkspaceState, useStartWorkspace, useStopWorkspace } from '../../api/hooks'; import './ControlButton.scss'; import { Play, Square, LoaderCircle } from 'lucide-react'; export const ControlButton: React.FC = () => { - const { data: workspaceState } = useWorkspaceState({ - queryKey: ['workspaceState'], - enabled: true, - }); + + const workspaceState = { //TODO + status: 'running', + username: 'pad.ws', + name: 'pad.ws', + base_url: 'https://pad.ws', + agent: 'pad.ws', + error: null + } - const { mutate: startWorkspace, isPending: isStarting } = useStartWorkspace(); - const { mutate: stopWorkspace, isPending: isStopping } = useStopWorkspace(); + const isStarting = false; //TODO + const isStopping = false; //TODO // Determine current status const currentStatus = workspaceState?.status || 'unknown'; @@ -18,9 +22,9 @@ export const ControlButton: React.FC = () => { const handleClick = () => { if (isStarting || isStopping) return; if (currentStatus === 'running') { - stopWorkspace(); + console.log('TODO: stopWorkspace'); //TODO } else if (currentStatus === 'stopped' || currentStatus === 'error') { - startWorkspace(); + console.log('TODO: startWorkspace'); //TODO } }; diff --git a/src/frontend/src/ui/AccountDialog.tsx b/src/frontend/src/ui/AccountDialog.tsx index 40e0d5b..361fc77 100644 --- a/src/frontend/src/ui/AccountDialog.tsx +++ b/src/frontend/src/ui/AccountDialog.tsx @@ -1,6 +1,5 @@ import React, { useState, useCallback } from "react"; import { Dialog } from "@atyrode/excalidraw"; -import { useUserProfile } from "../api/hooks"; import md5 from 'crypto-js/md5'; import "./AccountDialog.scss"; @@ -16,11 +15,30 @@ const getGravatarUrl = (email: string, size = 100) => { }; const AccountDialog: React.FC = ({ - excalidrawAPI, onClose, }) => { const [modalIsShown, setModalIsShown] = useState(true); - const { data: profile, isLoading, isError } = useUserProfile(); + +/* export interface UserProfile { + id: string; + email: string; + username: string; + name: string; + given_name: string; + family_name: string; + email_verified: boolean; +} */ + + const profile = { //TODO + id: '1234567890', + email: 'john.doe@example.com', + username: 'johndoe', + name: 'John Doe', + email_verified: true, + } + + const isLoading = false; //TODO + const isError = false; //TODO const handleClose = useCallback(() => { setModalIsShown(false); diff --git a/src/frontend/src/ui/AuthDialog.tsx b/src/frontend/src/ui/AuthDialog.tsx index 1386dba..4140b19 100644 --- a/src/frontend/src/ui/AuthDialog.tsx +++ b/src/frontend/src/ui/AuthDialog.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect, useMemo } from "react"; import { capture } from "../utils/posthog"; -import { queryClient } from "../api/queryClient"; import { GoogleIcon, GithubIcon } from "../icons"; import "./AuthDialog.scss"; @@ -63,7 +62,6 @@ export const AuthDialog = ({ const authCompleted = localStorage.getItem('auth_completed'); if (authCompleted) { localStorage.removeItem('auth_completed'); - queryClient.invalidateQueries({ queryKey: ['auth'] }); clearInterval(intervalId); handleClose(); } diff --git a/src/frontend/src/ui/BackupsDialog.scss b/src/frontend/src/ui/BackupsDialog.scss deleted file mode 100644 index 4c8b072..0000000 --- a/src/frontend/src/ui/BackupsDialog.scss +++ /dev/null @@ -1,233 +0,0 @@ -/* Backups Modal Styles */ - -.excalidraw .Dialog--fullscreen { - &.backups-modal { - .Dialog { - &__content { - margin-top: 0 !important; - } - } - .Island { - padding-left: 8px !important; - padding-right: 10px !important; - } - } -} - -.backups-modal { - - .Island { - padding-top: 15px !important; - padding-bottom: 20px !important; - } - - &__wrapper { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 5; - background-color: rgba(0, 0, 0, 0.2); - backdrop-filter: blur(1px); - } - - &__title-container { - display: flex; - align-items: center; - } - - &__title { - margin: 0 auto; - font-size: 1.5rem; - font-weight: 600; - color: white; - text-align: center; - } - - &__content { - display: flex; - flex-direction: column; - align-items: center; - padding: 20px; - max-height: 80vh; - overflow-y: auto; - } - - &__header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - width: 100%; - } - - &__close-button { - background: none; - border: none; - color: #ffffff; - font-size: 1.5rem; - cursor: pointer; - padding: 0; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: background-color 0.2s ease; - - &:hover { - background-color: rgba(255, 255, 255, 0.1); - } - } - - &__loading, - &__error, - &__empty { - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; - color: #a0a0a9; - font-style: italic; - font-size: 18px; - animation: fadeIn 0.5s cubic-bezier(0.00, 1.26, 0.64, 0.95) forwards; - } - - &__error { - color: #f44336; - } - - &__list { - list-style: none; - padding: 0; - margin: 0; - max-height: 100%; - overflow-y: auto; - width: 100%; - } - - &__item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 15px; - margin-bottom: 8px; - background-color: #464652; - border: 2px solid #727279; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - position: relative; - overflow: hidden; - - &:hover { - border: 2px solid #cc6d24; - } - - &:last-child { - margin-bottom: 0; - } - } - - &__item-content { - display: flex; - align-items: center; - gap: 10px; - } - - &__number { - font-size: 0.9rem; - font-weight: 600; - color: #fa8933; - background-color: rgba(250, 137, 51, 0.1); - padding: 4px 8px; - border-radius: 4px; - min-width: 28px; - text-align: center; - } - - &__timestamp { - font-size: 0.9rem; - color: #ffffff; - } - - &__restore-button { - background-color: transparent; - border: none; - color: #fa8933; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: 4px; - transition: all 0.2s ease; - - &:hover { - background-color: rgba(250, 137, 51, 0.1); - } - } - - &__confirmation { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 20px; - background-color: #464652; - border: 2px solid #727279; - border-radius: 6px; - text-align: center; - color: #ffffff; - animation: fadeIn 0.4s cubic-bezier(0.00, 1.26, 0.64, 0.95) forwards; - width: 80%; - max-width: 500px; - } - - &__warning { - color: #f44336; - font-weight: 500; - margin: 0.5rem 0 1rem; - } - - &__actions { - display: flex; - gap: 1rem; - margin-top: 20px; - } - - &__button { - display: flex; - align-items: center; - justify-content: center; - padding: 10px 16px; - height: 44px; - border-radius: 6px; - border: 2px solid #727279; - font-size: 15px; - font-weight: 500; - transition: all 0.2s ease; - cursor: pointer; - min-width: 120px; - - &:hover { - border: 2px solid #cc6d24; - } - - &--restore { - background-color: #464652; - color: white; - } - - &--cancel { - background-color: #464652; - color: #ffffff; - } - } -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} diff --git a/src/frontend/src/ui/BackupsDialog.tsx b/src/frontend/src/ui/BackupsDialog.tsx deleted file mode 100644 index 6cfe627..0000000 --- a/src/frontend/src/ui/BackupsDialog.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { useState, useCallback } from "react"; -import { Dialog } from "@atyrode/excalidraw"; -import { usePadBackups, CanvasBackup } from "../api/hooks"; -import { normalizeCanvasData, getActivePad } from "../utils/canvasUtils"; -import "./BackupsDialog.scss"; - -interface BackupsModalProps { - excalidrawAPI?: any; - onClose?: () => void; -} - -const BackupsModal: React.FC = ({ - excalidrawAPI, - onClose, -}) => { - const [modalIsShown, setModalIsShown] = useState(true); - const activePadId = getActivePad(); - const { data, isLoading, error } = usePadBackups(activePadId); - const [selectedBackup, setSelectedBackup] = useState(null); - - // Functions from CanvasBackups.tsx - const handleBackupSelect = (backup: CanvasBackup) => { - setSelectedBackup(backup); - }; - - const handleRestoreBackup = () => { - if (selectedBackup && excalidrawAPI) { - // Load the backup data into the canvas - const normalizedData = normalizeCanvasData(selectedBackup.data); - excalidrawAPI.updateScene(normalizedData); - setSelectedBackup(null); - handleClose(); - } - }; - - const handleCancel = () => { - setSelectedBackup(null); - }; - - // Format date function from CanvasBackups.tsx - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const handleClose = useCallback(() => { - setModalIsShown(false); - if (onClose) { - onClose(); - } - }, [onClose]); - - // Dialog content - const dialogContent = ( -
- {isLoading ? ( -
Loading backups...
- ) : error ? ( -
Error loading backups
- ) : !data || data.backups.length === 0 ? ( -
No backups available
- ) : selectedBackup ? ( -
-

Restore canvas from backup #{data.backups.findIndex(b => b.id === selectedBackup.id) + 1} created on {formatDate(selectedBackup.timestamp)}?

-

This will replace your current canvas!

-
- - -
-
- ) : ( -
    - {data.backups.map((backup, index) => ( -
  • handleBackupSelect(backup)} - > -
    - #{index + 1} - {formatDate(backup.timestamp)} -
    - -
  • - ))} -
- )} -
- ); - - return ( - <> - {modalIsShown && ( -
- -

- {data?.pad_name ? `${data.pad_name} (this pad) - Backups` : 'Canvas Backups'} -

-
- } - closeOnClickOutside={true} - children={dialogContent} - /> - - )} - - ); -}; - -export default BackupsModal; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 1058282..83aec3e 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -3,13 +3,11 @@ import React, { useState } from 'react'; import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types'; import type { MainMenu as MainMenuType } from '@atyrode/excalidraw'; -import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore, Settings, Terminal, FileText } from 'lucide-react'; +import { LogOut, SquarePlus, LayoutDashboard, User, Text, Settings, Terminal, FileText } from 'lucide-react'; import AccountDialog from './AccountDialog'; import md5 from 'crypto-js/md5'; import { capture } from '../utils/posthog'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; -import { useUserProfile } from "../api/hooks"; -import { queryClient } from "../api/queryClient"; import "./MainMenu.scss"; // Function to generate gravatar URL @@ -20,8 +18,6 @@ const getGravatarUrl = (email: string, size = 32) => { interface MainMenuConfigProps { MainMenu: typeof MainMenuType; excalidrawAPI: ExcalidrawImperativeAPI | null; - showBackupsModal: boolean; - setShowBackupsModal: (show: boolean) => void; showPadsModal: boolean; setShowPadsModal: (show: boolean) => void; showSettingsModal?: boolean; @@ -31,12 +27,23 @@ interface MainMenuConfigProps { export const MainMenuConfig: React.FC = ({ MainMenu, excalidrawAPI, - setShowBackupsModal, setShowPadsModal, setShowSettingsModal = (show: boolean) => {}, }) => { const [showAccountModal, setShowAccountModal] = useState(false); - const { data, isLoading, isError } = useUserProfile(); + + const data = { // TODO + id: '1234567890', + email: 'test@example.com', + username: 'testuser', + name: 'Test User', + given_name: 'Test', + family_name: 'User', + email_verified: true, + } + + const isLoading = false; //TODO + const isError = false; //TODO let username = ""; let email = ""; @@ -113,10 +120,6 @@ export const MainMenuConfig: React.FC = ({ }); }; - const handleCanvasBackupsClick = () => { - setShowBackupsModal(true); - }; - const handleManagePadsClick = () => { setShowPadsModal(true); }; @@ -180,10 +183,8 @@ export const MainMenuConfig: React.FC = ({ // Wait for the iframe to complete await Promise.all(promises); - - // Invalidate auth query to show the AuthModal - queryClient.invalidateQueries({ queryKey: ['auth'] }); - queryClient.invalidateQueries({ queryKey: ['userProfile'] }); + + // TODO: Invalidate auth query to show the AuthModal? or deprecated logic? // No need to redirect to the logout URL since we're already handling it via iframe console.debug("[pad.ws] Logged out successfully"); @@ -228,12 +229,6 @@ export const MainMenuConfig: React.FC = ({ > Manage pads... - } - onClick={handleCanvasBackupsClick} - > - Load backup... - diff --git a/src/frontend/src/ui/PadsDialog.scss b/src/frontend/src/ui/PadsDialog.scss deleted file mode 100644 index 033e0b6..0000000 --- a/src/frontend/src/ui/PadsDialog.scss +++ /dev/null @@ -1,215 +0,0 @@ -.pads-dialog { - &__wrapper { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1000; - background-color: rgba(0, 0, 0, 0.2); - backdrop-filter: blur(1px); - } - - &__title-container { - display: flex; - align-items: center; - justify-content: space-between; - } - - &__title { - margin: 0; - font-size: 1.2rem; - font-weight: 600; - } - - &__new-pad-button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.8rem; - border-radius: 4px; - border: none; - background-color: #cc6d24; - color: white; - font-size: 0.9rem; - cursor: pointer; - transition: background-color 0.2s ease; - - &:hover { - background-color: #b05e1f; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &--creating { - opacity: 0.7; - cursor: wait; - } - } - - &__content { - padding: 1rem; - max-height: 70vh; - overflow-y: auto; - } - - &__loading, - &__error, - &__empty { - text-align: center; - padding: 2rem 0; - color: var(--text-primary-color); - } - - &__list { - list-style: none; - padding: 0; - margin: 0; - } - - &__item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem; - border-radius: 6px; - margin-bottom: 0.5rem; - background-color: var(--island-bg-color); - transition: background-color 0.2s ease; - - &:hover { - background-color: var(--button-hover-bg); - } - - &--active { - border-left: 3px solid #cc6d24; - } - } - - &__item-content { - display: flex; - flex-direction: column; - flex: 1; - padding: 0.25rem; - border-radius: 4px; - transition: background-color 0.2s ease; - - &--clickable { - cursor: pointer; - - &:hover { - background-color: var(--button-hover-bg); - } - } - - &--current { - cursor: default; - } - } - - &__name { - font-weight: 500; - margin-bottom: 0.25rem; - } - - &__current { - font-weight: normal; - font-style: italic; - color: #cc6d24; - } - - &__timestamps { - display: flex; - flex-direction: column; - gap: 0.2rem; - } - - &__timestamp { - font-size: 0.8rem; - color: var(--text-secondary-color); - } - - &__actions { - display: flex; - gap: 0.5rem; - } - - &__icon-button { - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - border-radius: 4px; - padding: 0.4rem; - cursor: pointer; - color: var(--text-primary-color); - transition: background-color 0.2s ease, color 0.2s ease; - - &:hover { - background-color: var(--button-hover-bg); - color: #cc6d24; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - - &:hover { - background: none; - color: var(--text-primary-color); - } - } - } - - &__edit-form { - display: flex; - flex-direction: column; - width: 100%; - gap: 0.5rem; - - input { - padding: 0.5rem; - border-radius: 4px; - border: 1px solid var(--button-gray-2); - background-color: var(--input-bg-color); - color: var(--text-primary-color); - font-size: 1rem; - } - } - - &__edit-actions { - display: flex; - gap: 0.5rem; - } - - &__button { - padding: 0.4rem 0.8rem; - border-radius: 4px; - border: none; - cursor: pointer; - font-size: 0.9rem; - transition: background-color 0.2s ease; - - &--save { - background-color: #cc6d24; - color: white; - - &:hover { - background-color: #b05e1f; - } - } - - &--cancel { - background-color: var(--button-gray-2); - color: var(--text-primary-color); - - &:hover { - background-color: var(--button-gray-3); - } - } - } -} diff --git a/src/frontend/src/ui/PadsDialog.tsx b/src/frontend/src/ui/PadsDialog.tsx deleted file mode 100644 index c269afb..0000000 --- a/src/frontend/src/ui/PadsDialog.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import React, { useState, useCallback } from "react"; -import { Dialog } from "@atyrode/excalidraw"; -import { Pencil, Trash2, FilePlus2 } from "lucide-react"; -import { useAllPads, useRenamePad, useDeletePad, PadData } from "../api/hooks"; -import { loadPadData, getActivePad, setActivePad, saveCurrentPadBeforeSwitching, createNewPad } from "../utils/canvasUtils"; -import { queryClient } from "../api/queryClient"; -import { capture } from "../utils/posthog"; -import "./PadsDialog.scss"; - -interface PadsDialogProps { - excalidrawAPI?: any; - onClose?: () => void; -} - -const PadsDialog: React.FC = ({ - excalidrawAPI, - onClose, -}) => { - const [modalIsShown, setModalIsShown] = useState(true); - const { data: pads, isLoading, error } = useAllPads(); - const activePadId = getActivePad(); - const [editingPadId, setEditingPadId] = useState(null); - const [newPadName, setNewPadName] = useState(""); - const [isCreatingPad, setIsCreatingPad] = useState(false); - - // Get the renamePad mutation - const { mutate: renamePad } = useRenamePad({ - onSuccess: (data, variables) => { - console.debug("[pad.ws] Pad renamed successfully"); - - // Update the cache directly instead of refetching - const { padId, newName } = variables; - - // Get the current pads from the query cache - const currentPads = queryClient.getQueryData(['allPads']); - - if (currentPads) { - // Create a new array with the updated pad name - const updatedPads = currentPads.map(pad => - pad.id === padId - ? { ...pad, display_name: newName } - : pad - ); - - // Update the query cache with the new data - queryClient.setQueryData(['allPads'], updatedPads); - } - - // Reset editing state - setEditingPadId(null); - }, - onError: (error) => { - console.error("[pad.ws] Failed to rename pad:", error); - setEditingPadId(null); - } - }); - - // Get the deletePad mutation - const { mutate: deletePad } = useDeletePad({ - onSuccess: (data, padId) => { - console.debug("[pad.ws] Pad deleted successfully"); - - // Update the cache directly instead of refetching - // Get the current pads from the query cache - const currentPads = queryClient.getQueryData(['allPads']); - - if (currentPads) { - // Create a new array without the deleted pad - const updatedPads = currentPads.filter(pad => pad.id !== padId); - - // Update the query cache with the new data - queryClient.setQueryData(['allPads'], updatedPads); - } - }, - onError: (error) => { - console.error("[pad.ws] Failed to delete pad:", error); - } - }); - - const handleCreateNewPad = async () => { - if (isCreatingPad || !excalidrawAPI) return; // Prevent multiple clicks or if API not available - - try { - setIsCreatingPad(true); - - // Create a new pad using the imported function - // Note: createNewPad already updates the query cache internally - const newPad = await createNewPad(excalidrawAPI, activePadId, (data) => { - console.debug("[pad.ws] Canvas saved before creating new pad"); - }); - - // Track pad creation event - capture("pad_created", { - padId: newPad.id, - padName: newPad.display_name - }); - - // Set the new pad as active and load it - setActivePad(newPad.id); - loadPadData(excalidrawAPI, newPad.id, newPad.data); - - // Close the dialog - handleClose(); - } catch (error) { - console.error('[pad.ws] Error creating new pad:', error); - } finally { - setIsCreatingPad(false); - } - }; - - const handleClose = useCallback(() => { - setModalIsShown(false); - if (onClose) { - onClose(); - } - }, [onClose]); - - const handleRenameClick = (pad: PadData) => { - setEditingPadId(pad.id); - setNewPadName(pad.display_name); - }; - - const handleRenameSubmit = (padId: string) => { - if (newPadName.trim() === "") return; - - // Track pad rename event - capture("pad_renamed", { - padId, - newName: newPadName - }); - - // Call the renamePad mutation - renamePad({ padId, newName: newPadName }); - }; - - const handleDeleteClick = (pad: PadData) => { - // Don't allow deleting the last pad - if (pads && pads.length <= 1) { - alert("Cannot delete the last pad"); - return; - } - - // Confirm deletion - if (!window.confirm(`Are you sure you want to delete "${pad.display_name}"?`)) { - return; - } - - // Track pad deletion event - capture("pad_deleted", { - padId: pad.id, - padName: pad.display_name - }); - - // If deleting the active pad, switch to another pad first but keep dialog open - if (pad.id === activePadId && pads) { - const otherPad = pads.find(p => p.id !== pad.id); - if (otherPad && excalidrawAPI) { - handleLoadPad(otherPad, true); // Pass true to keep dialog open - } - } - - // Call the deletePad mutation - deletePad(pad.id); - }; - - const handleLoadPad = (pad: PadData, keepDialogOpen: boolean = false) => { - if (!excalidrawAPI) return; - - // Save the current canvas before switching tabs - if (activePadId) { - saveCurrentPadBeforeSwitching(excalidrawAPI, activePadId, (data) => { - console.debug("[pad.ws] Canvas saved before switching"); - }); - } - - // Set the new active pad ID - setActivePad(pad.id); - - // Load the pad data - loadPadData(excalidrawAPI, pad.id, pad.data); - - // Close the dialog only if keepDialogOpen is false - if (!keepDialogOpen) { - handleClose(); - } - }; - - // Format date function - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - // Dialog content - const dialogContent = ( -
- {isLoading ? ( -
Loading pads...
- ) : error ? ( -
Error loading pads
- ) : !pads || pads.length === 0 ? ( -
No pads available
- ) : ( -
    - {pads.map((pad) => ( -
  • - {editingPadId === pad.id ? ( -
    - setNewPadName(e.target.value)} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleRenameSubmit(pad.id); - } else if (e.key === 'Escape') { - setEditingPadId(null); - } - }} - /> -
    - - -
    -
    - ) : ( - <> -
    pad.id !== activePadId && handleLoadPad(pad)} - > - - {pad.display_name} - {pad.id === activePadId && (current)} - -
    - Created: {formatDate(pad.created_at)} - Last updated: {formatDate(pad.updated_at || pad.created_at)} -
    -
    -
    - - -
    - - )} -
  • - ))} -
- )} -
- ); - - return ( - <> - {modalIsShown && ( -
- -

- Manage Pads -

- -
- } - closeOnClickOutside={true} - children={dialogContent} - /> - - )} - - ); -}; - -export default PadsDialog; diff --git a/src/frontend/src/ui/SettingsDialog.tsx b/src/frontend/src/ui/SettingsDialog.tsx index 4876f5c..b743650 100644 --- a/src/frontend/src/ui/SettingsDialog.tsx +++ b/src/frontend/src/ui/SettingsDialog.tsx @@ -5,7 +5,6 @@ import { UserSettings, DEFAULT_SETTINGS } from "../types/settings"; import { RefreshCw } from "lucide-react"; import { normalizeCanvasData } from "../utils/canvasUtils"; import { capture } from "../utils/posthog"; -import { api } from "../api/hooks"; import "./SettingsDialog.scss"; interface SettingsDialogProps { @@ -49,7 +48,7 @@ const SettingsDialog: React.FC = ({ capture('restore_tutorial_canvas_clicked'); // Use the API function from hooks.ts to fetch the default canvas - const defaultCanvasData = await api.getDefaultCanvas(); + const defaultCanvasData = null; //TODO console.debug("Default canvas data:", defaultCanvasData); diff --git a/src/frontend/src/ui/TabContextMenu.scss b/src/frontend/src/ui/TabContextMenu.scss deleted file mode 100644 index a840665..0000000 --- a/src/frontend/src/ui/TabContextMenu.scss +++ /dev/null @@ -1,160 +0,0 @@ -/* Unified context menu styles */ - -/* Base context menu container */ -.tab-context-menu, -.context-menu { - position: fixed; - border-radius: 4px; - box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); - padding: 0.5rem 0; - list-style: none; - user-select: none; - margin: -0.25rem 0 0 0.125rem; - min-width: 9.5rem; - z-index: 1000; - background-color: var(--popup-secondary-bg-color, #fff); - border: 1px solid var(--button-gray-3, #ccc); - cursor: default; -} - -/* Context menu list */ -.context-menu { - padding: 0; - margin: 0; -} - -/* Button text color */ -.tab-context-menu button { - color: var(--popup-text-color, #333); -} - -/* Menu items */ -.tab-context-menu .menu-item, -.context-menu-item { - position: relative; - width: 100%; - min-width: 9.5rem; - margin: 0; - padding: 0.25rem 1rem 0.25rem 1.25rem; - text-align: start; - border-radius: 0; - background-color: transparent; - border: none; - white-space: nowrap; - font-family: inherit; - cursor: pointer; -} - -/* Menu item layout for tab context menu */ -.tab-context-menu .menu-item { - display: grid; - grid-template-columns: 1fr 0.2fr; - align-items: center; -} - -/* Menu item layout for context menu */ -.context-menu-item { - display: flex; - justify-content: space-between; - align-items: center; -} - -/* Menu item label */ -.tab-context-menu .menu-item .menu-item__label { - justify-self: start; - margin-inline-end: 20px; -} - -.context-menu-item__label { - flex: 1; -} - -/* Delete item styling */ -.tab-context-menu .menu-item.delete .menu-item__label, -.context-menu-item.dangerous { - color: #e53935; -} - -/* Hover states */ -.tab-context-menu .menu-item:hover, -.context-menu-item:hover { - color: var(--popup-bg-color, #fff); - background-color: var(--select-highlight-color, #f5f5f5); -} - -/* Dangerous hover states */ -.tab-context-menu .menu-item.delete:hover, -.context-menu-item.dangerous:hover { - background-color: #e53935; -} - -.tab-context-menu .menu-item.delete:hover .menu-item__label, -.context-menu-item.dangerous:hover { - color: var(--popup-bg-color, #fff); -} - -/* Focus state */ -.tab-context-menu .menu-item:focus { - z-index: 1; -} - -/* Separator */ -.context-menu-item-separator { - margin: 0.25rem 0; - border: none; - border-top: 1px solid var(--button-gray-3, #ccc); -} - -/* Shortcut display */ -.context-menu-item__shortcut { - margin-left: 1rem; - color: var(--text-color-secondary, #666); - font-size: 0.8rem; -} - -/* Checkmark for selected items */ -.context-menu-item.checkmark::before { - content: "✓"; - position: absolute; - left: 0.5rem; -} - -/* Form styling for rename functionality */ -.tab-context-menu form { - padding: 0.5rem 1rem; - display: flex; -} - -.tab-context-menu form input { - flex: 1; - padding: 0.25rem 0.5rem; - border: 1px solid var(--button-gray-3, #ccc); - border-radius: 4px; - margin-right: 0.25rem; - background-color: var(--input-bg-color, #fff); - color: var(--text-color-primary, #333); -} - -.tab-context-menu form button { - background-color: var(--color-surface-primary-container, #4285f4); - color: var(--color-on-primary-container, white); - border: none; - border-radius: 4px; - padding: 0.25rem 0.5rem; - cursor: pointer; -} - -.tab-context-menu form button:hover { - background-color: var(--color-primary-light, #3367d6); -} - -/* Responsive adjustments */ -@media (max-width: 640px) { - .tab-context-menu .menu-item { - display: block; - } - - .tab-context-menu .menu-item .menu-item__label { - margin-inline-end: 0; - } -} diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx deleted file mode 100644 index 9e8b9a2..0000000 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import clsx from 'clsx'; - -import './TabContextMenu.scss'; - -const CONTEXT_MENU_SEPARATOR = "separator"; - -type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; -type ContextMenuItems = (ContextMenuItem | false | null | undefined)[]; - -interface Action { - name: string; - label: string | (() => string); - predicate?: () => boolean; - checked?: (appState: any) => boolean; - dangerous?: boolean; -} - -interface ContextMenuProps { - actionManager: ActionManager; - items: ContextMenuItems; - top: number; - left: number; - onClose: (callback?: () => void) => void; -} - -interface ActionManager { - executeAction: (action: Action, source: string) => void; - app: { - props: any; - }; -} - -interface TabContextMenuProps { - x: number; - y: number; - padId: string; - padName: string; - onRename: (padId: string, newName: string) => void; - onDelete: (padId: string) => void; - onClose: () => void; -} - -// Popover component -const Popover: React.FC<{ - onCloseRequest: () => void; - top: number; - left: number; - fitInViewport?: boolean; - offsetLeft?: number; - offsetTop?: number; - viewportWidth?: number; - viewportHeight?: number; - children: React.ReactNode; -}> = ({ - onCloseRequest, - top, - left, - children, - fitInViewport = false, - offsetLeft = 0, - offsetTop = 0, - viewportWidth = window.innerWidth, - viewportHeight = window.innerHeight -}) => { - const popoverRef = useRef(null); - - // Handle clicks outside the popover to close it - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { - onCloseRequest(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [onCloseRequest]); - - // Adjust position if needed to fit in viewport - useEffect(() => { - if (fitInViewport && popoverRef.current) { - const rect = popoverRef.current.getBoundingClientRect(); - const adjustedLeft = Math.min(left, viewportWidth - rect.width); - const adjustedTop = Math.min(top, viewportHeight - rect.height); - - if (popoverRef.current) { - popoverRef.current.style.left = `${adjustedLeft}px`; - popoverRef.current.style.top = `${adjustedTop}px`; - } - } - }, [fitInViewport, left, top, viewportWidth, viewportHeight]); - - return ( -
- {children} -
- ); -}; - -// ContextMenu component -const ContextMenu: React.FC = ({ - actionManager, - items, - top, - left, - onClose -}) => { - // Filter items based on predicate - const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { - if ( - item && - (item === CONTEXT_MENU_SEPARATOR || - !item.predicate || - item.predicate()) - ) { - acc.push(item); - } - return acc; - }, []); - - return ( - { - onClose(); - }} - top={top} - left={left} - fitInViewport={true} - viewportWidth={window.innerWidth} - viewportHeight={window.innerHeight} - > -
    event.preventDefault()} - > - {filteredItems.map((item, idx) => { - if (item === CONTEXT_MENU_SEPARATOR) { - if ( - !filteredItems[idx - 1] || - filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR - ) { - return null; - } - return
    ; - } - - const actionName = item.name; - let label = ""; - if (item.label) { - if (typeof item.label === "function") { - label = item.label(); - } else { - label = item.label; - } - } - - return ( -
  • { - // Log the click - console.debug('[pad.ws] Menu item clicked:', item.name); - - // Store the callback to execute after closing - const callback = () => { - actionManager.executeAction(item, "contextMenu"); - }; - - // Close the menu and execute the callback - onClose(callback); - }} - > - -
  • - ); - })} -
-
- ); -}; - -// Simple ActionManager implementation for the tab context menu -class TabActionManager implements ActionManager { - padId: string; - padName: string; - onRename: (padId: string, newName: string) => void; - onDelete: (padId: string) => void; - app: any; - - constructor( - padId: string, - padName: string, - onRename: (padId: string, newName: string) => void, - onDelete: (padId: string) => void - ) { - this.padId = padId; - this.padName = padName; - this.onRename = onRename; - this.onDelete = onDelete; - this.app = { props: {} }; - } - - executeAction(action: Action, source: string) { - console.debug('[pad.ws] Executing action:', action.name, 'from source:', source); - - if (action.name === 'rename') { - const newName = window.prompt('Rename pad', this.padName); - if (newName && newName.trim() !== '') { - this.onRename(this.padId, newName); - } - } else if (action.name === 'delete') { - console.debug('[pad.ws] Attempting to delete pad:', this.padId, this.padName); - if (window.confirm(`Are you sure you want to delete "${this.padName}"?`)) { - console.debug('[pad.ws] User confirmed delete, calling onDelete'); - this.onDelete(this.padId); - } - } - } -} - -// Main TabContextMenu component -const TabContextMenu: React.FC = ({ - x, - y, - padId, - padName, - onRename, - onDelete, - onClose -}) => { - // Create an action manager instance - const actionManager = new TabActionManager(padId, padName, onRename, onDelete); - - // Define menu items - const menuItems = [ - { - name: 'rename', - label: 'Rename', - predicate: () => true, - }, - CONTEXT_MENU_SEPARATOR, // Add separator between rename and delete - { - name: 'delete', - label: 'Delete', - predicate: () => true, - dangerous: true, - } - ]; - - // Create a wrapper for onClose that handles the callback - const handleClose = (callback?: () => void) => { - console.debug('[pad.ws] TabContextMenu handleClose called, has callback:', !!callback); - - // First call the original onClose - onClose(); - - // Then execute the callback if provided - if (callback) { - callback(); - } - }; - - return ( - - ); -}; - -export default TabContextMenu; diff --git a/src/frontend/src/ui/Tabs.scss b/src/frontend/src/ui/Tabs.scss deleted file mode 100644 index fa12aa6..0000000 --- a/src/frontend/src/ui/Tabs.scss +++ /dev/null @@ -1,123 +0,0 @@ -.tabs-bar { - margin-inline-start: 0.6rem; - height: var(--lg-button-size); - position: relative; - - Button { - height: var(--lg-button-size) !important; - width: 100px !important; - min-width: 100px !important; - margin-right: 0.6rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - - &.active-pad { - background-color: #cc6d24 !important; - color: var(--color-on-primary) !important; - font-weight: bold; - border: 1px solid #cccccc !important; - - .tab-position { - color: var(--color-on-primary) !important; - } - } - - &.creating-pad { - opacity: 0.6; - cursor: not-allowed; - } - - .tab-content { - position: relative; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - - - .tab-position { - position: absolute; - bottom: -7px; - right: -4px; - font-size: 9px; - opacity: 0.7; - color: var(--keybinding-color); - font-weight: normal; - } - } - } - - .tabs-wrapper { - display: flex; - flex-direction: row; - align-items: center; - position: relative; - } - - .tabs-container { - display: flex; - flex-direction: row; - align-items: center; - position: relative; - - .loading-indicator { - font-size: 0.8rem; - color: var(--color-muted); - margin-right: 0.5rem; - } - } - - .scroll-buttons-container { - display: flex; - flex-direction: row; - align-items: center; - } - - .scroll-button { - height: var(--lg-button-size) !important; - width: var(--lg-button-size) !important; // Square button - display: flex; - align-items: center; - justify-content: center; - background-color: var(--button-bg, var(--island-bg-color)); - border: none; - cursor: pointer; - z-index: 1; - margin-right: 0.6rem !important; - border-radius: var(--border-radius-lg); - transition: background-color 0.2s ease; - color: #bdbdbd; // Light gray color for the icons - flex-shrink: 0; // Prevent button from shrinking - min-width: unset !important; // Override any min-width inheritance - max-width: unset !important; // Override any max-width inheritance - - &:hover:not(.disabled) { - color: #ffffff; - } - - &:active:not(.disabled) { - color: #ffffff; - } - - &.disabled { - color: #575757; // Light gray color for the icons - opacity: 1; - cursor: default; - } - - &.left { - margin-right: 4px; // Add a small margin between left button and tabs - } - - } - - .new-tab-button-container { - Button { - border: none !important; - min-width: auto !important; - width: var(--lg-button-size) !important; - } - } -} diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx deleted file mode 100644 index 0997cfa..0000000 --- a/src/frontend/src/ui/Tabs.tsx +++ /dev/null @@ -1,497 +0,0 @@ -import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; - -import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; -import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; -import { FilePlus2, ChevronLeft, ChevronRight } from "lucide-react"; -import { useAllPads, useSaveCanvas, useRenamePad, useDeletePad, PadData } from "../api/hooks"; -import { queryClient } from "../api/queryClient"; -import { capture } from "../utils/posthog"; -import { - getPadData, - storePadData, - setActivePad, - getStoredActivePad, - loadPadData, - saveCurrentPadBeforeSwitching, - createNewPad, - setScrollIndex, - getStoredScrollIndex -} from "../utils/canvasUtils"; -import TabContextMenu from "./TabContextMenu"; -import "./Tabs.scss"; - -interface TabsProps { - excalidrawAPI: ExcalidrawImperativeAPI; -} - -const Tabs: React.FC = ({ - excalidrawAPI, -}: { - excalidrawAPI: ExcalidrawImperativeAPI; -}) => { - const { data: pads, isLoading } = useAllPads(); - const appState = excalidrawAPI.getAppState(); - const [isCreatingPad, setIsCreatingPad] = useState(false); - const [activePadId, setActivePadId] = useState(null); - const [startPadIndex, setStartPadIndex] = useState(getStoredScrollIndex()); - const PADS_PER_PAGE = 5; // Show 5 pads at a time - - // Context menu state - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - x: number; - y: number; - padId: string; - padName: string; - }>({ - visible: false, - x: 0, - y: 0, - padId: '', - padName: '' - }); - - // Get the saveCanvas mutation - const { mutate: saveCanvas } = useSaveCanvas({ - onSuccess: () => { - console.debug("[pad.ws] Canvas saved to database successfully"); - }, - onError: (error) => { - console.error("[pad.ws] Failed to save canvas to database:", error); - } - }); - - // Get the renamePad mutation - const { mutate: renamePad } = useRenamePad({ - onSuccess: (data, variables) => { - console.debug("[pad.ws] Pad renamed successfully"); - - // Update the cache directly instead of refetching - const { padId, newName } = variables; - - // Get the current pads from the query cache - const currentPads = queryClient.getQueryData(['allPads']); - - if (currentPads) { - // Create a new array with the updated pad name - const updatedPads = currentPads.map(pad => - pad.id === padId - ? { ...pad, display_name: newName } - : pad - ); - - // Update the query cache with the new data - queryClient.setQueryData(['allPads'], updatedPads); - } - }, - onError: (error) => { - console.error("[pad.ws] Failed to rename pad:", error); - } - }); - - // Get the deletePad mutation - const { mutate: deletePad } = useDeletePad({ - onSuccess: (data, padId) => { - console.debug("[pad.ws] Pad deleted successfully"); - - // Update the cache directly instead of refetching - // Get the current pads from the query cache - const currentPads = queryClient.getQueryData(['allPads']); - - if (currentPads) { - // Create a new array without the deleted pad - const updatedPads = currentPads.filter(pad => pad.id !== padId); - - // Update the query cache with the new data - queryClient.setQueryData(['allPads'], updatedPads); - - // Recompute the startPadIndex to avoid visual artifacts - // If deleting a pad would result in an empty space at the end of the tab bar - if (startPadIndex > 0 && startPadIndex + PADS_PER_PAGE > updatedPads.length) { - // Calculate the new index that ensures the tab bar is filled properly - // but never goes below 0 - const newIndex = Math.max(0, updatedPads.length - PADS_PER_PAGE); - setStartPadIndex(newIndex); - setScrollIndex(newIndex); - } - } - }, - onError: (error) => { - console.error("[pad.ws] Failed to delete pad:", error); - } - }); - - const handlePadSelect = (pad: any) => { - // Save the current canvas before switching tabs - if (activePadId) { - saveCurrentPadBeforeSwitching(excalidrawAPI, activePadId, saveCanvas); - } - - // Set the new active pad ID - setActivePadId(pad.id); - // Store the active pad ID globally - setActivePad(pad.id); - - // Load the pad data - loadPadData(excalidrawAPI, pad.id, pad.data); - }; - - // Listen for active pad change events - useEffect(() => { - const handleActivePadChange = (event: Event) => { - const customEvent = event as CustomEvent; - const newActivePadId = customEvent.detail; - console.debug(`[pad.ws] Received activePadChanged event with padId: ${newActivePadId}`); - setActivePadId(newActivePadId); - }; - - // Add event listener - window.addEventListener('activePadChanged', handleActivePadChange); - - // Clean up - return () => { - window.removeEventListener('activePadChanged', handleActivePadChange); - }; - }, []); - - // Set the active pad ID when the component mounts and when the pads data changes - useEffect(() => { - if (!isLoading && pads && pads.length > 0) { - // Check if there's a stored active pad ID - const storedActivePadId = getStoredActivePad(); - - if (!activePadId || !pads.some(pad => pad.id === activePadId)) { - // Find the pad that matches the stored ID, or use the first pad if no match - let padToActivate = pads[0]; - - if (storedActivePadId) { - // Try to find the pad with the stored ID - const matchingPad = pads.find(pad => pad.id === storedActivePadId); - if (matchingPad) { - console.debug(`[pad.ws] Found stored active pad: ${storedActivePadId}`); - padToActivate = matchingPad; - } else { - console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`); - } - } - - // Set the active pad ID - setActivePadId(padToActivate.id); - // Store the active pad ID globally - setActivePad(padToActivate.id); - - // If the current canvas is empty, load the pad data - const currentElements = excalidrawAPI.getSceneElements(); - if (currentElements.length === 0) { - // Load the pad data using the imported function - loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data); - } - } else if (storedActivePadId && storedActivePadId !== activePadId) { - // Update local state to match global state - setActivePadId(storedActivePadId); - } - - // Store all pads in local storage for the first time - pads.forEach(pad => { - // Only store if not already in local storage - if (!getPadData(pad.id)) { - storePadData(pad.id, pad.data); - } - }); - } - }, [pads, isLoading, activePadId, excalidrawAPI]); - - const handleCreateNewPad = async () => { - if (isCreatingPad) return; // Prevent multiple clicks - - try { - setIsCreatingPad(true); - - // Create a new pad using the imported function - const newPad = await createNewPad(excalidrawAPI, activePadId, saveCanvas); - - // Track pad creation event - capture("pad_created", { - padId: newPad.id, - padName: newPad.display_name - }); - - // Set the active pad ID in the component state - setActivePadId(newPad.id); - - // Get the current pads from the query cache - const currentPads = queryClient.getQueryData(['allPads']); - - if (currentPads) { - // Find the index of the newly created pad - const newPadIndex = currentPads.findIndex(pad => pad.id === newPad.id); - - if (newPadIndex !== -1) { - const newStartIndex = Math.max(0, Math.min(newPadIndex - PADS_PER_PAGE + 1, currentPads.length - PADS_PER_PAGE)); - setStartPadIndex(newStartIndex); - setScrollIndex(newStartIndex); - } - } - } catch (error) { - console.error('Error creating new pad:', error); - } finally { - setIsCreatingPad(false); - } - }; - - // Navigation functions - move by 1 pad at a time - const showPreviousPads = () => { - const newIndex = Math.max(0, startPadIndex - 1); - setStartPadIndex(newIndex); - setScrollIndex(newIndex); - }; - - const showNextPads = () => { - if (pads) { - const newIndex = Math.min(startPadIndex + 1, Math.max(0, pads.length - PADS_PER_PAGE)); - setStartPadIndex(newIndex); - setScrollIndex(newIndex); - } - }; - - // Create a ref for the tabs wrapper to handle wheel events - const tabsWrapperRef = useRef(null); - - // Track last wheel event time to throttle scrolling - const lastWheelTimeRef = useRef(0); - const wheelThrottleMs = 70; // Minimum time between wheel actions in milliseconds - - // Set up wheel event listener with passive: false to properly prevent default behavior - useLayoutEffect(() => { - const handleWheel = (e: WheelEvent) => { - // Always prevent default to stop page navigation - e.preventDefault(); - e.stopPropagation(); - - // Throttle wheel events to prevent too rapid scrolling - const now = Date.now(); - if (now - lastWheelTimeRef.current < wheelThrottleMs) { - return; - } - - // Update last wheel time - lastWheelTimeRef.current = now; - - // Prioritize horizontal scrolling (deltaX) if present - if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - // Horizontal scrolling - if (e.deltaX > 0 && pads && startPadIndex < pads.length - PADS_PER_PAGE) { - showNextPads(); - } else if (e.deltaX < 0 && startPadIndex > 0) { - showPreviousPads(); - } - } else { - // Vertical scrolling - treat down as right, up as left (common convention) - if (e.deltaY > 0 && pads && startPadIndex < pads.length - PADS_PER_PAGE) { - showNextPads(); - } else if (e.deltaY < 0 && startPadIndex > 0) { - showPreviousPads(); - } - } - }; - - const tabsWrapper = tabsWrapperRef.current; - if (tabsWrapper) { - // Add wheel event listener with passive: false option - tabsWrapper.addEventListener('wheel', handleWheel, { passive: false }); - - // Clean up the event listener when component unmounts - return () => { - tabsWrapper.removeEventListener('wheel', handleWheel); - }; - } - }, [pads, startPadIndex, PADS_PER_PAGE]); // Dependencies needed for boundary checks - - return ( - <> -
- -
- {!appState.viewModeEnabled && ( - <> -
- {/* New pad button */} -
- {} : handleCreateNewPad} - className={isCreatingPad ? "creating-pad" : ""} - children={ -
- -
- } - /> - } /> -
- -
- {/* Loading indicator */} - {isLoading && ( -
- Loading pads... -
- )} - - {/* List visible pads (5 at a time) */} - {!isLoading && pads && pads.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((pad) => ( -
{ - e.preventDefault(); - setContextMenu({ - visible: true, - x: e.clientX, - y: e.clientY, - padId: pad.id, - padName: pad.display_name - }); - }} - > - {/* Show tooltip for all active tabs or truncated names */} - {(activePadId === pad.id || pad.display_name.length > 11) ? ( - 11 - ? `${pad.display_name} (current pad)` - : "Current pad") - : pad.display_name - } - children={ -
- ))} - -
- - {/* Left scroll button - only visible when there are more pads than can fit in the view */} - {pads && pads.length > PADS_PER_PAGE && ( - - 0 ? `\n(${startPadIndex} more)` : ''}`} - children={ - - } - /> - - )} - - {/* Right scroll button - only visible when there are more pads than can fit in the view */} - {pads && pads.length > PADS_PER_PAGE && ( - - 0 ? `\n(${Math.max(0, pads.length - (startPadIndex + PADS_PER_PAGE))} more)` : ''}`} - children={ - - } - /> - - )} -
- - )} -
-
-
- - {/* Context Menu */} - {contextMenu.visible && ( - { - // Track pad rename event - capture("pad_renamed", { - padId, - newName - }); - - // Call the renamePad mutation - renamePad({ padId, newName }); - }} - onDelete={(padId) => { - // Don't allow deleting the last pad - if (pads && pads.length <= 1) { - alert("Cannot delete the last pad"); - return; - } - - // Find the pad name before deletion for analytics - const padToDelete = pads?.find(p => p.id === padId); - const padName = padToDelete?.display_name || ""; - - // Track pad deletion event - capture("pad_deleted", { - padId, - padName - }); - - // If deleting the active pad, switch to another pad first - if (padId === activePadId && pads) { - const otherPad = pads.find(p => p.id !== padId); - if (otherPad) { - handlePadSelect(otherPad); - } - } - - // Call the deletePad mutation - deletePad(padId); - }} - onClose={() => { - setContextMenu(prev => ({ ...prev, visible: false })); - }} - /> - )} - - ); -}; - -export default Tabs; diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts index 26588bd..5fc846c 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/utils/canvasUtils.ts @@ -1,10 +1,4 @@ import { DEFAULT_SETTINGS } from '../types/settings'; -import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; -import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; -import type { AppState } from "@atyrode/excalidraw/types"; -import { CanvasData, PadData } from '../api/hooks'; -import { fetchApi } from '../api/apiUtils'; -import { queryClient } from '../api/queryClient'; /** * @@ -51,285 +45,4 @@ export function normalizeCanvasData(data: any) { appState.searchMatches = null; return { ...data, appState }; -} - -// Local storage keys -export const LOCAL_STORAGE_PADS_KEY = 'pad_ws_pads'; -export const LOCAL_STORAGE_ACTIVE_PAD_KEY = 'pad_ws_active_pad'; -export const LOCAL_STORAGE_SCROLL_INDEX_KEY = 'pad_ws_scroll_index'; - -/** - * Stores pad data in local storage - * @param padId The ID of the pad to store - * @param data The pad data to store - */ -export function storePadData(padId: string, data: any): void { - try { - // Get existing pads data from local storage - const storedPadsString = localStorage.getItem(LOCAL_STORAGE_PADS_KEY); - const storedPads = storedPadsString ? JSON.parse(storedPadsString) : {}; - - // Update the pad data - storedPads[padId] = data; - - // Save back to local storage - localStorage.setItem(LOCAL_STORAGE_PADS_KEY, JSON.stringify(storedPads)); - - console.debug(`[pad.ws] Stored pad ${padId} data in local storage`); - } catch (error) { - console.error('[pad.ws] Error storing pad data in local storage:', error); - } -} - -/** - * Gets pad data from local storage - * @param padId The ID of the pad to retrieve - * @returns The pad data or null if not found - */ -export function getPadData(padId: string): any | null { - try { - // Get pads data from local storage - const storedPadsString = localStorage.getItem(LOCAL_STORAGE_PADS_KEY); - if (!storedPadsString) return null; - - const storedPads = JSON.parse(storedPadsString); - - // Return the pad data if it exists - return storedPads[padId] || null; - } catch (error) { - console.error('[pad.ws] Error getting pad data from local storage:', error); - return null; - } -} - -/** - * Sets the active pad ID globally and stores it in local storage - * @param padId The ID of the pad to set as active - */ -export function setActivePad(padId: string): void { - (window as any).activePadId = padId; - - // Store the active pad ID in local storage - try { - localStorage.setItem(LOCAL_STORAGE_ACTIVE_PAD_KEY, padId); - console.debug(`[pad.ws] Stored active pad ID ${padId} in local storage`); - } catch (error) { - console.error('[pad.ws] Error storing active pad ID in local storage:', error); - } - - // Dispatch a custom event to notify components of the active pad change - const event = new CustomEvent('activePadChanged', { detail: padId }); - window.dispatchEvent(event); - - console.debug(`[pad.ws] Set active pad to ${padId}`); -} - -/** - * Gets the current active pad ID from the global variable - * @returns The active pad ID or null if not set - */ -export function getActivePad(): string | null { - return (window as any).activePadId || null; -} - -/** - * Gets the stored active pad ID from local storage - * @returns The stored active pad ID or null if not found - */ -export function getStoredActivePad(): string | null { - try { - const storedActivePadId = localStorage.getItem(LOCAL_STORAGE_ACTIVE_PAD_KEY); - return storedActivePadId; - } catch (error) { - console.error('[pad.ws] Error getting active pad ID from local storage:', error); - return null; - } -} - -/** - * Sets the scroll index in local storage - * @param index The scroll index to store - */ -export function setScrollIndex(index: number): void { - try { - localStorage.setItem(LOCAL_STORAGE_SCROLL_INDEX_KEY, index.toString()); - console.debug(`[pad.ws] Stored scroll index ${index} in local storage`); - } catch (error) { - console.error('[pad.ws] Error storing scroll index in local storage:', error); - } -} - -/** - * Gets the stored scroll index from local storage - * @returns The stored scroll index or 0 if not found - */ -export function getStoredScrollIndex(): number { - try { - const storedScrollIndex = localStorage.getItem(LOCAL_STORAGE_SCROLL_INDEX_KEY); - return storedScrollIndex ? parseInt(storedScrollIndex, 10) : 0; - } catch (error) { - console.error('[pad.ws] Error getting scroll index from local storage:', error); - return 0; - } -} - -/** - * Saves the current pad data before switching to another pad - * @param excalidrawAPI The Excalidraw API instance - * @param activePadId The current active pad ID - * @param saveCanvas The saveCanvas mutation function - */ -export function saveCurrentPadBeforeSwitching( - excalidrawAPI: ExcalidrawImperativeAPI, - activePadId: string | null, - saveCanvas: (data: CanvasData) => void -): void { - if (!activePadId) return; - - // Get the current elements, state, and files - const elements = excalidrawAPI.getSceneElements(); - const appState = excalidrawAPI.getAppState(); - const files = excalidrawAPI.getFiles(); - - // Create the canvas data object - const canvasData = { - elements: [...elements] as any[], // Convert readonly array to mutable array - appState, - files - }; - - // Save the canvas data to local storage - storePadData(activePadId, canvasData); - - // Save the canvas data to the server - saveCanvas(canvasData); - - console.debug("[pad.ws] Saved canvas before switching"); -} - -/** - * Loads pad data into the Excalidraw canvas - * @param excalidrawAPI The Excalidraw API instance - * @param padId The ID of the pad to load - * @param serverData The server data to use as fallback - */ -export function loadPadData( - excalidrawAPI: ExcalidrawImperativeAPI, - padId: string, - serverData: any -): void { - // Try to get the pad data from local storage first - const localPadData = getPadData(padId); - - if (localPadData) { - // Use the local data if available - console.debug(`[pad.ws] Loading pad ${padId} data from local storage`); - excalidrawAPI.updateScene(normalizeCanvasData(localPadData)); - } else if (serverData) { - // Fall back to the server data - console.debug(`[pad.ws] No local data found for pad ${padId}, using server data`); - excalidrawAPI.updateScene(normalizeCanvasData(serverData)); - } -} - -/** - * Creates a new pad from the default template - * @param excalidrawAPI The Excalidraw API instance - * @param activePadId The current active pad ID - * @param saveCanvas The saveCanvas mutation function - * @returns Promise resolving to the new pad data - */ -export async function createNewPad( - excalidrawAPI: ExcalidrawImperativeAPI, - activePadId: string | null, - saveCanvas: (data: CanvasData) => void -): Promise { - // Save the current canvas before creating a new pad - if (activePadId) { - saveCurrentPadBeforeSwitching(excalidrawAPI, activePadId, saveCanvas); - } - - // Create a new pad from the default template - const newPad = await fetchApi('/api/pad/from-template/default', { - method: 'POST', - body: JSON.stringify({ - display_name: `New Pad ${new Date().toLocaleTimeString()}`, - }), - }); - - // Manually update the pads list instead of refetching - // Get the current pads from the query cache - const currentPads = queryClient.getQueryData(['allPads']) || []; - - // Add the new pad to the list - queryClient.setQueryData(['allPads'], [...currentPads, newPad]); - - // Store the new pad data in local storage - storePadData(newPad.id, newPad.data); - - // Update the canvas with the new pad's data - // Normalize the data before updating the scene - excalidrawAPI.updateScene(normalizeCanvasData(newPad.data)); - console.debug("[pad.ws] Loaded new pad data"); - - // Set the active pad ID globally - setActivePad(newPad.id); - - return newPad; -} - -/** - * Saves the current canvas state using the Excalidraw API - * @param saveCanvas The saveCanvas mutation function from useSaveCanvas hook - * @param onSuccess Optional callback to run after successful save - * @param onError Optional callback to run if save fails - */ -export function saveCurrentCanvas( - saveCanvas: (data: CanvasData) => void, - onSuccess?: () => void, - onError?: (error: any) => void -) { - try { - // Get the excalidrawAPI from the window object - const excalidrawAPI = (window as any).excalidrawAPI as ExcalidrawImperativeAPI | null; - - if (excalidrawAPI) { - // Get the current elements, state, and files - const elements = excalidrawAPI.getSceneElements(); - const appState = excalidrawAPI.getAppState(); - const files = excalidrawAPI.getFiles(); - - // Save the canvas data - saveCanvas({ - elements: [...elements] as any[], // Convert readonly array to mutable array - appState, - files - }); - - // Call onSuccess callback if provided - if (onSuccess) { - onSuccess(); - } - - return true; - } else { - console.warn("[pad.ws] ExcalidrawAPI not available"); - - // Call onError callback if provided - if (onError) { - onError(new Error("ExcalidrawAPI not available")); - } - - return false; - } - } catch (error) { - console.error("[pad.ws] Error saving canvas:", error); - - // Call onError callback if provided - if (onError) { - onError(error); - } - - return false; - } -} +} \ No newline at end of file diff --git a/src/frontend/src/utils/posthog.ts b/src/frontend/src/utils/posthog.ts index 212eb14..b956baf 100644 --- a/src/frontend/src/utils/posthog.ts +++ b/src/frontend/src/utils/posthog.ts @@ -1,20 +1,18 @@ import posthog from 'posthog-js'; -import { getAppConfig } from '../api/configService'; // Initialize PostHog with empty values first posthog.init('', { api_host: '' }); +const config = { //TODO + posthogKey: 'phc_RBmyKvfGVKCpPYkSFV2U2oAFWxwEKrDQzHmnKXPmodf', + posthogHost: 'https://eu.i.posthog.com' +} + // Then update with real values when config is loaded -getAppConfig().then(config => { - if (config.posthogKey) { - posthog.init(config.posthogKey, { - api_host: config.posthogHost, - }); - console.debug('[pad.ws] PostHog initialized successfully'); - } else { - console.warn('[pad.ws] PostHog API key not found. Analytics will not be tracked.'); - } +posthog.init(config.posthogKey, { + api_host: config.posthogHost, }); +console.debug('[pad.ws] PostHog initialized successfully'); // Helper function to track custom events export const capture = (eventName: string, properties?: Record) => { From 82423bd40c721e253a07f479a3e4eaee636930f3 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 17:40:27 +0000 Subject: [PATCH 003/149] refactor: simplify Excalidraw integration and remove unused components - Removed the ExcalidrawWrapper component to streamline the integration of Excalidraw directly within the App component. - Updated the App component to handle Excalidraw rendering and state management, enhancing clarity and maintainability. - Cleaned up index.html by removing unnecessary script imports related to Excalidraw. - Adjusted authentication handling and UI elements to reflect the new structure, ensuring a more cohesive user experience. --- src/frontend/index.html | 6 -- src/frontend/index.tsx | 15 +-- src/frontend/src/App.tsx | 112 ++++++++++++++------- src/frontend/src/ExcalidrawWrapper.tsx | 133 ------------------------- 4 files changed, 77 insertions(+), 189 deletions(-) delete mode 100644 src/frontend/src/ExcalidrawWrapper.tsx diff --git a/src/frontend/index.html b/src/frontend/index.html index 52a2c82..95645b3 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -15,12 +15,6 @@
- - diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index 47a295b..7031f24 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -7,31 +7,18 @@ import { PostHogProvider } from 'posthog-js/react'; import "@atyrode/excalidraw/index.css"; import "./index.scss"; -import type * as TExcalidraw from "@atyrode/excalidraw"; - import App from "./src/App"; import AuthGate from "./src/AuthGate"; -declare global { - interface Window { - ExcalidrawLib: typeof TExcalidraw; - } -} async function initApp() { const rootElement = document.getElementById("root")!; const root = createRoot(rootElement); - const { Excalidraw } = window.ExcalidrawLib; root.render( - { }} - excalidrawLib={window.ExcalidrawLib} - > - - + , ); diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 04a1190..9f85321 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,40 +1,53 @@ import React, { useState, useEffect } from "react"; -import { ExcalidrawWrapper } from "./ExcalidrawWrapper"; -import type * as TExcalidraw from "@atyrode/excalidraw"; -import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; +import { Excalidraw, MainMenu } from "@atyrode/excalidraw"; +import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; +import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; -export interface AppProps { - useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; - customArgs?: any[]; - children?: React.ReactNode; - excalidrawLib: typeof TExcalidraw; -} +import DiscordButton from './ui/DiscordButton'; +import GitHubButton from './ui/GitHubButton'; +import { MainMenuConfig } from './ui/MainMenu'; +import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; +import AuthDialog from './ui/AuthDialog'; +import SettingsDialog from './ui/SettingsDialog'; +import { capture } from './utils/posthog'; -export default function App({ - useCustom, - customArgs, - children, - excalidrawLib, -}: AppProps) { - const { useHandleLibrary, MainMenu } = excalidrawLib; +const defaultInitialData = { + elements: [], + appState: { + gridModeEnabled: true, + gridSize: 20, + gridStep: 5, + }, + files: {}, +}; +export default function App() { const isAuthenticated = false; //TODO - - // Excalidraw API ref + const [isExiting, setIsExiting] = useState(false); + const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - useCustom(excalidrawAPI, customArgs); - useHandleLibrary({ excalidrawAPI }); useEffect(() => { - if (excalidrawAPI) { - (window as any).excalidrawAPI = excalidrawAPI; + if (isAuthenticated) { + setIsExiting(true); + capture('signed_in'); } - return () => { - (window as any).excalidrawAPI = null; - }; - }, [excalidrawAPI]); + }, [isAuthenticated]); + + const handleCloseSettingsModal = () => { + setShowSettingsModal(false); + }; + + const handleOnChange = (elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { + // TODO + }; - /* PostHog user identification */ + const handleOnScrollChange = (scrollX: number, scrollY: number) => { + // TODO + lockEmbeddables(excalidrawAPI?.getAppState()); + }; + + // TODO // useEffect(() => { // if (userProfile?.id) { // posthog.identify(userProfile.id); @@ -48,17 +61,44 @@ export default function App({ // } // }, [userProfile]); + // Render Excalidraw directly with props and associated UI return ( - <> - {/* Keep the wrapper div */} + setExcalidrawAPI(api)} + theme="dark" + initialData={defaultInitialData} + onChange={handleOnChange} + name="Pad.ws" + onScrollChange={handleOnScrollChange} + validateEmbeddable={true} + renderEmbeddable={(element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} + renderTopRightUI={() => ( +
+ + +
+ )} > - {children} -
+ + {isAuthenticated === false && ( + {}} + /> + )} - + {showSettingsModal && ( + + )} + + ); } diff --git a/src/frontend/src/ExcalidrawWrapper.tsx b/src/frontend/src/ExcalidrawWrapper.tsx deleted file mode 100644 index a27f01e..0000000 --- a/src/frontend/src/ExcalidrawWrapper.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { Children, cloneElement, useState, useEffect } from 'react'; -import DiscordButton from './ui/DiscordButton'; -import GitHubButton from './ui/GitHubButton'; -import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types'; -import type { NonDeletedExcalidrawElement } from '@atyrode/excalidraw/element/types'; -import type { AppState } from '@atyrode/excalidraw/types'; -import { MainMenuConfig } from './ui/MainMenu'; -import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; -import AuthDialog from './ui/AuthDialog'; -import SettingsDialog from './ui/SettingsDialog'; -import { capture } from './utils/posthog'; - -const defaultInitialData = { - elements: [], - appState: { - gridModeEnabled: true, - gridSize: 20, - gridStep: 5, - }, - files: {}, -}; - -interface ExcalidrawWrapperProps { - children: React.ReactNode; - excalidrawAPI: ExcalidrawImperativeAPI | null; - setExcalidrawAPI: (api: ExcalidrawImperativeAPI) => void; - initialData?: any; - onChange: (elements: NonDeletedExcalidrawElement[], state: AppState) => void; - onScrollChange: (scrollX: number, scrollY: number) => void; - MainMenu: any; - renderTopRightUI?: () => React.ReactNode; -} - -export const ExcalidrawWrapper: React.FC = ({ - children, - excalidrawAPI, - setExcalidrawAPI, - initialData, - onChange, - onScrollChange, - MainMenu, - renderTopRightUI, -}) => { - - const isAuthenticated = false; //TODO - - // Add state for modal animation - const [isExiting, setIsExiting] = useState(false); - - // State for modals - const [showPadsModal, setShowPadsModal] = useState(false); - const [showSettingsModal, setShowSettingsModal] = useState(false); - - // Handle auth state changes - useEffect(() => { - if (isAuthenticated) { - setIsExiting(true); - capture('signed_in'); - } - }, [isAuthenticated]); - - const handleClosePadsModal = () => { - setShowPadsModal(false); - }; - - const handleCloseSettingsModal = () => { - setShowSettingsModal(false); - }; - - const renderExcalidraw = (children: React.ReactNode) => { - const Excalidraw = Children.toArray(children).find( - (child: any) => - React.isValidElement(child) && - typeof child.type !== "string" && - child.type.displayName === "Excalidraw", - ); - - if (!Excalidraw) { - return null; - } - - return cloneElement( - Excalidraw as React.ReactElement, - { - excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api), - theme: "dark", - initialData: initialData ?? defaultInitialData, - onChange: onChange, - name: "Pad.ws", - onScrollChange: (scrollX, scrollY) => { - lockEmbeddables(excalidrawAPI?.getAppState()); - if (onScrollChange) onScrollChange(scrollX, scrollY); - }, - validateEmbeddable: true, - renderEmbeddable: (element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI), - renderTopRightUI: renderTopRightUI ?? (() => ( -
- - -
- )), - }, - <> - - {isAuthenticated === false && ( - {}} - /> - )} - - {showSettingsModal && ( - - )} - - ); - }; - - return ( -
- {renderExcalidraw(children)} -
- ); -}; From ca0489697c8b3ccf43abedf21bc619398e456fac Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 19:36:58 +0000 Subject: [PATCH 004/149] refactor: clean up Vite configuration and comment out analytics captures - Removed the custom build-info plugin from vite.config.mts to simplify the configuration. - Commented out analytics capture calls in ActionButton.tsx and SettingsDialog.tsx for future implementation consideration. - Streamlined the codebase by eliminating unused imports and enhancing maintainability. --- src/frontend/src/pad/buttons/ActionButton.tsx | 6 +-- src/frontend/src/ui/SettingsDialog.tsx | 4 +- src/frontend/vite.config.mts | 37 +------------------ 3 files changed, 6 insertions(+), 41 deletions(-) diff --git a/src/frontend/src/pad/buttons/ActionButton.tsx b/src/frontend/src/pad/buttons/ActionButton.tsx index 98d1b21..10c70c9 100644 --- a/src/frontend/src/pad/buttons/ActionButton.tsx +++ b/src/frontend/src/pad/buttons/ActionButton.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Terminal, Braces, Settings, Plus, ExternalLink, Monitor } from 'lucide-react'; import { ActionType, TargetType, CodeVariant, ActionButtonProps } from './types'; import './ActionButton.scss'; -import { capture } from '../../utils/posthog'; +// import { capture } from '../../utils/posthog'; import { ExcalidrawElementFactory, PlacementMode } from '../../lib/ExcalidrawElementFactory'; // Interface for button settings stored in customData @@ -296,11 +296,11 @@ const ActionButton: React.FC = ({ const executeAction = () => { - capture('action_button_clicked', { + /* capture('action_button_clicked', { target: selectedTarget, action: selectedAction, codeVariant: selectedTarget === 'code' ? selectedCodeVariant : null - }); + }); */ //TODO if (selectedAction === 'embed') { const excalidrawAPI = (window as any).excalidrawAPI; diff --git a/src/frontend/src/ui/SettingsDialog.tsx b/src/frontend/src/ui/SettingsDialog.tsx index b743650..b9f8253 100644 --- a/src/frontend/src/ui/SettingsDialog.tsx +++ b/src/frontend/src/ui/SettingsDialog.tsx @@ -4,7 +4,7 @@ import { Range } from "./Range"; import { UserSettings, DEFAULT_SETTINGS } from "../types/settings"; import { RefreshCw } from "lucide-react"; import { normalizeCanvasData } from "../utils/canvasUtils"; -import { capture } from "../utils/posthog"; +// import { capture } from "../utils/posthog"; import "./SettingsDialog.scss"; interface SettingsDialogProps { @@ -45,7 +45,7 @@ const SettingsDialog: React.FC = ({ try { setIsRestoring(true); - capture('restore_tutorial_canvas_clicked'); + // capture('restore_tutorial_canvas_clicked'); // Use the API function from hooks.ts to fetch the default canvas const defaultCanvasData = null; //TODO diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts index 856bb0d..e1f10be 100644 --- a/src/frontend/vite.config.mts +++ b/src/frontend/vite.config.mts @@ -1,32 +1,4 @@ -import { defineConfig, loadEnv, Plugin } from "vite"; -import fs from "fs"; -import path from "path"; - -// Create a plugin to generate build-info.json during build -const generateBuildInfoPlugin = (): Plugin => ({ - name: 'generate-build-info', - closeBundle() { - // Generate a unique build hash (timestamp + random string) - const buildInfo = { - buildHash: Date.now().toString(36) + Math.random().toString(36).substring(2), - timestamp: Date.now() - }; - - // Ensure the dist directory exists - const distDir = path.resolve(__dirname, 'dist'); - if (!fs.existsSync(distDir)) { - fs.mkdirSync(distDir, { recursive: true }); - } - - // Write to the output directory - fs.writeFileSync( - path.resolve(distDir, 'build-info.json'), - JSON.stringify(buildInfo, null, 2) - ); - - console.debug('[pad.ws] Generated build-info.json with hash:', buildInfo.buildHash); - } -}); +import { defineConfig, loadEnv } from "vite"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -47,14 +19,7 @@ export default defineConfig(({ mode }) => { }, }, }, - define: { - // Make non-prefixed CODER_URL available to import.meta.env - 'import.meta.env.CODER_URL': JSON.stringify(env.CODER_URL), - }, publicDir: "public", - plugins: [ - generateBuildInfoPlugin(), - ], optimizeDeps: { esbuildOptions: { // Bumping to 2022 due to "Arbitrary module namespace identifier names" not being From 53a4a30fa7c822d3054012de219a77688b48aa22 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 19:44:19 +0000 Subject: [PATCH 005/149] feat: enhance authentication handling and add status endpoint - Updated the authentication router to include a new `/api/auth/status` endpoint for checking user authentication status. - Modified the logout functionality to clear the session cookie upon successful logout. - Refactored the frontend to utilize the new authentication status hook, improving user experience by dynamically managing authentication state. - Adjusted the main application routing to reflect the new API structure, ensuring a consistent and organized API design. --- src/backend/main.py | 2 +- src/backend/routers/auth_router.py | 54 ++++++++++++- src/frontend/index.tsx | 18 +++-- src/frontend/src/App.tsx | 83 +++++++++---------- src/frontend/src/hooks/useAuthStatus.ts | 55 +++++++++++++ src/frontend/src/hooks/useLogout.ts | 49 +++++++++++ src/frontend/src/ui/AuthDialog.tsx | 44 ++-------- src/frontend/src/ui/MainMenu.tsx | 103 ++++++++++++------------ 8 files changed, 265 insertions(+), 143 deletions(-) create mode 100644 src/frontend/src/hooks/useAuthStatus.ts create mode 100644 src/frontend/src/hooks/useLogout.ts diff --git a/src/backend/main.py b/src/backend/main.py index 6f07ee0..52d8833 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -116,7 +116,7 @@ async def read_root(request: Request, auth: Optional[UserSession] = Depends(opti return FileResponse(os.path.join(STATIC_DIR, "index.html")) # Include routers in the main app with the /api prefix -app.include_router(auth_router, prefix="/auth") +app.include_router(auth_router, prefix="/api/auth") app.include_router(user_router, prefix="/api/users") app.include_router(workspace_router, prefix="/api/workspace") app.include_router(pad_router, prefix="/api/pad") diff --git a/src/backend/routers/auth_router.py b/src/backend/routers/auth_router.py index 2212124..0b0d278 100644 --- a/src/backend/routers/auth_router.py +++ b/src/backend/routers/auth_router.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Request, HTTPException, Depends from fastapi.responses import RedirectResponse, FileResponse, JSONResponse import os +from datetime import datetime from config import (get_auth_url, get_token_url, set_session, delete_session, get_session, FRONTEND_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SERVER_URL, OIDC_REALM, OIDC_REDIRECT_URI, STATIC_DIR) @@ -96,7 +97,58 @@ async def logout(request: Request): logout_url = f"{OIDC_SERVER_URL}/realms/{OIDC_REALM}/protocol/openid-connect/logout" full_logout_url = f"{logout_url}?id_token_hint={id_token}&post_logout_redirect_uri={FRONTEND_URL}" - # Create a redirect response to Keycloak's logout endpoint + # Create a response with the logout URL and clear the session cookie response = JSONResponse({"status": "success", "logout_url": full_logout_url}) + response.delete_cookie( + key="session_id", + path="/", + secure=True, + httponly=True, + samesite="lax" + ) return response + +@auth_router.get("/status") +async def auth_status(request: Request): + """Check if the user is authenticated and return session information""" + session_id = request.cookies.get('session_id') + + if not session_id: + return JSONResponse({ + "authenticated": False, + "message": "No session found" + }) + + session_data = get_session(session_id) + if not session_data: + return JSONResponse({ + "authenticated": False, + "message": "Invalid session" + }) + + # Decode the access token to get user info + try: + access_token = session_data.get('access_token') + if not access_token: + return JSONResponse({ + "authenticated": False, + "message": "No access token found" + }) + + user_info = jwt.decode(access_token, options={"verify_signature": False}) + + return JSONResponse({ + "authenticated": True, + "user": { + "username": user_info.get('preferred_username'), + "email": user_info.get('email'), + "name": user_info.get('name') + }, + "expires_in": session_data.get('expires_in') + }) + except Exception as e: + return JSONResponse({ + "authenticated": False, + "message": f"Error decoding token: {str(e)}" + }) diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index 7031f24..fdb7e37 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -1,8 +1,9 @@ import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import posthog from "./src/utils/posthog"; -import { PostHogProvider } from 'posthog-js/react'; +// import posthog from "./src/utils/posthog"; +// import { PostHogProvider } from 'posthog-js/react'; import "@atyrode/excalidraw/index.css"; import "./index.scss"; @@ -11,16 +12,21 @@ import App from "./src/App"; import AuthGate from "./src/AuthGate"; +// Create a client +const queryClient = new QueryClient(); + async function initApp() { const rootElement = document.getElementById("root")!; const root = createRoot(rootElement); root.render( - - + // + + {/* */} - - , + {/* */} + + // , ); } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 9f85321..7177ae9 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import { Excalidraw, MainMenu } from "@atyrode/excalidraw"; +import { useAuthStatus } from "./hooks/useAuthStatus"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -9,7 +10,7 @@ import { MainMenuConfig } from './ui/MainMenu'; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; import SettingsDialog from './ui/SettingsDialog'; -import { capture } from './utils/posthog'; +// import { capture } from './utils/posthog'; const defaultInitialData = { elements: [], @@ -22,18 +23,11 @@ const defaultInitialData = { }; export default function App() { - const isAuthenticated = false; //TODO - const [isExiting, setIsExiting] = useState(false); + const { isAuthenticated } = useAuthStatus(); + const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - useEffect(() => { - if (isAuthenticated) { - setIsExiting(true); - capture('signed_in'); - } - }, [isAuthenticated]); - const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; @@ -63,42 +57,41 @@ export default function App() { // Render Excalidraw directly with props and associated UI return ( -
{/* Keep the wrapper div */} - setExcalidrawAPI(api)} - theme="dark" - initialData={defaultInitialData} - onChange={handleOnChange} - name="Pad.ws" - onScrollChange={handleOnScrollChange} - validateEmbeddable={true} - renderEmbeddable={(element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} - renderTopRightUI={() => ( -
- - -
- )} - > - setExcalidrawAPI(api)} + theme="dark" + initialData={defaultInitialData} + onChange={handleOnChange} + name="Pad.ws" + onScrollChange={handleOnScrollChange} + validateEmbeddable={true} + renderEmbeddable={(element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} + renderTopRightUI={() => ( +
+ {/* //TODO */} + +
+ )} + > + + + {isAuthenticated !== true && ( + {}} /> - {isAuthenticated === false && ( - {}} - /> - )} + )} - {showSettingsModal && ( - - )} -
-
+ {showSettingsModal && ( + + )} + ); } diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts new file mode 100644 index 0000000..138d456 --- /dev/null +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; + +interface UserInfo { + username?: string; + email?: string; + name?: string; +} + +interface AuthStatusResponse { + authenticated: boolean; + user?: UserInfo; + expires_in?: number; + message?: string; +} + +const fetchAuthStatus = async (): Promise => { + const response = await fetch('/api/auth/status'); + if (!response.ok) { + let errorMessage = 'Failed to fetch authentication status.'; + try { + const errorData = await response.json(); + if (errorData && errorData.message) { + errorMessage = errorData.message; + } + } catch (e) { + // Ignore if error response is not JSON or empty //TODO + } + throw new Error(errorMessage); + } + return response.json(); +}; + +export const useAuthStatus = () => { + const { data, isLoading, error, isError, refetch } = useQuery({ + queryKey: ['authStatus'], + queryFn: fetchAuthStatus, + // Optional configurations you might consider: //TODO + // staleTime: 5 * 60 * 1000, // Data is considered fresh for 5 minutes + // cacheTime: 10 * 60 * 1000, // Data is kept in cache for 10 minutes + // refetchOnWindowFocus: true, // Automatically refetch when the window gains focus + // retry: 1, // Retry failed requests once before showing an error + }); + + console.log("Auth status", data); + + return { + isAuthenticated: data?.authenticated ?? false, + user: data?.user, + expires_in: data?.expires_in, + isLoading, + error: error || (data?.authenticated === false && data?.message ? new Error(data.message) : null), + isError, + refetchAuthStatus: refetch, + }; +}; diff --git a/src/frontend/src/hooks/useLogout.ts b/src/frontend/src/hooks/useLogout.ts new file mode 100644 index 0000000..712ae19 --- /dev/null +++ b/src/frontend/src/hooks/useLogout.ts @@ -0,0 +1,49 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +interface LogoutResponse { + status: string; + logout_url: string; +} + +interface LogoutError extends Error {} + +const logoutUser = async (): Promise => { + const response = await fetch('/api/auth/logout', { + method: 'GET', + credentials: 'include', + }); + + if (!response.ok) { + let errorMessage = `Logout failed with status: ${response.status}`; + try { + const errorData = await response.json(); + if (errorData && (errorData.detail || errorData.message)) { + errorMessage = errorData.detail || errorData.message; + } + } catch (e) { + console.warn('Could not parse JSON from logout error response.'); + } + throw new Error(errorMessage); + } + + return response.json(); +}; + +export const useLogout = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: logoutUser, + onSuccess: (data) => { + console.debug('Logout mutation successful, Keycloak URL:', data.logout_url); + + // Invalidate authStatus query to trigger a re-fetch and update UI. + // This will make useAuthStatus re-evaluate, and isAuthenticated should become false. + // TODO + queryClient.invalidateQueries({ queryKey: ['authStatus'] }); + }, + onError: (error) => { + console.error('Logout mutation failed:', error.message); + }, + }); +}; diff --git a/src/frontend/src/ui/AuthDialog.tsx b/src/frontend/src/ui/AuthDialog.tsx index 4140b19..4008041 100644 --- a/src/frontend/src/ui/AuthDialog.tsx +++ b/src/frontend/src/ui/AuthDialog.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from "react"; -import { capture } from "../utils/posthog"; +//import { capture } from "../utils/posthog"; import { GoogleIcon, GithubIcon } from "../icons"; import "./AuthDialog.scss"; @@ -18,7 +18,6 @@ export const AuthDialog = ({ onClose, children, }: AuthDialogProps) => { - const [modalIsShown, setModalIsShown] = useState(true); // Array of random messages that the logo can "say" const logoMessages = [ @@ -40,7 +39,7 @@ export const AuthDialog = ({ }, []); useEffect(() => { - capture("auth_modal_shown"); + //capture("auth_modal_shown"); // Load GitHub buttons script const script = document.createElement('script'); @@ -57,31 +56,6 @@ export const AuthDialog = ({ }; }, []); - useEffect(() => { - const checkLocalStorage = () => { - const authCompleted = localStorage.getItem('auth_completed'); - if (authCompleted) { - localStorage.removeItem('auth_completed'); - clearInterval(intervalId); - handleClose(); - } - }; - - const intervalId = setInterval(checkLocalStorage, 500); - - return () => { - clearInterval(intervalId); - }; - }, []); - - const handleClose = React.useCallback(() => { - setModalIsShown(false); - - if (onClose) { - onClose(); - } - }, [onClose]); - // Prepare the content for the Dialog const dialogContent = (
@@ -92,7 +66,7 @@ export const AuthDialog = ({
- {/* GitHub Star button */} + {/* GitHub Star button Star - + //TODO*/} @@ -141,8 +115,6 @@ export const AuthDialog = ({ ); return ( - <> - {modalIsShown && (
{}} title={ - )} - ); }; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 83aec3e..48972aa 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -6,7 +6,8 @@ import type { MainMenu as MainMenuType } from '@atyrode/excalidraw'; import { LogOut, SquarePlus, LayoutDashboard, User, Text, Settings, Terminal, FileText } from 'lucide-react'; import AccountDialog from './AccountDialog'; import md5 from 'crypto-js/md5'; -import { capture } from '../utils/posthog'; +// import { capture } from '../utils/posthog'; +import { useLogout } from '../hooks/useLogout'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; import "./MainMenu.scss"; @@ -31,6 +32,7 @@ export const MainMenuConfig: React.FC = ({ setShowSettingsModal = (show: boolean) => {}, }) => { const [showAccountModal, setShowAccountModal] = useState(false); + const { mutate: logoutMutation, isPending: isLoggingOut } = useLogout(); const data = { // TODO id: '1234567890', @@ -132,65 +134,59 @@ export const MainMenuConfig: React.FC = ({ setShowAccountModal(true); }; - const handleLogout = async () => { - capture('logout_clicked'); - - try { - // Call the logout endpoint and get the logout_url - const response = await fetch('/auth/logout', { - method: 'GET', - credentials: 'include' - }); - const data = await response.json(); - const keycloakLogoutUrl = data.logout_url; - - // Create a function to create an iframe and return a promise that resolves when it loads or times out - const createIframeLoader = (url: string, debugName: string): Promise => { - return new Promise((resolve) => { - const iframe = document.createElement("iframe"); - iframe.style.display = "none"; - iframe.src = url; - console.debug(`[pad.ws] (Silently) Priming ${debugName} logout for ${url}`); - - const cleanup = () => { - if (iframe.parentNode) iframe.parentNode.removeChild(iframe); - resolve(); - }; + const handleLogout = () => { + // capture('logout_clicked'); - iframe.onload = cleanup; - // Fallback: remove iframe after 2s if onload doesn't fire - const timeoutId = window.setTimeout(cleanup, 2000); + logoutMutation(undefined, { + onSuccess: (data) => { + const keycloakLogoutUrl = data.logout_url; - // Also clean up if the iframe errors - iframe.onerror = () => { - clearTimeout(timeoutId); - cleanup(); - }; + const createIframeLoader = (url: string, debugName: string): Promise => { + return new Promise((resolve, reject) => { // Added reject for error handling + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.src = url; + console.debug(`[pad.ws] (Silently) Priming ${debugName} logout for ${url}`); - // Add the iframe to the DOM - document.body.appendChild(iframe); - }); - }; + let timeoutId: number | undefined; - // Create a promise for Keycloak logout iframe - const promises = []; + const cleanup = (error?: any) => { + if (timeoutId) clearTimeout(timeoutId); + if (iframe.parentNode) iframe.parentNode.removeChild(iframe); + if (error) reject(error); else resolve(); + }; - // Add Keycloak logout iframe - promises.push(createIframeLoader(keycloakLogoutUrl, "Keycloak")); + iframe.onload = () => cleanup(); + // Fallback: remove iframe after 2s if onload doesn't fire + timeoutId = window.setTimeout(() => cleanup(new Error(`${debugName} iframe load timed out`)), 2000); - // Wait for both iframes to complete - await Promise.all(promises); + iframe.onerror = (event) => { // event can be an ErrorEvent or string + const errorMsg = typeof event === 'string' ? event : (event instanceof ErrorEvent ? event.message : `Error loading ${debugName} iframe`); + cleanup(new Error(errorMsg)); + }; + document.body.appendChild(iframe); + }); + }; - // Wait for the iframe to complete - await Promise.all(promises); + const promises = [createIframeLoader(keycloakLogoutUrl, "Keycloak")]; - // TODO: Invalidate auth query to show the AuthModal? or deprecated logic? - - // No need to redirect to the logout URL since we're already handling it via iframe - console.debug("[pad.ws] Logged out successfully"); - } catch (error) { - console.error("[pad.ws] Logout failed:", error); - } + Promise.all(promises) + .then(() => { + console.debug("[pad.ws] Keycloak iframe logout process completed successfully."); + // Auth status is invalidated by the useLogout hook's onSuccess, + // UI should update automatically. + }) + .catch(err => { + console.error("[pad.ws] Error during iframe logout process:", err); + // Optionally, inform the user that part of the logout (e.g., Keycloak session termination) might have failed. + }); + }, + onError: (error) => { + // Error is already logged by the hook's onError. + // You can add component-specific UI feedback here if needed, e.g., a toast notification. + console.error("[pad.ws] Logout failed in MainMenu component:", error.message); + } + }); }; return ( @@ -280,8 +276,9 @@ export const MainMenuConfig: React.FC = ({ } onClick={handleLogout} + disabled={isLoggingOut} // Disable button while logout is in progress > - Logout + {isLoggingOut ? "Logging out..." : "Logout"} From 77631666e9082a81e18ec3a020af88d9a271865c Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 20:16:11 +0000 Subject: [PATCH 006/149] simplified login flow --- src/frontend/public/auth/popup-close.html | 19 +++- src/frontend/src/hooks/useAuthStatus.ts | 38 +++++-- src/frontend/src/ui/AuthDialog.scss | 32 +++--- src/frontend/src/ui/AuthDialog.tsx | 115 +++++----------------- 4 files changed, 86 insertions(+), 118 deletions(-) diff --git a/src/frontend/public/auth/popup-close.html b/src/frontend/public/auth/popup-close.html index f86d8b2..2f5a003 100644 --- a/src/frontend/public/auth/popup-close.html +++ b/src/frontend/public/auth/popup-close.html @@ -1,13 +1,26 @@ + Authentication Complete + - + + \ No newline at end of file diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index 138d456..bd7942f 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; interface UserInfo { username?: string; @@ -23,7 +24,7 @@ const fetchAuthStatus = async (): Promise => { errorMessage = errorData.message; } } catch (e) { - // Ignore if error response is not JSON or empty //TODO + // Ignore if error response is not JSON or empty } throw new Error(errorMessage); } @@ -34,13 +35,38 @@ export const useAuthStatus = () => { const { data, isLoading, error, isError, refetch } = useQuery({ queryKey: ['authStatus'], queryFn: fetchAuthStatus, - // Optional configurations you might consider: //TODO - // staleTime: 5 * 60 * 1000, // Data is considered fresh for 5 minutes - // cacheTime: 10 * 60 * 1000, // Data is kept in cache for 10 minutes - // refetchOnWindowFocus: true, // Automatically refetch when the window gains focus - // retry: 1, // Retry failed requests once before showing an error + gcTime: 10 * 60 * 1000, // Data is kept in cache for 10 minutes + staleTime: 5 * 60 * 1000, // Data is considered fresh for 5 minutes + refetchOnWindowFocus: true, // Automatically refetch when the window gains focus + retry: 1, // Retry failed requests once before showing an error }); + useEffect(() => { + // Listen for auth completion message from popup + const handleAuthMessage = (event: MessageEvent) => { + if (event.origin === window.location.origin && + event.data?.type === 'AUTH_COMPLETE') { + // Refetch auth status when popup signals completion + refetch(); + } + }; + + // Listen for localStorage changes + const handleStorageChange = (event: StorageEvent) => { + if (event.key === 'auth_completed') { + refetch(); + } + }; + + window.addEventListener('message', handleAuthMessage); + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('message', handleAuthMessage); + window.removeEventListener('storage', handleStorageChange); + }; + }, [refetch]); + console.log("Auth status", data); return { diff --git a/src/frontend/src/ui/AuthDialog.scss b/src/frontend/src/ui/AuthDialog.scss index adac568..d20bda4 100644 --- a/src/frontend/src/ui/AuthDialog.scss +++ b/src/frontend/src/ui/AuthDialog.scss @@ -3,7 +3,7 @@ .excalidraw .Dialog--fullscreen { &.auth-modal { .Dialog__close { - display: none; + display: none; } } } @@ -13,6 +13,7 @@ &__logo-container { display: none; } + &__content { height: 100%; margin-bottom: 0; @@ -21,6 +22,7 @@ justify-content: space-between; flex: 1; } + &__buttons { margin-bottom: auto; } @@ -36,7 +38,6 @@ } .auth-modal { - .Island { padding-top: 15px !important; padding-bottom: 20px !important; @@ -70,6 +71,7 @@ transform: translate(-270px, -318px); opacity: 0; } + to { transform: translate(-230px, -318px); opacity: 1; @@ -81,7 +83,7 @@ height: 60px; object-fit: contain; } - + &__logo-speech-bubble { position: absolute; background-color: rgb(232, 232, 232); @@ -97,7 +99,7 @@ animation-delay: 1.5s; opacity: 0; transform: translateY(5px); - + &::before { content: ''; position: absolute; @@ -108,12 +110,13 @@ border-color: transparent rgb(232, 232, 232) transparent transparent; } } - + @keyframes bubble-appear { from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); @@ -131,7 +134,7 @@ font-weight: 700; color: white; text-align: center; - + &-dot { color: #fa8933; } @@ -184,7 +187,7 @@ cursor: pointer; background-color: #464652; color: white; - + &:hover { border: 2px solid #cc6d24; } @@ -192,7 +195,6 @@ } &__footer { - display: flex; justify-content: center; align-items: center; @@ -202,18 +204,6 @@ padding-top: 10px; border-top: 1px solid var(--dialog-border-color); width: 100%; - - &-link { - display: flex; - align-items: center; - color: #b8723c !important; - font-size: 14px; - transition: color 0.15s; - - &:hover { - color: #a0a0a0 !important; - } - } } &__warning { @@ -222,4 +212,4 @@ font-size: 14px; font-weight: 400; } -} +} \ No newline at end of file diff --git a/src/frontend/src/ui/AuthDialog.tsx b/src/frontend/src/ui/AuthDialog.tsx index 4008041..800be1a 100644 --- a/src/frontend/src/ui/AuthDialog.tsx +++ b/src/frontend/src/ui/AuthDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useMemo } from "react"; //import { capture } from "../utils/posthog"; import { GoogleIcon, GithubIcon } from "../icons"; import "./AuthDialog.scss"; @@ -18,8 +18,6 @@ export const AuthDialog = ({ onClose, children, }: AuthDialogProps) => { - - // Array of random messages that the logo can "say" const logoMessages = [ "Hello there!", "Welcome to pad.ws!", @@ -31,114 +29,55 @@ export const AuthDialog = ({ "Let's get productive!", "Let's turn ideas into code!" ]; - - // Select a random message when component mounts - const randomMessage = useMemo(() => { - const randomIndex = Math.floor(Math.random() * logoMessages.length); - return logoMessages[randomIndex]; - }, []); - - useEffect(() => { - //capture("auth_modal_shown"); - - // Load GitHub buttons script - const script = document.createElement('script'); - script.src = 'https://buttons.github.io/buttons.js'; - script.async = true; - script.defer = true; - document.body.appendChild(script); - - return () => { - // Clean up script when component unmounts - if (document.body.contains(script)) { - document.body.removeChild(script); - } - }; - }, []); - // Prepare the content for the Dialog + const randomMessage = useMemo(() => + logoMessages[Math.floor(Math.random() * logoMessages.length)], + [] + ); + const dialogContent = (

{description}

- - {/* Sign-in buttons */} +
- -
- {/* Footer */}
- {/* Warning message */} -
+
{warningText}
- - {/* GitHub Star button - - Star - //TODO*/} - -
-
); return ( -
-
- pad.ws logo -
- {randomMessage} -
+
+
+ pad.ws logo +
{randomMessage}
+
+ { }} + title={ + - {}} - title={ - - } - closeOnClickOutside={false} - children={children || dialogContent} - /> -
+ } + closeOnClickOutside={false} + children={children || dialogContent} + /> +
); }; From b7b0d986b54a938412079b78f28d904ee27217ff Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 20:53:21 +0000 Subject: [PATCH 007/149] refactor: streamline authentication popup handling and enhance UI components - Simplified the authentication completion process in the popup by storing the completion timestamp in localStorage and directly closing the window. - Removed the message event listener for auth completion, transitioning to a localStorage-based approach for better performance and reliability. - Enhanced the AuthDialog component by dynamically loading the GitHub buttons script and adding a GitHub Star button for improved user engagement. - Updated the MainMenu component to reset the Excalidraw scene upon logout, ensuring a clean state for the user experience. --- src/frontend/public/auth/popup-close.html | 17 ++----------- src/frontend/src/hooks/useAuthStatus.ts | 13 +--------- src/frontend/src/ui/AuthDialog.tsx | 31 ++++++++++++++++++++++- src/frontend/src/ui/MainMenu.tsx | 11 ++++---- 4 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/frontend/public/auth/popup-close.html b/src/frontend/public/auth/popup-close.html index 2f5a003..419ea47 100644 --- a/src/frontend/public/auth/popup-close.html +++ b/src/frontend/public/auth/popup-close.html @@ -1,26 +1,13 @@ - Authentication Complete - - \ No newline at end of file diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index bd7942f..e2c30c4 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -42,27 +42,16 @@ export const useAuthStatus = () => { }); useEffect(() => { - // Listen for auth completion message from popup - const handleAuthMessage = (event: MessageEvent) => { - if (event.origin === window.location.origin && - event.data?.type === 'AUTH_COMPLETE') { - // Refetch auth status when popup signals completion - refetch(); - } - }; - - // Listen for localStorage changes + const handleStorageChange = (event: StorageEvent) => { if (event.key === 'auth_completed') { refetch(); } }; - window.addEventListener('message', handleAuthMessage); window.addEventListener('storage', handleStorageChange); return () => { - window.removeEventListener('message', handleAuthMessage); window.removeEventListener('storage', handleStorageChange); }; }, [refetch]); diff --git a/src/frontend/src/ui/AuthDialog.tsx b/src/frontend/src/ui/AuthDialog.tsx index 800be1a..276f459 100644 --- a/src/frontend/src/ui/AuthDialog.tsx +++ b/src/frontend/src/ui/AuthDialog.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useEffect } from "react"; //import { capture } from "../utils/posthog"; import { GoogleIcon, GithubIcon } from "../icons"; import "./AuthDialog.scss"; @@ -35,6 +35,24 @@ export const AuthDialog = ({ [] ); + useEffect(() => { + //capture("auth_modal_shown"); + + // Load GitHub buttons script + const script = document.createElement('script'); + script.src = 'https://buttons.github.io/buttons.js'; + script.async = true; + script.defer = true; + document.body.appendChild(script); + + return () => { + // Clean up script when component unmounts + if (document.body.contains(script)) { + document.body.removeChild(script); + } + }; + }, []); + const dialogContent = (

{description}

@@ -55,6 +73,17 @@ export const AuthDialog = ({
{warningText}
+ + {/* GitHub Star button */} + + Star +
); diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 48972aa..caf64ad 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -173,20 +173,21 @@ export const MainMenuConfig: React.FC = ({ Promise.all(promises) .then(() => { console.debug("[pad.ws] Keycloak iframe logout process completed successfully."); - // Auth status is invalidated by the useLogout hook's onSuccess, - // UI should update automatically. }) .catch(err => { console.error("[pad.ws] Error during iframe logout process:", err); - // Optionally, inform the user that part of the logout (e.g., Keycloak session termination) might have failed. }); }, onError: (error) => { - // Error is already logged by the hook's onError. - // You can add component-specific UI feedback here if needed, e.g., a toast notification. console.error("[pad.ws] Logout failed in MainMenu component:", error.message); } }); + + excalidrawAPI.updateScene({ + appState: {}, + elements: [], + files: [], + }); }; return ( From 0e484122506081742da5c408ea993bebe74feecd Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 23:04:26 +0000 Subject: [PATCH 008/149] feat: enhance session management and authentication flow - Updated the refresh_token function to use 'refresh_expires_in' for token expiry. - Added a new endpoint for refreshing the session, improving user experience by allowing seamless token renewal. - Refactored the auth_status endpoint to utilize user session data directly, enhancing clarity and maintainability. - Improved the useAuthStatus hook to handle session expiration and refresh logic, ensuring timely updates to authentication state. - Enhanced error handling in the logout process for better debugging and user feedback. --- src/backend/config.py | 3 +- src/backend/routers/auth_router.py | 67 ++++++++++++++----------- src/frontend/src/hooks/useAuthStatus.ts | 65 ++++++++++++++++++++---- src/frontend/src/hooks/useLogout.ts | 4 +- 4 files changed, 98 insertions(+), 41 deletions(-) diff --git a/src/backend/config.py b/src/backend/config.py index bce429e..a38a5af 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -167,7 +167,8 @@ async def refresh_token(session_id: str, token_data: Dict[str, Any]) -> Tuple[bo new_token_data = refresh_response.json() # Update session with new tokens - expiry = new_token_data['expires_in'] + expiry = new_token_data['refresh_expires_in'] + print(f"New expiry in refresh_token: {expiry}") set_session(session_id, new_token_data, expiry) return True, new_token_data diff --git a/src/backend/routers/auth_router.py b/src/backend/routers/auth_router.py index 0b0d278..3149ed0 100644 --- a/src/backend/routers/auth_router.py +++ b/src/backend/routers/auth_router.py @@ -3,13 +3,16 @@ import httpx from fastapi import APIRouter, Request, HTTPException, Depends from fastapi.responses import RedirectResponse, FileResponse, JSONResponse +from config import refresh_token import os -from datetime import datetime +from typing import Optional +import time from config import (get_auth_url, get_token_url, set_session, delete_session, get_session, FRONTEND_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SERVER_URL, OIDC_REALM, OIDC_REDIRECT_URI, STATIC_DIR) from dependencies import get_coder_api from coder import CoderAPI +from dependencies import optional_auth, UserSession auth_router = APIRouter() @@ -61,7 +64,7 @@ async def callback( raise HTTPException(status_code=400, detail="Auth failed") token_data = token_response.json() - expiry = token_data['expires_in'] + expiry = token_data['refresh_expires_in'] set_session(session_id, token_data, expiry) access_token = token_data['access_token'] user_info = jwt.decode(access_token, options={"verify_signature": False}) @@ -110,45 +113,53 @@ async def logout(request: Request): return response @auth_router.get("/status") -async def auth_status(request: Request): +async def auth_status( + request: Request, + user_session: Optional[UserSession] = Depends(optional_auth) +): """Check if the user is authenticated and return session information""" - session_id = request.cookies.get('session_id') - - if not session_id: - return JSONResponse({ - "authenticated": False, - "message": "No session found" - }) - - session_data = get_session(session_id) - if not session_data: + if not user_session: return JSONResponse({ "authenticated": False, - "message": "Invalid session" + "message": "Not authenticated" }) - # Decode the access token to get user info try: - access_token = session_data.get('access_token') - if not access_token: - return JSONResponse({ - "authenticated": False, - "message": "No access token found" - }) - - user_info = jwt.decode(access_token, options={"verify_signature": False}) + expires_in = user_session.token_data.get('exp') - time.time() return JSONResponse({ "authenticated": True, "user": { - "username": user_info.get('preferred_username'), - "email": user_info.get('email'), - "name": user_info.get('name') + "username": user_session.username, + "email": user_session.email, + "name": user_session.name }, - "expires_in": session_data.get('expires_in') + "expires_in": expires_in }) except Exception as e: return JSONResponse({ "authenticated": False, - "message": f"Error decoding token: {str(e)}" + "message": f"Error processing session: {str(e)}" }) + +@auth_router.post("/refresh") +async def refresh_session(request: Request): + """Refresh the current session's access token""" + session_id = request.cookies.get('session_id') + if not session_id: + raise HTTPException(status_code=401, detail="No session found") + + session_data = get_session(session_id) + if not session_data: + raise HTTPException(status_code=401, detail="Invalid session") + + # Try to refresh the token + success, new_session = await refresh_token(session_id, session_data) + if not success: + raise HTTPException(status_code=401, detail="Failed to refresh session") + + # Return the new expiry time + return JSONResponse({ + "expires_in": new_session.get('expires_in'), + "authenticated": True + }) \ No newline at end of file diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index e2c30c4..7fa08b8 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -1,5 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; interface UserInfo { username?: string; @@ -31,32 +31,77 @@ const fetchAuthStatus = async (): Promise => { return response.json(); }; +const refreshSession = async (): Promise => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + throw new Error('Failed to refresh session'); + } + return response.json(); +}; + export const useAuthStatus = () => { + const queryClient = useQueryClient(); + const { data, isLoading, error, isError, refetch } = useQuery({ queryKey: ['authStatus'], queryFn: fetchAuthStatus, - gcTime: 10 * 60 * 1000, // Data is kept in cache for 10 minutes - staleTime: 5 * 60 * 1000, // Data is considered fresh for 5 minutes - refetchOnWindowFocus: true, // Automatically refetch when the window gains focus - retry: 1, // Retry failed requests once before showing an error }); + const expiresAt = useMemo(() => { + if (data?.expires_in) { + return new Date(Date.now() + data.expires_in * 1000); + } + return null; + }, [data?.expires_in]); + useEffect(() => { - + const refreshSessionIfNeeded = async () => { + if (!data?.authenticated || !expiresAt) { + return; + } + + const currentTime = new Date(); + + // If the token has already expired, refetch the status + if (expiresAt < currentTime) { + refetch(); + return; + } + + // Refresh if less than 5 minutes remaining + const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60 * 1000); + if (expiresAt < fiveMinutesFromNow) { + try { + const newData = await refreshSession(); + queryClient.setQueryData(['authStatus'], newData); + } catch (error) { + console.error('Failed to refresh session:', error); + refetch(); + } + } + }; + + // Handle auth completion from popup const handleStorageChange = (event: StorageEvent) => { if (event.key === 'auth_completed') { refetch(); } }; + // Set up interval to check session expiry + const intervalId = setInterval(refreshSessionIfNeeded, 60 * 1000); + + // Add event listeners window.addEventListener('storage', handleStorageChange); return () => { + clearInterval(intervalId); window.removeEventListener('storage', handleStorageChange); }; - }, [refetch]); - - console.log("Auth status", data); + }, [data, queryClient, refetch, expiresAt]); return { isAuthenticated: data?.authenticated ?? false, diff --git a/src/frontend/src/hooks/useLogout.ts b/src/frontend/src/hooks/useLogout.ts index 712ae19..9ca5491 100644 --- a/src/frontend/src/hooks/useLogout.ts +++ b/src/frontend/src/hooks/useLogout.ts @@ -35,7 +35,7 @@ export const useLogout = () => { return useMutation({ mutationFn: logoutUser, onSuccess: (data) => { - console.debug('Logout mutation successful, Keycloak URL:', data.logout_url); + console.debug('[pad.ws]Logout mutation successful, Keycloak URL:', data.logout_url); // Invalidate authStatus query to trigger a re-fetch and update UI. // This will make useAuthStatus re-evaluate, and isAuthenticated should become false. @@ -43,7 +43,7 @@ export const useLogout = () => { queryClient.invalidateQueries({ queryKey: ['authStatus'] }); }, onError: (error) => { - console.error('Logout mutation failed:', error.message); + console.error('[pad.ws] Logout mutation failed:', error.message); }, }); }; From 4b5bdbac35d80a91f81c25e78eb7c78507fd30d5 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 23:05:22 +0000 Subject: [PATCH 009/149] refactor: clean up authentication router and improve logout error handling - Removed unnecessary print statement for missing session ID in the authentication callback. - Simplified the auth_status endpoint by removing the request parameter, enhancing clarity. - Updated error logging in the useLogout hook to provide more context in case of JSON parsing failures. --- src/backend/routers/auth_router.py | 2 -- src/frontend/src/hooks/useLogout.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/backend/routers/auth_router.py b/src/backend/routers/auth_router.py index 3149ed0..a580c37 100644 --- a/src/backend/routers/auth_router.py +++ b/src/backend/routers/auth_router.py @@ -44,7 +44,6 @@ async def callback( ): session_id = request.cookies.get('session_id') if not session_id: - print("No session ID found") raise HTTPException(status_code=400, detail="No session") # Exchange code for token @@ -114,7 +113,6 @@ async def logout(request: Request): @auth_router.get("/status") async def auth_status( - request: Request, user_session: Optional[UserSession] = Depends(optional_auth) ): """Check if the user is authenticated and return session information""" diff --git a/src/frontend/src/hooks/useLogout.ts b/src/frontend/src/hooks/useLogout.ts index 9ca5491..11f5d9c 100644 --- a/src/frontend/src/hooks/useLogout.ts +++ b/src/frontend/src/hooks/useLogout.ts @@ -21,7 +21,7 @@ const logoutUser = async (): Promise => { errorMessage = errorData.detail || errorData.message; } } catch (e) { - console.warn('Could not parse JSON from logout error response.'); + console.warn('[pad.ws] Could not parse JSON from logout error response.'); } throw new Error(errorMessage); } @@ -35,7 +35,7 @@ export const useLogout = () => { return useMutation({ mutationFn: logoutUser, onSuccess: (data) => { - console.debug('[pad.ws]Logout mutation successful, Keycloak URL:', data.logout_url); + console.debug('[pad.ws] Logout mutation successful, Keycloak URL:', data.logout_url); // Invalidate authStatus query to trigger a re-fetch and update UI. // This will make useAuthStatus re-evaluate, and isAuthenticated should become false. From fe4dd290d8b44899c63b11f8e0590f9fdb2c50a5 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 23:15:50 +0000 Subject: [PATCH 010/149] refactor: clean up imports and improve authentication state handling - Removed unused redis import from config.py to streamline dependencies. - Simplified the import statements in dependencies.py for clarity. - Updated the condition for rendering the AuthDialog in App.tsx to explicitly check for false authentication state. - Adjusted the useAuthStatus hook to return undefined for isAuthenticated when data is not available, enhancing state management. --- src/backend/config.py | 2 -- src/backend/dependencies.py | 2 +- src/frontend/src/App.tsx | 2 +- src/frontend/src/hooks/useAuthStatus.ts | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/backend/config.py b/src/backend/config.py index a38a5af..c24d321 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -2,7 +2,6 @@ import json import time import httpx -import redis from redis import ConnectionPool, Redis import jwt from jwt.jwks_client import PyJWKClient @@ -168,7 +167,6 @@ async def refresh_token(session_id: str, token_data: Dict[str, Any]) -> Tuple[bo # Update session with new tokens expiry = new_token_data['refresh_expires_in'] - print(f"New expiry in refresh_token: {expiry}") set_session(session_id, new_token_data, expiry) return True, new_token_data diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 749c26b..21aaa1b 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -2,7 +2,7 @@ from typing import Optional, Dict, Any from uuid import UUID -from fastapi import Request, HTTPException, Depends +from fastapi import Request, HTTPException from config import get_session, is_token_expired, refresh_token from database.service import UserService diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 7177ae9..27e56a0 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -80,7 +80,7 @@ export default function App() { setShowSettingsModal={setShowSettingsModal} /> - {isAuthenticated !== true && ( + {isAuthenticated === false && ( {}} /> diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index 7fa08b8..6a1b8dd 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -104,7 +104,7 @@ export const useAuthStatus = () => { }, [data, queryClient, refetch, expiresAt]); return { - isAuthenticated: data?.authenticated ?? false, + isAuthenticated: data?.authenticated ?? undefined, user: data?.user, expires_in: data?.expires_in, isLoading, From c613993ce2c374bb245aa108fba0a8b48ed22296 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 23:40:51 +0000 Subject: [PATCH 011/149] feat: add padding configuration and improve iframe load timeout - Introduced a new padding configuration in the default initial data for better layout control. - Increased the iframe load timeout from 2 seconds to 5 seconds to enhance reliability in loading external content. --- src/frontend/src/App.tsx | 8 ++++++++ src/frontend/src/ui/MainMenu.tsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 27e56a0..c567a97 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -18,6 +18,14 @@ const defaultInitialData = { gridModeEnabled: true, gridSize: 20, gridStep: 5, + pad: { + moduleBorderOffset: { + top: 40, + right: 5, + bottom: 5, + left: 5, + }, + }, }, files: {}, }; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index caf64ad..a3d02b0 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -158,7 +158,7 @@ export const MainMenuConfig: React.FC = ({ iframe.onload = () => cleanup(); // Fallback: remove iframe after 2s if onload doesn't fire - timeoutId = window.setTimeout(() => cleanup(new Error(`${debugName} iframe load timed out`)), 2000); + timeoutId = window.setTimeout(() => cleanup(new Error(`${debugName} iframe load timed out`)), 5000); iframe.onerror = (event) => { // event can be an ErrorEvent or string const errorMsg = typeof event === 'string' ? event : (event instanceof ErrorEvent ? event.message : `Error loading ${debugName} iframe`); From 9229c3812d7584e53d01b7b2d46bf1d5c4db0fd0 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 12 May 2025 23:57:58 +0000 Subject: [PATCH 012/149] feat: integrate app configuration and PostHog initialization - Added a new custom hook, useAppConfig, to fetch and manage application configuration. - Integrated PostHog initialization in the App component based on the fetched configuration. - Updated AuthGate to utilize app configuration for OIDC priming and improved error handling for configuration loading. - Enhanced PostHog initialization logic to prevent multiple initializations and handle errors gracefully. --- src/frontend/src/App.tsx | 14 ++++++++ src/frontend/src/AuthGate.tsx | 17 +++++----- src/frontend/src/hooks/useAppConfig.ts | 45 ++++++++++++++++++++++++++ src/frontend/src/utils/posthog.ts | 40 ++++++++++++++++------- 4 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 src/frontend/src/hooks/useAppConfig.ts diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index c567a97..b638ac3 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect } from "react"; import { Excalidraw, MainMenu } from "@atyrode/excalidraw"; +import { initializePostHog } from "./utils/posthog"; import { useAuthStatus } from "./hooks/useAuthStatus"; +import { useAppConfig } from "./hooks/useAppConfig"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -32,6 +34,7 @@ const defaultInitialData = { export default function App() { const { isAuthenticated } = useAuthStatus(); + const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); @@ -48,6 +51,17 @@ export default function App() { // TODO lockEmbeddables(excalidrawAPI?.getAppState()); }; + + useEffect(() => { + if (appConfig && appConfig.posthogKey && appConfig.posthogHost) { + initializePostHog({ + posthogKey: appConfig.posthogKey, + posthogHost: appConfig.posthogHost, + }); + } else if (configError) { + console.error('[pad.ws] Failed to load app config, PostHog initialization might be skipped or delayed:', configError); + } + }, [appConfig, configError]); // TODO // useEffect(() => { diff --git a/src/frontend/src/AuthGate.tsx b/src/frontend/src/AuthGate.tsx index 43968ce..fd54903 100644 --- a/src/frontend/src/AuthGate.tsx +++ b/src/frontend/src/AuthGate.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import { useAppConfig } from "./hooks/useAppConfig"; // Import useAppConfig /** * If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it. @@ -13,21 +14,16 @@ export default function AuthGate() { const [coderAuthDone, setCoderAuthDone] = useState(false); const iframeRef = useRef(null); const timeoutRef = useRef(null); + const { config, isLoadingConfig, configError } = useAppConfig(); // Use the hook const isAuthenticated = false; //TODO useEffect(() => { - // Only run the Coder OIDC priming once per session, after auth is confirmed - if (isAuthenticated && !coderAuthDone) { + if (isAuthenticated && !coderAuthDone && config && !isLoadingConfig && !configError) { const setupIframe = async () => { try { - // Get config from API - const config = { - coderUrl: 'https://coder.pad.ws' //TODO - }; - if (!config.coderUrl) { - console.warn('[pad.ws] Coder URL not found, skipping OIDC priming'); + console.warn('[pad.ws] Coder URL not found in config, skipping OIDC priming'); setCoderAuthDone(true); return; } @@ -66,9 +62,12 @@ export default function AuthGate() { clearTimeout(timeoutRef.current); } }; + } else if (configError) { + console.error('[pad.ws] Failed to load app config for OIDC priming:', configError); + setCoderAuthDone(true); // Mark as done to prevent retries if config fails } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated, coderAuthDone]); + }, [isAuthenticated, coderAuthDone, config, isLoadingConfig, configError]); return null; } diff --git a/src/frontend/src/hooks/useAppConfig.ts b/src/frontend/src/hooks/useAppConfig.ts new file mode 100644 index 0000000..750ac3b --- /dev/null +++ b/src/frontend/src/hooks/useAppConfig.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +// Removed: import posthog from 'posthog-js'; +// Removed: import { useEffect } from 'react'; + +interface AppConfig { + coderUrl: string; + posthogKey: string; + posthogHost: string; +} + +const fetchAppConfig = async (): Promise => { + const response = await fetch('/api/app/config'); + if (!response.ok) { + let errorMessage = 'Failed to fetch app configuration.'; + try { + const errorData = await response.json(); + if (errorData && errorData.message) { + errorMessage = errorData.message; + } + } catch (e) { + // Ignore if error response is not JSON or empty + } + throw new Error(errorMessage); + } + return response.json(); +}; + +export const useAppConfig = () => { + const { data, isLoading, error, isError } = useQuery({ + queryKey: ['appConfig'], + queryFn: fetchAppConfig, + staleTime: Infinity, // Config is not expected to change during a session + gcTime: Infinity, // Renamed from cacheTime in v5 + }); + + // useEffect for posthog.init() has been removed from here. + // It will be handled in App.tsx to ensure single initialization. + + return { + config: data, + isLoadingConfig: isLoading, + configError: error, + isConfigError: isError, + }; +}; diff --git a/src/frontend/src/utils/posthog.ts b/src/frontend/src/utils/posthog.ts index b956baf..75af816 100644 --- a/src/frontend/src/utils/posthog.ts +++ b/src/frontend/src/utils/posthog.ts @@ -1,23 +1,39 @@ import posthog from 'posthog-js'; -// Initialize PostHog with empty values first -posthog.init('', { api_host: '' }); +let isPostHogInitialized = false; -const config = { //TODO - posthogKey: 'phc_RBmyKvfGVKCpPYkSFV2U2oAFWxwEKrDQzHmnKXPmodf', - posthogHost: 'https://eu.i.posthog.com' +interface PostHogConfigKeys { + posthogKey: string; + posthogHost: string; } -// Then update with real values when config is loaded -posthog.init(config.posthogKey, { - api_host: config.posthogHost, -}); -console.debug('[pad.ws] PostHog initialized successfully'); +export const initializePostHog = (config: PostHogConfigKeys) => { + if (isPostHogInitialized) { + console.warn('[pad.ws] PostHog is already initialized. Skipping re-initialization.'); + return; + } + if (!config || !config.posthogKey || !config.posthogHost) { + console.warn('[pad.ws] PostHog initialization skipped due to missing or invalid config.'); + return; + } + + try { + posthog.init(config.posthogKey, { + api_host: config.posthogHost, + }); + isPostHogInitialized = true; + console.debug('[pad.ws] PostHog initialized successfully from posthog.ts with config:', config); + } catch (error) { + console.error('[pad.ws] Error initializing PostHog:', error); + } +}; -// Helper function to track custom events export const capture = (eventName: string, properties?: Record) => { + if (!isPostHogInitialized) { + console.warn(`[pad.ws] PostHog not yet initialized. Event "${eventName}" was not captured.`); + return; + } posthog.capture(eventName, properties); }; -// Export PostHog instance for direct use export default posthog; From 75a7a3670b4f092924f8beccdc72949001c70881 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 13 May 2025 11:03:24 +0000 Subject: [PATCH 013/149] refactor backend --- src/backend/config.py | 5 + src/backend/database/__init__.py | 12 - src/backend/database/database.py | 46 +-- src/backend/database/models/__init__.py | 5 +- src/backend/database/models/backup_model.py | 42 -- src/backend/database/models/pad_model.py | 26 +- src/backend/database/repository/__init__.py | 4 - .../database/repository/backup_repository.py | 116 ------ .../database/repository/pad_repository.py | 9 - .../repository/template_pad_repository.py | 63 --- src/backend/database/service/__init__.py | 17 - .../database/service/backup_service.py | 176 -------- src/backend/database/service/pad_service.py | 125 ------ .../database/service/template_pad_service.py | 98 ----- src/backend/database/service/user_service.py | 181 --------- src/backend/dependencies.py | 7 - src/backend/main.py | 61 +-- src/backend/routers/app_router.py | 31 +- src/backend/routers/pad_router.py | 378 +----------------- src/backend/routers/template_pad_router.py | 118 ------ .../{user_router.py => users_router.py} | 94 +---- 21 files changed, 46 insertions(+), 1568 deletions(-) delete mode 100644 src/backend/database/models/backup_model.py delete mode 100644 src/backend/database/repository/backup_repository.py delete mode 100644 src/backend/database/repository/template_pad_repository.py delete mode 100644 src/backend/database/service/__init__.py delete mode 100644 src/backend/database/service/backup_service.py delete mode 100644 src/backend/database/service/pad_service.py delete mode 100644 src/backend/database/service/template_pad_service.py delete mode 100644 src/backend/database/service/user_service.py delete mode 100644 src/backend/routers/template_pad_router.py rename src/backend/routers/{user_router.py => users_router.py} (53%) diff --git a/src/backend/config.py b/src/backend/config.py index c24d321..40a6be8 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -53,6 +53,11 @@ # Create a Redis client that uses the connection pool redis_client = Redis(connection_pool=redis_pool) + +default_pad = {} +with open("templates/default.json", 'r') as f: + default_pad = json.load(f) + def get_redis_client(): """Get a Redis client from the connection pool""" return Redis(connection_pool=redis_pool) diff --git a/src/backend/database/__init__.py b/src/backend/database/__init__.py index 6c5c0e7..8ca3628 100644 --- a/src/backend/database/__init__.py +++ b/src/backend/database/__init__.py @@ -9,12 +9,6 @@ get_session, get_user_repository, get_pad_repository, - get_backup_repository, - get_template_pad_repository, - get_user_service, - get_pad_service, - get_backup_service, - get_template_pad_service ) __all__ = [ @@ -22,10 +16,4 @@ 'get_session', 'get_user_repository', 'get_pad_repository', - 'get_backup_repository', - 'get_template_pad_repository', - 'get_user_service', - 'get_pad_service', - 'get_backup_service', - 'get_template_pad_service', ] diff --git a/src/backend/database/database.py b/src/backend/database/database.py index 87ca120..cd441d8 100644 --- a/src/backend/database/database.py +++ b/src/backend/database/database.py @@ -31,9 +31,7 @@ engine = create_async_engine(DATABASE_URL, echo=False) # Create async session factory -async_session = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False -) +async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async def init_db() -> None: @@ -59,45 +57,3 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: yield session finally: await session.close() - -# Dependency for getting repositories -async def get_user_repository(session: AsyncSession = Depends(get_session)): - """Get a user repository""" - from .repository import UserRepository - return UserRepository(session) - -async def get_pad_repository(session: AsyncSession = Depends(get_session)): - """Get a pad repository""" - from .repository import PadRepository - return PadRepository(session) - -async def get_backup_repository(session: AsyncSession = Depends(get_session)): - """Get a backup repository""" - from .repository import BackupRepository - return BackupRepository(session) - -async def get_template_pad_repository(session: AsyncSession = Depends(get_session)): - """Get a template pad repository""" - from .repository import TemplatePadRepository - return TemplatePadRepository(session) - -# Dependency for getting services -async def get_user_service(session: AsyncSession = Depends(get_session)): - """Get a user service""" - from .service import UserService - return UserService(session) - -async def get_pad_service(session: AsyncSession = Depends(get_session)): - """Get a pad service""" - from .service import PadService - return PadService(session) - -async def get_backup_service(session: AsyncSession = Depends(get_session)): - """Get a backup service""" - from .service import BackupService - return BackupService(session) - -async def get_template_pad_service(session: AsyncSession = Depends(get_session)): - """Get a template pad service""" - from .service import TemplatePadService - return TemplatePadService(session) diff --git a/src/backend/database/models/__init__.py b/src/backend/database/models/__init__.py index 41fe372..30e13f8 100644 --- a/src/backend/database/models/__init__.py +++ b/src/backend/database/models/__init__.py @@ -6,15 +6,12 @@ from .base_model import Base, BaseModel, SCHEMA_NAME from .user_model import UserModel -from .pad_model import PadModel, TemplatePadModel -from .backup_model import BackupModel +from .pad_model import PadModel __all__ = [ 'Base', 'BaseModel', 'UserModel', 'PadModel', - 'BackupModel', - 'TemplatePadModel', 'SCHEMA_NAME', ] diff --git a/src/backend/database/models/backup_model.py b/src/backend/database/models/backup_model.py deleted file mode 100644 index 5e2f7d3..0000000 --- a/src/backend/database/models/backup_model.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Dict, Any, TYPE_CHECKING - -from sqlalchemy import Column, ForeignKey, Index, UUID -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import relationship, Mapped - -from .base_model import Base, BaseModel, SCHEMA_NAME - -if TYPE_CHECKING: - from .pad_model import PadModel - -class BackupModel(Base, BaseModel): - """Model for backups table in app schema""" - __tablename__ = "backups" - __table_args__ = ( - Index("ix_backups_source_id", "source_id"), - Index("ix_backups_created_at", "created_at"), - {"schema": SCHEMA_NAME} - ) - - # Backup-specific fields - source_id = Column( - UUID(as_uuid=True), - ForeignKey(f"{SCHEMA_NAME}.pads.id", ondelete="CASCADE"), - nullable=False - ) - data = Column(JSONB, nullable=False) - - # Relationships - pad: Mapped["PadModel"] = relationship("PadModel", back_populates="backups") - - def __repr__(self) -> str: - return f"" - - def to_dict(self) -> Dict[str, Any]: - """Convert model instance to dictionary with additional fields""" - result = super().to_dict() - # Convert data to dict if it's not already - if isinstance(result["data"], str): - import json - result["data"] = json.loads(result["data"]) - return result diff --git a/src/backend/database/models/pad_model.py b/src/backend/database/models/pad_model.py index fafddbc..e3d5ec8 100644 --- a/src/backend/database/models/pad_model.py +++ b/src/backend/database/models/pad_model.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Any, TYPE_CHECKING +from typing import Dict, Any, TYPE_CHECKING from sqlalchemy import Column, String, ForeignKey, Index, UUID from sqlalchemy.dialects.postgresql import JSONB @@ -7,7 +7,6 @@ from .base_model import Base, BaseModel, SCHEMA_NAME if TYPE_CHECKING: - from .backup_model import BackupModel from .user_model import UserModel class PadModel(Base, BaseModel): @@ -30,12 +29,6 @@ class PadModel(Base, BaseModel): # Relationships owner: Mapped["UserModel"] = relationship("UserModel", back_populates="pads") - backups: Mapped[List["BackupModel"]] = relationship( - "BackupModel", - back_populates="pad", - cascade="all, delete-orphan", - lazy="selectin" - ) def __repr__(self) -> str: return f"" @@ -48,20 +41,3 @@ def to_dict(self) -> Dict[str, Any]: import json result["data"] = json.loads(result["data"]) return result - - -class TemplatePadModel(Base, BaseModel): - """Model for template pads table in app schema""" - __tablename__ = "template_pads" - __table_args__ = ( - Index("ix_template_pads_display_name", "display_name"), - Index("ix_template_pads_name", "name"), - {"schema": SCHEMA_NAME} - ) - - name = Column(String(100), nullable=False, unique=True) - display_name = Column(String(100), nullable=False) - data = Column(JSONB, nullable=False) - - def __repr__(self) -> str: - return f"" \ No newline at end of file diff --git a/src/backend/database/repository/__init__.py b/src/backend/database/repository/__init__.py index a2433d7..db654d5 100644 --- a/src/backend/database/repository/__init__.py +++ b/src/backend/database/repository/__init__.py @@ -6,12 +6,8 @@ from .user_repository import UserRepository from .pad_repository import PadRepository -from .backup_repository import BackupRepository -from .template_pad_repository import TemplatePadRepository __all__ = [ 'UserRepository', 'PadRepository', - 'BackupRepository', - 'TemplatePadRepository', ] diff --git a/src/backend/database/repository/backup_repository.py b/src/backend/database/repository/backup_repository.py deleted file mode 100644 index dcae35a..0000000 --- a/src/backend/database/repository/backup_repository.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Backup repository for database operations related to backups. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID -from datetime import datetime - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import delete, func, join - -from ..models import BackupModel, PadModel - -class BackupRepository: - """Repository for backup-related database operations""" - - def __init__(self, session: AsyncSession): - """Initialize the repository with a database session""" - self.session = session - - async def create(self, source_id: UUID, data: Dict[str, Any]) -> BackupModel: - """Create a new backup""" - backup = BackupModel(source_id=source_id, data=data) - self.session.add(backup) - await self.session.commit() - await self.session.refresh(backup) - return backup - - async def get_by_id(self, backup_id: UUID) -> Optional[BackupModel]: - """Get a backup by ID""" - stmt = select(BackupModel).where(BackupModel.id == backup_id) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_by_source(self, source_id: UUID) -> List[BackupModel]: - """Get all backups for a specific source pad""" - stmt = select(BackupModel).where(BackupModel.source_id == source_id).order_by(BackupModel.created_at.desc()) - result = await self.session.execute(stmt) - return result.scalars().all() - - async def get_latest_by_source(self, source_id: UUID) -> Optional[BackupModel]: - """Get the most recent backup for a specific source pad""" - stmt = select(BackupModel).where(BackupModel.source_id == source_id).order_by(BackupModel.created_at.desc()).limit(1) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_by_date_range(self, source_id: UUID, start_date: datetime, end_date: datetime) -> List[BackupModel]: - """Get backups for a specific source pad within a date range""" - stmt = select(BackupModel).where( - BackupModel.source_id == source_id, - BackupModel.created_at >= start_date, - BackupModel.created_at <= end_date - ).order_by(BackupModel.created_at.desc()) - result = await self.session.execute(stmt) - return result.scalars().all() - - async def delete(self, backup_id: UUID) -> bool: - """Delete a backup""" - stmt = delete(BackupModel).where(BackupModel.id == backup_id) - result = await self.session.execute(stmt) - await self.session.commit() - return result.rowcount > 0 - - async def delete_older_than(self, source_id: UUID, keep_count: int) -> int: - """Delete older backups, keeping only the most recent ones""" - # Get the created_at timestamp of the backup at position keep_count - subquery = select(BackupModel.created_at).where( - BackupModel.source_id == source_id - ).order_by(BackupModel.created_at.desc()).offset(keep_count).limit(1) - - result = await self.session.execute(subquery) - cutoff_date = result.scalar() - - if not cutoff_date: - return 0 # Not enough backups to delete any - - # Delete backups older than the cutoff date - stmt = delete(BackupModel).where( - BackupModel.source_id == source_id, - BackupModel.created_at < cutoff_date - ) - result = await self.session.execute(stmt) - await self.session.commit() - return result.rowcount - - async def count_by_source(self, source_id: UUID) -> int: - """Count the number of backups for a specific source pad""" - stmt = select(func.count()).select_from(BackupModel).where(BackupModel.source_id == source_id) - result = await self.session.execute(stmt) - return result.scalar() - - async def get_backups_by_user(self, user_id: UUID, limit: int = 10) -> List[BackupModel]: - """ - Get backups for a user's first pad directly using a join operation. - This eliminates the N+1 query problem by fetching the pad and its backups in a single query. - - Args: - user_id: The user ID to get backups for - limit: Maximum number of backups to return - - Returns: - List of backup models - """ - # Create a join between PadModel and BackupModel - stmt = select(BackupModel).join( - PadModel, - BackupModel.source_id == PadModel.id - ).where( - PadModel.owner_id == user_id - ).order_by( - BackupModel.created_at.desc() - ).limit(limit) - - result = await self.session.execute(stmt) - return result.scalars().all() diff --git a/src/backend/database/repository/pad_repository.py b/src/backend/database/repository/pad_repository.py index a9b6c67..699e6bd 100644 --- a/src/backend/database/repository/pad_repository.py +++ b/src/backend/database/repository/pad_repository.py @@ -38,15 +38,6 @@ async def get_by_owner(self, owner_id: UUID) -> List[PadModel]: result = await self.session.execute(stmt) return result.scalars().all() - async def get_by_name(self, owner_id: UUID, display_name: str) -> Optional[PadModel]: - """Get a pad by owner and display name""" - stmt = select(PadModel).where( - PadModel.owner_id == owner_id, - PadModel.display_name == display_name - ) - result = await self.session.execute(stmt) - return result.scalars().first() - async def update(self, pad_id: UUID, data: Dict[str, Any]) -> Optional[PadModel]: """Update a pad""" stmt = update(PadModel).where(PadModel.id == pad_id).values(**data).returning(PadModel) diff --git a/src/backend/database/repository/template_pad_repository.py b/src/backend/database/repository/template_pad_repository.py deleted file mode 100644 index ff85e39..0000000 --- a/src/backend/database/repository/template_pad_repository.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Template pad repository for database operations related to template pads. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import update, delete - -from ..models import TemplatePadModel - -class TemplatePadRepository: - """Repository for template pad-related database operations""" - - def __init__(self, session: AsyncSession): - """Initialize the repository with a database session""" - self.session = session - - async def create(self, name: str, display_name: str, data: Dict[str, Any]) -> TemplatePadModel: - """Create a new template pad""" - template_pad = TemplatePadModel(name=name, display_name=display_name, data=data) - self.session.add(template_pad) - await self.session.commit() - await self.session.refresh(template_pad) - return template_pad - - async def get_by_id(self, template_id: UUID) -> Optional[TemplatePadModel]: - """Get a template pad by ID""" - stmt = select(TemplatePadModel).where(TemplatePadModel.id == template_id) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_by_name(self, name: str) -> Optional[TemplatePadModel]: - """Get a template pad by name""" - stmt = select(TemplatePadModel).where(TemplatePadModel.name == name) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_all(self) -> List[TemplatePadModel]: - """Get all template pads""" - stmt = select(TemplatePadModel).order_by(TemplatePadModel.display_name) - result = await self.session.execute(stmt) - return result.scalars().all() - - async def update(self, name: str, data: Dict[str, Any]) -> Optional[TemplatePadModel]: - """Update a template pad""" - stmt = update(TemplatePadModel).where(TemplatePadModel.name == name).values(**data).returning(TemplatePadModel) - result = await self.session.execute(stmt) - await self.session.commit() - return result.scalars().first() - - async def update_data(self, name: str, template_data: Dict[str, Any]) -> Optional[TemplatePadModel]: - """Update just the data field of a template pad""" - return await self.update(name, {"data": template_data}) - - async def delete(self, name: str) -> bool: - """Delete a template pad""" - stmt = delete(TemplatePadModel).where(TemplatePadModel.name == name) - result = await self.session.execute(stmt) - await self.session.commit() - return result.rowcount > 0 diff --git a/src/backend/database/service/__init__.py b/src/backend/database/service/__init__.py deleted file mode 100644 index d362c0b..0000000 --- a/src/backend/database/service/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Service module for business logic. - -This module provides access to all services used for business logic operations. -""" - -from .user_service import UserService -from .pad_service import PadService -from .backup_service import BackupService -from .template_pad_service import TemplatePadService - -__all__ = [ - 'UserService', - 'PadService', - 'BackupService', - 'TemplatePadService', -] diff --git a/src/backend/database/service/backup_service.py b/src/backend/database/service/backup_service.py deleted file mode 100644 index a9e44db..0000000 --- a/src/backend/database/service/backup_service.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Backup service for business logic related to backups. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID -from datetime import datetime, timezone - -from sqlalchemy.ext.asyncio import AsyncSession - -from ..repository import BackupRepository, PadRepository, UserRepository - -class BackupService: - """Service for backup-related business logic""" - - def __init__(self, session: AsyncSession): - """Initialize the service with a database session""" - self.session = session - self.repository = BackupRepository(session) - self.pad_repository = PadRepository(session) - - async def create_backup(self, source_id: UUID, data: Dict[str, Any]) -> Dict[str, Any]: - """Create a new backup""" - # Validate input - if not data: - raise ValueError("Backup data is required") - - # Check if source pad exists - source_pad = await self.pad_repository.get_by_id(source_id) - if not source_pad: - raise ValueError(f"Pad with ID '{source_id}' does not exist") - - # Create backup - backup = await self.repository.create(source_id, data) - return backup.to_dict() - - async def get_backup(self, backup_id: UUID) -> Optional[Dict[str, Any]]: - """Get a backup by ID""" - backup = await self.repository.get_by_id(backup_id) - return backup.to_dict() if backup else None - - async def get_backups_by_source(self, source_id: UUID) -> List[Dict[str, Any]]: - """Get all backups for a specific source pad""" - # Check if source pad exists - source_pad = await self.pad_repository.get_by_id(source_id) - if not source_pad: - raise ValueError(f"Pad with ID '{source_id}' does not exist") - - backups = await self.repository.get_by_source(source_id) - return [backup.to_dict() for backup in backups] - - async def get_latest_backup(self, source_id: UUID) -> Optional[Dict[str, Any]]: - """Get the most recent backup for a specific source pad""" - # Check if source pad exists - source_pad = await self.pad_repository.get_by_id(source_id) - if not source_pad: - raise ValueError(f"Pad with ID '{source_id}' does not exist") - - backup = await self.repository.get_latest_by_source(source_id) - return backup.to_dict() if backup else None - - async def get_backups_by_date_range(self, source_id: UUID, start_date: datetime, end_date: datetime) -> List[Dict[str, Any]]: - """Get backups for a specific source pad within a date range""" - # Check if source pad exists - source_pad = await self.pad_repository.get_by_id(source_id) - if not source_pad: - raise ValueError(f"Pad with ID '{source_id}' does not exist") - - # Validate date range - if start_date > end_date: - raise ValueError("Start date must be before end date") - - backups = await self.repository.get_by_date_range(source_id, start_date, end_date) - return [backup.to_dict() for backup in backups] - - async def delete_backup(self, backup_id: UUID) -> bool: - """Delete a backup""" - # Get the backup to check if it exists - backup = await self.repository.get_by_id(backup_id) - if not backup: - raise ValueError(f"Backup with ID '{backup_id}' does not exist") - - return await self.repository.delete(backup_id) - - async def manage_backups(self, source_id: UUID, max_backups: int = 10) -> int: - """Manage backups for a source pad, keeping only the most recent ones""" - # Check if source pad exists - source_pad = await self.pad_repository.get_by_id(source_id) - if not source_pad: - raise ValueError(f"Pad with ID '{source_id}' does not exist") - - # Validate max_backups - if max_backups < 1: - raise ValueError("Maximum number of backups must be at least 1") - - # Count current backups - backup_count = await self.repository.count_by_source(source_id) - - # If we have more backups than the maximum, delete the oldest ones - if backup_count > max_backups: - return await self.repository.delete_older_than(source_id, max_backups) - - return 0 # No backups deleted - - async def get_backups_by_user(self, user_id: UUID, limit: int = 10) -> List[Dict[str, Any]]: - """ - Get backups for a user's first pad directly using a join operation. - This eliminates the N+1 query problem by fetching the pad and its backups in a single query. - - Args: - user_id: The user ID to get backups for - limit: Maximum number of backups to return - - Returns: - List of backup dictionaries - """ - # Check if user exists - user_repository = UserRepository(self.session) - user = await user_repository.get_by_id(user_id) - if not user: - raise ValueError(f"User with ID '{user_id}' does not exist") - - # Get backups directly with a single query - backups = await self.repository.get_backups_by_user(user_id, limit) - return [backup.to_dict() for backup in backups] - - async def create_backup_if_needed(self, source_id: UUID, data: Dict[str, Any], - min_interval_minutes: int = 5, - max_backups: int = 10) -> Optional[Dict[str, Any]]: - """ - Create a backup only if needed: - - If there are no existing backups - - If the latest backup is older than the specified interval - - Args: - source_id: The ID of the source pad - data: The data to backup - min_interval_minutes: Minimum time between backups in minutes - max_backups: Maximum number of backups to keep - - Returns: - The created backup dict if a backup was created, None otherwise - """ - # Check if source pad exists - source_pad = await self.pad_repository.get_by_id(source_id) - if not source_pad: - raise ValueError(f"Pad with ID '{source_id}' does not exist") - - # Get the latest backup - latest_backup = await self.repository.get_latest_by_source(source_id) - - # Calculate the current time with timezone information - current_time = datetime.now(timezone.utc) - - # Determine if we need to create a backup - create_backup = False - - if not latest_backup: - # No backups exist yet, so create one - create_backup = True - else: - # Check if the latest backup is older than the minimum interval - backup_age = current_time - latest_backup.created_at - if backup_age.total_seconds() > (min_interval_minutes * 60): - create_backup = True - - # Create a backup if needed - if create_backup: - backup = await self.repository.create(source_id, data) - - # Manage backups (clean up old ones) - await self.manage_backups(source_id, max_backups) - - return backup.to_dict() - - return None diff --git a/src/backend/database/service/pad_service.py b/src/backend/database/service/pad_service.py deleted file mode 100644 index c1c382e..0000000 --- a/src/backend/database/service/pad_service.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Pad service for business logic related to pads. -""" - -from typing import List, Optional, Dict, Any, TYPE_CHECKING -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession - -from ..repository import PadRepository, UserRepository -from .user_service import UserService -from ..models import PadModel -# Use TYPE_CHECKING to avoid circular imports -if TYPE_CHECKING: - from dependencies import UserSession - -class PadService: - """Service for pad-related business logic""" - - def __init__(self, session: AsyncSession): - """Initialize the service with a database session""" - self.session = session - self.repository = PadRepository(session) - self.user_repository = UserRepository(session) - - async def create_pad(self, owner_id: UUID, display_name: str, data: Dict[str, Any], user_session: "UserSession" = None) -> Dict[str, Any]: - """Create a new pad""" - # Validate input - if not display_name: - raise ValueError("Display name is required") - - if not data: - raise ValueError("Pad data is required") - - # Check if owner exists - owner = await self.user_repository.get_by_id(owner_id) - if not owner and user_session: - # Reaching here is an anomaly, this is a failsafe - # User should already exist in the database - - # Create a UserService instance - user_service = UserService(self.session) - - # Create token data dictionary from UserSession properties - token_data = { - "username": user_session.username, - "email": user_session.email, - "email_verified": user_session.email_verified, - "name": user_session.name, - "given_name": user_session.given_name, - "family_name": user_session.family_name, - "roles": user_session.roles - } - - # Use sync_user_with_token_data which handles race conditions - try: - await user_service.sync_user_with_token_data(owner_id, token_data) - # Get the user again to confirm it exists - owner = await self.user_repository.get_by_id(owner_id) - if not owner: - raise ValueError(f"Failed to create user with ID '{owner_id}'") - except Exception as e: - print(f"Error creating user as failsafe: {str(e)}") - raise ValueError(f"Failed to create user with ID '{owner_id}': {str(e)}") - - # Create pad - pad = await self.repository.create(owner_id, display_name, data) - return pad.to_dict() - - async def get_pad(self, pad_id: UUID) -> Optional[Dict[str, Any]]: - """Get a pad by ID""" - pad = await self.repository.get_by_id(pad_id) - return pad.to_dict() if pad else None - - async def get_pads_by_owner(self, owner_id: UUID) -> List[Dict[str, Any]]: - """Get all pads for a specific owner""" - # Check if owner exists - owner = await self.user_repository.get_by_id(owner_id) - if not owner: - # Return empty list instead of raising an error - # This allows the pad_router to handle the case where a user doesn't exist - return [] - - pads: list[PadModel] = await self.repository.get_by_owner(owner_id) - return [pad.to_dict() for pad in pads] - - async def get_pad_by_name(self, owner_id: UUID, display_name: str) -> Optional[Dict[str, Any]]: - """Get a pad by owner and display name""" - pad = await self.repository.get_by_name(owner_id, display_name) - return pad.to_dict() if pad else None - - async def update_pad(self, pad_id: UUID, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update a pad""" - # Get the pad to check if it exists - pad = await self.repository.get_by_id(pad_id) - if not pad: - raise ValueError(f"Pad with ID '{pad_id}' does not exist") - - # Validate display_name if it's being updated - if 'display_name' in data and not data['display_name']: - raise ValueError("Display name cannot be empty") - - # Update pad - updated_pad = await self.repository.update(pad_id, data) - return updated_pad.to_dict() if updated_pad else None - - async def update_pad_data(self, pad_id: UUID, pad_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update just the data field of a pad""" - # Get the pad to check if it exists - pad = await self.repository.get_by_id(pad_id) - if not pad: - raise ValueError(f"Pad with ID '{pad_id}' does not exist") - - # Update pad data - updated_pad = await self.repository.update_data(pad_id, pad_data) - return updated_pad.to_dict() if updated_pad else None - - async def delete_pad(self, pad_id: UUID) -> bool: - """Delete a pad""" - # Get the pad to check if it exists - pad = await self.repository.get_by_id(pad_id) - if not pad: - raise ValueError(f"Pad with ID '{pad_id}' does not exist") - - return await self.repository.delete(pad_id) diff --git a/src/backend/database/service/template_pad_service.py b/src/backend/database/service/template_pad_service.py deleted file mode 100644 index ead220b..0000000 --- a/src/backend/database/service/template_pad_service.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Template pad service for business logic related to template pads. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession - -from ..repository import TemplatePadRepository - -class TemplatePadService: - """Service for template pad-related business logic""" - - def __init__(self, session: AsyncSession): - """Initialize the service with a database session""" - self.session = session - self.repository = TemplatePadRepository(session) - - async def create_template(self, name: str, display_name: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Create a new template pad""" - # Validate input - if not name: - raise ValueError("Name is required") - - if not display_name: - raise ValueError("Display name is required") - - if not data: - raise ValueError("Template data is required") - - # Check if template with same name already exists - existing_template = await self.repository.get_by_name(name) - if existing_template: - raise ValueError(f"Template with name '{name}' already exists") - - # Create template pad - template_pad = await self.repository.create(name, display_name, data) - return template_pad.to_dict() - - async def get_template(self, template_id: UUID) -> Optional[Dict[str, Any]]: - """Get a template pad by ID""" - template_pad = await self.repository.get_by_id(template_id) - return template_pad.to_dict() if template_pad else None - - async def get_template_by_name(self, name: str) -> Optional[Dict[str, Any]]: - """Get a template pad by name""" - template_pad = await self.repository.get_by_name(name) - return template_pad.to_dict() if template_pad else None - - async def get_all_templates(self) -> List[Dict[str, Any]]: - """Get all template pads""" - template_pads = await self.repository.get_all() - return [template_pad.to_dict() for template_pad in template_pads] - - async def update_template(self, name: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update a template pad""" - # Get the template pad to check if it exists - template_pad = await self.repository.get_by_name(name) - if not template_pad: - raise ValueError(f"Template pad with name '{name}' does not exist") - - # Validate name and display_name if they're being updated - if 'name' in data and not data['name']: - raise ValueError("Name cannot be empty") - - if 'display_name' in data and not data['display_name']: - raise ValueError("Display name cannot be empty") - - # Check if new name already exists (if being updated) - if 'name' in data and data['name'] != template_pad.name: - existing_template = await self.repository.get_by_name(data['name']) - if existing_template: - raise ValueError(f"Template with name '{data['name']}' already exists") - - # Update template pad - updated_template = await self.repository.update(name, data) - return updated_template.to_dict() if updated_template else None - - async def update_template_data(self, name: str, template_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update just the data field of a template pad""" - # Get the template pad to check if it exists - template_pad = await self.repository.get_by_name(name) - if not template_pad: - raise ValueError(f"Template pad with name '{name}' does not exist") - - # Update template pad data - updated_template = await self.repository.update_data(name, template_data) - return updated_template.to_dict() if updated_template else None - - async def delete_template(self, name: str) -> bool: - """Delete a template pad""" - # Get the template pad to check if it exists - template_pad = await self.repository.get_by_name(name) - if not template_pad: - raise ValueError(f"Template pad with name '{name}' does not exist") - - return await self.repository.delete(name) diff --git a/src/backend/database/service/user_service.py b/src/backend/database/service/user_service.py deleted file mode 100644 index 00a834d..0000000 --- a/src/backend/database/service/user_service.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -User service for business logic related to users. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession - -from ..repository import UserRepository - -class UserService: - """Service for user-related business logic""" - - def __init__(self, session: AsyncSession): - """Initialize the service with a database session""" - self.session = session - self.repository = UserRepository(session) - - async def create_user(self, user_id: UUID, username: str, email: str, - email_verified: bool = False, name: str = None, - given_name: str = None, family_name: str = None, - roles: list = None) -> Dict[str, Any]: - """Create a new user with specified ID and optional fields""" - # Validate input - if not user_id or not username or not email: - raise ValueError("User ID, username, and email are required") - - # Check if user_id already exists - existing_id = await self.repository.get_by_id(user_id) - if existing_id: - raise ValueError(f"User with ID '{user_id}' already exists") - - # Check if username already exists - existing_user = await self.repository.get_by_username(username) - if existing_user: - raise ValueError(f"Username '{username}' is already taken") - - # Create user - user = await self.repository.create( - user_id=user_id, - username=username, - email=email, - email_verified=email_verified, - name=name, - given_name=given_name, - family_name=family_name, - roles=roles - ) - return user.to_dict() - - async def get_user(self, user_id: UUID) -> Optional[Dict[str, Any]]: - """Get a user by ID""" - user = await self.repository.get_by_id(user_id) - return user.to_dict() if user else None - - async def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: - """Get a user by username""" - user = await self.repository.get_by_username(username) - return user.to_dict() if user else None - - async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: - """Get a user by email""" - user = await self.repository.get_by_email(email) - return user.to_dict() if user else None - - async def get_all_users(self) -> List[Dict[str, Any]]: - """Get all users""" - users = await self.repository.get_all() - return [user.to_dict() for user in users] - - async def update_user(self, user_id: UUID, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update a user""" - # Validate input - if 'username' in data and not data['username']: - raise ValueError("Username cannot be empty") - - if 'email' in data and not data['email']: - raise ValueError("Email cannot be empty") - - # Check if username already exists (if being updated) - if 'username' in data: - existing_user = await self.repository.get_by_username(data['username']) - if existing_user and existing_user.id != user_id: - raise ValueError(f"Username '{data['username']}' is already taken") - - # Check if email already exists (if being updated) - if 'email' in data: - existing_email = await self.repository.get_by_email(data['email']) - if existing_email and existing_email.id != user_id: - raise ValueError(f"Email '{data['email']}' is already registered") - - # Update user - user = await self.repository.update(user_id, data) - return user.to_dict() if user else None - - async def delete_user(self, user_id: UUID) -> bool: - """Delete a user""" - return await self.repository.delete(user_id) - - async def update_user_if_needed(self, user_id: UUID, token_data: Dict[str, Any], user_data: Dict[str, Any]) -> Dict[str, Any]: - """ - Update user only if data has changed - - Args: - user_id: The user's UUID - token_data: Dictionary containing user data from the authentication token - user_data: Current user data from the database - - Returns: - The updated user data dictionary or the original if no update was needed - """ - # Check if user data needs to be updated - update_data = {} - fields_to_check = [ - "username", "email", "email_verified", - "name", "given_name", "family_name" - ] - - for field in fields_to_check: - token_value = token_data.get(field) - if token_value is not None and user_data.get(field) != token_value: - update_data[field] = token_value - - # Handle roles separately as they might have a different structure - if "roles" in token_data and user_data.get("roles") != token_data["roles"]: - update_data["roles"] = token_data["roles"] - - # Update user if any field has changed - if update_data: - return await self.update_user(user_id, update_data) - - return user_data - - async def sync_user_with_token_data(self, user_id: UUID, token_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Synchronize user data in the database with data from the authentication token. - If the user doesn't exist, it will be created. If it exists but has different data, - it will be updated to match the token data. - - Args: - user_id: The user's UUID - token_data: Dictionary containing user data from the authentication token - - Returns: - The user data dictionary or None if operation failed - """ - # Check if user exists - user_data = await self.get_user(user_id) - - # If user doesn't exist, create a new one - if not user_data: - try: - print(f"User with ID '{user_id}' does not exist. Creating user from token data.") - return await self.create_user( - user_id=user_id, - username=token_data.get("username", ""), - email=token_data.get("email", ""), - email_verified=token_data.get("email_verified", False), - name=token_data.get("name"), - given_name=token_data.get("given_name"), - family_name=token_data.get("family_name"), - roles=token_data.get("roles", []) - ) - except ValueError as e: - print(f"Error creating user: {e}") - # Handle case where user might have been created in a race condition - if "already exists" in str(e): - print(f"Race condition detected: User with ID '{user_id}' was created by another process.") - user_data = await self.get_user(user_id) - if user_data: - # User exists now, proceed with update if needed - return await self.update_user_if_needed(user_id, token_data, user_data) - else: - # This shouldn't happen - user creation failed but user doesn't exist - raise ValueError(f"Failed to create or find user with ID '{user_id}'") - else: - raise e - - # User exists, update if needed - return await self.update_user_if_needed(user_id, token_data, user_data) diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 21aaa1b..82223b6 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -5,7 +5,6 @@ from fastapi import Request, HTTPException from config import get_session, is_token_expired, refresh_token -from database.service import UserService from coder import CoderAPI class UserSession: @@ -84,12 +83,6 @@ def roles(self) -> list: def is_admin(self) -> bool: """Check if user has admin role""" return "admin" in self.roles - - async def get_user_data(self, user_service: UserService) -> Dict[str, Any]: - """Get user data from database, caching the result""" - if self._user_data is None and self.id: - self._user_data = await user_service.get_user(self.id) - return self._user_data class AuthDependency: """ diff --git a/src/backend/main.py b/src/backend/main.py index 52d8833..367b246 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -13,68 +13,16 @@ from config import STATIC_DIR, ASSETS_DIR, POSTHOG_API_KEY, POSTHOG_HOST, redis_client, redis_pool from dependencies import UserSession, optional_auth from routers.auth_router import auth_router -from routers.user_router import user_router +from routers.users_router import users_router from routers.workspace_router import workspace_router from routers.pad_router import pad_router -from routers.template_pad_router import template_pad_router from routers.app_router import app_router -from database.service import TemplatePadService -from database.database import async_session # Initialize PostHog if API key is available if POSTHOG_API_KEY: posthog.project_api_key = POSTHOG_API_KEY posthog.host = POSTHOG_HOST -async def load_templates(): - """ - Load all templates from the templates directory into the database if they don't exist. - - This function reads all JSON files in the templates directory, extracts the display name - from the "appState.pad.displayName" field, uses the filename as the name, and stores - the entire JSON as the data. - """ - try: - # Get a session and template service - async with async_session() as session: - template_service = TemplatePadService(session) - - # Get the templates directory path - templates_dir = os.path.join(os.path.dirname(__file__), "templates") - - # Iterate through all JSON files in the templates directory - for filename in os.listdir(templates_dir): - if filename.endswith(".json"): - # Use the filename without extension as the name - name = os.path.splitext(filename)[0] - - # Check if template already exists - existing_template = await template_service.get_template_by_name(name) - - if not existing_template: - - file_path = os.path.join(templates_dir, filename) - - # Read the JSON file - with open(file_path, 'r') as f: - template_data = json.load(f) - - # Extract the display name from the JSON - display_name = template_data.get("appState", {}).get("pad", {}).get("displayName", "Untitled") - - # Create the template if it doesn't exist - await template_service.create_template( - name=name, - display_name=display_name, - data=template_data - ) - print(f"Added template: {name} ({display_name})") - else: - print(f"Template already in database: '{name}'") - - except Exception as e: - print(f"Error loading templates: {str(e)}") - @asynccontextmanager async def lifespan(_: FastAPI): # Initialize database @@ -84,10 +32,6 @@ async def lifespan(_: FastAPI): redis_client.ping() print("Redis connection established successfully") - # Load all templates from the templates directory - await load_templates() - print("Templates loaded successfully") - yield # Clean up connections when shutting down @@ -117,10 +61,9 @@ async def read_root(request: Request, auth: Optional[UserSession] = Depends(opti # Include routers in the main app with the /api prefix app.include_router(auth_router, prefix="/api/auth") -app.include_router(user_router, prefix="/api/users") +app.include_router(users_router, prefix="/api/users") app.include_router(workspace_router, prefix="/api/workspace") app.include_router(pad_router, prefix="/api/pad") -app.include_router(template_pad_router, prefix="/api/templates") app.include_router(app_router, prefix="/api/app") if __name__ == "__main__": diff --git a/src/backend/routers/app_router.py b/src/backend/routers/app_router.py index 2070224..f053808 100644 --- a/src/backend/routers/app_router.py +++ b/src/backend/routers/app_router.py @@ -7,21 +7,22 @@ app_router = APIRouter() -@app_router.get("/build-info") -async def get_build_info(): - """ - Return the current build information from the static assets - """ - try: - # Read the build-info.json file that will be generated during build - build_info_path = os.path.join(STATIC_DIR, "build-info.json") - with open(build_info_path, 'r') as f: - build_info = json.load(f) - return build_info - except Exception as e: - # Return a default response if file doesn't exist - print(f"Error reading build-info.json: {str(e)}") - return {"buildHash": "development", "timestamp": int(time.time())} +# TODO: Add build info back in +# @app_router.get("/build-info") +# async def get_build_info(): +# """ +# Return the current build information from the static assets +# """ +# try: +# # Read the build-info.json file that will be generated during build +# build_info_path = os.path.join(STATIC_DIR, "build-info.json") +# with open(build_info_path, 'r') as f: +# build_info = json.load(f) +# return build_info +# except Exception as e: +# # Return a default response if file doesn't exist +# print(f"Error reading build-info.json: {str(e)}") +# return {"buildHash": "development", "timestamp": int(time.time())} @app_router.get("/config") async def get_app_config(): diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index de3b669..d00484b 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -1,381 +1,15 @@ from uuid import UUID -from typing import Dict, Any -from fastapi import APIRouter, HTTPException, Depends, Request -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends from dependencies import UserSession, require_auth -from database import get_pad_service, get_backup_service, get_template_pad_service -from database.service import PadService, BackupService, TemplatePadService -from config import MAX_BACKUPS_PER_USER, MIN_INTERVAL_MINUTES, DEFAULT_PAD_NAME, DEFAULT_TEMPLATE_NAME -pad_router = APIRouter() - -def ensure_pad_metadata(data: Dict[str, Any], pad_id: str, display_name: str) -> Dict[str, Any]: - """ - Ensure the pad metadata (uniqueId and displayName) is set in the data. - - Args: - data: The pad data to modify - pad_id: The pad ID to set as uniqueId - display_name: The display name to set - - Returns: - The modified data - """ - # Ensure the appState and pad objects exist - if "appState" not in data: - data["appState"] = {} - if "pad" not in data["appState"]: - data["appState"]["pad"] = {} - - # Set the uniqueId to match the database ID - data["appState"]["pad"]["uniqueId"] = str(pad_id) - data["appState"]["pad"]["displayName"] = display_name - - return data - - -@pad_router.post("") -async def update_first_pad( - data: Dict[str, Any], - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), - backup_service: BackupService = Depends(get_backup_service), - template_pad_service: TemplatePadService = Depends(get_template_pad_service), -): - """ - Update the first pad for the authenticated user. - - This is a backward compatibility endpoint that assumes the user is trying to update their first pad. - It will be deprecated in the future. Please use POST /api/pad/{pad_id} instead. - """ - try: - # Get user's pads - user_pads = await pad_service.get_pads_by_owner(user.id) - - # If user has no pads, create a default one - if not user_pads: - new_pad = await create_pad_from_template( - name=DEFAULT_TEMPLATE_NAME, - display_name=DEFAULT_PAD_NAME, - user=user, - pad_service=pad_service, - template_pad_service=template_pad_service, - backup_service=backup_service - ) - pad_id = new_pad["id"] - else: - # Use the first pad - pad_id = user_pads[0]["id"] - - # Get the pad to verify ownership - pad = await pad_service.get_pad(pad_id) - - if not pad: - raise HTTPException(status_code=404, detail="Pad not found") - - # Verify the user owns this pad - if str(pad["owner_id"]) != str(user.id): - raise HTTPException(status_code=403, detail="You don't have permission to update this pad") - - # Ensure the uniqueId and displayName are set in the data - data = ensure_pad_metadata(data, str(pad_id), pad["display_name"]) - - # Update the pad - await pad_service.update_pad_data(pad_id, data) - - # Create a backup if needed - await backup_service.create_backup_if_needed( - source_id=pad_id, - data=data, - min_interval_minutes=MIN_INTERVAL_MINUTES, - max_backups=MAX_BACKUPS_PER_USER - ) - - # Return success with deprecation notice - return JSONResponse( - content={"status": "success", "message": "This endpoint is deprecated. Please use POST /api/pad/{pad_id} instead."}, - headers={"Deprecation": "true", "Sunset": "Mon, 10 May 2025 00:00:00 GMT"} - ) - except Exception as e: - print(f"Error updating pad: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to update pad: {str(e)}") - - -@pad_router.post("/{pad_id}") -async def update_specific_pad( - pad_id: UUID, - data: Dict[str, Any], - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), - backup_service: BackupService = Depends(get_backup_service), -): - """Update a specific pad's data for the authenticated user""" - try: - # Get the pad to verify ownership - pad = await pad_service.get_pad(pad_id) - - if not pad: - raise HTTPException(status_code=404, detail="Pad not found") - - # Verify the user owns this pad - if str(pad["owner_id"]) != str(user.id): - raise HTTPException(status_code=403, detail="You don't have permission to update this pad") - - # Ensure the uniqueId and displayName are set in the data - data = ensure_pad_metadata(data, str(pad_id), pad["display_name"]) - - # Update the pad - await pad_service.update_pad_data(pad_id, data) - - # Create a backup if needed - await backup_service.create_backup_if_needed( - source_id=pad_id, - data=data, - min_interval_minutes=MIN_INTERVAL_MINUTES, - max_backups=MAX_BACKUPS_PER_USER - ) - - return {"status": "success"} - except Exception as e: - print(f"Error updating pad: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to update pad: {str(e)}") - - -@pad_router.patch("/{pad_id}") -async def rename_pad( - pad_id: UUID, - data: Dict[str, str], - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), -): - """Rename a pad for the authenticated user""" - try: - # Get the pad to verify ownership - pad = await pad_service.get_pad(pad_id) - - if not pad: - raise HTTPException(status_code=404, detail="Pad not found") - - # Verify the user owns this pad - if str(pad["owner_id"]) != str(user.id): - raise HTTPException(status_code=403, detail="You don't have permission to rename this pad") - - # Check if display_name is provided - if "display_name" not in data: - raise HTTPException(status_code=400, detail="display_name is required") - - # Update the pad's display name - update_data = {"display_name": data["display_name"]} - updated_pad = await pad_service.update_pad(pad_id, update_data) - - return {"status": "success", "pad": updated_pad} - except ValueError as e: - print(f"Error renaming pad: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - print(f"Error renaming pad: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to rename pad: {str(e)}") - - -@pad_router.delete("/{pad_id}") -async def delete_pad( - pad_id: UUID, - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), -): - """Delete a pad for the authenticated user""" - try: - # Get the pad to verify ownership - pad = await pad_service.get_pad(pad_id) - - if not pad: - raise HTTPException(status_code=404, detail="Pad not found") - - # Verify the user owns this pad - if str(pad["owner_id"]) != str(user.id): - raise HTTPException(status_code=403, detail="You don't have permission to delete this pad") - - # Delete the pad - success = await pad_service.delete_pad(pad_id) - - if not success: - raise HTTPException(status_code=500, detail="Failed to delete pad") - - return {"status": "success"} - except ValueError as e: - print(f"Error deleting pad: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - print(f"Error deleting pad: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to delete pad: {str(e)}") - - -@pad_router.get("") -async def get_all_pads( - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), - template_pad_service: TemplatePadService = Depends(get_template_pad_service), - backup_service: BackupService = Depends(get_backup_service) -): - """Get all pads for the authenticated user""" - try: - # Get user's pads - user_pads = await pad_service.get_pads_by_owner(user.id) - - if not user_pads: - # Create a default pad if user doesn't have any - new_pad = await create_pad_from_template( - name=DEFAULT_TEMPLATE_NAME, - display_name=DEFAULT_PAD_NAME, - user=user, - pad_service=pad_service, - template_pad_service=template_pad_service, - backup_service=backup_service - ) - - # Return the new pad in a list - return [new_pad] - - # Ensure each pad's data has the uniqueId and displayName set - for pad in user_pads: - pad_data = pad["data"] - - # Ensure the uniqueId and displayName are set in the data - pad_data = ensure_pad_metadata(pad_data, str(pad["id"]), pad["display_name"]) - - # Return all pads - return user_pads - except Exception as e: - print(f"Error getting pad data: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to get pad data: {str(e)}") - - -@pad_router.post("/from-template/{name}") -async def create_pad_from_template( - name: str, - display_name: str = DEFAULT_PAD_NAME, - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), - template_pad_service: TemplatePadService = Depends(get_template_pad_service), - backup_service: BackupService = Depends(get_backup_service) -): - """Create a new pad from a template""" - - try: - # Get the template - template = await template_pad_service.get_template_by_name(name) - if not template: - raise HTTPException(status_code=404, detail="Template not found") - - # Get the template data - template_data = template["data"] - - # Before creating, ensure the pad object exists in the data - template_data = ensure_pad_metadata(template_data, "", "") - - # Create a new pad using the template data - pad = await pad_service.create_pad( - owner_id=user.id, - display_name=display_name, - data=template_data, - user_session=user - ) - - # Set the uniqueId and displayName to match the database ID and display name - template_data = ensure_pad_metadata(template_data, str(pad["id"]), display_name) - - # Update the pad with the modified data - await pad_service.update_pad_data(pad["id"], template_data) - - # Create an initial backup for the new pad - await backup_service.create_backup_if_needed( - source_id=pad["id"], - data=template_data, - min_interval_minutes=0, # Always create initial backup - max_backups=MAX_BACKUPS_PER_USER - ) - - return pad - except ValueError as e: - print(f"Error creating pad from template: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - print(f"Error creating pad from template: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to create pad from template: {str(e)}") +pad_router = APIRouter() -@pad_router.get("/{pad_id}/backups") -async def get_pad_backups( +@pad_router.get("/{pad_id}") +async def get_pad( pad_id: UUID, - limit: int = MAX_BACKUPS_PER_USER, - user: UserSession = Depends(require_auth), - pad_service: PadService = Depends(get_pad_service), - backup_service: BackupService = Depends(get_backup_service) -): - """Get backups for a specific pad""" - # Limit the number of backups to the maximum configured value - if limit > MAX_BACKUPS_PER_USER: - limit = MAX_BACKUPS_PER_USER - - try: - # Get the pad to verify ownership - pad = await pad_service.get_pad(pad_id) - - if not pad: - raise HTTPException(status_code=404, detail="Pad not found") - - # Verify the user owns this pad - if str(pad["owner_id"]) != str(user.id): - raise HTTPException(status_code=403, detail="You don't have permission to access this pad's backups") - - # Get backups for this specific pad - backups_data = await backup_service.get_backups_by_source(pad_id) - - # Limit the number of backups if needed - if len(backups_data) > limit: - backups_data = backups_data[:limit] - - # Format backups to match the expected response format - backups = [] - for backup in backups_data: - backups.append({ - "id": backup["id"], - "timestamp": backup["created_at"], - "data": backup["data"] - }) - - return {"backups": backups, "pad_name": pad["display_name"]} - except Exception as e: - print(f"Error getting pad backups: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to get pad backups: {str(e)}") - - -@pad_router.get("/recent") -async def get_recent_canvas_backups( - limit: int = MAX_BACKUPS_PER_USER, user: UserSession = Depends(require_auth), - backup_service: BackupService = Depends(get_backup_service) ): - """Get the most recent canvas backups for the authenticated user""" - # Limit the number of backups to the maximum configured value - if limit > MAX_BACKUPS_PER_USER: - limit = MAX_BACKUPS_PER_USER - - try: - # Get backups directly with a single query - backups_data = await backup_service.get_backups_by_user(user.id, limit) - - # Format backups to match the expected response format - backups = [] - for backup in backups_data: - backups.append({ - "id": backup["id"], - "timestamp": backup["created_at"], - "data": backup["data"] - }) - - return {"backups": backups} - except Exception as e: - print(f"Error getting canvas backups: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to get canvas backups: {str(e)}") + """Get a specific pad for the authenticated user""" + raise NotImplementedError("Not implemented") \ No newline at end of file diff --git a/src/backend/routers/template_pad_router.py b/src/backend/routers/template_pad_router.py deleted file mode 100644 index 6b57786..0000000 --- a/src/backend/routers/template_pad_router.py +++ /dev/null @@ -1,118 +0,0 @@ -from typing import Dict, Any - -from fastapi import APIRouter, HTTPException, Depends - -from dependencies import UserSession, require_auth, require_admin -from database import get_template_pad_service -from database.service import TemplatePadService - -template_pad_router = APIRouter() - - -@template_pad_router.post("") -async def create_template_pad( - data: Dict[str, Any], - name: str, - display_name: str, - _: bool = Depends(require_admin), - template_pad_service: TemplatePadService = Depends(get_template_pad_service) -): - """Create a new template pad (admin only)""" - try: - template_pad = await template_pad_service.create_template( - name=name, - display_name=display_name, - data=data - ) - return template_pad - except ValueError as e: - print(f"Error creating template pad: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - - -@template_pad_router.get("") -async def get_all_template_pads( - _: bool = Depends(require_admin), - template_pad_service: TemplatePadService = Depends(get_template_pad_service) -): - """Get all template pads""" - try: - template_pads = await template_pad_service.get_all_templates() - return template_pads - except Exception as e: - print(f"Error getting template pads: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to get template pads: {str(e)}") - - -@template_pad_router.get("/{name}") -async def get_template_pad( - name: str, - _: UserSession = Depends(require_auth), - template_pad_service: TemplatePadService = Depends(get_template_pad_service) -): - """Get a specific template pad by name""" - template_pad = await template_pad_service.get_template_by_name(name) - if not template_pad: - print(f"Template pad not found: {name}") - raise HTTPException(status_code=404, detail="Template pad not found") - - return template_pad - - -@template_pad_router.put("/{name}") -async def update_template_pad( - name: str, - data: Dict[str, Any], - _: bool = Depends(require_admin), - template_pad_service: TemplatePadService = Depends(get_template_pad_service) -): - """Update a template pad (admin only)""" - try: - updated_template = await template_pad_service.update_template(name, data) - if not updated_template: - print(f"Template pad not found: {name}") - raise HTTPException(status_code=404, detail="Template pad not found") - - return updated_template - except ValueError as e: - print(f"Error updating template pad: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - - -@template_pad_router.put("/{name}/data") -async def update_template_pad_data( - name: str, - data: Dict[str, Any], - _: bool = Depends(require_admin), - template_pad_service: TemplatePadService = Depends(get_template_pad_service) -): - """Update just the data field of a template pad (admin only)""" - try: - updated_template = await template_pad_service.update_template_data(name, data) - if not updated_template: - print(f"Template pad not found: {name}") - raise HTTPException(status_code=404, detail="Template pad not found") - - return updated_template - except ValueError as e: - print(f"Error updating template pad data: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - - -@template_pad_router.delete("/{name}") -async def delete_template_pad( - name: str, - _: bool = Depends(require_admin), - template_pad_service: TemplatePadService = Depends(get_template_pad_service) -): - """Delete a template pad (admin only)""" - try: - success = await template_pad_service.delete_template(name) - if not success: - print(f"Template pad not found: {name}") - raise HTTPException(status_code=404, detail="Template pad not found") - - return {"status": "success", "message": "Template pad deleted successfully"} - except ValueError as e: - print(f"Error deleting template pad: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) diff --git a/src/backend/routers/user_router.py b/src/backend/routers/users_router.py similarity index 53% rename from src/backend/routers/user_router.py rename to src/backend/routers/users_router.py index fe62a01..b6c3642 100644 --- a/src/backend/routers/user_router.py +++ b/src/backend/routers/users_router.py @@ -7,58 +7,14 @@ from fastapi import APIRouter, Depends, HTTPException from config import get_redis_client, get_jwks_client, OIDC_CLIENT_ID, FRONTEND_URL -from database import get_user_service -from database.service import UserService from dependencies import UserSession, require_admin, require_auth -user_router = APIRouter() +users_router = APIRouter() -@user_router.post("") -async def create_user( - user_id: UUID, - username: str, - email: str, - email_verified: bool = False, - name: str = None, - given_name: str = None, - family_name: str = None, - roles: list = None, - _: bool = Depends(require_admin), - user_service: UserService = Depends(get_user_service) -): - """Create a new user (admin only)""" - try: - user = await user_service.create_user( - user_id=user_id, - username=username, - email=email, - email_verified=email_verified, - name=name, - given_name=given_name, - family_name=family_name, - roles=roles - ) - return user - except ValueError as e: - print(f"Error creating user: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - - -@user_router.get("") -async def get_all_users( - _: bool = Depends(require_admin), - user_service: UserService = Depends(get_user_service) -): - """Get all users (admin only)""" - users = await user_service.get_all_users() - return users - - -@user_router.get("/me") +@users_router.get("/me") async def get_user_info( user: UserSession = Depends(require_auth), - user_service: UserService = Depends(get_user_service), ): """Get the current user's information and sync with token data""" @@ -73,15 +29,16 @@ async def get_user_info( "roles": user.roles } - try: - # Sync user with token data - user_data = await user_service.sync_user_with_token_data(user.id, token_data) - except Exception as e: - print(f"Error syncing user data: {str(e)}") - raise HTTPException( - status_code=500, - detail=f"Error syncing user data: {e}" - ) + # try: + # # Sync user with token data + # user_data = await user_service.sync_user_with_token_data(user.id, token_data) + # except Exception as e: + # print(f"Error syncing user data: {str(e)}") + # raise HTTPException( + # status_code=500, + # detail=f"Error syncing user data: {e}" + # ) + raise NotImplementedError("/me Not implemented") if os.getenv("VITE_PUBLIC_POSTHOG_KEY"): telemetry = user_data.copy() @@ -91,20 +48,9 @@ async def get_user_info( return user_data -@user_router.get("/count") -async def get_user_count( - _: bool = Depends(require_admin), -): - """Get the number of active sessions (admin only)""" - client = get_redis_client() - session_count = len(client.keys("session:*")) - return {"active_sessions": session_count } - - -@user_router.get("/online") +@users_router.get("/online") async def get_online_users( _: bool = Depends(require_admin), - user_service: UserService = Depends(get_user_service) ): """Get all online users with their information (admin only)""" client = get_redis_client() @@ -138,6 +84,7 @@ async def get_online_users( user_id = UUID(decoded.get('sub')) # Fetch user data from database + raise NotImplementedError("/online Not implemented") user_data = await user_service.get_user(user_id) if user_data: online_users.append(user_data) @@ -146,16 +93,3 @@ async def get_online_users( continue return {"online_users": online_users, "count": len(online_users)} - -@user_router.get("/{user_id}") -async def get_user( - user_id: UUID, - _: bool = Depends(require_admin), - user_service: UserService = Depends(get_user_service) -): - """Get a user by ID (admin only)""" - user = await user_service.get_user(user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - return user From 5fe16bba121ee2c1a83fb777a4a8db62ea990746 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 13 May 2025 11:03:51 +0000 Subject: [PATCH 014/149] removed unused imports --- src/backend/database/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/backend/database/__init__.py b/src/backend/database/__init__.py index 8ca3628..c1b7a13 100644 --- a/src/backend/database/__init__.py +++ b/src/backend/database/__init__.py @@ -7,13 +7,9 @@ from .database import ( init_db, get_session, - get_user_repository, - get_pad_repository, ) __all__ = [ 'init_db', 'get_session', - 'get_user_repository', - 'get_pad_repository', ] From 8d1cf530480822a3532fe29ec82d8901298a12b6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 14 May 2025 02:54:34 +0000 Subject: [PATCH 015/149] add domain classes --- src/backend/database/models/__init__.py | 8 +- src/backend/database/models/pad_model.py | 87 +++++++-- src/backend/database/models/user_model.py | 118 ++++++++++-- src/backend/database/repository/__init__.py | 13 -- .../database/repository/pad_repository.py | 57 ------ .../database/repository/user_repository.py | 76 -------- src/backend/domain/pad.py | 136 ++++++++++++++ src/backend/domain/user.py | 170 ++++++++++++++++++ src/backend/routers/pad_router.py | 65 ++++++- 9 files changed, 551 insertions(+), 179 deletions(-) delete mode 100644 src/backend/database/repository/__init__.py delete mode 100644 src/backend/database/repository/pad_repository.py delete mode 100644 src/backend/database/repository/user_repository.py create mode 100644 src/backend/domain/pad.py create mode 100644 src/backend/domain/user.py diff --git a/src/backend/database/models/__init__.py b/src/backend/database/models/__init__.py index 30e13f8..250a149 100644 --- a/src/backend/database/models/__init__.py +++ b/src/backend/database/models/__init__.py @@ -5,13 +5,13 @@ """ from .base_model import Base, BaseModel, SCHEMA_NAME -from .user_model import UserModel -from .pad_model import PadModel +from .user_model import UserStore +from .pad_model import PadStore __all__ = [ 'Base', 'BaseModel', - 'UserModel', - 'PadModel', + 'UserStore', + 'PadStore', 'SCHEMA_NAME', ] diff --git a/src/backend/database/models/pad_model.py b/src/backend/database/models/pad_model.py index e3d5ec8..86b181c 100644 --- a/src/backend/database/models/pad_model.py +++ b/src/backend/database/models/pad_model.py @@ -1,16 +1,19 @@ -from typing import Dict, Any, TYPE_CHECKING +from typing import Dict, Any, Optional, List, TYPE_CHECKING +from uuid import UUID +from datetime import datetime -from sqlalchemy import Column, String, ForeignKey, Index, UUID +from sqlalchemy import Column, String, ForeignKey, Index, UUID as SQLUUID, select, update, delete from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import relationship, Mapped +from sqlalchemy.ext.asyncio import AsyncSession from .base_model import Base, BaseModel, SCHEMA_NAME if TYPE_CHECKING: - from .user_model import UserModel + from .user_model import UserStore -class PadModel(Base, BaseModel): - """Model for pads table in app schema""" +class PadStore(Base, BaseModel): + """Combined model and repository for pad storage""" __tablename__ = "pads" __table_args__ = ( Index("ix_pads_owner_id", "owner_id"), @@ -20,7 +23,7 @@ class PadModel(Base, BaseModel): # Pad-specific fields owner_id = Column( - UUID(as_uuid=True), + SQLUUID(as_uuid=True), ForeignKey(f"{SCHEMA_NAME}.users.id", ondelete="CASCADE"), nullable=False ) @@ -28,16 +31,68 @@ class PadModel(Base, BaseModel): data = Column(JSONB, nullable=False) # Relationships - owner: Mapped["UserModel"] = relationship("UserModel", back_populates="pads") + owner: Mapped["UserStore"] = relationship("UserStore", back_populates="pads") def __repr__(self) -> str: - return f"" - + return f"" + + @classmethod + async def create_pad( + cls, + session: AsyncSession, + owner_id: UUID, + display_name: str, + data: Dict[str, Any] + ) -> 'PadStore': + """Create a new pad""" + pad = cls(owner_id=owner_id, display_name=display_name, data=data) + session.add(pad) + await session.commit() + await session.refresh(pad) + return pad + + @classmethod + async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['PadStore']: + """Get a pad by ID""" + stmt = select(cls).where(cls.id == pad_id) + result = await session.execute(stmt) + return result.scalars().first() + + @classmethod + async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> List['PadStore']: + """Get all pads for a specific owner""" + stmt = select(cls).where(cls.owner_id == owner_id).order_by(cls.created_at) + result = await session.execute(stmt) + return result.scalars().all() + + async def save(self, session: AsyncSession) -> 'PadStore': + """Save the current pad state""" + if self.id is None: + session.add(self) + await session.commit() + await session.refresh(self) + return self + + async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'PadStore': + """Update the pad's data""" + self.data = data + self.updated_at = datetime.now() + return await self.save(session) + + async def delete(self, session: AsyncSession) -> bool: + """Delete the pad""" + stmt = delete(self.__class__).where(self.__class__.id == self.id) + result = await session.execute(stmt) + await session.commit() + return result.rowcount > 0 + def to_dict(self) -> Dict[str, Any]: - """Convert model instance to dictionary with additional fields""" - result = super().to_dict() - # Convert data to dict if it's not already - if isinstance(result["data"], str): - import json - result["data"] = json.loads(result["data"]) - return result + """Convert to dictionary representation""" + return { + "id": str(self.id), + "owner_id": str(self.owner_id), + "display_name": self.display_name, + "data": self.data, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat() + } diff --git a/src/backend/database/models/user_model.py b/src/backend/database/models/user_model.py index 28526b5..6f93ee5 100644 --- a/src/backend/database/models/user_model.py +++ b/src/backend/database/models/user_model.py @@ -1,15 +1,19 @@ -from typing import List, TYPE_CHECKING -from sqlalchemy import Column, Index, String, UUID, Boolean +from typing import List, Optional, Dict, Any, TYPE_CHECKING +from uuid import UUID +from datetime import datetime + +from sqlalchemy import Column, Index, String, UUID as SQLUUID, Boolean, select, update, delete from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import relationship, Mapped +from sqlalchemy.ext.asyncio import AsyncSession from .base_model import Base, BaseModel, SCHEMA_NAME if TYPE_CHECKING: - from .pad_model import PadModel + from .pad_model import PadStore -class UserModel(Base, BaseModel): - """Model for users table in app schema""" +class UserStore(Base, BaseModel): + """Combined model and repository for user storage""" __tablename__ = "users" __table_args__ = ( Index("ix_users_username", "username"), @@ -18,7 +22,7 @@ class UserModel(Base, BaseModel): ) # Override the default id column to use Keycloak's UUID - id = Column(UUID(as_uuid=True), primary_key=True) + id = Column(SQLUUID(as_uuid=True), primary_key=True) # User-specific fields username = Column(String(254), nullable=False, unique=True) @@ -30,12 +34,106 @@ class UserModel(Base, BaseModel): roles = Column(JSONB, nullable=False, default=[]) # Relationships - pads: Mapped[List["PadModel"]] = relationship( - "PadModel", + pads: Mapped[List["PadStore"]] = relationship( + "PadStore", back_populates="owner", cascade="all, delete-orphan", lazy="selectin" ) - + def __repr__(self) -> str: - return f"" + return f"" + + @classmethod + async def create_user( + cls, + session: AsyncSession, + id: UUID, + username: str, + email: str, + email_verified: bool = False, + name: Optional[str] = None, + given_name: Optional[str] = None, + family_name: Optional[str] = None, + roles: List[str] = None + ) -> 'UserStore': + """Create a new user""" + user = cls( + id=id, + username=username, + email=email, + email_verified=email_verified, + name=name, + given_name=given_name, + family_name=family_name, + roles=roles or [] + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + @classmethod + async def get_by_id(cls, session: AsyncSession, user_id: UUID) -> Optional['UserStore']: + """Get a user by ID""" + stmt = select(cls).where(cls.id == user_id) + result = await session.execute(stmt) + return result.scalars().first() + + @classmethod + async def get_by_username(cls, session: AsyncSession, username: str) -> Optional['UserStore']: + """Get a user by username""" + stmt = select(cls).where(cls.username == username) + result = await session.execute(stmt) + return result.scalars().first() + + @classmethod + async def get_by_email(cls, session: AsyncSession, email: str) -> Optional['UserStore']: + """Get a user by email""" + stmt = select(cls).where(cls.email == email) + result = await session.execute(stmt) + return result.scalars().first() + + @classmethod + async def get_all(cls, session: AsyncSession) -> List['UserStore']: + """Get all users""" + stmt = select(cls) + result = await session.execute(stmt) + return result.scalars().all() + + async def save(self, session: AsyncSession) -> 'UserStore': + """Save the current user state""" + if self.id is None: + session.add(self) + await session.commit() + await session.refresh(self) + return self + + async def update(self, session: AsyncSession, data: Dict[str, Any]) -> 'UserStore': + """Update user data""" + for key, value in data.items(): + setattr(self, key, value) + self.updated_at = datetime.now() + return await self.save(session) + + async def delete(self, session: AsyncSession) -> bool: + """Delete the user""" + stmt = delete(self.__class__).where(self.__class__.id == self.id) + result = await session.execute(stmt) + await session.commit() + return result.rowcount > 0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation""" + return { + "id": str(self.id), + "username": self.username, + "email": self.email, + "email_verified": self.email_verified, + "name": self.name, + "given_name": self.given_name, + "family_name": self.family_name, + "roles": self.roles, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat() + } diff --git a/src/backend/database/repository/__init__.py b/src/backend/database/repository/__init__.py deleted file mode 100644 index db654d5..0000000 --- a/src/backend/database/repository/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Repository module for database operations. - -This module provides access to all repositories used for database operations. -""" - -from .user_repository import UserRepository -from .pad_repository import PadRepository - -__all__ = [ - 'UserRepository', - 'PadRepository', -] diff --git a/src/backend/database/repository/pad_repository.py b/src/backend/database/repository/pad_repository.py deleted file mode 100644 index 699e6bd..0000000 --- a/src/backend/database/repository/pad_repository.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Pad repository for database operations related to pads. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import update, delete - -from ..models import PadModel - -class PadRepository: - """Repository for pad-related database operations""" - - def __init__(self, session: AsyncSession): - """Initialize the repository with a database session""" - self.session = session - - async def create(self, owner_id: UUID, display_name: str, data: Dict[str, Any]) -> PadModel: - """Create a new pad""" - pad = PadModel(owner_id=owner_id, display_name=display_name, data=data) - self.session.add(pad) - await self.session.commit() - await self.session.refresh(pad) - return pad - - async def get_by_id(self, pad_id: UUID) -> Optional[PadModel]: - """Get a pad by ID""" - stmt = select(PadModel).where(PadModel.id == pad_id) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_by_owner(self, owner_id: UUID) -> List[PadModel]: - """Get all pads for a specific owner, sorted by created_at timestamp""" - stmt = select(PadModel).where(PadModel.owner_id == owner_id).order_by(PadModel.created_at) - result = await self.session.execute(stmt) - return result.scalars().all() - - async def update(self, pad_id: UUID, data: Dict[str, Any]) -> Optional[PadModel]: - """Update a pad""" - stmt = update(PadModel).where(PadModel.id == pad_id).values(**data).returning(PadModel) - result = await self.session.execute(stmt) - await self.session.commit() - return result.scalars().first() - - async def update_data(self, pad_id: UUID, pad_data: Dict[str, Any]) -> Optional[PadModel]: - """Update just the data field of a pad""" - return await self.update(pad_id, {"data": pad_data}) - - async def delete(self, pad_id: UUID) -> bool: - """Delete a pad""" - stmt = delete(PadModel).where(PadModel.id == pad_id) - result = await self.session.execute(stmt) - await self.session.commit() - return result.rowcount > 0 diff --git a/src/backend/database/repository/user_repository.py b/src/backend/database/repository/user_repository.py deleted file mode 100644 index 2e25e26..0000000 --- a/src/backend/database/repository/user_repository.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -User repository for database operations related to users. -""" - -from typing import List, Optional, Dict, Any -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import update, delete - -from ..models import UserModel - -class UserRepository: - """Repository for user-related database operations""" - - def __init__(self, session: AsyncSession): - """Initialize the repository with a database session""" - self.session = session - - async def create(self, user_id: UUID, username: str, email: str, email_verified: bool = False, - name: str = None, given_name: str = None, family_name: str = None, - roles: list = None) -> UserModel: - """Create a new user with specified ID and optional fields""" - user = UserModel( - id=user_id, - username=username, - email=email, - email_verified=email_verified, - name=name, - given_name=given_name, - family_name=family_name, - roles=roles or [] - ) - self.session.add(user) - await self.session.commit() - await self.session.refresh(user) - return user - - async def get_by_id(self, user_id: UUID) -> Optional[UserModel]: - """Get a user by ID""" - stmt = select(UserModel).where(UserModel.id == user_id) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_by_username(self, username: str) -> Optional[UserModel]: - """Get a user by username""" - stmt = select(UserModel).where(UserModel.username == username) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_by_email(self, email: str) -> Optional[UserModel]: - """Get a user by email""" - stmt = select(UserModel).where(UserModel.email == email) - result = await self.session.execute(stmt) - return result.scalars().first() - - async def get_all(self) -> List[UserModel]: - """Get all users""" - stmt = select(UserModel) - result = await self.session.execute(stmt) - return result.scalars().all() - - async def update(self, user_id: UUID, data: Dict[str, Any]) -> Optional[UserModel]: - """Update a user""" - stmt = update(UserModel).where(UserModel.id == user_id).values(**data).returning(UserModel) - result = await self.session.execute(stmt) - await self.session.commit() - return result.scalars().first() - - async def delete(self, user_id: UUID) -> bool: - """Delete a user""" - stmt = delete(UserModel).where(UserModel.id == user_id) - result = await self.session.execute(stmt) - await self.session.commit() - return result.rowcount > 0 diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py new file mode 100644 index 0000000..558439b --- /dev/null +++ b/src/backend/domain/pad.py @@ -0,0 +1,136 @@ +from uuid import UUID +from typing import Dict, Any, Set, List, Optional, Callable, Union +import json +import copy +import asyncio +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models.pad_model import PadStore + + +class Pad: + """ + Domain entity representing a collaborative pad. + + This class contains the core business logic for pad manipulation, + manages the collaboration state, and provides methods for Redis + synchronization and database persistence. + """ + + def __init__( + self, + id: UUID, + owner_id: UUID, + display_name: str, + data: Dict[str, Any] = None, + created_at: datetime = None, + updated_at: datetime = None, + store: PadStore = None + ): + self.id = id + self.owner_id = owner_id + self.display_name = display_name + self.data = data or {} + self.created_at = created_at or datetime.now() + self.updated_at = updated_at or datetime.now() + self._store = store + + @classmethod + async def create( + cls, + session: AsyncSession, + owner_id: UUID, + display_name: str, + data: Dict[str, Any] = None + ) -> 'Pad': + """Create a new pad""" + store = await PadStore.create_pad( + session=session, + owner_id=owner_id, + display_name=display_name, + data=data or {} + ) + return cls.from_store(store) + + @classmethod + async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad']: + """Get a pad by ID""" + store = await PadStore.get_by_id(session, pad_id) + return cls.from_store(store) if store else None + + @classmethod + async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> list['Pad']: + """Get all pads for a specific owner""" + stores = await PadStore.get_by_owner(session, owner_id) + return [cls.from_store(store) for store in stores] + + @classmethod + def from_store(cls, store: PadStore) -> 'Pad': + """Create a Pad instance from a store""" + return cls( + id=store.id, + owner_id=store.owner_id, + display_name=store.display_name, + data=store.data, + created_at=store.created_at, + updated_at=store.updated_at, + store=store + ) + + async def save(self, session: AsyncSession) -> 'Pad': + """Save the pad to the database""" + if not self._store: + self._store = PadStore( + id=self.id, + owner_id=self.owner_id, + display_name=self.display_name, + data=self.data, + created_at=self.created_at, + updated_at=self.updated_at + ) + else: + self._store.display_name = self.display_name + self._store.data = self.data + self._store.updated_at = datetime.now() + + self._store = await self._store.save(session) + self.id = self._store.id + self.created_at = self._store.created_at + self.updated_at = self._store.updated_at + return self + + async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'Pad': + """Update the pad's data""" + self.data = data + self.updated_at = datetime.now() + if self._store: + self._store = await self._store.update_data(session, data) + return self + + async def delete(self, session: AsyncSession) -> bool: + """Delete the pad""" + if self._store: + return await self._store.delete(session) + return False + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation""" + return { + "id": str(self.id), + "owner_id": str(self.owner_id), + "display_name": self.display_name, + "data": self.data, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat() + } + + def to_model_data(self) -> Dict[str, Any]: + """Convert pad instance to dictionary for database model""" + return { + "owner_id": self.owner_id, + "display_name": self.display_name, + "data": self.data + } + + \ No newline at end of file diff --git a/src/backend/domain/user.py b/src/backend/domain/user.py new file mode 100644 index 0000000..7b47d23 --- /dev/null +++ b/src/backend/domain/user.py @@ -0,0 +1,170 @@ +from uuid import UUID +from typing import Dict, Any, Optional, List +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database.models.user_model import UserStore + + +class User: + """ + Domain entity representing a user. + + This class contains the core business logic for user management + and provides methods for database persistence. + """ + + def __init__( + self, + id: UUID, + username: str, + email: str, + email_verified: bool = False, + name: Optional[str] = None, + given_name: Optional[str] = None, + family_name: Optional[str] = None, + roles: List[str] = None, + created_at: datetime = None, + updated_at: datetime = None, + store: UserStore = None + ): + self.id = id + self.username = username + self.email = email + self.email_verified = email_verified + self.name = name + self.given_name = given_name + self.family_name = family_name + self.roles = roles or [] + self.created_at = created_at or datetime.now() + self.updated_at = updated_at or datetime.now() + self._store = store + + @classmethod + async def create( + cls, + session: AsyncSession, + id: UUID, + username: str, + email: str, + email_verified: bool = False, + name: Optional[str] = None, + given_name: Optional[str] = None, + family_name: Optional[str] = None, + roles: List[str] = None + ) -> 'User': + """Create a new user""" + store = await UserStore.create_user( + session=session, + id=id, + username=username, + email=email, + email_verified=email_verified, + name=name, + given_name=given_name, + family_name=family_name, + roles=roles + ) + return cls.from_store(store) + + @classmethod + async def get_by_id(cls, session: AsyncSession, user_id: UUID) -> Optional['User']: + """Get a user by ID""" + store = await UserStore.get_by_id(session, user_id) + return cls.from_store(store) if store else None + + @classmethod + async def get_by_username(cls, session: AsyncSession, username: str) -> Optional['User']: + """Get a user by username""" + store = await UserStore.get_by_username(session, username) + return cls.from_store(store) if store else None + + @classmethod + async def get_by_email(cls, session: AsyncSession, email: str) -> Optional['User']: + """Get a user by email""" + store = await UserStore.get_by_email(session, email) + return cls.from_store(store) if store else None + + @classmethod + async def get_all(cls, session: AsyncSession) -> List['User']: + """Get all users""" + stores = await UserStore.get_all(session) + return [cls.from_store(store) for store in stores] + + @classmethod + def from_store(cls, store: UserStore) -> 'User': + """Create a User instance from a store""" + return cls( + id=store.id, + username=store.username, + email=store.email, + email_verified=store.email_verified, + name=store.name, + given_name=store.given_name, + family_name=store.family_name, + roles=store.roles, + created_at=store.created_at, + updated_at=store.updated_at, + store=store + ) + + async def save(self, session: AsyncSession) -> 'User': + """Save the user to the database""" + if not self._store: + self._store = UserStore( + id=self.id, + username=self.username, + email=self.email, + email_verified=self.email_verified, + name=self.name, + given_name=self.given_name, + family_name=self.family_name, + roles=self.roles, + created_at=self.created_at, + updated_at=self.updated_at + ) + else: + self._store.username = self.username + self._store.email = self.email + self._store.email_verified = self.email_verified + self._store.name = self.name + self._store.given_name = self.given_name + self._store.family_name = self.family_name + self._store.roles = self.roles + self._store.updated_at = datetime.now() + + self._store = await self._store.save(session) + self.id = self._store.id + self.created_at = self._store.created_at + self.updated_at = self._store.updated_at + return self + + async def update(self, session: AsyncSession, data: Dict[str, Any]) -> 'User': + """Update user data""" + for key, value in data.items(): + setattr(self, key, value) + self.updated_at = datetime.now() + if self._store: + self._store = await self._store.update(session, data) + return self + + async def delete(self, session: AsyncSession) -> bool: + """Delete the user""" + if self._store: + return await self._store.delete(session) + return False + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation""" + return { + "id": str(self.id), + "username": self.username, + "email": self.email, + "email_verified": self.email_verified, + "name": self.name, + "given_name": self.given_name, + "family_name": self.family_name, + "roles": self.roles, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat() + } diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index d00484b..738f125 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -1,15 +1,74 @@ from uuid import UUID +from typing import Dict, Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession from dependencies import UserSession, require_auth +from database.models import PadStore +from database.database import get_session +from domain.pad import Pad pad_router = APIRouter() +@pad_router.get("/test") +async def test( + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + # Create a test pad + test_pad = await PadStore.create_pad( + session=session, + owner_id=user.id, + display_name="Test Pad", + data={"content": "This is a test pad"} + ) + + # Load the pad from database using its ID + loaded_pad = await PadStore.get_by_id(session, test_pad.id) + + if not loaded_pad: + raise HTTPException( + status_code=500, + detail="Failed to load created pad" + ) + + return { + "created_pad": test_pad.to_dict(), + "loaded_pad": loaded_pad.to_dict() + } + + + @pad_router.get("/{pad_id}") async def get_pad( pad_id: UUID, user: UserSession = Depends(require_auth), -): + session: AsyncSession = Depends(lambda: AsyncSession()) +) -> Dict[str, Any]: """Get a specific pad for the authenticated user""" - raise NotImplementedError("Not implemented") \ No newline at end of file + try: + # Get the pad using the domain class + pad = await Pad.get_by_id(session, pad_id) + if not pad: + raise HTTPException( + status_code=404, + detail="Pad not found" + ) + + # Check if the user owns the pad + if pad.owner_id != user.id: + raise HTTPException( + status_code=403, + detail="Not authorized to access this pad" + ) + + return pad.to_dict() + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to get pad: {str(e)}" + ) + From 297ee326e286d07d7e8e85b5c54ddeea494bb7fd Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 14 May 2025 04:13:25 +0000 Subject: [PATCH 016/149] moved session to domain object --- src/backend/config.py | 2 +- src/backend/dependencies.py | 34 +++++--- src/backend/domain/pad.py | 7 -- src/backend/domain/session.py | 134 +++++++++++++++++++++++++++++ src/backend/routers/auth_router.py | 33 +++---- src/backend/routers/pad_router.py | 44 +++++----- 6 files changed, 199 insertions(+), 55 deletions(-) create mode 100644 src/backend/domain/session.py diff --git a/src/backend/config.py b/src/backend/config.py index 40a6be8..6cac305 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -178,7 +178,6 @@ async def refresh_token(session_id: str, token_data: Dict[str, Any]) -> Tuple[bo except Exception as e: print(f"Error refreshing token: {str(e)}") return False, token_data - def get_jwks_client(): """Get or create a PyJWKClient for token verification""" global _jwks_client @@ -186,3 +185,4 @@ def get_jwks_client(): jwks_url = f"{OIDC_SERVER_URL}/realms/{OIDC_REALM}/protocol/openid-connect/certs" _jwks_client = PyJWKClient(jwks_url) return _jwks_client + diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 82223b6..27c7886 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -1,12 +1,25 @@ import jwt from typing import Optional, Dict, Any from uuid import UUID +import os from fastapi import Request, HTTPException -from config import get_session, is_token_expired, refresh_token +from config import get_redis_client +from domain.session import Session from coder import CoderAPI +# Initialize session domain +redis_client = get_redis_client() +oidc_config = { + 'server_url': os.getenv('OIDC_SERVER_URL'), + 'realm': os.getenv('OIDC_REALM'), + 'client_id': os.getenv('OIDC_CLIENT_ID'), + 'client_secret': os.getenv('OIDC_CLIENT_SECRET'), + 'redirect_uri': os.getenv('REDIRECT_URI') +} +session = Session(redis_client, oidc_config) + class UserSession: """ Unified user session model that integrates authentication data with user information. @@ -17,16 +30,15 @@ def __init__(self, access_token: str, token_data: dict, user_id: UUID = None): self._user_data = None # Get the signing key and decode with verification - from config import get_jwks_client, OIDC_CLIENT_ID try: - jwks_client = get_jwks_client() + jwks_client = session._get_jwks_client() signing_key = jwks_client.get_signing_key_from_jwt(access_token) self.token_data = jwt.decode( access_token, signing_key.key, algorithms=["RS256"], - audience=OIDC_CLIENT_ID + audience=oidc_config['client_id'] ) except jwt.InvalidTokenError as e: @@ -102,22 +114,22 @@ async def __call__(self, request: Request) -> Optional[UserSession]: return self._handle_auth_error("Not authenticated") # Get session data from Redis - session = get_session(session_id) - if not session: + session_data = session.get(session_id) + if not session_data: return self._handle_auth_error("Not authenticated") # Handle token expiration - if is_token_expired(session): + if session.is_token_expired(session_data): # Try to refresh the token - success, new_session = await refresh_token(session_id, session) + success, new_session = await session.refresh_token(session_id, session_data) if not success: return self._handle_auth_error("Session expired") - session = new_session + session_data = new_session # Create user session object user_session = UserSession( - access_token=session.get('access_token'), - token_data=session + access_token=session_data.get('access_token'), + token_data=session_data ) # Check admin requirement if specified diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 558439b..c2bcc20 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -125,12 +125,5 @@ def to_dict(self) -> Dict[str, Any]: "updated_at": self.updated_at.isoformat() } - def to_model_data(self) -> Dict[str, Any]: - """Convert pad instance to dictionary for database model""" - return { - "owner_id": self.owner_id, - "display_name": self.display_name, - "data": self.data - } \ No newline at end of file diff --git a/src/backend/domain/session.py b/src/backend/domain/session.py new file mode 100644 index 0000000..4b43856 --- /dev/null +++ b/src/backend/domain/session.py @@ -0,0 +1,134 @@ +from typing import Optional, Dict, Any, Tuple +import json +import time +import jwt +from jwt.jwks_client import PyJWKClient +import httpx +from redis import Redis + +class Session: + """Domain class for managing user sessions""" + + def __init__(self, redis_client: Redis, oidc_config: Dict[str, str]): + self.redis_client = redis_client + self.oidc_config = oidc_config + self._jwks_client = None + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + """Get session data from Redis""" + session_data = self.redis_client.get(f"session:{session_id}") + if session_data: + return json.loads(session_data) + return None + + def set(self, session_id: str, data: Dict[str, Any], expiry: int) -> None: + """Store session data in Redis with expiry in seconds""" + self.redis_client.setex( + f"session:{session_id}", + expiry, + json.dumps(data) + ) + + def delete(self, session_id: str) -> None: + """Delete session data from Redis""" + self.redis_client.delete(f"session:{session_id}") + + def get_auth_url(self) -> str: + """Generate the authentication URL for OIDC login""" + auth_url = f"{self.oidc_config['server_url']}/realms/{self.oidc_config['realm']}/protocol/openid-connect/auth" + params = { + 'client_id': self.oidc_config['client_id'], + 'response_type': 'code', + 'redirect_uri': self.oidc_config['redirect_uri'], + 'scope': 'openid profile email' + } + return f"{auth_url}?{'&'.join(f'{k}={v}' for k,v in params.items())}" + + def get_token_url(self) -> str: + """Get the token endpoint URL""" + return f"{self.oidc_config['server_url']}/realms/{self.oidc_config['realm']}/protocol/openid-connect/token" + + def is_token_expired(self, token_data: Dict[str, Any], buffer_seconds: int = 30) -> bool: + """Check if the access token is expired""" + if not token_data or 'access_token' not in token_data: + return True + + try: + # Get the signing key + jwks_client = self._get_jwks_client() + signing_key = jwks_client.get_signing_key_from_jwt(token_data['access_token']) + + # Decode with verification + decoded = jwt.decode( + token_data['access_token'], + signing_key.key, + algorithms=["RS256"], + audience=self.oidc_config['client_id'], + ) + + # Check expiration + exp_time = decoded.get('exp', 0) + current_time = time.time() + return current_time + buffer_seconds >= exp_time + except jwt.ExpiredSignatureError: + return True + except Exception as e: + print(f"Error checking token expiration: {str(e)}") + return True + + async def refresh_token(self, session_id: str, token_data: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]: + """Refresh the access token using the refresh token""" + if not token_data or 'refresh_token' not in token_data: + return False, token_data + + try: + async with httpx.AsyncClient() as client: + refresh_response = await client.post( + self.get_token_url(), + data={ + 'grant_type': 'refresh_token', + 'client_id': self.oidc_config['client_id'], + 'client_secret': self.oidc_config['client_secret'], + 'refresh_token': token_data['refresh_token'] + } + ) + + if refresh_response.status_code != 200: + print(f"Token refresh failed: {refresh_response.text}") + return False, token_data + + # Get new token data + new_token_data = refresh_response.json() + + # Update session with new tokens + expiry = new_token_data['refresh_expires_in'] + self.set(session_id, new_token_data, expiry) + + return True, new_token_data + except Exception as e: + print(f"Error refreshing token: {str(e)}") + return False, token_data + + def _get_jwks_client(self) -> PyJWKClient: + """Get or create a PyJWKClient for token verification""" + if self._jwks_client is None: + jwks_url = f"{self.oidc_config['server_url']}/realms/{self.oidc_config['realm']}/protocol/openid-connect/certs" + self._jwks_client = PyJWKClient(jwks_url) + return self._jwks_client + + def track_event(self, session_id: str, event_type: str, metadata: Dict[str, Any] = None) -> None: + """Track a session event (login, logout, etc.)""" + session_data = self.get(session_id) + if session_data: + if 'events' not in session_data: + session_data['events'] = [] + + event = { + 'type': event_type, + 'timestamp': time.time(), + 'metadata': metadata or {} + } + session_data['events'].append(event) + + # Update session with new event + self.set(session_id, session_data, session_data.get('expires_in', 3600)) \ No newline at end of file diff --git a/src/backend/routers/auth_router.py b/src/backend/routers/auth_router.py index a580c37..266f509 100644 --- a/src/backend/routers/auth_router.py +++ b/src/backend/routers/auth_router.py @@ -3,14 +3,12 @@ import httpx from fastapi import APIRouter, Request, HTTPException, Depends from fastapi.responses import RedirectResponse, FileResponse, JSONResponse -from config import refresh_token import os from typing import Optional import time -from config import (get_auth_url, get_token_url, set_session, delete_session, get_session, - FRONTEND_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SERVER_URL, OIDC_REALM, OIDC_REDIRECT_URI, STATIC_DIR) -from dependencies import get_coder_api +from config import (FRONTEND_URL, STATIC_DIR) +from dependencies import get_coder_api, session from coder import CoderAPI from dependencies import optional_auth, UserSession @@ -21,7 +19,7 @@ async def login(request: Request, kc_idp_hint: str = None, popup: str = None): session_id = secrets.token_urlsafe(32) - auth_url = get_auth_url() + auth_url = session.get_auth_url() state = "popup" if popup == "1" else "default" if kc_idp_hint: @@ -49,13 +47,13 @@ async def callback( # Exchange code for token async with httpx.AsyncClient() as client: token_response = await client.post( - get_token_url(), + session.get_token_url(), data={ 'grant_type': 'authorization_code', - 'client_id': OIDC_CLIENT_ID, - 'client_secret': OIDC_CLIENT_SECRET, + 'client_id': session.oidc_config['client_id'], + 'client_secret': session.oidc_config['client_secret'], 'code': code, - 'redirect_uri': OIDC_REDIRECT_URI + 'redirect_uri': session.oidc_config['redirect_uri'] } ) @@ -64,7 +62,9 @@ async def callback( token_data = token_response.json() expiry = token_data['refresh_expires_in'] - set_session(session_id, token_data, expiry) + session.set(session_id, token_data, expiry) + session.track_event(session_id, 'login') + access_token = token_data['access_token'] user_info = jwt.decode(access_token, options={"verify_signature": False}) @@ -86,17 +86,20 @@ async def callback( async def logout(request: Request): session_id = request.cookies.get('session_id') - session_data = get_session(session_id) + session_data = session.get(session_id) if not session_data: return RedirectResponse('/') id_token = session_data.get('id_token', '') + # Track logout event before deleting session + session.track_event(session_id, 'logout') + # Delete the session from Redis - delete_session(session_id) + session.delete(session_id) # Create the Keycloak logout URL with redirect back to our app - logout_url = f"{OIDC_SERVER_URL}/realms/{OIDC_REALM}/protocol/openid-connect/logout" + logout_url = f"{session.oidc_config['server_url']}/realms/{session.oidc_config['realm']}/protocol/openid-connect/logout" full_logout_url = f"{logout_url}?id_token_hint={id_token}&post_logout_redirect_uri={FRONTEND_URL}" # Create a response with the logout URL and clear the session cookie @@ -147,12 +150,12 @@ async def refresh_session(request: Request): if not session_id: raise HTTPException(status_code=401, detail="No session found") - session_data = get_session(session_id) + session_data = session.get(session_id) if not session_data: raise HTTPException(status_code=401, detail="Invalid session") # Try to refresh the token - success, new_session = await refresh_token(session_id, session_data) + success, new_session = await session.refresh_token(session_id, session_data) if not success: raise HTTPException(status_code=401, detail="Failed to refresh session") diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index 738f125..c39795f 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -11,32 +11,34 @@ pad_router = APIRouter() -@pad_router.get("/test") -async def test( +@pad_router.get("/") +async def initialize_pad( user: UserSession = Depends(require_auth), session: AsyncSession = Depends(get_session) ) -> Dict[str, Any]: - # Create a test pad - test_pad = await PadStore.create_pad( - session=session, - owner_id=user.id, - display_name="Test Pad", - data={"content": "This is a test pad"} - ) + # First try to get any existing pads for the user + existing_pads = await PadStore.get_by_owner(session, user.id) - # Load the pad from database using its ID - loaded_pad = await PadStore.get_by_id(session, test_pad.id) - - if not loaded_pad: - raise HTTPException( - status_code=500, - detail="Failed to load created pad" + if existing_pads: + # User already has pads, load the first one + pad = await Pad.get_by_id(session, existing_pads[0].id) + return { + "pad": pad.to_dict(), + "is_new": False + } + else: + # Create a new pad for first-time user + new_pad = await Pad.create( + session=session, + owner_id=user.id, + display_name="My First Pad", + data={"content": "Welcome to your first pad!"} ) - - return { - "created_pad": test_pad.to_dict(), - "loaded_pad": loaded_pad.to_dict() - } + + return { + "pad": new_pad.to_dict(), + "is_new": True + } From 1264a5b3df4945e7f157ad8e4738d701a59ce9cf Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 14 May 2025 04:36:57 +0000 Subject: [PATCH 017/149] pad_data now stores appstates separately --- src/backend/domain/pad.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index c2bcc20..1297b28 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -1,10 +1,8 @@ from uuid import UUID -from typing import Dict, Any, Set, List, Optional, Callable, Union -import json -import copy -import asyncio +from typing import Dict, Any, Optional from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession +from config import default_pad from database.models.pad_model import PadStore @@ -42,14 +40,23 @@ async def create( session: AsyncSession, owner_id: UUID, display_name: str, - data: Dict[str, Any] = None + data: Dict[str, Any] = default_pad ) -> 'Pad': - """Create a new pad""" + """Create a new pad with multi-user app state support""" + # Create a deep copy of the default template + pad_data = { + "files": data.get("files", {}), + "elements": data.get("elements", []), + "appState": { + str(owner_id): data.get("appState", {}) + } + } + store = await PadStore.create_pad( session=session, owner_id=owner_id, display_name=display_name, - data=data or {} + data=pad_data ) return cls.from_store(store) From 047372a375e33f5857f80084b12e8d09442cea78 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 14 May 2025 04:51:28 +0000 Subject: [PATCH 018/149] adapted pad_data to dict --- src/backend/routers/pad_router.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index c39795f..f4ba620 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -22,8 +22,12 @@ async def initialize_pad( if existing_pads: # User already has pads, load the first one pad = await Pad.get_by_id(session, existing_pads[0].id) + pad_dict = pad.to_dict() + # Get only this user's appState + user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) + pad_dict["data"]["appState"] = user_app_state return { - "pad": pad.to_dict(), + "pad": pad_dict, "is_new": False } else: @@ -31,12 +35,14 @@ async def initialize_pad( new_pad = await Pad.create( session=session, owner_id=user.id, - display_name="My First Pad", - data={"content": "Welcome to your first pad!"} + display_name="My First Pad" ) - + pad_dict = new_pad.to_dict() + # Get only this user's appState + user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) + pad_dict["data"]["appState"] = user_app_state return { - "pad": new_pad.to_dict(), + "pad": pad_dict, "is_new": True } @@ -65,7 +71,11 @@ async def get_pad( detail="Not authorized to access this pad" ) - return pad.to_dict() + pad_dict = pad.to_dict() + # Get only this user's appState + user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) + pad_dict["data"]["appState"] = user_app_state + return pad_dict except HTTPException: raise except Exception as e: From 3c304a95799cbec1386b0bec46d3ab3f36905723 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Wed, 14 May 2025 23:42:13 +0000 Subject: [PATCH 019/149] remove coms --- src/frontend/src/hooks/useAppConfig.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/frontend/src/hooks/useAppConfig.ts b/src/frontend/src/hooks/useAppConfig.ts index 750ac3b..5a52f95 100644 --- a/src/frontend/src/hooks/useAppConfig.ts +++ b/src/frontend/src/hooks/useAppConfig.ts @@ -1,6 +1,4 @@ import { useQuery } from '@tanstack/react-query'; -// Removed: import posthog from 'posthog-js'; -// Removed: import { useEffect } from 'react'; interface AppConfig { coderUrl: string; @@ -33,9 +31,6 @@ export const useAppConfig = () => { gcTime: Infinity, // Renamed from cacheTime in v5 }); - // useEffect for posthog.init() has been removed from here. - // It will be handled in App.tsx to ensure single initialization. - return { config: data, isLoadingConfig: isLoading, From d4e997e8139b0b6f7d89d36ead2a347273937540 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Thu, 15 May 2025 00:58:27 +0000 Subject: [PATCH 020/149] basic tabs implem --- src/backend/routers/pad_router.py | 24 +++----- src/frontend/src/App.tsx | 98 ++++++++++++++++++------------- src/frontend/src/ui/TabBar.scss | 58 ++++++++++++++++++ src/frontend/src/ui/TabBar.tsx | 42 +++++++++++++ 4 files changed, 165 insertions(+), 57 deletions(-) create mode 100644 src/frontend/src/ui/TabBar.scss create mode 100644 src/frontend/src/ui/TabBar.tsx diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index f4ba620..44b9f73 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -22,29 +22,19 @@ async def initialize_pad( if existing_pads: # User already has pads, load the first one pad = await Pad.get_by_id(session, existing_pads[0].id) - pad_dict = pad.to_dict() - # Get only this user's appState - user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) - pad_dict["data"]["appState"] = user_app_state - return { - "pad": pad_dict, - "is_new": False - } + else: # Create a new pad for first-time user - new_pad = await Pad.create( + pad = await Pad.create( session=session, owner_id=user.id, display_name="My First Pad" ) - pad_dict = new_pad.to_dict() - # Get only this user's appState - user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) - pad_dict["data"]["appState"] = user_app_state - return { - "pad": pad_dict, - "is_new": True - } + + pad_dict = pad.to_dict() + user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) + pad_dict["data"]["appState"] = user_app_state + return pad_dict diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index b638ac3..2fcd1f9 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,18 +1,17 @@ import React, { useState, useEffect } from "react"; -import { Excalidraw, MainMenu } from "@atyrode/excalidraw"; +import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; import { initializePostHog } from "./utils/posthog"; import { useAuthStatus } from "./hooks/useAuthStatus"; import { useAppConfig } from "./hooks/useAppConfig"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; +import TabBar from "./ui/TabBar"; import DiscordButton from './ui/DiscordButton'; -import GitHubButton from './ui/GitHubButton'; import { MainMenuConfig } from './ui/MainMenu'; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; import SettingsDialog from './ui/SettingsDialog'; -// import { capture } from './utils/posthog'; const defaultInitialData = { elements: [], @@ -34,11 +33,21 @@ const defaultInitialData = { export default function App() { const { isAuthenticated } = useAuthStatus(); - const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); - + const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); + const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); + // Add state for tab management + const [activeTabId, setActiveTabId] = useState('tab1'); + + // Placeholder tabs data + const tabs = [ + { id: 'tab1', label: 'Canvas 1' }, + { id: 'tab2', label: 'Canvas 2' }, + { id: 'tab3', label: 'Canvas 3' } + ]; + const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; @@ -62,7 +71,7 @@ export default function App() { console.error('[pad.ws] Failed to load app config, PostHog initialization might be skipped or delayed:', configError); } }, [appConfig, configError]); - + // TODO // useEffect(() => { // if (userProfile?.id) { @@ -79,41 +88,50 @@ export default function App() { // Render Excalidraw directly with props and associated UI return ( - setExcalidrawAPI(api)} - theme="dark" - initialData={defaultInitialData} - onChange={handleOnChange} - name="Pad.ws" - onScrollChange={handleOnScrollChange} - validateEmbeddable={true} - renderEmbeddable={(element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} - renderTopRightUI={() => ( -
- {/* //TODO */} - -
- )} - > - - - {isAuthenticated === false && ( - {}} - /> - )} - - {showSettingsModal && ( - + setExcalidrawAPI(api)} + theme="dark" + initialData={defaultInitialData} + onChange={handleOnChange} + name="Pad.ws" + onScrollChange={handleOnScrollChange} + validateEmbeddable={true} + renderEmbeddable={(element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} + renderTopRightUI={() => ( +
+ +
+ )} + > + - )} -
+ + {isAuthenticated === false && ( + { }} + /> + )} + + {showSettingsModal && ( + + )} + +
+ +
+
+ ); } diff --git a/src/frontend/src/ui/TabBar.scss b/src/frontend/src/ui/TabBar.scss new file mode 100644 index 0000000..6801038 --- /dev/null +++ b/src/frontend/src/ui/TabBar.scss @@ -0,0 +1,58 @@ +.tabs-bar { + margin-inline-start: 0.6rem; + height: var(--lg-button-size); + position: relative; + display: flex; + gap: 8px; + align-items: center; + padding: 0 8px; + + .tab { + height: var(--lg-button-size) !important; + width: 100px !important; + min-width: 100px !important; + margin-right: 0.6rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; + border: none; + border-radius: var(--border-radius-lg); + background: var(--island-bg-color); + color: #bdbdbd; + font-size: 14px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; + outline: none; + + &:hover { + background: #4a4a54; + color: #ffffff; + } + + &.active-pad { + background-color: #cc6d24 !important; + color: var(--color-on-primary) !important; + font-weight: bold; + border: 1px solid #cccccc !important; + } + + &.new-tab { + min-width: var(--lg-button-size) !important; + width: var(--lg-button-size) !important; + padding: 0; + background: var(--island-bg-color); + color: #bdbdbd; + + &:hover { + color: #ffffff; + background: #4a4a54; + } + } + } +} \ No newline at end of file diff --git a/src/frontend/src/ui/TabBar.tsx b/src/frontend/src/ui/TabBar.tsx new file mode 100644 index 0000000..3821f3b --- /dev/null +++ b/src/frontend/src/ui/TabBar.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Plus } from 'lucide-react'; +import './TabBar.scss'; + +interface Tab { + id: string; + label: string; +} + +interface TabBarProps { + tabs: Tab[]; + activeTabId: string; + onTabSelect: (tabId: string) => void; + onNewTab?: () => void; +} + +const TabBar: React.FC = ({ tabs, activeTabId, onTabSelect, onNewTab }) => { + return ( +
+ {tabs.map((tab) => ( + + ))} + {onNewTab && ( + + )} +
+ ); +}; + +export default TabBar; \ No newline at end of file From e9e12713096857676ce9dc35148c6cf46d39d42d Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Thu, 15 May 2025 02:08:13 +0000 Subject: [PATCH 021/149] add hook for tab loading --- src/backend/database/models/user_model.py | 24 ++++++- src/backend/domain/user.py | 7 +- src/backend/routers/users_router.py | 25 ++++---- src/frontend/src/App.tsx | 47 ++++++-------- src/frontend/src/hooks/usePadTabs.ts | 78 +++++++++++++++++++++++ 5 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 src/frontend/src/hooks/usePadTabs.ts diff --git a/src/backend/database/models/user_model.py b/src/backend/database/models/user_model.py index 6f93ee5..fccea57 100644 --- a/src/backend/database/models/user_model.py +++ b/src/backend/database/models/user_model.py @@ -2,7 +2,7 @@ from uuid import UUID from datetime import datetime -from sqlalchemy import Column, Index, String, UUID as SQLUUID, Boolean, select, update, delete +from sqlalchemy import Column, Index, String, UUID as SQLUUID, Boolean, select, update, delete, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import relationship, Mapped from sqlalchemy.ext.asyncio import AsyncSession @@ -101,6 +101,28 @@ async def get_all(cls, session: AsyncSession) -> List['UserStore']: result = await session.execute(stmt) return result.scalars().all() + @classmethod + async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[str, Any]]: + """Get just the metadata of pads owned by the user without loading full pad data""" + from .pad_model import PadStore # Import here to avoid circular imports + + stmt = select( + PadStore.id, + PadStore.display_name, + PadStore.created_at, + PadStore.updated_at + ).where(PadStore.owner_id == user_id) + + result = await session.execute(stmt) + pads = result.all() + + return [{ + "id": str(pad.id), + "display_name": pad.display_name, + "created_at": pad.created_at.isoformat(), + "updated_at": pad.updated_at.isoformat() + } for pad in pads] + async def save(self, session: AsyncSession) -> 'UserStore': """Save the current user state""" if self.id is None: diff --git a/src/backend/domain/user.py b/src/backend/domain/user.py index 7b47d23..7247ff0 100644 --- a/src/backend/domain/user.py +++ b/src/backend/domain/user.py @@ -3,7 +3,7 @@ from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession -from ..database.models.user_model import UserStore +from database.models.user_model import UserStore class User: @@ -168,3 +168,8 @@ def to_dict(self) -> Dict[str, Any]: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat() } + + @classmethod + async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[str, Any]]: + """Get just the metadata of pads owned by the user without loading full pad data""" + return await UserStore.get_open_pads(session, user_id) diff --git a/src/backend/routers/users_router.py b/src/backend/routers/users_router.py index b6c3642..4160762 100644 --- a/src/backend/routers/users_router.py +++ b/src/backend/routers/users_router.py @@ -5,9 +5,12 @@ import posthog import jwt from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession from config import get_redis_client, get_jwks_client, OIDC_CLIENT_ID, FRONTEND_URL from dependencies import UserSession, require_admin, require_auth +from database.database import get_session +from domain.user import User users_router = APIRouter() @@ -15,8 +18,9 @@ @users_router.get("/me") async def get_user_info( user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session), ): - """Get the current user's information and sync with token data""" + """Get the current user's information and their pads""" # Create token data dictionary from UserSession properties token_data = { @@ -29,21 +33,18 @@ async def get_user_info( "roles": user.roles } - # try: - # # Sync user with token data - # user_data = await user_service.sync_user_with_token_data(user.id, token_data) - # except Exception as e: - # print(f"Error syncing user data: {str(e)}") - # raise HTTPException( - # status_code=500, - # detail=f"Error syncing user data: {e}" - # ) - raise NotImplementedError("/me Not implemented") + # Get user's pad metadata + pads = await User.get_open_pads(session, user.id) + + user_data = { + **token_data, + "pads": pads + } if os.getenv("VITE_PUBLIC_POSTHOG_KEY"): telemetry = user_data.copy() telemetry["$current_url"] = FRONTEND_URL - posthog.identify(distinct_id=user_data["id"], properties=telemetry) + posthog.identify(distinct_id=user.id, properties=telemetry) return user_data diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 2fcd1f9..21fecd0 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; import { initializePostHog } from "./utils/posthog"; import { useAuthStatus } from "./hooks/useAuthStatus"; import { useAppConfig } from "./hooks/useAppConfig"; +import { usePadTabs } from "./hooks/usePadTabs"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; import TabBar from "./ui/TabBar"; @@ -34,20 +35,15 @@ const defaultInitialData = { export default function App() { const { isAuthenticated } = useAuthStatus(); const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); + const { + tabs, + activeTabId, + isLoading: isLoadingTabs, + } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - // Add state for tab management - const [activeTabId, setActiveTabId] = useState('tab1'); - - // Placeholder tabs data - const tabs = [ - { id: 'tab1', label: 'Canvas 1' }, - { id: 'tab2', label: 'Canvas 2' }, - { id: 'tab3', label: 'Canvas 3' } - ]; - const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; @@ -57,10 +53,21 @@ export default function App() { }; const handleOnScrollChange = (scrollX: number, scrollY: number) => { - // TODO lockEmbeddables(excalidrawAPI?.getAppState()); }; + const handleTabSelect = (tabId: string) => { + // Find the selected tab + const selectedTab = tabs.find(tab => tab.id === tabId); + if (selectedTab) { + // TODO: Load pad content when needed + // For now, just initialize with default data + if (excalidrawAPI) { + excalidrawAPI.updateScene(defaultInitialData); + } + } + }; + useEffect(() => { if (appConfig && appConfig.posthogKey && appConfig.posthogHost) { initializePostHog({ @@ -72,20 +79,6 @@ export default function App() { } }, [appConfig, configError]); - // TODO - // useEffect(() => { - // if (userProfile?.id) { - // posthog.identify(userProfile.id); - // if (posthog.people && typeof posthog.people.set === "function") { - // const { - // id, // do not include in properties - // ...personProps - // } = userProfile; - // posthog.people.set(personProps); - // } - // } - // }, [userProfile]); - // Render Excalidraw directly with props and associated UI return ( <> @@ -126,9 +119,9 @@ export default function App() {
({ id: tab.id, label: tab.title }))} activeTabId={activeTabId} - onTabSelect={setActiveTabId} + onTabSelect={handleTabSelect} />
diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts new file mode 100644 index 0000000..219412b --- /dev/null +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -0,0 +1,78 @@ +import { useQuery } from '@tanstack/react-query'; + +interface Tab { + id: string; + title: string; + content: string; + createdAt: string; + updatedAt: string; +} + +interface PadResponse { + tabs: Tab[]; + activeTabId: string; +} + +interface UserResponse { + username: string; + email: string; + email_verified: boolean; + name: string; + given_name: string; + family_name: string; + roles: string[]; + pads: { + id: string; + owner_id: string; + display_name: string; + data: any; + created_at: string; + updated_at: string; + }[]; +} + +const fetchUserPads = async (): Promise => { + const response = await fetch('/api/users/me'); + if (!response.ok) { + let errorMessage = 'Failed to fetch user pads.'; + try { + const errorData = await response.json(); + if (errorData && errorData.message) { + errorMessage = errorData.message; + } + } catch (e) { + // Ignore if error response is not JSON or empty + } + throw new Error(errorMessage); + } + const userData: UserResponse = await response.json(); + + // Transform pads into tabs format + const tabs = userData.pads.map(pad => ({ + id: pad.id, + title: pad.display_name, + content: JSON.stringify(pad.data), + createdAt: pad.created_at, + updatedAt: pad.updated_at + })); + + return { + tabs, + activeTabId: tabs[0]?.id || '' + }; +}; + +export const usePadTabs = () => { + const { data, isLoading, error, isError } = useQuery({ + queryKey: ['padTabs'], + queryFn: fetchUserPads, + }); + + return { + tabs: data?.tabs ?? [], + activeTabId: data?.activeTabId, + isLoading, + error, + isError + }; +}; \ No newline at end of file From ee5007bd057263ec3bf6affc91f34a2e2893f7e8 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Thu, 15 May 2025 02:49:43 +0000 Subject: [PATCH 022/149] add new pad button --- src/backend/routers/pad_router.py | 19 ++++++++++++++- src/frontend/src/App.tsx | 3 +++ src/frontend/src/hooks/usePadTabs.ts | 28 +++++++++++++++++----- src/frontend/src/icons/NewPadIcon.tsx | 34 +++++++++++++++++++++++++++ src/frontend/src/icons/index.ts | 1 + src/frontend/src/ui/TabBar.tsx | 6 ++--- 6 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 src/frontend/src/icons/NewPadIcon.tsx diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index 44b9f73..ff4bc48 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -36,7 +36,24 @@ async def initialize_pad( pad_dict["data"]["appState"] = user_app_state return pad_dict - +@pad_router.post("/new") +async def create_new_pad( + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + """Create a new pad for the authenticated user""" + try: + pad = await Pad.create( + session=session, + owner_id=user.id, + display_name="New Pad" + ) + return pad.to_dict() + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to create new pad: {str(e)}" + ) @pad_router.get("/{pad_id}") async def get_pad( diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 21fecd0..6881da2 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -39,6 +39,8 @@ export default function App() { tabs, activeTabId, isLoading: isLoadingTabs, + createNewPad, + isCreating } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); @@ -122,6 +124,7 @@ export default function App() { tabs={tabs.map(tab => ({ id: tab.id, label: tab.title }))} activeTabId={activeTabId} onTabSelect={handleTabSelect} + onNewTab={createNewPad} /> diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 219412b..782101c 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -1,9 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; interface Tab { id: string; title: string; - content: string; createdAt: string; updatedAt: string; } @@ -23,9 +22,7 @@ interface UserResponse { roles: string[]; pads: { id: string; - owner_id: string; display_name: string; - data: any; created_at: string; updated_at: string; }[]; @@ -51,7 +48,6 @@ const fetchUserPads = async (): Promise => { const tabs = userData.pads.map(pad => ({ id: pad.id, title: pad.display_name, - content: JSON.stringify(pad.data), createdAt: pad.created_at, updatedAt: pad.updated_at })); @@ -62,17 +58,37 @@ const fetchUserPads = async (): Promise => { }; }; +const createNewPad = async (): Promise => { + const response = await fetch('/api/pad/new', { + method: 'POST', + }); + if (!response.ok) { + throw new Error('Failed to create new pad'); + } +}; + export const usePadTabs = () => { + const queryClient = useQueryClient(); + const { data, isLoading, error, isError } = useQuery({ queryKey: ['padTabs'], queryFn: fetchUserPads, }); + const createPadMutation = useMutation({ + mutationFn: createNewPad, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['padTabs'] }); + }, + }); + return { tabs: data?.tabs ?? [], activeTabId: data?.activeTabId, isLoading, error, - isError + isError, + createNewPad: createPadMutation.mutate, + isCreating: createPadMutation.isPending }; }; \ No newline at end of file diff --git a/src/frontend/src/icons/NewPadIcon.tsx b/src/frontend/src/icons/NewPadIcon.tsx new file mode 100644 index 0000000..51c561d --- /dev/null +++ b/src/frontend/src/icons/NewPadIcon.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface NewPadIconProps { + className?: string; + width?: number; + height?: number; +} + +export const NewPadIcon: React.FC = ({ + className = "", + width = 20, + height = 20, +}) => { + return ( + + + + + + + ); +}; + +export default NewPadIcon; \ No newline at end of file diff --git a/src/frontend/src/icons/index.ts b/src/frontend/src/icons/index.ts index 05d4ba1..9f70ef4 100644 --- a/src/frontend/src/icons/index.ts +++ b/src/frontend/src/icons/index.ts @@ -1,3 +1,4 @@ export { default as GoogleIcon } from './GoogleIcon'; export { default as GithubIcon } from './GithubIcon'; export { default as DiscordIcon } from './DiscordIcon'; +export { default as NewPadIcon } from './NewPadIcon'; diff --git a/src/frontend/src/ui/TabBar.tsx b/src/frontend/src/ui/TabBar.tsx index 3821f3b..a161553 100644 --- a/src/frontend/src/ui/TabBar.tsx +++ b/src/frontend/src/ui/TabBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Plus } from 'lucide-react'; +import { NewPadIcon } from '../icons'; import './TabBar.scss'; interface Tab { @@ -30,9 +30,9 @@ const TabBar: React.FC = ({ tabs, activeTabId, onTabSelect, onNewTa )}
From 342c4b1f966e56025825b980e4c45ec9b9f0d67e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Thu, 15 May 2025 04:15:20 +0000 Subject: [PATCH 023/149] load pad from api --- src/backend/routers/pad_router.py | 8 ++--- src/frontend/src/App.tsx | 50 +++++++++++++-------------- src/frontend/src/hooks/usePadData.ts | 51 ++++++++++++++++++++++++++++ src/frontend/src/hooks/usePadTabs.ts | 30 ++++++++++++++-- 4 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 src/frontend/src/hooks/usePadData.ts diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index ff4bc48..1b5a309 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -34,7 +34,7 @@ async def initialize_pad( pad_dict = pad.to_dict() user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) pad_dict["data"]["appState"] = user_app_state - return pad_dict + return pad_dict["data"] @pad_router.post("/new") async def create_new_pad( @@ -48,7 +48,7 @@ async def create_new_pad( owner_id=user.id, display_name="New Pad" ) - return pad.to_dict() + return pad.to_dict()["data"] except Exception as e: raise HTTPException( status_code=500, @@ -59,7 +59,7 @@ async def create_new_pad( async def get_pad( pad_id: UUID, user: UserSession = Depends(require_auth), - session: AsyncSession = Depends(lambda: AsyncSession()) + session: AsyncSession = Depends(get_session) ) -> Dict[str, Any]: """Get a specific pad for the authenticated user""" try: @@ -82,7 +82,7 @@ async def get_pad( # Get only this user's appState user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) pad_dict["data"]["appState"] = user_app_state - return pad_dict + return pad_dict["data"] except HTTPException: raise except Exception as e: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 6881da2..e2b8a0b 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,19 +1,25 @@ import React, { useState, useEffect } from "react"; import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; -import { initializePostHog } from "./utils/posthog"; +import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; +import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; + +// Hooks import { useAuthStatus } from "./hooks/useAuthStatus"; import { useAppConfig } from "./hooks/useAppConfig"; import { usePadTabs } from "./hooks/usePadTabs"; -import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; -import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; -import TabBar from "./ui/TabBar"; +import { usePad } from "./hooks/usePadData"; +// Components +import TabBar from "./ui/TabBar"; import DiscordButton from './ui/DiscordButton'; import { MainMenuConfig } from './ui/MainMenu'; -import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; import SettingsDialog from './ui/SettingsDialog'; +// Utils +import { initializePostHog } from "./utils/posthog"; +import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; + const defaultInitialData = { elements: [], appState: { @@ -37,51 +43,45 @@ export default function App() { const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); const { tabs, - activeTabId, + selectedTabId, isLoading: isLoadingTabs, createNewPad, - isCreating + isCreating, + selectTab } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); + const { padData } = usePad(selectedTabId, excalidrawAPI); + const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; const handleOnChange = (elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - // TODO + // TODO: Implement change handling }; const handleOnScrollChange = (scrollX: number, scrollY: number) => { lockEmbeddables(excalidrawAPI?.getAppState()); }; - const handleTabSelect = (tabId: string) => { - // Find the selected tab - const selectedTab = tabs.find(tab => tab.id === tabId); - if (selectedTab) { - // TODO: Load pad content when needed - // For now, just initialize with default data - if (excalidrawAPI) { - excalidrawAPI.updateScene(defaultInitialData); - } - } + const handleTabSelect = async (tabId: string) => { + await selectTab(tabId); }; useEffect(() => { - if (appConfig && appConfig.posthogKey && appConfig.posthogHost) { + if (appConfig?.posthogKey && appConfig?.posthogHost) { initializePostHog({ posthogKey: appConfig.posthogKey, posthogHost: appConfig.posthogHost, }); } else if (configError) { - console.error('[pad.ws] Failed to load app config, PostHog initialization might be skipped or delayed:', configError); + console.error('[pad.ws] Failed to load app config:', configError); } }, [appConfig, configError]); - // Render Excalidraw directly with props and associated UI return ( <> - {isAuthenticated === false && ( - { }} - /> + {!isAuthenticated && ( + { }} /> )} {showSettingsModal && ( @@ -122,7 +120,7 @@ export default function App() {
({ id: tab.id, label: tab.title }))} - activeTabId={activeTabId} + activeTabId={selectedTabId} onTabSelect={handleTabSelect} onNewTab={createNewPad} /> diff --git a/src/frontend/src/hooks/usePadData.ts b/src/frontend/src/hooks/usePadData.ts new file mode 100644 index 0000000..f54d0c9 --- /dev/null +++ b/src/frontend/src/hooks/usePadData.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; +import type { ExcalidrawElement } from "@atyrode/excalidraw/element/types"; +import { normalizeCanvasData } from '../utils/canvasUtils'; + +interface PadData { + elements?: readonly ExcalidrawElement[]; + appState?: Pick; + files?: Record; +} + +const fetchPadById = async (padId: string): Promise => { + const response = await fetch(`/api/pad/${padId}`); + if (!response.ok) { + let errorMessage = 'Failed to fetch pad data.'; + try { + const errorData = await response.json(); + if (errorData && errorData.detail) { + errorMessage = errorData.detail; + } + } catch (e) { + // Ignore if error response is not JSON or empty + } + throw new Error(errorMessage); + } + return response.json(); +}; + +export const usePad = (padId: string, excalidrawAPI: ExcalidrawImperativeAPI | null) => { + const { data, isLoading, error, isError } = useQuery({ + queryKey: ['pad', padId], + queryFn: () => fetchPadById(padId), + enabled: !!padId, // Only run the query if padId is provided + }); + + useEffect(() => { + if (data && excalidrawAPI) { + const normalizedData = normalizeCanvasData(data); + console.log(`[Pad] Loading pad ${padId}`); + excalidrawAPI.updateScene(normalizedData); + } + }, [data, excalidrawAPI, padId]); + + return { + padData: data, + isLoading, + error, + isError + }; +}; \ No newline at end of file diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 782101c..f216d6f 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -1,4 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; interface Tab { id: string; @@ -69,12 +70,20 @@ const createNewPad = async (): Promise => { export const usePadTabs = () => { const queryClient = useQueryClient(); + const [selectedTabId, setSelectedTabId] = useState(''); const { data, isLoading, error, isError } = useQuery({ queryKey: ['padTabs'], - queryFn: fetchUserPads, + queryFn: fetchUserPads }); + // Set initial selected tab when data is loaded + useEffect(() => { + if (data?.tabs.length && !selectedTabId) { + setSelectedTabId(data.tabs[0].id); + } + }, [data, selectedTabId]); + const createPadMutation = useMutation({ mutationFn: createNewPad, onSuccess: () => { @@ -82,13 +91,28 @@ export const usePadTabs = () => { }, }); + const selectTab = async (tabId: string) => { + setSelectedTabId(tabId); + try { + const response = await fetch(`/api/pad/${tabId}`); + if (!response.ok) { + throw new Error('Failed to fetch pad data'); + } + const padData = await response.json(); + queryClient.setQueryData(['pad', tabId], padData); + } catch (error) { + console.error('Error fetching pad data:', error); + } + }; + return { tabs: data?.tabs ?? [], - activeTabId: data?.activeTabId, + selectedTabId: selectedTabId || data?.activeTabId || '', isLoading, error, isError, createNewPad: createPadMutation.mutate, - isCreating: createPadMutation.isPending + isCreating: createPadMutation.isPending, + selectTab }; }; \ No newline at end of file From 94e8de41c1c1e22014241180b5c0e77789859ac0 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 06:25:29 +0000 Subject: [PATCH 024/149] removed double fetch on tab select --- src/frontend/src/hooks/usePadTabs.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index f216d6f..4fd4810 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -93,16 +93,6 @@ export const usePadTabs = () => { const selectTab = async (tabId: string) => { setSelectedTabId(tabId); - try { - const response = await fetch(`/api/pad/${tabId}`); - if (!response.ok) { - throw new Error('Failed to fetch pad data'); - } - const padData = await response.json(); - queryClient.setQueryData(['pad', tabId], padData); - } catch (error) { - console.error('Error fetching pad data:', error); - } }; return { From 563844a90675cfabbbef2f70edaead55a09078ef Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 06:48:07 +0000 Subject: [PATCH 025/149] cache pads in redis --- src/backend/domain/pad.py | 84 ++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 1297b28..e26457d 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -2,7 +2,8 @@ from typing import Dict, Any, Optional from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession -from config import default_pad +from config import default_pad, get_redis_client +import json from database.models.pad_model import PadStore @@ -16,6 +17,9 @@ class Pad: synchronization and database persistence. """ + # Cache expiration time in seconds (1 hour) + CACHE_EXPIRY = 3600 + def __init__( self, id: UUID, @@ -33,6 +37,7 @@ def __init__( self.created_at = created_at or datetime.now() self.updated_at = updated_at or datetime.now() self._store = store + self._redis = get_redis_client() @classmethod async def create( @@ -58,19 +63,51 @@ async def create( display_name=display_name, data=pad_data ) - return cls.from_store(store) + pad = cls.from_store(store) + await pad.cache() + return pad @classmethod async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad']: - """Get a pad by ID""" + """Get a pad by ID, first trying Redis cache then falling back to database""" + # Try to get from Redis cache first + redis_client = get_redis_client() + cache_key = f"pad:{pad_id}" + + # Check if the hash exists + if redis_client.exists(cache_key): + try: + # Get all fields from the hash + data = redis_client.hgetall(cache_key) + if data: + return cls( + id=UUID(data['id']), + owner_id=UUID(data['owner_id']), + display_name=data['display_name'], + data=json.loads(data['data']), + created_at=datetime.fromisoformat(data['created_at']), + updated_at=datetime.fromisoformat(data['updated_at']) + ) + except (json.JSONDecodeError, KeyError, ValueError): + print(f"Error parsing cached pad data, id: {cache_key}") + + # Fall back to database store = await PadStore.get_by_id(session, pad_id) - return cls.from_store(store) if store else None + if store: + pad = cls.from_store(store) + await pad.cache() # Cache for future use + return pad + return None @classmethod async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> list['Pad']: """Get all pads for a specific owner""" stores = await PadStore.get_by_owner(session, owner_id) - return [cls.from_store(store) for store in stores] + pads = [cls.from_store(store) for store in stores] + # Cache all pads + for pad in pads: + await pad.cache() + return pads @classmethod def from_store(cls, store: PadStore) -> 'Pad': @@ -86,7 +123,7 @@ def from_store(cls, store: PadStore) -> 'Pad': ) async def save(self, session: AsyncSession) -> 'Pad': - """Save the pad to the database""" + """Save the pad to the database and update cache""" if not self._store: self._store = PadStore( id=self.id, @@ -105,22 +142,51 @@ async def save(self, session: AsyncSession) -> 'Pad': self.id = self._store.id self.created_at = self._store.created_at self.updated_at = self._store.updated_at + + # Update cache + await self.cache() return self async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'Pad': - """Update the pad's data""" + """Update the pad's data and refresh cache""" self.data = data self.updated_at = datetime.now() if self._store: self._store = await self._store.update_data(session, data) + await self.cache() return self async def delete(self, session: AsyncSession) -> bool: - """Delete the pad""" + """Delete the pad from both database and cache""" if self._store: - return await self._store.delete(session) + success = await self._store.delete(session) + if success: + await self.invalidate_cache() + return success return False + async def cache(self) -> None: + """Cache the pad data in Redis using hash structure""" + cache_key = f"pad:{self.id}" + + # Store each field separately in the hash + cache_data = { + 'id': str(self.id), + 'owner_id': str(self.owner_id), + 'display_name': self.display_name, + 'data': json.dumps(self.data), + 'created_at': self.created_at.isoformat(), + 'updated_at': self.updated_at.isoformat() + } + + self._redis.hset(cache_key, mapping=cache_data) + self._redis.expire(cache_key, self.CACHE_EXPIRY) + + async def invalidate_cache(self) -> None: + """Remove the pad from Redis cache""" + cache_key = f"pad:{self.id}" + self._redis.delete(cache_key) + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary representation""" return { From 957b4fe90c385e4a5d20eddd83c8b43fb2f3ca0b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 07:38:00 +0000 Subject: [PATCH 026/149] websocket basic implem --- src/backend/domain/pad.py | 23 +++ src/backend/main.py | 2 + src/backend/requirements.txt | 5 +- src/backend/routers/ws_router.py | 169 ++++++++++++++++++++++ src/frontend/src/App.tsx | 9 +- src/frontend/src/hooks/usePadWebSocket.ts | 108 ++++++++++++++ 6 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/backend/routers/ws_router.py create mode 100644 src/frontend/src/hooks/usePadWebSocket.ts diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index e26457d..7cb4a74 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -156,6 +156,29 @@ async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'Pad await self.cache() return self + async def broadcast_event(self, event_type: str, event_data: Dict[str, Any]) -> None: + """Broadcast an event to all connected clients""" + stream_key = f"pad:stream:{self.id}" + message = { + "type": event_type, + "pad_id": str(self.id), + "data": event_data, + "timestamp": datetime.now().isoformat() + } + self._redis.xadd(stream_key, message) + + async def get_stream_position(self) -> str: + """Get the current position in the pad's stream""" + stream_key = f"pad:stream:{self.id}" + info = self._redis.xinfo_stream(stream_key) + return info.get("last-generated-id", "0-0") + + async def get_recent_events(self, count: int = 100) -> list[Dict[str, Any]]: + """Get recent events from the pad's stream""" + stream_key = f"pad:stream:{self.id}" + messages = self._redis.xrevrange(stream_key, count=count) + return [msg[1] for msg in messages] + async def delete(self, session: AsyncSession) -> bool: """Delete the pad from both database and cache""" if self._store: diff --git a/src/backend/main.py b/src/backend/main.py index 367b246..331aa9c 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -17,6 +17,7 @@ from routers.workspace_router import workspace_router from routers.pad_router import pad_router from routers.app_router import app_router +from routers.ws_router import ws_router # Initialize PostHog if API key is available if POSTHOG_API_KEY: @@ -65,6 +66,7 @@ async def read_root(request: Request, auth: Optional[UserSession] = Depends(opti app.include_router(workspace_router, prefix="/api/workspace") app.include_router(pad_router, prefix="/api/pad") app.include_router(app_router, prefix="/api/app") +app.include_router(ws_router) # WebSocket router doesn't need /api prefix if __name__ == "__main__": import uvicorn diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index d82093d..eb93c29 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -1,5 +1,5 @@ fastapi -uvicorn +uvicorn[standard] httpx jinja2 asyncpg @@ -11,4 +11,5 @@ posthog redis psycopg2-binary python-multipart -cryptography # Required for JWT key handling +websockets +cryptography diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py new file mode 100644 index 0000000..b280554 --- /dev/null +++ b/src/backend/routers/ws_router.py @@ -0,0 +1,169 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Cookie +from typing import Dict, Set, Optional +from uuid import UUID +import json +import asyncio +from config import get_redis_client +from dependencies import UserSession, session +from datetime import datetime + +ws_router = APIRouter() + +# Store active WebSocket connections +active_connections: Dict[UUID, Set[WebSocket]] = {} + +async def get_ws_user(websocket: WebSocket) -> Optional[UserSession]: + """WebSocket-specific authentication dependency""" + # Get session ID from cookies + session_id = websocket.cookies.get('session_id') + if not session_id: + return None + + # Get session data from Redis + session_data = session.get(session_id) + if not session_data: + return None + + # Handle token expiration + if session.is_token_expired(session_data): + # Try to refresh the token + success, new_session = await session.refresh_token(session_id, session_data) + if not success: + return None + session_data = new_session + + # Create user session object + return UserSession( + access_token=session_data.get('access_token'), + token_data=session_data + ) + +async def cleanup_connection(pad_id: UUID, websocket: WebSocket): + """Clean up WebSocket connection and remove from active connections""" + # Remove from active connections first to prevent any race conditions + if pad_id in active_connections: + active_connections[pad_id].discard(websocket) + if not active_connections[pad_id]: + del active_connections[pad_id] + + # Only try to close if the connection is still open + try: + if websocket.client_state.CONNECTED: + await websocket.close() + except Exception as e: + # Ignore "connection already closed" errors + if "already completed" not in str(e) and "close message has been sent" not in str(e): + print(f"Error closing WebSocket connection: {e}") + +async def handle_redis_messages(websocket: WebSocket, pad_id: UUID, redis_client, stream_key: str): + """Handle Redis stream messages asynchronously""" + try: + # Get the last message ID to start from + last_id = "0" + + while websocket.client_state.CONNECTED: + # Get messages from Redis stream with a timeout + messages = await asyncio.to_thread( + redis_client.xread, + {stream_key: last_id}, + block=1000 + ) + + if messages and websocket.client_state.CONNECTED: + for stream, stream_messages in messages: + for message_id, message_data in stream_messages: + # Update last_id to avoid processing the same message twice + last_id = message_id + + # Forward message to all connected clients + for connection in active_connections[pad_id].copy(): + try: + if connection.client_state.CONNECTED: + await connection.send_json(message_data) + except (WebSocketDisconnect, Exception) as e: + if "close message has been sent" not in str(e): + print(f"Error sending message to client: {e}") + await cleanup_connection(pad_id, connection) + except Exception as e: + print(f"Error in Redis message handling: {e}") + finally: + await cleanup_connection(pad_id, websocket) + +@ws_router.websocket("/ws/pad/{pad_id}") +async def websocket_endpoint( + websocket: WebSocket, + pad_id: UUID, + user: Optional[UserSession] = Depends(get_ws_user) +): + """WebSocket endpoint for real-time pad updates""" + if not user: + await websocket.close(code=4001, reason="Authentication required") + return + + await websocket.accept() + + # Add connection to active connections + if pad_id not in active_connections: + active_connections[pad_id] = set() + active_connections[pad_id].add(websocket) + + # Subscribe to Redis stream for this pad + redis_client = get_redis_client() + stream_key = f"pad:stream:{pad_id}" + + try: + # Send initial connection success + if websocket.client_state.CONNECTED: + await websocket.send_json({ + "type": "connected", + "pad_id": str(pad_id), + "user_id": str(user.id) + }) + + # Broadcast user joined message + join_message = { + "type": "user_joined", + "pad_id": str(pad_id), + "user_id": str(user.id), + "timestamp": datetime.now().isoformat() + } + redis_client.xadd(stream_key, join_message) + + # Start Redis message handling in a separate task + redis_task = asyncio.create_task(handle_redis_messages(websocket, pad_id, redis_client, stream_key)) + + # Wait for WebSocket disconnect + while websocket.client_state.CONNECTED: + try: + # Keep the connection alive and handle any incoming messages + data = await websocket.receive_text() + # Handle any client messages here if needed + except WebSocketDisconnect: + break + except Exception as e: + print(f"Error in WebSocket connection: {e}") + break + + except Exception as e: + print(f"Error in WebSocket endpoint: {e}") + finally: + # Clean up + redis_task.cancel() + try: + await redis_task + except asyncio.CancelledError: + pass + + # Broadcast user left message before cleanup + try: + leave_message = { + "type": "user_left", + "pad_id": str(pad_id), + "user_id": str(user.id), + "timestamp": datetime.now().isoformat() + } + redis_client.xadd(stream_key, leave_message) + except Exception as e: + print(f"Error broadcasting leave message: {e}") + + await cleanup_connection(pad_id, websocket) \ No newline at end of file diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index e2b8a0b..b0eef87 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { useAuthStatus } from "./hooks/useAuthStatus"; import { useAppConfig } from "./hooks/useAppConfig"; import { usePadTabs } from "./hooks/usePadTabs"; import { usePad } from "./hooks/usePadData"; +import { usePadWebSocket } from "./hooks/usePadWebSocket"; // Components import TabBar from "./ui/TabBar"; @@ -54,13 +55,19 @@ export default function App() { const [excalidrawAPI, setExcalidrawAPI] = useState(null); const { padData } = usePad(selectedTabId, excalidrawAPI); + const { sendMessage, isConnected } = usePadWebSocket(selectedTabId); const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; const handleOnChange = (elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - // TODO: Implement change handling + if (isConnected && selectedTabId) { + sendMessage('pad_update', { + elements, + appState: state + }); + } }; const handleOnScrollChange = (scrollX: number, scrollY: number) => { diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts new file mode 100644 index 0000000..13defc6 --- /dev/null +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useAuthStatus } from './useAuthStatus'; + +interface WebSocketMessage { + type: string; + pad_id: string; + data: any; + timestamp: string; + user_id?: string; +} + +export const usePadWebSocket = (padId: string | null) => { + const wsRef = useRef(null); + const { user } = useAuthStatus(); + + const connect = useCallback(() => { + if (!padId || !user) return; + + // Close existing connection if any + if (wsRef.current) { + console.log(`[WebSocket] Closing connection to previous pad`); + wsRef.current.close(); + wsRef.current = null; + } + + // Determine WebSocket protocol based on current page protocol + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pad/${padId}`); + wsRef.current = ws; + + ws.onopen = () => { + console.log(`[WebSocket] Connected to pad ${padId}`); + }; + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + console.log(`[WebSocket] Received message:`, message); + + // Handle different message types here + switch (message.type) { + case 'connected': + console.log(`[WebSocket] Successfully connected to pad ${message.pad_id}`); + break; + case 'user_joined': + console.log(`[WebSocket] User ${message.user_id} joined pad ${message.pad_id}`); + break; + case 'user_left': + console.log(`[WebSocket] User ${message.user_id} left pad ${message.pad_id}`); + break; + case 'pad_update': + console.log(`[WebSocket] Pad ${message.pad_id} updated by user ${message.user_id}`); + break; + default: + // Default handler for any message type + console.log(`[WebSocket] Received ${message.type} message:`, { + pad_id: message.pad_id, + user_id: message.user_id, + timestamp: message.timestamp, + data: message.data + }); + } + } catch (error) { + console.error('[WebSocket] Error parsing message:', error); + } + }; + + ws.onerror = (error) => { + console.error('[WebSocket] Error:', error); + }; + + ws.onclose = () => { + console.log(`[WebSocket] Disconnected from pad ${padId}`); + wsRef.current = null; + }; + + return () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + }, [padId, user]); + + // Connect when padId changes + useEffect(() => { + const cleanup = connect(); + return () => { + cleanup?.(); + }; + }, [connect]); + + // Function to send messages through WebSocket + const sendMessage = useCallback((type: string, data: any) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type, + pad_id: padId, + data, + timestamp: new Date().toISOString() + })); + } + }, [padId]); + + return { + sendMessage, + isConnected: wsRef.current?.readyState === WebSocket.OPEN + }; +}; \ No newline at end of file From 8a2e6427c60a55ec48b11f15825cb3037fea1314 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 09:37:53 +0000 Subject: [PATCH 027/149] redis async --- src/backend/config.py | 92 +++++++++------ src/backend/dependencies.py | 28 +++-- src/backend/domain/pad.py | 111 ++++++++++++------ src/backend/domain/session.py | 174 ++++++++++++++++++++++------ src/backend/main.py | 15 +-- src/backend/routers/auth_router.py | 56 ++++++--- src/backend/routers/users_router.py | 77 +++++++----- src/backend/routers/ws_router.py | 153 +++++++++++++----------- 8 files changed, 462 insertions(+), 244 deletions(-) diff --git a/src/backend/config.py b/src/backend/config.py index 6cac305..938752f 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -2,7 +2,7 @@ import json import time import httpx -from redis import ConnectionPool, Redis +from redis import asyncio as aioredis import jwt from jwt.jwks_client import PyJWKClient from typing import Optional, Dict, Any, Tuple @@ -36,32 +36,55 @@ REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}" -# Create a Redis connection pool -redis_pool = ConnectionPool( - host=REDIS_HOST, - password=REDIS_PASSWORD, - port=REDIS_PORT, - db=0, - decode_responses=True, - max_connections=10, # Adjust based on your application's needs - socket_timeout=5.0, - socket_connect_timeout=1.0, - health_check_interval=30 -) - -# Create a Redis client that uses the connection pool -redis_client = Redis(connection_pool=redis_pool) - - -default_pad = {} +class RedisService: + """Service for managing Redis connections with proper lifecycle management.""" + + _instance = None + + @classmethod + async def get_instance(cls) -> aioredis.Redis: + """Get or create a Redis client instance.""" + if cls._instance is None: + cls._instance = cls() + await cls._instance.initialize() + return cls._instance.client + + def __init__(self): + self.client = None + + async def initialize(self) -> None: + """Initialize the Redis client.""" + self.client = aioredis.from_url( + REDIS_URL, + password=REDIS_PASSWORD, + decode_responses=True, + health_check_interval=30 + ) + + async def close(self) -> None: + """Close the Redis client connection.""" + if self.client: + await self.client.close() + self.client = None + print("Redis client closed.") + +# Simplified functions to maintain backwards compatibility +async def get_redis_client() -> aioredis.Redis: + """Get a Redis client. Creates one if it doesn't exist.""" + return await RedisService.get_instance() + +async def close_redis_client() -> None: + """Close the Redis client connection.""" + if RedisService._instance: + await RedisService._instance.close() + RedisService._instance = None + +default_pad = {} with open("templates/default.json", 'r') as f: default_pad = json.load(f) -def get_redis_client(): - """Get a Redis client from the connection pool""" - return Redis(connection_pool=redis_pool) - # ===== Coder API Configuration ===== CODER_API_KEY = os.getenv("CODER_API_KEY") CODER_URL = os.getenv("CODER_URL") @@ -73,27 +96,27 @@ def get_redis_client(): _jwks_client = None # Session management functions -def get_session(session_id: str) -> Optional[Dict[str, Any]]: +async def get_session(session_id: str) -> Optional[Dict[str, Any]]: """Get session data from Redis""" - client = get_redis_client() - session_data = client.get(f"session:{session_id}") + client = await get_redis_client() + session_data = await client.get(f"session:{session_id}") if session_data: return json.loads(session_data) return None -def set_session(session_id: str, data: Dict[str, Any], expiry: int) -> None: +async def set_session(session_id: str, data: Dict[str, Any], expiry: int) -> None: """Store session data in Redis with expiry in seconds""" - client = get_redis_client() - client.setex( - f"session:{session_id}", + client = await get_redis_client() + await client.setex( + f"session:{session_id}", expiry, json.dumps(data) ) -def delete_session(session_id: str) -> None: +async def delete_session(session_id: str) -> None: """Delete session data from Redis""" - client = get_redis_client() - client.delete(f"session:{session_id}") + client = await get_redis_client() + await client.delete(f"session:{session_id}") def get_auth_url() -> str: """Generate the authentication URL for Keycloak login""" @@ -172,12 +195,13 @@ async def refresh_token(session_id: str, token_data: Dict[str, Any]) -> Tuple[bo # Update session with new tokens expiry = new_token_data['refresh_expires_in'] - set_session(session_id, new_token_data, expiry) + await set_session(session_id, new_token_data, expiry) return True, new_token_data except Exception as e: print(f"Error refreshing token: {str(e)}") return False, token_data + def get_jwks_client(): """Get or create a PyJWKClient for token verification""" global _jwks_client diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 27c7886..079f60b 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -9,8 +9,7 @@ from domain.session import Session from coder import CoderAPI -# Initialize session domain -redis_client = get_redis_client() +# oidc_config for session creation and user sessions oidc_config = { 'server_url': os.getenv('OIDC_SERVER_URL'), 'realm': os.getenv('OIDC_REALM'), @@ -18,20 +17,25 @@ 'client_secret': os.getenv('OIDC_CLIENT_SECRET'), 'redirect_uri': os.getenv('REDIRECT_URI') } -session = Session(redis_client, oidc_config) + +async def get_session_domain() -> Session: + """Get a Session domain instance for the current request.""" + redis_client = await get_redis_client() + return Session(redis_client, oidc_config) class UserSession: """ Unified user session model that integrates authentication data with user information. This provides a single interface for accessing both token data and user details. """ - def __init__(self, access_token: str, token_data: dict, user_id: UUID = None): + def __init__(self, access_token: str, token_data: dict, session_domain: Session, user_id: UUID = None): self.access_token = access_token self._user_data = None + self._session_domain = session_domain # Get the signing key and decode with verification try: - jwks_client = session._get_jwks_client() + jwks_client = self._session_domain._get_jwks_client() signing_key = jwks_client.get_signing_key_from_jwt(access_token) self.token_data = jwt.decode( @@ -109,27 +113,31 @@ async def __call__(self, request: Request) -> Optional[UserSession]: # Get session ID from cookies session_id = request.cookies.get('session_id') + # Get session domain instance + current_session_domain = await get_session_domain() + # Handle missing session ID if not session_id: return self._handle_auth_error("Not authenticated") # Get session data from Redis - session_data = session.get(session_id) + session_data = await current_session_domain.get(session_id) if not session_data: return self._handle_auth_error("Not authenticated") # Handle token expiration - if session.is_token_expired(session_data): + if current_session_domain.is_token_expired(session_data): # Try to refresh the token - success, new_session = await session.refresh_token(session_id, session_data) + success, new_session_data = await current_session_domain.refresh_token(session_id, session_data) if not success: return self._handle_auth_error("Session expired") - session_data = new_session + session_data = new_session_data # Create user session object user_session = UserSession( access_token=session_data.get('access_token'), - token_data=session_data + token_data=session_data, + session_domain=current_session_domain ) # Check admin requirement if specified diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 7cb4a74..9c3a6b7 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -6,6 +6,7 @@ import json from database.models.pad_model import PadStore +from redis.asyncio import Redis as AsyncRedis class Pad: @@ -37,7 +38,6 @@ def __init__( self.created_at = created_at or datetime.now() self.updated_at = updated_at or datetime.now() self._store = store - self._redis = get_redis_client() @classmethod async def create( @@ -64,38 +64,46 @@ async def create( data=pad_data ) pad = cls.from_store(store) - await pad.cache() + + try: + await pad.cache() + except Exception as e: + print(f"Warning: Failed to cache pad {pad.id}: {str(e)}") + return pad @classmethod async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad']: """Get a pad by ID, first trying Redis cache then falling back to database""" - # Try to get from Redis cache first - redis_client = get_redis_client() + redis = await get_redis_client() cache_key = f"pad:{pad_id}" - # Check if the hash exists - if redis_client.exists(cache_key): - try: - # Get all fields from the hash - data = redis_client.hgetall(cache_key) - if data: - return cls( - id=UUID(data['id']), - owner_id=UUID(data['owner_id']), - display_name=data['display_name'], - data=json.loads(data['data']), - created_at=datetime.fromisoformat(data['created_at']), - updated_at=datetime.fromisoformat(data['updated_at']) + try: + if await redis.exists(cache_key): + cached_data = await redis.hgetall(cache_key) + if cached_data: + pad_instance = cls( + id=UUID(cached_data['id']), + owner_id=UUID(cached_data['owner_id']), + display_name=cached_data['display_name'], + data=json.loads(cached_data['data']), + created_at=datetime.fromisoformat(cached_data['created_at']), + updated_at=datetime.fromisoformat(cached_data['updated_at']) ) - except (json.JSONDecodeError, KeyError, ValueError): - print(f"Error parsing cached pad data, id: {cache_key}") + return pad_instance + except (json.JSONDecodeError, KeyError, ValueError) as e: + print(f"Error parsing cached pad data for id {pad_id}: {str(e)}") + except Exception as e: + print(f"Unexpected error retrieving pad from cache: {str(e)}") # Fall back to database store = await PadStore.get_by_id(session, pad_id) if store: pad = cls.from_store(store) - await pad.cache() # Cache for future use + try: + await pad.cache() + except Exception as e: + print(f"Warning: Failed to cache pad {pad.id}: {str(e)}") return pad return None @@ -104,9 +112,14 @@ async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> list['Pad' """Get all pads for a specific owner""" stores = await PadStore.get_by_owner(session, owner_id) pads = [cls.from_store(store) for store in stores] - # Cache all pads + + # Cache all pads, handling errors for each individually for pad in pads: - await pad.cache() + try: + await pad.cache() + except Exception as e: + print(f"Warning: Failed to cache pad {pad.id}: {str(e)}") + return pads @classmethod @@ -143,8 +156,11 @@ async def save(self, session: AsyncSession) -> 'Pad': self.created_at = self._store.created_at self.updated_at = self._store.updated_at - # Update cache - await self.cache() + try: + await self.cache() + except Exception as e: + print(f"Warning: Failed to cache pad {self.id} after save: {str(e)}") + return self async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'Pad': @@ -153,11 +169,17 @@ async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'Pad self.updated_at = datetime.now() if self._store: self._store = await self._store.update_data(session, data) - await self.cache() + + try: + await self.cache() + except Exception as e: + print(f"Warning: Failed to cache pad {self.id} after update: {str(e)}") + return self async def broadcast_event(self, event_type: str, event_data: Dict[str, Any]) -> None: """Broadcast an event to all connected clients""" + redis = await get_redis_client() stream_key = f"pad:stream:{self.id}" message = { "type": event_type, @@ -165,34 +187,50 @@ async def broadcast_event(self, event_type: str, event_data: Dict[str, Any]) -> "data": event_data, "timestamp": datetime.now().isoformat() } - self._redis.xadd(stream_key, message) + try: + await redis.xadd(stream_key, message) + except Exception as e: + print(f"Error broadcasting event to pad {self.id}: {str(e)}") async def get_stream_position(self) -> str: """Get the current position in the pad's stream""" + redis = await get_redis_client() stream_key = f"pad:stream:{self.id}" - info = self._redis.xinfo_stream(stream_key) - return info.get("last-generated-id", "0-0") + try: + info = await redis.xinfo_stream(stream_key) + return info.get("last-generated-id", "0-0") + except Exception as e: + print(f"Error getting stream position for pad {self.id}: {str(e)}") + return "0-0" async def get_recent_events(self, count: int = 100) -> list[Dict[str, Any]]: """Get recent events from the pad's stream""" + redis = await get_redis_client() stream_key = f"pad:stream:{self.id}" - messages = self._redis.xrevrange(stream_key, count=count) - return [msg[1] for msg in messages] + try: + messages = await redis.xrevrange(stream_key, count=count) + return [msg[1] for msg in messages] + except Exception as e: + print(f"Error getting recent events for pad {self.id}: {str(e)}") + return [] async def delete(self, session: AsyncSession) -> bool: """Delete the pad from both database and cache""" if self._store: success = await self._store.delete(session) if success: - await self.invalidate_cache() + try: + await self.invalidate_cache() + except Exception as e: + print(f"Warning: Failed to invalidate cache for pad {self.id}: {str(e)}") return success return False async def cache(self) -> None: """Cache the pad data in Redis using hash structure""" + redis = await get_redis_client() cache_key = f"pad:{self.id}" - # Store each field separately in the hash cache_data = { 'id': str(self.id), 'owner_id': str(self.owner_id), @@ -202,13 +240,16 @@ async def cache(self) -> None: 'updated_at': self.updated_at.isoformat() } - self._redis.hset(cache_key, mapping=cache_data) - self._redis.expire(cache_key, self.CACHE_EXPIRY) + async with redis.pipeline() as pipe: + await pipe.hset(cache_key, mapping=cache_data) + await pipe.expire(cache_key, self.CACHE_EXPIRY) + await pipe.execute() async def invalidate_cache(self) -> None: """Remove the pad from Redis cache""" + redis = await get_redis_client() cache_key = f"pad:{self.id}" - self._redis.delete(cache_key) + await redis.delete(cache_key) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary representation""" diff --git a/src/backend/domain/session.py b/src/backend/domain/session.py index 4b43856..45fbe49 100644 --- a/src/backend/domain/session.py +++ b/src/backend/domain/session.py @@ -4,37 +4,90 @@ import jwt from jwt.jwks_client import PyJWKClient import httpx -from redis import Redis +from redis.asyncio import Redis as AsyncRedis class Session: """Domain class for managing user sessions""" - def __init__(self, redis_client: Redis, oidc_config: Dict[str, str]): + def __init__(self, redis_client: AsyncRedis, oidc_config: Dict[str, str]): + """ + Initialize a new Session instance. + + Args: + redis_client: The Redis client to use for session storage + oidc_config: Configuration for the OIDC provider + """ self.redis_client = redis_client self.oidc_config = oidc_config self._jwks_client = None - def get(self, session_id: str) -> Optional[Dict[str, Any]]: - """Get session data from Redis""" - session_data = self.redis_client.get(f"session:{session_id}") - if session_data: - return json.loads(session_data) + async def get(self, session_id: str) -> Optional[Dict[str, Any]]: + """ + Get session data from Redis. + + Args: + session_id: The session ID to retrieve + + Returns: + The session data or None if not found + """ + try: + session_data = await self.redis_client.get(f"session:{session_id}") + if session_data: + return json.loads(session_data) + except json.JSONDecodeError as e: + print(f"Error decoding session data for {session_id}: {str(e)}") + except Exception as e: + print(f"Error retrieving session {session_id}: {str(e)}") return None - def set(self, session_id: str, data: Dict[str, Any], expiry: int) -> None: - """Store session data in Redis with expiry in seconds""" - self.redis_client.setex( - f"session:{session_id}", - expiry, - json.dumps(data) - ) + async def set(self, session_id: str, data: Dict[str, Any], expiry: int) -> bool: + """ + Store session data in Redis with expiry in seconds. + + Args: + session_id: The session ID to store + data: The session data to store + expiry: Time to live in seconds + + Returns: + True if successful, False otherwise + """ + try: + await self.redis_client.setex( + f"session:{session_id}", + expiry, + json.dumps(data) + ) + return True + except Exception as e: + print(f"Error storing session {session_id}: {str(e)}") + return False - def delete(self, session_id: str) -> None: - """Delete session data from Redis""" - self.redis_client.delete(f"session:{session_id}") + async def delete(self, session_id: str) -> bool: + """ + Delete session data from Redis. + + Args: + session_id: The session ID to delete + + Returns: + True if successful, False otherwise + """ + try: + await self.redis_client.delete(f"session:{session_id}") + return True + except Exception as e: + print(f"Error deleting session {session_id}: {str(e)}") + return False def get_auth_url(self) -> str: - """Generate the authentication URL for OIDC login""" + """ + Generate the authentication URL for OIDC login. + + Returns: + The authentication URL + """ auth_url = f"{self.oidc_config['server_url']}/realms/{self.oidc_config['realm']}/protocol/openid-connect/auth" params = { 'client_id': self.oidc_config['client_id'], @@ -45,11 +98,25 @@ def get_auth_url(self) -> str: return f"{auth_url}?{'&'.join(f'{k}={v}' for k,v in params.items())}" def get_token_url(self) -> str: - """Get the token endpoint URL""" + """ + Get the token endpoint URL. + + Returns: + The token endpoint URL + """ return f"{self.oidc_config['server_url']}/realms/{self.oidc_config['realm']}/protocol/openid-connect/token" def is_token_expired(self, token_data: Dict[str, Any], buffer_seconds: int = 30) -> bool: - """Check if the access token is expired""" + """ + Check if the access token is expired. + + Args: + token_data: The token data to check + buffer_seconds: Buffer time in seconds before actual expiration + + Returns: + True if the token is expired, False otherwise + """ if not token_data or 'access_token' not in token_data: return True @@ -77,7 +144,16 @@ def is_token_expired(self, token_data: Dict[str, Any], buffer_seconds: int = 30) return True async def refresh_token(self, session_id: str, token_data: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]: - """Refresh the access token using the refresh token""" + """ + Refresh the access token using the refresh token. + + Args: + session_id: The session ID + token_data: The current token data containing the refresh token + + Returns: + Tuple of (success, token_data) + """ if not token_data or 'refresh_token' not in token_data: return False, token_data @@ -102,7 +178,9 @@ async def refresh_token(self, session_id: str, token_data: Dict[str, Any]) -> Tu # Update session with new tokens expiry = new_token_data['refresh_expires_in'] - self.set(session_id, new_token_data, expiry) + success = await self.set(session_id, new_token_data, expiry) + if not success: + return False, token_data return True, new_token_data except Exception as e: @@ -110,25 +188,45 @@ async def refresh_token(self, session_id: str, token_data: Dict[str, Any]) -> Tu return False, token_data def _get_jwks_client(self) -> PyJWKClient: - """Get or create a PyJWKClient for token verification""" + """ + Get or create a PyJWKClient for token verification. + + Returns: + The JWKs client + """ if self._jwks_client is None: jwks_url = f"{self.oidc_config['server_url']}/realms/{self.oidc_config['realm']}/protocol/openid-connect/certs" self._jwks_client = PyJWKClient(jwks_url) return self._jwks_client - def track_event(self, session_id: str, event_type: str, metadata: Dict[str, Any] = None) -> None: - """Track a session event (login, logout, etc.)""" - session_data = self.get(session_id) - if session_data: - if 'events' not in session_data: - session_data['events'] = [] + async def track_event(self, session_id: str, event_type: str, metadata: Dict[str, Any] = None) -> bool: + """ + Track a session event (login, logout, etc.). + + Args: + session_id: The session ID + event_type: The type of event + metadata: Additional metadata for the event - event = { - 'type': event_type, - 'timestamp': time.time(), - 'metadata': metadata or {} - } - session_data['events'].append(event) - - # Update session with new event - self.set(session_id, session_data, session_data.get('expires_in', 3600)) \ No newline at end of file + Returns: + True if successful, False otherwise + """ + try: + session_data = await self.get(session_id) + if session_data: + if 'events' not in session_data: + session_data['events'] = [] + + event = { + 'type': event_type, + 'timestamp': time.time(), + 'metadata': metadata or {} + } + session_data['events'].append(event) + + # Update session with new event + return await self.set(session_id, session_data, session_data.get('expires_in', 3600)) + return False + except Exception as e: + print(f"Error tracking event {event_type} for session {session_id}: {str(e)}") + return False \ No newline at end of file diff --git a/src/backend/main.py b/src/backend/main.py index 331aa9c..bfcbcca 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -10,7 +10,10 @@ from fastapi.staticfiles import StaticFiles from database import init_db -from config import STATIC_DIR, ASSETS_DIR, POSTHOG_API_KEY, POSTHOG_HOST, redis_client, redis_pool +from config import ( + STATIC_DIR, ASSETS_DIR, POSTHOG_API_KEY, POSTHOG_HOST, + get_redis_client, close_redis_client +) from dependencies import UserSession, optional_auth from routers.auth_router import auth_router from routers.users_router import users_router @@ -30,17 +33,15 @@ async def lifespan(_: FastAPI): await init_db() print("Database connection established successfully") - redis_client.ping() + # Initialize Redis client and verify connection + redis = await get_redis_client() + await redis.ping() print("Redis connection established successfully") yield # Clean up connections when shutting down - try: - redis_pool.disconnect() - print("Redis connections closed") - except Exception as e: - print(f"Error closing Redis connections: {str(e)}") + await close_redis_client() app = FastAPI(lifespan=lifespan) diff --git a/src/backend/routers/auth_router.py b/src/backend/routers/auth_router.py index 266f509..a0416d8 100644 --- a/src/backend/routers/auth_router.py +++ b/src/backend/routers/auth_router.py @@ -8,18 +8,24 @@ import time from config import (FRONTEND_URL, STATIC_DIR) -from dependencies import get_coder_api, session +from dependencies import get_coder_api, get_session_domain from coder import CoderAPI from dependencies import optional_auth, UserSession +from domain.session import Session auth_router = APIRouter() @auth_router.get("/login") -async def login(request: Request, kc_idp_hint: str = None, popup: str = None): +async def login( + request: Request, + session_domain: Session = Depends(get_session_domain), + kc_idp_hint: str = None, + popup: str = None +): session_id = secrets.token_urlsafe(32) - auth_url = session.get_auth_url() + auth_url = session_domain.get_auth_url() state = "popup" if popup == "1" else "default" if kc_idp_hint: @@ -38,7 +44,8 @@ async def callback( request: Request, code: str, state: str = "default", - coder_api: CoderAPI = Depends(get_coder_api) + coder_api: CoderAPI = Depends(get_coder_api), + session_domain: Session = Depends(get_session_domain) ): session_id = request.cookies.get('session_id') if not session_id: @@ -47,13 +54,13 @@ async def callback( # Exchange code for token async with httpx.AsyncClient() as client: token_response = await client.post( - session.get_token_url(), + session_domain.get_token_url(), data={ 'grant_type': 'authorization_code', - 'client_id': session.oidc_config['client_id'], - 'client_secret': session.oidc_config['client_secret'], + 'client_id': session_domain.oidc_config['client_id'], + 'client_secret': session_domain.oidc_config['client_secret'], 'code': code, - 'redirect_uri': session.oidc_config['redirect_uri'] + 'redirect_uri': session_domain.oidc_config['redirect_uri'] } ) @@ -62,8 +69,14 @@ async def callback( token_data = token_response.json() expiry = token_data['refresh_expires_in'] - session.set(session_id, token_data, expiry) - session.track_event(session_id, 'login') + + # Store the token data in Redis + success = await session_domain.set(session_id, token_data, expiry) + if not success: + raise HTTPException(status_code=500, detail="Failed to store session") + + # Track the login event + await session_domain.track_event(session_id, 'login') access_token = token_data['access_token'] user_info = jwt.decode(access_token, options={"verify_signature": False}) @@ -83,23 +96,28 @@ async def callback( return RedirectResponse('/') @auth_router.get("/logout") -async def logout(request: Request): +async def logout(request: Request, session_domain: Session = Depends(get_session_domain)): session_id = request.cookies.get('session_id') - session_data = session.get(session_id) + if not session_id: + return RedirectResponse('/') + + session_data = await session_domain.get(session_id) if not session_data: return RedirectResponse('/') id_token = session_data.get('id_token', '') # Track logout event before deleting session - session.track_event(session_id, 'logout') + await session_domain.track_event(session_id, 'logout') # Delete the session from Redis - session.delete(session_id) + success = await session_domain.delete(session_id) + if not success: + print(f"Warning: Failed to delete session {session_id}") # Create the Keycloak logout URL with redirect back to our app - logout_url = f"{session.oidc_config['server_url']}/realms/{session.oidc_config['realm']}/protocol/openid-connect/logout" + logout_url = f"{session_domain.oidc_config['server_url']}/realms/{session_domain.oidc_config['realm']}/protocol/openid-connect/logout" full_logout_url = f"{logout_url}?id_token_hint={id_token}&post_logout_redirect_uri={FRONTEND_URL}" # Create a response with the logout URL and clear the session cookie @@ -144,23 +162,23 @@ async def auth_status( }) @auth_router.post("/refresh") -async def refresh_session(request: Request): +async def refresh_session(request: Request, session_domain: Session = Depends(get_session_domain)): """Refresh the current session's access token""" session_id = request.cookies.get('session_id') if not session_id: raise HTTPException(status_code=401, detail="No session found") - session_data = session.get(session_id) + session_data = await session_domain.get(session_id) if not session_data: raise HTTPException(status_code=401, detail="Invalid session") # Try to refresh the token - success, new_session = await session.refresh_token(session_id, session_data) + success, new_token_data = await session_domain.refresh_token(session_id, session_data) if not success: raise HTTPException(status_code=401, detail="Failed to refresh session") # Return the new expiry time return JSONResponse({ - "expires_in": new_session.get('expires_in'), + "expires_in": new_token_data.get('expires_in'), "authenticated": True }) \ No newline at end of file diff --git a/src/backend/routers/users_router.py b/src/backend/routers/users_router.py index 4160762..eb2dc60 100644 --- a/src/backend/routers/users_router.py +++ b/src/backend/routers/users_router.py @@ -54,43 +54,58 @@ async def get_online_users( _: bool = Depends(require_admin), ): """Get all online users with their information (admin only)""" - client = get_redis_client() - - # Get all session keys - session_keys = client.keys("session:*") - - # Extract user IDs from sessions and fetch user data - online_users = [] - for key in session_keys: - session_data = client.get(key) - if session_data: + try: + client = await get_redis_client() + + # Get all session keys + session_keys = await client.keys("session:*") + + # Extract user IDs from sessions and fetch user data + online_users = [] + jwks_client = get_jwks_client() + + for key in session_keys: try: + # Get session data + session_data_raw = await client.get(key) + if not session_data_raw: + continue + # Parse session data - session_json = json.loads(session_data) + session_json = json.loads(session_data_raw) # Extract user ID from token token_data = session_json.get('access_token') - if token_data: - # Decode JWT token to get user ID - jwks_client = get_jwks_client() - signing_key = jwks_client.get_signing_key_from_jwt(token_data) - decoded = jwt.decode( - token_data, - signing_key.key, - algorithms=["RS256"], - audience=OIDC_CLIENT_ID, - ) - - # Get user ID from token - user_id = UUID(decoded.get('sub')) + if not token_data: + continue - # Fetch user data from database - raise NotImplementedError("/online Not implemented") - user_data = await user_service.get_user(user_id) - if user_data: - online_users.append(user_data) + # Decode JWT token to get user ID + signing_key = jwks_client.get_signing_key_from_jwt(token_data) + decoded = jwt.decode( + token_data, + signing_key.key, + algorithms=["RS256"], + audience=OIDC_CLIENT_ID, + ) + + # Get user ID from token + user_id = UUID(decoded.get('sub')) + + # This endpoint is partially implemented - would need to fetch user data + raise NotImplementedError("/online Not implemented") + + except json.JSONDecodeError as e: + print(f"Error parsing session data: {str(e)}") + continue + except jwt.PyJWTError as e: + print(f"Error decoding JWT: {str(e)}") + continue except Exception as e: print(f"Error processing session {key}: {str(e)}") continue - - return {"online_users": online_users, "count": len(online_users)} + + return {"online_users": online_users, "count": len(online_users)} + + except Exception as e: + print(f"Error getting online users: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to retrieve online users") diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index b280554..26b42c3 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -4,7 +4,7 @@ import json import asyncio from config import get_redis_client -from dependencies import UserSession, session +from dependencies import UserSession, get_session_domain from datetime import datetime ws_router = APIRouter() @@ -14,29 +14,31 @@ async def get_ws_user(websocket: WebSocket) -> Optional[UserSession]: """WebSocket-specific authentication dependency""" - # Get session ID from cookies - session_id = websocket.cookies.get('session_id') - if not session_id: - return None - - # Get session data from Redis - session_data = session.get(session_id) - if not session_data: - return None + try: + session_id = websocket.cookies.get('session_id') + if not session_id: + return None - # Handle token expiration - if session.is_token_expired(session_data): - # Try to refresh the token - success, new_session = await session.refresh_token(session_id, session_data) - if not success: + current_session_domain = await get_session_domain() + + session_data = await current_session_domain.get(session_id) + if not session_data: return None - session_data = new_session - - # Create user session object - return UserSession( - access_token=session_data.get('access_token'), - token_data=session_data - ) + + if current_session_domain.is_token_expired(session_data): + success, new_session_data = await current_session_domain.refresh_token(session_id, session_data) + if not success: + return None + session_data = new_session_data + + return UserSession( + access_token=session_data.get('access_token'), + token_data=session_data, + session_domain=current_session_domain + ) + except Exception as e: + print(f"Error in WebSocket authentication: {str(e)}") + return None async def cleanup_connection(pad_id: UUID, websocket: WebSocket): """Clean up WebSocket connection and remove from active connections""" @@ -58,34 +60,40 @@ async def cleanup_connection(pad_id: UUID, websocket: WebSocket): async def handle_redis_messages(websocket: WebSocket, pad_id: UUID, redis_client, stream_key: str): """Handle Redis stream messages asynchronously""" try: - # Get the last message ID to start from last_id = "0" while websocket.client_state.CONNECTED: - # Get messages from Redis stream with a timeout - messages = await asyncio.to_thread( - redis_client.xread, - {stream_key: last_id}, - block=1000 - ) - - if messages and websocket.client_state.CONNECTED: - for stream, stream_messages in messages: - for message_id, message_data in stream_messages: - # Update last_id to avoid processing the same message twice - last_id = message_id - - # Forward message to all connected clients - for connection in active_connections[pad_id].copy(): - try: - if connection.client_state.CONNECTED: - await connection.send_json(message_data) - except (WebSocketDisconnect, Exception) as e: - if "close message has been sent" not in str(e): - print(f"Error sending message to client: {e}") - await cleanup_connection(pad_id, connection) + try: + messages = await redis_client.xread( + {stream_key: last_id}, + block=1000 + ) + + if messages and websocket.client_state.CONNECTED: + for stream, stream_messages in messages: + for message_id, message_data in stream_messages: + # Update last_id to avoid processing the same message twice + last_id = message_id + + # Forward message to all connected clients + for connection in active_connections[pad_id].copy(): + try: + if connection.client_state.CONNECTED: + await connection.send_json(message_data) + except (WebSocketDisconnect, Exception) as e: + if "close message has been sent" not in str(e): + print(f"Error sending message to client: {e}") + await cleanup_connection(pad_id, connection) + except asyncio.CancelledError: + raise + except Exception as e: + print(f"Error handling Redis stream: {e}") + await asyncio.sleep(1) # Throttle reconnection attempts + + except asyncio.CancelledError: + raise except Exception as e: - print(f"Error in Redis message handling: {e}") + print(f"Fatal error in Redis message handling: {e}") finally: await cleanup_connection(pad_id, websocket) @@ -102,16 +110,17 @@ async def websocket_endpoint( await websocket.accept() - # Add connection to active connections if pad_id not in active_connections: active_connections[pad_id] = set() active_connections[pad_id].add(websocket) - # Subscribe to Redis stream for this pad - redis_client = get_redis_client() - stream_key = f"pad:stream:{pad_id}" + redis_client = None + redis_task = None try: + redis_client = await get_redis_client() + stream_key = f"pad:stream:{pad_id}" + # Send initial connection success if websocket.client_state.CONNECTED: await websocket.send_json({ @@ -121,15 +130,17 @@ async def websocket_endpoint( }) # Broadcast user joined message - join_message = { - "type": "user_joined", - "pad_id": str(pad_id), - "user_id": str(user.id), - "timestamp": datetime.now().isoformat() - } - redis_client.xadd(stream_key, join_message) + try: + join_message = { + "type": "user_joined", + "pad_id": str(pad_id), + "user_id": str(user.id), + "timestamp": datetime.now().isoformat() + } + await redis_client.xadd(stream_key, join_message) + except Exception as e: + print(f"Error broadcasting join message: {e}") - # Start Redis message handling in a separate task redis_task = asyncio.create_task(handle_redis_messages(websocket, pad_id, redis_client, stream_key)) # Wait for WebSocket disconnect @@ -148,21 +159,23 @@ async def websocket_endpoint( print(f"Error in WebSocket endpoint: {e}") finally: # Clean up - redis_task.cancel() - try: - await redis_task - except asyncio.CancelledError: - pass + if redis_task: + redis_task.cancel() + try: + await redis_task + except asyncio.CancelledError: + pass # Broadcast user left message before cleanup try: - leave_message = { - "type": "user_left", - "pad_id": str(pad_id), - "user_id": str(user.id), - "timestamp": datetime.now().isoformat() - } - redis_client.xadd(stream_key, leave_message) + if redis_client: + leave_message = { + "type": "user_left", + "pad_id": str(pad_id), + "user_id": str(user.id), + "timestamp": datetime.now().isoformat() + } + await redis_client.xadd(stream_key, leave_message) except Exception as e: print(f"Error broadcasting leave message: {e}") From d25d1950c715618c45df1371c618cbf042d0bd37 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 09:50:36 +0000 Subject: [PATCH 028/149] stream only last messages --- src/backend/routers/ws_router.py | 46 +++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index 26b42c3..ca68c15 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -60,13 +60,18 @@ async def cleanup_connection(pad_id: UUID, websocket: WebSocket): async def handle_redis_messages(websocket: WebSocket, pad_id: UUID, redis_client, stream_key: str): """Handle Redis stream messages asynchronously""" try: - last_id = "0" + # Start with latest ID to only get new messages - not entire history + last_id = "$" + + # First, trim the stream to keep only the latest 100 messages + await redis_client.xtrim(stream_key, maxlen=100, approximate=True) while websocket.client_state.CONNECTED: try: messages = await redis_client.xread( {stream_key: last_id}, - block=1000 + block=1000, + count=100 # Limit to fetching 100 messages at a time ) if messages and websocket.client_state.CONNECTED: @@ -79,7 +84,11 @@ async def handle_redis_messages(websocket: WebSocket, pad_id: UUID, redis_client for connection in active_connections[pad_id].copy(): try: if connection.client_state.CONNECTED: - await connection.send_json(message_data) + # Convert message_data from Redis format to JSON-serializable dict + json_message = {k.decode('utf-8') if isinstance(k, bytes) else k: + v.decode('utf-8') if isinstance(v, bytes) else v + for k, v in message_data.items()} + await connection.send_json(json_message) except (WebSocketDisconnect, Exception) as e: if "close message has been sent" not in str(e): print(f"Error sending message to client: {e}") @@ -137,7 +146,10 @@ async def websocket_endpoint( "user_id": str(user.id), "timestamp": datetime.now().isoformat() } - await redis_client.xadd(stream_key, join_message) + # Convert dict to proper format for Redis xadd + field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v + for k, v in join_message.items()} + await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) except Exception as e: print(f"Error broadcasting join message: {e}") @@ -148,9 +160,28 @@ async def websocket_endpoint( try: # Keep the connection alive and handle any incoming messages data = await websocket.receive_text() - # Handle any client messages here if needed + message_data = json.loads(data) + + # Add user_id and timestamp to the message + message_data.update({ + "user_id": str(user.id), + "pad_id": str(pad_id), + "timestamp": datetime.now().isoformat() + }) + + # Publish the message to Redis stream to be broadcasted to all clients + # Convert dict to proper format for Redis xadd (field-value pairs) + field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v + for k, v in message_data.items()} + await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) except WebSocketDisconnect: break + except json.JSONDecodeError as e: + print(f"Invalid JSON received: {e}") + await websocket.send_json({ + "type": "error", + "message": "Invalid message format" + }) except Exception as e: print(f"Error in WebSocket connection: {e}") break @@ -175,7 +206,10 @@ async def websocket_endpoint( "user_id": str(user.id), "timestamp": datetime.now().isoformat() } - await redis_client.xadd(stream_key, leave_message) + # Convert dict to proper format for Redis xadd + field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v + for k, v in leave_message.items()} + await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) except Exception as e: print(f"Error broadcasting leave message: {e}") From 61108d2d465efad8f000993800dd5acfc1720219 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 11:13:01 +0000 Subject: [PATCH 029/149] debounce --- src/backend/routers/ws_router.py | 1 + src/frontend/src/App.tsx | 54 ++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index ca68c15..abea261 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -168,6 +168,7 @@ async def websocket_endpoint( "pad_id": str(pad_id), "timestamp": datetime.now().isoformat() }) + print(f"Received message from {user.id} on pad {str(pad_id)[:5]}") # Publish the message to Redis stream to be broadcasted to all clients # Convert dict to proper format for Redis xadd (field-value pairs) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index b0eef87..e79b55a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -39,6 +39,23 @@ const defaultInitialData = { files: {}, }; +// Create a debounce function +const debounce = any>(func: F, waitFor: number) => { + let timeout: ReturnType | null = null; + + return (...args: Parameters): ReturnType | undefined => { + if (timeout !== null) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + func(...args); + }, waitFor); + + return undefined; + }; +}; + export default function App() { const { isAuthenticated } = useAuthStatus(); const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); @@ -61,20 +78,39 @@ export default function App() { setShowSettingsModal(false); }; - const handleOnChange = (elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - if (isConnected && selectedTabId) { - sendMessage('pad_update', { - elements, - appState: state - }); - } - }; + // Create debounced version of sendMessage + const debouncedSendMessage = useCallback( + debounce((type: string, data: any) => { + if (isConnected && selectedTabId) { + // Only log on failure to avoid noise + sendMessage(type, data); + } else if (!isConnected && selectedTabId) { + // Add a slight delay to check connection status again before showing the error + // This helps avoid false alarms during connection establishment + setTimeout(() => { + if (!isConnected) { + console.log(`[App] WebSocket not connected - changes will not be saved`); + } + }, 100); + } + }, 250), + [isConnected, selectedTabId, sendMessage] + ); + + const handleOnChange = useCallback((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { + // No logging on every change to reduce noise + debouncedSendMessage('pad_update', { + elements, + appState: state + }); + }, [debouncedSendMessage]); const handleOnScrollChange = (scrollX: number, scrollY: number) => { lockEmbeddables(excalidrawAPI?.getAppState()); }; const handleTabSelect = async (tabId: string) => { + // Only log tab changes to reduce noise await selectTab(tabId); }; From 2fb82bae89cead33df8e863bd6ac51c58db39245 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 21:31:43 +0000 Subject: [PATCH 030/149] remove handle redis msg --- src/backend/routers/ws_router.py | 79 ++++++++------------------------ 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index abea261..1550eeb 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -57,55 +57,6 @@ async def cleanup_connection(pad_id: UUID, websocket: WebSocket): if "already completed" not in str(e) and "close message has been sent" not in str(e): print(f"Error closing WebSocket connection: {e}") -async def handle_redis_messages(websocket: WebSocket, pad_id: UUID, redis_client, stream_key: str): - """Handle Redis stream messages asynchronously""" - try: - # Start with latest ID to only get new messages - not entire history - last_id = "$" - - # First, trim the stream to keep only the latest 100 messages - await redis_client.xtrim(stream_key, maxlen=100, approximate=True) - - while websocket.client_state.CONNECTED: - try: - messages = await redis_client.xread( - {stream_key: last_id}, - block=1000, - count=100 # Limit to fetching 100 messages at a time - ) - - if messages and websocket.client_state.CONNECTED: - for stream, stream_messages in messages: - for message_id, message_data in stream_messages: - # Update last_id to avoid processing the same message twice - last_id = message_id - - # Forward message to all connected clients - for connection in active_connections[pad_id].copy(): - try: - if connection.client_state.CONNECTED: - # Convert message_data from Redis format to JSON-serializable dict - json_message = {k.decode('utf-8') if isinstance(k, bytes) else k: - v.decode('utf-8') if isinstance(v, bytes) else v - for k, v in message_data.items()} - await connection.send_json(json_message) - except (WebSocketDisconnect, Exception) as e: - if "close message has been sent" not in str(e): - print(f"Error sending message to client: {e}") - await cleanup_connection(pad_id, connection) - except asyncio.CancelledError: - raise - except Exception as e: - print(f"Error handling Redis stream: {e}") - await asyncio.sleep(1) # Throttle reconnection attempts - - except asyncio.CancelledError: - raise - except Exception as e: - print(f"Fatal error in Redis message handling: {e}") - finally: - await cleanup_connection(pad_id, websocket) - @ws_router.websocket("/ws/pad/{pad_id}") async def websocket_endpoint( websocket: WebSocket, @@ -124,7 +75,6 @@ async def websocket_endpoint( active_connections[pad_id].add(websocket) redis_client = None - redis_task = None try: redis_client = await get_redis_client() @@ -153,8 +103,6 @@ async def websocket_endpoint( except Exception as e: print(f"Error broadcasting join message: {e}") - redis_task = asyncio.create_task(handle_redis_messages(websocket, pad_id, redis_client, stream_key)) - # Wait for WebSocket disconnect while websocket.client_state.CONNECTED: try: @@ -175,6 +123,17 @@ async def websocket_endpoint( field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v for k, v in message_data.items()} await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + + # Forward message to all connected clients for this pad + for connection in active_connections[pad_id].copy(): + try: + if connection.client_state.CONNECTED: + await connection.send_json(message_data) + except (WebSocketDisconnect, Exception) as e: + if "close message has been sent" not in str(e): + print(f"Error sending message to client: {e}") + await cleanup_connection(pad_id, connection) + except WebSocketDisconnect: break except json.JSONDecodeError as e: @@ -190,14 +149,6 @@ async def websocket_endpoint( except Exception as e: print(f"Error in WebSocket endpoint: {e}") finally: - # Clean up - if redis_task: - redis_task.cancel() - try: - await redis_task - except asyncio.CancelledError: - pass - # Broadcast user left message before cleanup try: if redis_client: @@ -211,6 +162,14 @@ async def websocket_endpoint( field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v for k, v in leave_message.items()} await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + + # Notify other clients about user leaving + for connection in active_connections[pad_id].copy(): + if connection != websocket and connection.client_state.CONNECTED: + try: + await connection.send_json(leave_message) + except Exception: + pass except Exception as e: print(f"Error broadcasting leave message: {e}") From 5a8ce18045b0cebfddc4bf816c65fa3b69000814 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 22:14:15 +0000 Subject: [PATCH 031/149] refactor: update @atyrode/excalidraw dependency to version 0.18.0-15 in package.json and yarn.lock --- src/frontend/package.json | 2 +- src/frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 1a70207..794d508 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-12", + "@atyrode/excalidraw": "^0.18.0-15", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 83c6a24..95cf347 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-12": - version "0.18.0-12" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-12.tgz#a3ebe48cef65f3703152d5d07510f7b5e42a3342" - integrity sha512-3Z1yi7/enXtG3rTxkfe8rdhVhEJM9pkFCCNht/uDEYtGlb6FIASdO700oh6olbOqxX93OqwpHtnW7UM4i4JSzQ== +"@atyrode/excalidraw@^0.18.0-15": + version "0.18.0-15" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-15.tgz#c330633ff8b60473aa30668b4b0c3196f6ad90f3" + integrity sha512-Fn1+oQHgPv1O9wSa6x6DO1zuAQzY59IyhUwgTEOsIzcbwYjpGaztW6XksfmQxvX79SkQrs21YwUVpCHkhgIh7w== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" From dd624bba36e8e3a0cacfedacfd0b9fd621ff0f6e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 22:59:47 +0000 Subject: [PATCH 032/149] refactor: reorganize project structure by moving utility functions to the 'lib' directory and updating import paths --- src/frontend/index.tsx | 2 +- src/frontend/src/CustomEmbeddableRenderer.tsx | 2 +- src/frontend/src/hooks/usePadData.ts | 2 +- .../src/{utils/canvasUtils.ts => lib/canvas.ts} | 6 ------ src/frontend/src/{utils => lib}/debounce.ts | 0 ...ExcalidrawElementFactory.ts => elementFactory.ts} | 12 +++++++++--- src/frontend/src/{utils => lib}/elementPlacement.ts | 0 src/frontend/src/{utils => lib}/posthog.ts | 0 src/frontend/src/pad/buttons/ActionButton.tsx | 2 +- src/frontend/src/pad/editors/HtmlEditor.tsx | 2 +- src/frontend/src/ui/AuthDialog.tsx | 2 +- src/frontend/src/ui/MainMenu.tsx | 2 +- src/frontend/src/ui/SettingsDialog.tsx | 4 ++-- 13 files changed, 18 insertions(+), 18 deletions(-) rename src/frontend/src/{utils/canvasUtils.ts => lib/canvas.ts} (93%) rename src/frontend/src/{utils => lib}/debounce.ts (100%) rename src/frontend/src/lib/{ExcalidrawElementFactory.ts => elementFactory.ts} (96%) rename src/frontend/src/{utils => lib}/elementPlacement.ts (100%) rename src/frontend/src/{utils => lib}/posthog.ts (100%) diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index fdb7e37..7fae3f1 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -2,7 +2,7 @@ import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -// import posthog from "./src/utils/posthog"; +// import posthog from "./src/lib/posthog"; // import { PostHogProvider } from 'posthog-js/react'; import "@atyrode/excalidraw/index.css"; diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index 6a34312..f9c7a0d 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { Lock } from 'lucide-react'; -import { debounce } from './utils/debounce'; +import { debounce } from './lib/debounce'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; import type { AppState } from '@atyrode/excalidraw/types'; import { diff --git a/src/frontend/src/hooks/usePadData.ts b/src/frontend/src/hooks/usePadData.ts index f54d0c9..99c37a4 100644 --- a/src/frontend/src/hooks/usePadData.ts +++ b/src/frontend/src/hooks/usePadData.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { ExcalidrawElement } from "@atyrode/excalidraw/element/types"; -import { normalizeCanvasData } from '../utils/canvasUtils'; +import { normalizeCanvasData } from '../lib/canvas'; interface PadData { elements?: readonly ExcalidrawElement[]; diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/lib/canvas.ts similarity index 93% rename from src/frontend/src/utils/canvasUtils.ts rename to src/frontend/src/lib/canvas.ts index 5fc846c..3c25701 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/lib/canvas.ts @@ -25,12 +25,6 @@ export function normalizeCanvasData(data: any) { // Merge existing pad properties with our updates appState.pad = { ...existingPad, // Preserve all existing properties (uniqueId, displayName, etc.) - moduleBorderOffset: { - left: 10, - right: 10, - top: 40, - bottom: 10, - }, // Merge existing user settings with default settings userSettings: { ...DEFAULT_SETTINGS, diff --git a/src/frontend/src/utils/debounce.ts b/src/frontend/src/lib/debounce.ts similarity index 100% rename from src/frontend/src/utils/debounce.ts rename to src/frontend/src/lib/debounce.ts diff --git a/src/frontend/src/lib/ExcalidrawElementFactory.ts b/src/frontend/src/lib/elementFactory.ts similarity index 96% rename from src/frontend/src/lib/ExcalidrawElementFactory.ts rename to src/frontend/src/lib/elementFactory.ts index 6fe6ccd..0968645 100644 --- a/src/frontend/src/lib/ExcalidrawElementFactory.ts +++ b/src/frontend/src/lib/elementFactory.ts @@ -10,10 +10,10 @@ import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types'; import { PlacementMode, placeElement -} from '../utils/elementPlacement'; +} from './elementPlacement'; // Re-export PlacementMode to maintain backward compatibility -export { PlacementMode } from '../utils/elementPlacement'; +export { PlacementMode } from './elementPlacement'; // Base interface with common properties for all Excalidraw elements interface BaseElementOptions { @@ -122,7 +122,13 @@ export class ExcalidrawElementFactory { link: options.link, customData: { showHyperlinkIcon: false, - showClickableHint: false + showClickableHint: false, + borderOffsets: { + left: 10, + right: 10, + top: 40, + bottom: 10 + } } }; } diff --git a/src/frontend/src/utils/elementPlacement.ts b/src/frontend/src/lib/elementPlacement.ts similarity index 100% rename from src/frontend/src/utils/elementPlacement.ts rename to src/frontend/src/lib/elementPlacement.ts diff --git a/src/frontend/src/utils/posthog.ts b/src/frontend/src/lib/posthog.ts similarity index 100% rename from src/frontend/src/utils/posthog.ts rename to src/frontend/src/lib/posthog.ts diff --git a/src/frontend/src/pad/buttons/ActionButton.tsx b/src/frontend/src/pad/buttons/ActionButton.tsx index 10c70c9..c0f48b6 100644 --- a/src/frontend/src/pad/buttons/ActionButton.tsx +++ b/src/frontend/src/pad/buttons/ActionButton.tsx @@ -3,7 +3,7 @@ import { Terminal, Braces, Settings, Plus, ExternalLink, Monitor } from 'lucide- import { ActionType, TargetType, CodeVariant, ActionButtonProps } from './types'; import './ActionButton.scss'; // import { capture } from '../../utils/posthog'; -import { ExcalidrawElementFactory, PlacementMode } from '../../lib/ExcalidrawElementFactory'; +import { ExcalidrawElementFactory, PlacementMode } from '../../lib/elementFactory'; // Interface for button settings stored in customData interface ButtonSettings { diff --git a/src/frontend/src/pad/editors/HtmlEditor.tsx b/src/frontend/src/pad/editors/HtmlEditor.tsx index 78e1974..f149fc0 100644 --- a/src/frontend/src/pad/editors/HtmlEditor.tsx +++ b/src/frontend/src/pad/editors/HtmlEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; -import { ExcalidrawElementFactory } from '../../lib/ExcalidrawElementFactory'; +import { ExcalidrawElementFactory } from '../../lib/elementFactory'; import HtmlPreview from './HtmlPreview'; import './HtmlEditor.scss'; diff --git a/src/frontend/src/ui/AuthDialog.tsx b/src/frontend/src/ui/AuthDialog.tsx index 276f459..805cbf1 100644 --- a/src/frontend/src/ui/AuthDialog.tsx +++ b/src/frontend/src/ui/AuthDialog.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useEffect } from "react"; -//import { capture } from "../utils/posthog"; +//import { capture } from "../lib/posthog"; import { GoogleIcon, GithubIcon } from "../icons"; import "./AuthDialog.scss"; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index a3d02b0..00ef1a4 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -8,7 +8,7 @@ import AccountDialog from './AccountDialog'; import md5 from 'crypto-js/md5'; // import { capture } from '../utils/posthog'; import { useLogout } from '../hooks/useLogout'; -import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; +import { ExcalidrawElementFactory, PlacementMode } from '../lib/elementFactory'; import "./MainMenu.scss"; // Function to generate gravatar URL diff --git a/src/frontend/src/ui/SettingsDialog.tsx b/src/frontend/src/ui/SettingsDialog.tsx index b9f8253..a74c039 100644 --- a/src/frontend/src/ui/SettingsDialog.tsx +++ b/src/frontend/src/ui/SettingsDialog.tsx @@ -3,8 +3,8 @@ import { Dialog } from "@atyrode/excalidraw"; import { Range } from "./Range"; import { UserSettings, DEFAULT_SETTINGS } from "../types/settings"; import { RefreshCw } from "lucide-react"; -import { normalizeCanvasData } from "../utils/canvasUtils"; -// import { capture } from "../utils/posthog"; +import { normalizeCanvasData } from "../lib/canvas"; +// import { capture } from "../lib/posthog"; import "./SettingsDialog.scss"; interface SettingsDialogProps { From 6365d2c6aa2476cae7c5075f67322233893621d3 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 16 May 2025 23:23:38 +0000 Subject: [PATCH 033/149] add update and remove pad routes --- src/backend/domain/pad.py | 16 ++++++ src/backend/routers/pad_router.py | 82 ++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 9c3a6b7..b3e60c5 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -262,5 +262,21 @@ def to_dict(self) -> Dict[str, Any]: "updated_at": self.updated_at.isoformat() } + async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': + """Rename the pad by updating its display name""" + self.display_name = new_display_name + self.updated_at = datetime.now() + if self._store: + self._store.display_name = new_display_name + self._store.updated_at = self.updated_at + self._store = await self._store.save(session) + + try: + await self.cache() + except Exception as e: + print(f"Warning: Failed to cache pad {self.id} after rename: {str(e)}") + + return self + \ No newline at end of file diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index 1b5a309..9afa207 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel from dependencies import UserSession, require_auth from database.models import PadStore @@ -46,9 +47,9 @@ async def create_new_pad( pad = await Pad.create( session=session, owner_id=user.id, - display_name="New Pad" + display_name="New pad" ) - return pad.to_dict()["data"] + return pad.to_dict() except Exception as e: raise HTTPException( status_code=500, @@ -91,3 +92,80 @@ async def get_pad( detail=f"Failed to get pad: {str(e)}" ) +class RenameRequest(BaseModel): + display_name: str + +@pad_router.put("/{pad_id}/rename") +async def rename_pad( + pad_id: UUID, + rename_data: RenameRequest, + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + """Rename a pad for the authenticated user""" + try: + # Get the pad using the domain class + pad = await Pad.get_by_id(session, pad_id) + if not pad: + raise HTTPException( + status_code=404, + detail="Pad not found" + ) + + # Check if the user owns the pad + if pad.owner_id != user.id: + raise HTTPException( + status_code=403, + detail="Not authorized to rename this pad" + ) + + # Rename the pad + await pad.rename(session, rename_data.display_name) + return pad.to_dict() + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to rename pad: {str(e)}" + ) + +@pad_router.delete("/{pad_id}") +async def delete_pad( + pad_id: UUID, + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + """Delete a pad for the authenticated user""" + try: + # Get the pad using the domain class + pad = await Pad.get_by_id(session, pad_id) + if not pad: + raise HTTPException( + status_code=404, + detail="Pad not found" + ) + + # Check if the user owns the pad + if pad.owner_id != user.id: + raise HTTPException( + status_code=403, + detail="Not authorized to delete this pad" + ) + + # Delete the pad + success = await pad.delete(session) + if not success: + raise HTTPException( + status_code=500, + detail="Failed to delete pad" + ) + + return {"success": True, "message": "Pad deleted successfully"} + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to delete pad: {str(e)}" + ) From f20805105e3983df2374511fb45da8541ab35f49 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 00:12:07 +0000 Subject: [PATCH 034/149] feat: re-implement tab context menu and enhance tab management with new actions for renaming and deleting pads --- src/frontend/src/App.tsx | 93 +++--- src/frontend/src/hooks/usePadData.ts | 2 +- src/frontend/src/hooks/usePadTabs.ts | 116 +++++++- src/frontend/src/hooks/usePadWebSocket.ts | 48 +++- src/frontend/src/ui/TabContextMenu.scss | 160 +++++++++++ src/frontend/src/ui/TabContextMenu.tsx | 295 +++++++++++++++++++ src/frontend/src/ui/Tabs.scss | 123 ++++++++ src/frontend/src/ui/Tabs.tsx | 336 ++++++++++++++++++++++ 8 files changed, 1091 insertions(+), 82 deletions(-) create mode 100644 src/frontend/src/ui/TabContextMenu.scss create mode 100644 src/frontend/src/ui/TabContextMenu.tsx create mode 100644 src/frontend/src/ui/Tabs.scss create mode 100644 src/frontend/src/ui/Tabs.tsx diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index e79b55a..4e6f5a5 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -11,15 +11,16 @@ import { usePad } from "./hooks/usePadData"; import { usePadWebSocket } from "./hooks/usePadWebSocket"; // Components -import TabBar from "./ui/TabBar"; import DiscordButton from './ui/DiscordButton'; import { MainMenuConfig } from './ui/MainMenu'; import AuthDialog from './ui/AuthDialog'; import SettingsDialog from './ui/SettingsDialog'; // Utils -import { initializePostHog } from "./utils/posthog"; +// import { initializePostHog } from "./lib/posthog"; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; +import { debounce } from './lib/debounce'; +import Tabs from "./ui/Tabs"; const defaultInitialData = { elements: [], @@ -39,33 +40,18 @@ const defaultInitialData = { files: {}, }; -// Create a debounce function -const debounce = any>(func: F, waitFor: number) => { - let timeout: ReturnType | null = null; - - return (...args: Parameters): ReturnType | undefined => { - if (timeout !== null) { - clearTimeout(timeout); - } - - timeout = setTimeout(() => { - func(...args); - }, waitFor); - - return undefined; - }; -}; - export default function App() { const { isAuthenticated } = useAuthStatus(); const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); - const { - tabs, - selectedTabId, - isLoading: isLoadingTabs, - createNewPad, - isCreating, - selectTab + const { + tabs, + selectedTabId, + isLoading: isLoadingTabs, + createNewPadAsync, + isCreating: isCreatingPad, + renamePad, + deletePad, + selectTab } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); @@ -78,18 +64,14 @@ export default function App() { setShowSettingsModal(false); }; - // Create debounced version of sendMessage const debouncedSendMessage = useCallback( debounce((type: string, data: any) => { if (isConnected && selectedTabId) { - // Only log on failure to avoid noise sendMessage(type, data); } else if (!isConnected && selectedTabId) { - // Add a slight delay to check connection status again before showing the error - // This helps avoid false alarms during connection establishment setTimeout(() => { if (!isConnected) { - console.log(`[App] WebSocket not connected - changes will not be saved`); + console.log(`[pad.ws] WebSocket not connected - changes will not be saved`); } }, 100); } @@ -98,7 +80,6 @@ export default function App() { ); const handleOnChange = useCallback((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - // No logging on every change to reduce noise debouncedSendMessage('pad_update', { elements, appState: state @@ -109,21 +90,16 @@ export default function App() { lockEmbeddables(excalidrawAPI?.getAppState()); }; - const handleTabSelect = async (tabId: string) => { - // Only log tab changes to reduce noise - await selectTab(tabId); - }; - - useEffect(() => { - if (appConfig?.posthogKey && appConfig?.posthogHost) { - initializePostHog({ - posthogKey: appConfig.posthogKey, - posthogHost: appConfig.posthogHost, - }); - } else if (configError) { - console.error('[pad.ws] Failed to load app config:', configError); - } - }, [appConfig, configError]); + // useEffect(() => { + // if (appConfig?.posthogKey && appConfig?.posthogHost) { + // initializePostHog({ + // posthogKey: appConfig.posthogKey, + // posthogHost: appConfig.posthogHost, + // }); + // } else if (configError) { + // console.error('[pad.ws] Failed to load app config:', configError); + // } + // }, [appConfig, configError]); return ( <> @@ -160,14 +136,21 @@ export default function App() { /> )} -
- ({ id: tab.id, label: tab.title }))} - activeTabId={selectedTabId} - onTabSelect={handleTabSelect} - onNewTab={createNewPad} - /> -
+ {excalidrawAPI && ( +
+ +
+ )} ); diff --git a/src/frontend/src/hooks/usePadData.ts b/src/frontend/src/hooks/usePadData.ts index 99c37a4..395411f 100644 --- a/src/frontend/src/hooks/usePadData.ts +++ b/src/frontend/src/hooks/usePadData.ts @@ -37,7 +37,7 @@ export const usePad = (padId: string, excalidrawAPI: ExcalidrawImperativeAPI | n useEffect(() => { if (data && excalidrawAPI) { const normalizedData = normalizeCanvasData(data); - console.log(`[Pad] Loading pad ${padId}`); + console.log(`[pad.ws] Loading pad ${padId}`); excalidrawAPI.updateScene(normalizedData); } }, [data, excalidrawAPI, padId]); diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 4fd4810..472a0d6 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -59,13 +59,41 @@ const fetchUserPads = async (): Promise => { }; }; -const createNewPad = async (): Promise => { +// Assuming the backend returns an object with these fields for a new pad +interface NewPadApiResponse { + id: string; + display_name: string; + created_at: string; + updated_at: string; + // Potentially other fields like 'data' if the full pad object is returned +} + +const createNewPad = async (): Promise => { // Return type is Tab const response = await fetch('/api/pad/new', { method: 'POST', }); if (!response.ok) { - throw new Error('Failed to create new pad'); + // Try to parse error message from backend + let errorMessage = 'Failed to create new pad'; + try { + const errorData = await response.json(); + if (errorData && errorData.detail) { // FastAPI often uses 'detail' + errorMessage = errorData.detail; + } else if (errorData && errorData.message) { + errorMessage = errorData.message; + } + } catch (e) { + // Ignore if error response is not JSON or empty + } + throw new Error(errorMessage); } + const newPadResponse: NewPadApiResponse = await response.json(); + return { + id: newPadResponse.id, + title: newPadResponse.display_name, + createdAt: newPadResponse.created_at, + updatedAt: newPadResponse.updated_at, + }; }; export const usePadTabs = () => { @@ -74,18 +102,79 @@ export const usePadTabs = () => { const { data, isLoading, error, isError } = useQuery({ queryKey: ['padTabs'], - queryFn: fetchUserPads + queryFn: fetchUserPads, }); - // Set initial selected tab when data is loaded + // Effect to manage tab selection based on data changes and selectedTabId validity useEffect(() => { - if (data?.tabs.length && !selectedTabId) { - setSelectedTabId(data.tabs[0].id); + if (isLoading) { + return; } - }, [data, selectedTabId]); - const createPadMutation = useMutation({ - mutationFn: createNewPad, + if (data?.tabs && data.tabs.length > 0) { + const isValidSelection = selectedTabId && data.tabs.some(tab => tab.id === selectedTabId); + if (!isValidSelection) { + setSelectedTabId(data.tabs[0].id); + } + } else if (data?.tabs && data.tabs.length === 0) { + setSelectedTabId(''); + } + }, [data, isLoading]); + + + const createPadMutation = useMutation({ // Result: Tab, Error, Variables: void + mutationFn: createNewPad, // createNewPad now returns Promise + onSuccess: (newlyCreatedTab) => { + // Optimistically update the cache and select the new tab + queryClient.setQueryData(['padTabs'], (oldData) => { + const newTabs = oldData ? [...oldData.tabs, newlyCreatedTab] : [newlyCreatedTab]; + // Determine the activeTabId for PadResponse. If oldData exists, use its activeTabId, + // otherwise, it's the first fetch, so newTab is the one. + // However, fetchUserPads sets activeTabId to tabs[0].id. + // For consistency, let's mimic that or just ensure tabs are updated. + const currentActiveId = oldData?.activeTabId || newlyCreatedTab.id; + return { + tabs: newTabs, + activeTabId: currentActiveId // This might not be strictly necessary if selectedTabId drives UI + }; + }); + setSelectedTabId(newlyCreatedTab.id); + // Invalidate to ensure eventual consistency with the backend + queryClient.invalidateQueries({ queryKey: ['padTabs'] }); + }, + }); + + const renamePadAPI = async ({ padId, newName }: { padId: string, newName: string }): Promise => { + const response = await fetch(`/api/pad/${padId}/rename`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ display_name: newName }), + }); + if (!response.ok) { + throw new Error('Failed to rename pad'); + } + }; + + const renamePadMutation = useMutation({ + mutationFn: renamePadAPI, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['padTabs'] }); + }, + }); + + const deletePadAPI = async (padId: string): Promise => { + const response = await fetch(`/api/pad/${padId}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete pad'); + } + }; + + const deletePadMutation = useMutation({ + mutationFn: deletePadAPI, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['padTabs'] }); }, @@ -101,8 +190,13 @@ export const usePadTabs = () => { isLoading, error, isError, - createNewPad: createPadMutation.mutate, + createNewPad: createPadMutation.mutate, // Standard mutate for fire-and-forget + createNewPadAsync: createPadMutation.mutateAsync, // For components needing the result isCreating: createPadMutation.isPending, + renamePad: renamePadMutation.mutate, + isRenaming: renamePadMutation.isPending, + deletePad: deletePadMutation.mutate, + isDeleting: deletePadMutation.isPending, selectTab }; -}; \ No newline at end of file +}; diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 13defc6..89bf741 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -14,18 +14,35 @@ export const usePadWebSocket = (padId: string | null) => { const { user } = useAuthStatus(); const connect = useCallback(() => { - if (!padId || !user) return; + if (!padId || !user) { + // Ensure any existing connection is closed if padId becomes null or user logs out + if (wsRef.current) { + wsRef.current.close(); + // wsRef.current = null; // Let onclose handle this + } + return; + } - // Close existing connection if any - if (wsRef.current) { - console.log(`[WebSocket] Closing connection to previous pad`); + // Close existing connection if any (from a *different* padId) + if (wsRef.current && !wsRef.current.url.endsWith(padId)) { wsRef.current.close(); - wsRef.current = null; + // wsRef.current = null; // Let onclose handle setting wsRef.current to null + } else if (wsRef.current && wsRef.current.url.endsWith(padId)) { + // Already connected or connecting to the same padId, do nothing. + // The useEffect dependency on `connect` (which depends on `padId`) handles this. + return () => { // Return the existing cleanup if we're not making a new ws + if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { + // This is tricky, if connect is called but we don't make a new ws, + // what cleanup should be returned? The one for the existing ws. + // However, this path should ideally not be hit if deps are correct. + // For safety, we can return a no-op or the existing ws's close. + } + }; } - // Determine WebSocket protocol based on current page protocol const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pad/${padId}`); + const wsUrl = `${protocol}//${window.location.host}/ws/pad/${padId}`; + const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { @@ -69,27 +86,28 @@ export const usePadWebSocket = (padId: string | null) => { console.error('[WebSocket] Error:', error); }; - ws.onclose = () => { + ws.onclose = () => { // Removed event param as it's not used after removing logs console.log(`[WebSocket] Disconnected from pad ${padId}`); - wsRef.current = null; + // Only nullify if wsRef.current is THIS instance that just closed + if (wsRef.current === ws) { + wsRef.current = null; + } }; - return () => { - if (ws.readyState === WebSocket.OPEN) { + return () => { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(); } }; }, [padId, user]); - // Connect when padId changes useEffect(() => { - const cleanup = connect(); + const cleanup = connect(); return () => { cleanup?.(); }; }, [connect]); - // Function to send messages through WebSocket const sendMessage = useCallback((type: string, data: any) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ @@ -105,4 +123,4 @@ export const usePadWebSocket = (padId: string | null) => { sendMessage, isConnected: wsRef.current?.readyState === WebSocket.OPEN }; -}; \ No newline at end of file +}; diff --git a/src/frontend/src/ui/TabContextMenu.scss b/src/frontend/src/ui/TabContextMenu.scss new file mode 100644 index 0000000..c3c3f0b --- /dev/null +++ b/src/frontend/src/ui/TabContextMenu.scss @@ -0,0 +1,160 @@ +/* Unified context menu styles */ + +/* Base context menu container */ +.tab-context-menu, +.context-menu { + position: fixed; + border-radius: 4px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + padding: 0.5rem 0; + list-style: none; + user-select: none; + margin: -0.25rem 0 0 0.125rem; + min-width: 9.5rem; + z-index: 1000; + background-color: var(--popup-secondary-bg-color, #fff); + border: 1px solid var(--button-gray-3, #ccc); + cursor: default; +} + +/* Context menu list */ +.context-menu { + padding: 0; + margin: 0; +} + +/* Button text color */ +.tab-context-menu button { + color: var(--popup-text-color, #333); +} + +/* Menu items */ +.tab-context-menu .menu-item, +.context-menu-item { + position: relative; + width: 100%; + min-width: 9.5rem; + margin: 0; + padding: 0.25rem 1rem 0.25rem 1.25rem; + text-align: start; + border-radius: 0; + background-color: transparent; + border: none; + white-space: nowrap; + font-family: inherit; + cursor: pointer; +} + +/* Menu item layout for tab context menu */ +.tab-context-menu .menu-item { + display: grid; + grid-template-columns: 1fr 0.2fr; + align-items: center; +} + +/* Menu item layout for context menu */ +.context-menu-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Menu item label */ +.tab-context-menu .menu-item .menu-item__label { + justify-self: start; + margin-inline-end: 20px; +} + +.context-menu-item__label { + flex: 1; +} + +/* Delete item styling */ +.tab-context-menu .menu-item.delete .menu-item__label, +.context-menu-item.dangerous { + color: #e53935; +} + +/* Hover states */ +.tab-context-menu .menu-item:hover, +.context-menu-item:hover { + color: var(--popup-bg-color, #fff); + background-color: var(--select-highlight-color, #f5f5f5); +} + +/* Dangerous hover states */ +.tab-context-menu .menu-item.delete:hover, +.context-menu-item.dangerous:hover { + background-color: #e53935; +} + +.tab-context-menu .menu-item.delete:hover .menu-item__label, +.context-menu-item.dangerous:hover { + color: var(--popup-bg-color, #fff); +} + +/* Focus state */ +.tab-context-menu .menu-item:focus { + z-index: 1; +} + +/* Separator */ +.context-menu-item-separator { + margin: 0.25rem 0; + border: none; + border-top: 1px solid var(--button-gray-3, #ccc); +} + +/* Shortcut display */ +.context-menu-item__shortcut { + margin-left: 1rem; + color: var(--text-color-secondary, #666); + font-size: 0.8rem; +} + +/* Checkmark for selected items */ +.context-menu-item.checkmark::before { + content: "✓"; + position: absolute; + left: 0.5rem; +} + +/* Form styling for rename functionality */ +.tab-context-menu form { + padding: 0.5rem 1rem; + display: flex; +} + +.tab-context-menu form input { + flex: 1; + padding: 0.25rem 0.5rem; + border: 1px solid var(--button-gray-3, #ccc); + border-radius: 4px; + margin-right: 0.25rem; + background-color: var(--input-bg-color, #fff); + color: var(--text-color-primary, #333); +} + +.tab-context-menu form button { + background-color: var(--color-surface-primary-container, #4285f4); + color: var(--color-on-primary-container, white); + border: none; + border-radius: 4px; + padding: 0.25rem 0.5rem; + cursor: pointer; +} + +.tab-context-menu form button:hover { + background-color: var(--color-primary-light, #3367d6); +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .tab-context-menu .menu-item { + display: block; + } + + .tab-context-menu .menu-item .menu-item__label { + margin-inline-end: 0; + } +} \ No newline at end of file diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx new file mode 100644 index 0000000..9a3e671 --- /dev/null +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -0,0 +1,295 @@ +import React, { useState, useRef, useEffect } from 'react'; +import clsx from 'clsx'; + +import './TabContextMenu.scss'; + +const CONTEXT_MENU_SEPARATOR = "separator"; + +type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; +type ContextMenuItems = (ContextMenuItem | false | null | undefined)[]; + +interface Action { + name: string; + label: string | (() => string); + predicate?: () => boolean; + checked?: (appState: any) => boolean; + dangerous?: boolean; +} + +interface ContextMenuProps { + actionManager: ActionManager; + items: ContextMenuItems; + top: number; + left: number; + onClose: (callback?: () => void) => void; +} + +interface ActionManager { + executeAction: (action: Action, source: string) => void; + app: { + props: any; + }; +} + +interface TabContextMenuProps { + x: number; + y: number; + padId: string; + padName: string; + onRename: (padId: string, newName: string) => void; + onDelete: (padId: string) => void; + onClose: () => void; +} + +// Popover component +const Popover: React.FC<{ + onCloseRequest: () => void; + top: number; + left: number; + fitInViewport?: boolean; + offsetLeft?: number; + offsetTop?: number; + viewportWidth?: number; + viewportHeight?: number; + children: React.ReactNode; +}> = ({ + onCloseRequest, + top, + left, + children, + fitInViewport = false, + offsetLeft = 0, + offsetTop = 0, + viewportWidth = window.innerWidth, + viewportHeight = window.innerHeight +}) => { + const popoverRef = useRef(null); + + // Handle clicks outside the popover to close it + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + onCloseRequest(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onCloseRequest]); + + // Adjust position if needed to fit in viewport + useEffect(() => { + if (fitInViewport && popoverRef.current) { + const rect = popoverRef.current.getBoundingClientRect(); + const adjustedLeft = Math.min(left, viewportWidth - rect.width); + const adjustedTop = Math.min(top, viewportHeight - rect.height); + + if (popoverRef.current) { + popoverRef.current.style.left = `${adjustedLeft}px`; + popoverRef.current.style.top = `${adjustedTop}px`; + } + } + }, [fitInViewport, left, top, viewportWidth, viewportHeight]); + + return ( +
+ {children} +
+ ); +}; + +// ContextMenu component +const ContextMenu: React.FC = ({ + actionManager, + items, + top, + left, + onClose +}) => { + // Filter items based on predicate + const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { + if ( + item && + (item === CONTEXT_MENU_SEPARATOR || + !item.predicate || + item.predicate()) + ) { + acc.push(item); + } + return acc; + }, []); + + return ( + { + onClose(); + }} + top={top} + left={left} + fitInViewport={true} + viewportWidth={window.innerWidth} + viewportHeight={window.innerHeight} + > +
    event.preventDefault()} + > + {filteredItems.map((item, idx) => { + if (item === CONTEXT_MENU_SEPARATOR) { + if ( + !filteredItems[idx - 1] || + filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR + ) { + return null; + } + return
    ; + } + + const actionName = item.name; + let label = ""; + if (item.label) { + if (typeof item.label === "function") { + label = item.label(); + } else { + label = item.label; + } + } + + return ( +
  • { + // Log the click + console.debug('[pad.ws] Menu item clicked:', item.name); + + // Store the callback to execute after closing + const callback = () => { + actionManager.executeAction(item, "contextMenu"); + }; + + // Close the menu and execute the callback + onClose(callback); + }} + > + +
  • + ); + })} +
+
+ ); +}; + +// Simple ActionManager implementation for the tab context menu +class TabActionManager implements ActionManager { + padId: string; + padName: string; + onRename: (padId: string, newName: string) => void; + onDelete: (padId: string) => void; + app: any; + + constructor( + padId: string, + padName: string, + onRename: (padId: string, newName: string) => void, + onDelete: (padId: string) => void + ) { + this.padId = padId; + this.padName = padName; + this.onRename = onRename; + this.onDelete = onDelete; + this.app = { props: {} }; + } + + executeAction(action: Action, source: string) { + console.debug('[pad.ws] Executing action:', action.name, 'from source:', source); + + if (action.name === 'rename') { + const newName = window.prompt('Rename pad', this.padName); + if (newName && newName.trim() !== '') { + this.onRename(this.padId, newName); + } + } else if (action.name === 'delete') { + console.debug('[pad.ws] Attempting to delete pad:', this.padId, this.padName); + if (window.confirm(`Are you sure you want to delete "${this.padName}"?`)) { + console.debug('[pad.ws] User confirmed delete, calling onDelete'); + this.onDelete(this.padId); + } + } + } +} + +// Main TabContextMenu component +const TabContextMenu: React.FC = ({ + x, + y, + padId, + padName, + onRename, + onDelete, + onClose +}) => { + // Create an action manager instance + const actionManager = new TabActionManager(padId, padName, onRename, onDelete); + + // Define menu items + const menuItems = [ + { + name: 'rename', + label: 'Rename', + predicate: () => true, + }, + CONTEXT_MENU_SEPARATOR, // Add separator between rename and delete + { + name: 'delete', + label: 'Delete', + predicate: () => true, + dangerous: true, + } + ]; + + // Create a wrapper for onClose that handles the callback + const handleClose = (callback?: () => void) => { + console.debug('[pad.ws] TabContextMenu handleClose called, has callback:', !!callback); + + // First call the original onClose + onClose(); + + // Then execute the callback if provided + if (callback) { + callback(); + } + }; + + return ( + + ); +}; + +export default TabContextMenu; \ No newline at end of file diff --git a/src/frontend/src/ui/Tabs.scss b/src/frontend/src/ui/Tabs.scss new file mode 100644 index 0000000..0d944df --- /dev/null +++ b/src/frontend/src/ui/Tabs.scss @@ -0,0 +1,123 @@ +.tabs-bar { + margin-inline-start: 0.6rem; + height: var(--lg-button-size); + position: relative; + + Button { + height: var(--lg-button-size) !important; + width: 100px !important; + min-width: 100px !important; + margin-right: 0.6rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + &.active-pad { + background-color: #cc6d24 !important; + color: var(--color-on-primary) !important; + font-weight: bold; + border: 1px solid #cccccc !important; + + .tab-position { + color: var(--color-on-primary) !important; + } + } + + &.creating-pad { + opacity: 0.6; + cursor: not-allowed; + } + + .tab-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + + + .tab-position { + position: absolute; + bottom: -7px; + right: -4px; + font-size: 9px; + opacity: 0.7; + color: var(--keybinding-color); + font-weight: normal; + } + } + } + + .tabs-wrapper { + display: flex; + flex-direction: row; + align-items: center; + position: relative; + } + + .tabs-container { + display: flex; + flex-direction: row; + align-items: center; + position: relative; + + .loading-indicator { + font-size: 0.8rem; + color: var(--color-muted); + margin-right: 0.5rem; + } + } + + .scroll-buttons-container { + display: flex; + flex-direction: row; + align-items: center; + } + + .scroll-button { + height: var(--lg-button-size) !important; + width: var(--lg-button-size) !important; // Square button + display: flex; + align-items: center; + justify-content: center; + background-color: var(--button-bg, var(--island-bg-color)); + border: none; + cursor: pointer; + z-index: 1; + margin-right: 0.6rem !important; + border-radius: var(--border-radius-lg); + transition: background-color 0.2s ease; + color: #bdbdbd; // Light gray color for the icons + flex-shrink: 0; // Prevent button from shrinking + min-width: unset !important; // Override any min-width inheritance + max-width: unset !important; // Override any max-width inheritance + + &:hover:not(.disabled) { + color: #ffffff; + } + + &:active:not(.disabled) { + color: #ffffff; + } + + &.disabled { + color: #575757; // Light gray color for the icons + opacity: 1; + cursor: default; + } + + &.left { + margin-right: 4px; // Add a small margin between left button and tabs + } + + } + + .new-tab-button-container { + Button { + border: none !important; + min-width: auto !important; + width: var(--lg-button-size) !important; + } + } +} \ No newline at end of file diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx new file mode 100644 index 0000000..2d4bed8 --- /dev/null +++ b/src/frontend/src/ui/Tabs.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; + +import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; +import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; +import { FilePlus2, ChevronLeft, ChevronRight } from "lucide-react"; + +// Removed: import { usePadTabs } from "../hooks/usePadTabs"; +import { usePad } from "../hooks/usePadData"; // Keep usePad for isPadLoading and padError +import { capture } from "../lib/posthog"; +import TabContextMenu from "./TabContextMenu"; +import "./Tabs.scss"; + +// Define PadTab type if not already globally available or imported from usePadTabs +interface PadTab { + id: string; + title: string; + createdAt: string; + updatedAt: string; +} + +interface TabsProps { + excalidrawAPI: ExcalidrawImperativeAPI; + tabs: PadTab[]; + selectedTabId: string | null; // Can be null if no tab is selected + isLoading: boolean; // Loading state for the tab list + isCreatingPad: boolean; + createNewPadAsync: () => Promise; // Adjusted based on usePadTabs return + renamePad: (args: { padId: string; newName: string }) => void; + deletePad: (padId: string) => void; + selectTab: (tabId: string) => void; +} + +const Tabs: React.FC = ({ + excalidrawAPI, + tabs, + selectedTabId, + isLoading, + isCreatingPad, + createNewPadAsync, + renamePad, + deletePad, + selectTab, +}) => { + // Use the usePad hook to handle loading pad data when selectedTabId changes + // Note: selectedTabId comes from props now + const { isLoading: isPadLoading, error: padError } = usePad(selectedTabId, excalidrawAPI); + + const appState = excalidrawAPI.getAppState(); + const [startPadIndex, setStartPadIndex] = useState(0); + const PADS_PER_PAGE = 5; + + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + padId: string; + padName: string; + }>({ + visible: false, + x: 0, + y: 0, + padId: '', + padName: '' + }); + + const handlePadSelect = (pad: PadTab) => { + selectTab(pad.id); + }; + + const handleCreateNewPad = async () => { + if (isCreatingPad) return; + + try { + const newPad = await createNewPadAsync(); + + if (newPad) { + capture("pad_created", { + padId: newPad.id, + padName: newPad.title + }); + + // Scroll to the new tab if it's off-screen + // The `tabs` list will be updated by react-query. Need to wait for that update. + // This logic might need to be in a useEffect that watches `tabs` and `selectedTabId`. + // For now, we assume `usePadTabs` handles selection, and scrolling might need adjustment. + const newPadIndex = tabs.findIndex(tab => tab.id === newPad.id); // This will be based on PREVIOUS tabs list + if (newPadIndex !== -1) { // This check might be problematic due to timing + const newStartIndex = Math.max(0, Math.min(newPadIndex - PADS_PER_PAGE + 1, tabs.length - PADS_PER_PAGE)); + setStartPadIndex(newStartIndex); + } else { + // If newPad is not in current `tabs` (due to async update), try to scroll to end + if (tabs.length >= PADS_PER_PAGE) { + setStartPadIndex(Math.max(0, tabs.length + 1 - PADS_PER_PAGE)); + } + } + } + } catch (error) { + console.error('Error creating new pad:', error); + } + }; + + // Adjust scrolling logic when tabs array changes (e.g. after delete) + useEffect(() => { + if (tabs && startPadIndex > 0 && startPadIndex + PADS_PER_PAGE > tabs.length) { + const newIndex = Math.max(0, tabs.length - PADS_PER_PAGE); + setStartPadIndex(newIndex); + } + }, [tabs, startPadIndex, PADS_PER_PAGE]); + + + const showPreviousPads = () => { + const newIndex = Math.max(0, startPadIndex - 1); + setStartPadIndex(newIndex); + }; + + const showNextPads = () => { + if (tabs) { + const newIndex = Math.min(startPadIndex + 1, Math.max(0, tabs.length - PADS_PER_PAGE)); + setStartPadIndex(newIndex); + } + }; + + const tabsWrapperRef = useRef(null); + const lastWheelTimeRef = useRef(0); + const wheelThrottleMs = 70; + + useLayoutEffect(() => { + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const now = Date.now(); + if (now - lastWheelTimeRef.current < wheelThrottleMs) { + return; + } + lastWheelTimeRef.current = now; + + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + if (e.deltaX > 0 && tabs && startPadIndex < tabs.length - PADS_PER_PAGE) { + showNextPads(); + } else if (e.deltaX < 0 && startPadIndex > 0) { + showPreviousPads(); + } + } else { + if (e.deltaY > 0 && tabs && startPadIndex < tabs.length - PADS_PER_PAGE) { + showNextPads(); + } else if (e.deltaY < 0 && startPadIndex > 0) { + showPreviousPads(); + } + } + }; + + const localTabsWrapperRef = tabsWrapperRef.current; + if (localTabsWrapperRef) { + localTabsWrapperRef.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + localTabsWrapperRef.removeEventListener('wheel', handleWheel); + }; + } + }, [tabs, startPadIndex, PADS_PER_PAGE, showNextPads, showPreviousPads]); + + return ( + <> +
+ +
+ {!appState.viewModeEnabled && ( + <> +
+
+ {} : handleCreateNewPad} + className={isCreatingPad ? "creating-pad" : ""} + children={ +
+ +
+ } + /> + } /> +
+ +
+ {isLoading && !isPadLoading && ( +
+ Loading pads... +
+ )} + {isPadLoading && ( +
+ Loading pad content... +
+ )} + + {!isLoading && !isPadLoading && tabs && tabs.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((tab, index) => ( +
{ + e.preventDefault(); + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + padId: tab.id, + padName: tab.title + }); + }} + > + {(selectedTabId === tab.id || tab.title.length > 11) ? ( + 11 + ? `${tab.title} (current pad)` + : "Current pad") + : tab.title + } + children={ +
+ ))} + +
+ + {tabs && tabs.length > PADS_PER_PAGE && ( + + 0 ? `\n(${startPadIndex} more)` : ''}`} + children={ + + } + /> + + )} + + {tabs && tabs.length > PADS_PER_PAGE && ( + + 0 ? `\n(${Math.max(0, tabs.length - (startPadIndex + PADS_PER_PAGE))} more)` : ''}`} + children={ + + } + /> + + )} +
+ + )} +
+
+
+ + {contextMenu.visible && ( + { + capture("pad_renamed", { padId, newName }); + renamePad({ padId, newName }); + }} + onDelete={(padId) => { + if (tabs && tabs.length <= 1) { + alert("Cannot delete the last pad"); + return; + } + + const tabToDelete = tabs?.find(t => t.id === padId); + const padName = tabToDelete?.title || ""; + capture("pad_deleted", { padId, padName }); + + if (padId === selectedTabId && tabs) { + const otherTab = tabs.find(t => t.id !== padId); + if (otherTab) { + // Before deleting, select another tab. + // The actual deletion will trigger a list update and selection adjustment in usePadTabs. + selectTab(otherTab.id); + // It might be better to let usePadTabs handle selection after delete. + // For now, explicitly select, then delete. + } + } + deletePad(padId); + }} + onClose={() => { + setContextMenu(prev => ({ ...prev, visible: false })); + }} + /> + )} + + ); +}; + +export default Tabs; From 62f421373100fcdcbae456c6d9c2b8bab473cc33 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 00:23:15 +0000 Subject: [PATCH 035/149] feat: add DevTools component for collaboration event management and AppState editing --- src/frontend/src/CustomEmbeddableRenderer.tsx | 5 + src/frontend/src/pad/DevTools.scss | 489 ++++++++++ src/frontend/src/pad/DevTools.tsx | 839 ++++++++++++++++++ 3 files changed, 1333 insertions(+) create mode 100644 src/frontend/src/pad/DevTools.scss create mode 100644 src/frontend/src/pad/DevTools.tsx diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index f9c7a0d..4023add 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -9,6 +9,7 @@ import { ControlButton, Editor, Terminal, + DevTools, } from './pad'; import { ActionButton } from './pad/buttons'; import "./CustomEmbeddableRenderer.scss"; @@ -63,6 +64,10 @@ export const renderCustomEmbeddable = ( content = ; title = "Dashboard"; break; + case 'dev': + content = ; + title = "Dev Tools"; + break; default: title = "Untitled"; return null; diff --git a/src/frontend/src/pad/DevTools.scss b/src/frontend/src/pad/DevTools.scss new file mode 100644 index 0000000..4c6d17e --- /dev/null +++ b/src/frontend/src/pad/DevTools.scss @@ -0,0 +1,489 @@ +.dev-tools { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + + &__header { + display: flex; + flex-direction: column; + padding: 8px 12px; + background-color: #1e1e1e; + border-bottom: 1px solid #333; + + h2 { + margin: 0; + font-size: 14px; + color: #e0e0e0; + } + } + + &__sections { + display: flex; + gap: 8px; + margin-bottom: 8px; + } + + &__section { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 13px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #3a3a3a; + } + + &.active { + background-color: #3a3a3a; + border-color: #666; + } + + svg { + color: #4caf50; + } + } + + &__tabs { + display: flex; + gap: 8px; + justify-content: flex-end; + } + + &__tab { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #3a3a3a; + } + + &.active { + background-color: #3a3a3a; + border-color: #666; + } + + svg { + color: #4caf50; + } + } + + &__pointer-tracker { + background-color: #2d2d2d; + border-bottom: 1px solid #444; + padding: 8px 12px; + display: flex; + flex-direction: column; + + &-header { + display: flex; + align-items: center; + margin-bottom: 4px; + } + + &-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + background-color: #3a3a3a; + margin-right: 8px; + color: #4caf50; + } + + &-title { + font-size: 12px; + font-weight: 500; + color: #e0e0e0; + display: flex; + align-items: center; + } + + &-coords { + display: flex; + align-items: center; + font-family: monospace; + font-size: 12px; + color: #e0e0e0; + padding-left: 32px; + + span { + margin-right: 16px; + } + } + + &-time { + color: #a0a0a0; + font-size: 10px; + margin-left: auto; + } + } + + &__content { + flex: 1; + overflow: hidden; + position: relative; + } + + &__collab-container { + display: flex; + height: 100%; + width: 100%; + gap: 8px; + } + + // Wrapper for the two event lists (Sending & Received) + &__collab-events-wrapper { + display: flex; + flex-direction: column; // Arrange sending/received one on top of the other + flex: 1; // Takes 1 part of space in __collab-container (shares with __collab-details) + gap: 8px; // Gap between sending and received lists + overflow: hidden; // Prevent children from expanding this wrapper + } + + &__emit-container { + display: flex; + height: 100%; + width: 100%; + gap: 8px; + } + + &__emit-controls { + width: 250px; + border: 1px solid #333; + border-radius: 4px; + display: flex; + flex-direction: column; + background-color: #1e1e1e; + overflow: hidden; + } + + &__emit-header { + padding: 12px; + border-bottom: 1px solid #444; + + h3 { + margin: 0 0 8px 0; + font-size: 14px; + color: #e0e0e0; + } + + p { + margin: 0; + font-size: 12px; + color: #a0a0a0; + line-height: 1.4; + } + } + + &__emit-form { + padding: 12px; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__emit-field { + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 12px; + color: #e0e0e0; + } + + select { + padding: 6px 8px; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + + &:focus { + outline: none; + border-color: #666; + } + } + } + + &__emit-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 12px; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #3a3a3a; + } + + svg { + color: #4caf50; + } + } + + &__emit-editor { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid #333; + border-radius: 4px; + overflow: hidden; + } + + &__collab-events { + width: 250px; + border: 1px solid #333; + border-radius: 4px; + display: flex; + flex-direction: column; + background-color: #1e1e1e; + overflow: hidden; + + // Styles for when __collab-events is a direct child of __collab-events-wrapper + // This ensures the two lists (Sending & Received) share space equally within their wrapper. + .dev-tools__collab-events-wrapper > & { + width: auto; // Override the general fixed width below + flex-basis: 0; // Distribute space based on flex-grow + flex-grow: 1; // Each list will grow equally to fill half of the wrapper + flex-shrink: 1; // Allow shrinking if the wrapper itself is constrained + min-width: 0; // Crucial for allowing flex items to shrink below their content size + } + } + + &__collab-events-header { + padding: 6px 10px; + background-color: #2d2d2d; + border-bottom: 1px solid #444; + font-size: 12px; + font-weight: 500; + color: #e0e0e0; + } + + &__collab-events-list { + flex: 1; + overflow-y: auto; + padding: 4px; + } + + &__collab-empty { + padding: 12px; + color: #a0a0a0; + font-size: 12px; + text-align: center; + font-style: italic; + } + + &__collab-event-item { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 4px; + margin-bottom: 4px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #2a2a2a; + } + + &.active { + background-color: #3a3a3a; + } + } + + &__collab-event-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + background-color: #2d2d2d; + margin-right: 8px; + color: #e0e0e0; + } + + &__collab-event-info { + flex: 1; + overflow: hidden; + } + + &__collab-event-type { + font-size: 12px; + font-weight: 500; + color: #e0e0e0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + display: flex; + align-items: center; + } + + &__collab-event-live-indicator { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #4caf50; + margin-left: 6px; + display: inline-block; + animation: pulse 1.5s infinite; + } + + @keyframes pulse { + 0% { + opacity: 0.4; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.4; + } + } + + &__collab-event-time { + font-size: 10px; + color: #a0a0a0; + } + + &__collab-details { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid #333; + border-radius: 4px; + overflow: hidden; + } + + &__editor-header { + padding: 6px 10px; + background-color: #2d2d2d; + border-bottom: 1px solid #444; + font-size: 12px; + font-weight: 500; + color: #e0e0e0; + } + + // AppState Editor Styles + &__appstate-container { + display: flex; + height: 100%; + width: 100%; + gap: 8px; + } + + &__appstate-controls { + width: 250px; + border: 1px solid #333; + border-radius: 4px; + display: flex; + flex-direction: column; + background-color: #1e1e1e; + overflow: hidden; + } + + &__appstate-header { + padding: 12px; + border-bottom: 1px solid #444; + + h3 { + margin: 0 0 8px 0; + font-size: 14px; + color: #e0e0e0; + } + + p { + margin: 0; + font-size: 12px; + color: #a0a0a0; + line-height: 1.4; + } + } + + &__appstate-actions { + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + } + + &__appstate-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 12px; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover:not(:disabled) { + background-color: #3a3a3a; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + color: #4caf50; + + &.rotating { + animation: rotate 1.5s linear infinite; + } + } + } + + &__appstate-editor { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid #333; + border-radius: 4px; + overflow: hidden; + } + + @keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + } \ No newline at end of file diff --git a/src/frontend/src/pad/DevTools.tsx b/src/frontend/src/pad/DevTools.tsx new file mode 100644 index 0000000..f5c6cca --- /dev/null +++ b/src/frontend/src/pad/DevTools.tsx @@ -0,0 +1,839 @@ +import React, { useState, useEffect, useRef } from 'react'; +import MonacoEditor from '@monaco-editor/react'; +import { MousePointer, Edit, Clock, Move, Settings, Plus, Trash2, Radio, Send, RefreshCw, Save } from 'lucide-react'; +import './DevTools.scss'; +import { CollabEvent, CollabEventType } from '../lib/collab'; +import { useAuthStatus } from '../hooks/useAuthStatus'; + +interface DevToolsProps { + element?: any; // Excalidraw element + appState?: any; // Excalidraw app state + excalidrawAPI?: any; // Excalidraw API instance +} + +// Enum for DevTools sections and tabs +type DevToolsSection = 'collab' | 'appstate' | 'testing'; +type DevToolsTab = 'receive' | 'emit'; + +interface CollabLogData { + id: string; + timestamp: string; + type: CollabEventType; + data: CollabEvent; +} + +const DevTools: React.FC = ({ element, appState, excalidrawAPI }) => { + // Active section and tab states + const [activeSection, setActiveSection] = useState('collab'); + const [activeTab, setActiveTab] = useState('receive'); + + // AppState editor state + const [currentAppState, setCurrentAppState] = useState('{}'); + const [isAppStateLoading, setIsAppStateLoading] = useState(false); + + // Get user profile to determine own user ID + const { data: userProfile } = useAuthStatus(); + const currentUserId = userProfile?.id; + + // Store collaboration events + const [sendingLogs, setSendingLogs] = useState([]); + const [receivedLogs, setReceivedLogs] = useState([]); + // Current collab log to display + const [selectedLog, setSelectedLog] = useState(null); + + // Emit tab state + const [selectedEventType, setSelectedEventType] = useState('pointer_down'); + const [emitEventData, setEmitEventData] = useState('{\n "type": "pointer_down",\n "timestamp": 0,\n "pointer": {\n "x": 100,\n "y": 100\n },\n "button": "left"\n}'); + + // Testing section state + const [collaboratorCount, setCollaboratorCount] = useState(0); + const [roomId, setRoomId] = useState("test-room"); + const [isConnected, setIsConnected] = useState(false); + const [ws, setWs] = useState(null); + + + // Subscribe to all collaboration events for logging and local cursor updates + useEffect(() => { + const handleCollabEvent = (event: CustomEvent) => { + const collabEvent: CollabEvent = event.detail; + + // For all other event types, log them + const newCollabLog: CollabLogData = { + id: `collab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date(collabEvent.timestamp).toISOString(), + type: collabEvent.type, + data: collabEvent, + }; + + const eventEmitterId = collabEvent.emitter?.userId; + if (currentUserId && eventEmitterId === currentUserId) { + setSendingLogs(prevLogs => [newCollabLog, ...prevLogs].slice(0, 50)); // Keep last 50 sent + } else { + setReceivedLogs(prevLogs => [newCollabLog, ...prevLogs].slice(0, 50)); // Keep last 50 received + } + // Auto-select the newest log for display in the JSON viewer + setSelectedLog(newCollabLog); + }; + + document.addEventListener('collabEvent', handleCollabEvent as EventListener); + return () => { + document.removeEventListener('collabEvent', handleCollabEvent as EventListener); + }; + }, [currentUserId]); // Dependencies: currentUserId. State setters are stable. + + // Format collaboration event data as pretty JSON + const formatCollabEventData = (log: CollabLogData | null) => { + if (!log) return "{}"; + + // Format based on event type + switch (log.type) { + case 'pointer_down': + case 'pointer_up': + return JSON.stringify({ + type: log.type, + timestamp: new Date(log.data.timestamp).toLocaleString(), + emitter: log.data.emitter, + pointer: log.data.pointer, + button: log.data.button + }, null, 2); + + case 'elements_added': + return JSON.stringify({ + type: log.type, + timestamp: new Date(log.data.timestamp).toLocaleString(), + emitter: log.data.emitter, + addedElements: log.data.elements, + count: log.data.elements?.length || 0 + }, null, 2); + + case 'elements_edited': + return JSON.stringify({ + type: log.type, + timestamp: new Date(log.data.timestamp).toLocaleString(), + emitter: log.data.emitter, + editedElements: log.data.elements, + count: log.data.elements?.length || 0 + }, null, 2); + + case 'elements_deleted': + return JSON.stringify({ + type: log.type, + timestamp: new Date(log.data.timestamp).toLocaleString(), + emitter: log.data.emitter, + deletedElements: log.data.elements, + count: log.data.elements?.length || 0 + }, null, 2); + + case 'appstate_changed': + return JSON.stringify({ + type: log.type, + timestamp: new Date(log.data.timestamp).toLocaleString(), + emitter: log.data.emitter, + appState: log.data.appState + }, null, 2); + + default: + // For pointer_move and any other types, include emitter if present + return JSON.stringify({ + ...log.data, + timestamp: new Date(log.data.timestamp).toLocaleString(), // Ensure timestamp is formatted + }, null, 2); + } + }; + + // Get icon for collaboration event type + const getCollabEventIcon = (type: CollabEventType) => { + switch (type) { + case 'pointer_down': + case 'pointer_up': + return ; + case 'pointer_move': + return ; + case 'elements_added': + return ; + case 'elements_edited': + return ; + case 'elements_deleted': + return ; + case 'appstate_changed': + return ; + default: + return ; + } + }; + + // Function to emit a custom event + const handleEmitEvent = () => { + try { + // Parse the event data from the editor + const eventData = JSON.parse(emitEventData); + + // Ensure the event has a timestamp + if (!eventData.timestamp) { + eventData.timestamp = Date.now(); + } + + // Ensure the event has the correct type + eventData.type = selectedEventType; + + // Dispatch the event + const collabEvent = new CustomEvent('collabEvent', { + detail: eventData + }); + document.dispatchEvent(collabEvent); + + // Show success message + console.log('[DevTools] Emitted event:', eventData); + } catch (error) { + console.error('[DevTools] Error emitting event:', error); + alert(`Error emitting event: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + // Function to generate template event data based on selected type + const generateTemplateEventData = (type: CollabEventType): string => { + const timestamp = Date.now(); + + switch (type) { + case 'pointer_down': + case 'pointer_up': + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" }, + pointer: { x: 100, y: 100 }, + button: 'left' + }, null, 2); + + case 'pointer_move': + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" }, + pointer: { x: 100, y: 100 } + }, null, 2); + + case 'elements_added': + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" }, + elements: [{ + id: 'element-1', + type: 'rectangle', + x: 100, + y: 100, + width: 100, + height: 100, + version: 1 + }] + }, null, 2); + + case 'elements_edited': + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" }, + elements: [{ + id: 'element-1', + type: 'rectangle', + x: 150, + y: 150, + width: 100, + height: 100, + version: 2 + }] + }, null, 2); + + case 'elements_deleted': + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" }, + elements: [{ + id: 'element-1' + }] + }, null, 2); + + case 'appstate_changed': + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" }, + appState: { + viewBackgroundColor: '#ffffff' + } + }, null, 2); + + default: + return JSON.stringify({ + type, + timestamp, + emitter: { userId: "test" } + }, null, 2); + } + }; + + // Update event data template when event type changes + useEffect(() => { + setEmitEventData(generateTemplateEventData(selectedEventType)); + }, [selectedEventType]); + + // Effect to refresh emitEventData when Emit tab is selected + useEffect(() => { + if (activeTab === 'emit') { + setEmitEventData(generateTemplateEventData(selectedEventType)); + } + // Note: We don't need to do anything special for the 'receive' tab here, + // as its content is driven by `selectedLog` which updates independently. + }, [activeTab, selectedEventType]); // Re-run if activeTab or selectedEventType changes + + // Function to refresh the AppState + const refreshAppState = () => { + if (!excalidrawAPI) return; + + setIsAppStateLoading(true); + try { + const currentState = excalidrawAPI.getAppState(); + setCurrentAppState(JSON.stringify(currentState, null, 2)); + } catch (error) { + console.error('[DevTools] Error fetching AppState:', error); + } finally { + setIsAppStateLoading(false); + } + }; + + // Function to update the AppState + const updateAppState = () => { + if (!excalidrawAPI) return; + + try { + const newAppState = JSON.parse(currentAppState); + + // Fix collaborators issue (similar to the fix in canvas.ts) + // Check if collaborators is an empty object ({}) or undefined + const isEmptyObject = newAppState.collaborators && + Object.keys(newAppState.collaborators).length === 0 && + Object.getPrototypeOf(newAppState.collaborators) === Object.prototype; + + if (!newAppState.collaborators || isEmptyObject) { + // Apply the fix only if collaborators is empty or undefined + newAppState.collaborators = new Map(); + } + + excalidrawAPI.updateScene({ appState: newAppState }); + console.log('[DevTools] AppState updated successfully'); + } catch (error) { + console.error('[DevTools] Error updating AppState:', error); + alert(`Error updating AppState: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + // Initialize AppState when component mounts or section changes to appstate + useEffect(() => { + if (activeSection === 'appstate' && excalidrawAPI) { + refreshAppState(); + } + }, [activeSection, excalidrawAPI]); + + // WebSocket connection functions + const handleConnect = () => { + if (!roomId.trim()) { + alert("Please enter a Room ID."); + return; + } + if (ws && ws.readyState === WebSocket.OPEN) { + console.log("Already connected."); + return; + } + + // Ensure userId is set + if (!currentUserId) { + console.error("User ID not set, cannot connect."); + alert("User ID not available. Please ensure you are logged in and try again."); + return; + } + + // Set emitter info for outgoing events + if (userProfile) { + // Import from lib/collab if needed + // setRoomEmitterInfo(currentUserId, userProfile.given_name, userProfile.username); + } + + const wsUrl = `wss://alex.pad.ws/ws/collab/${roomId.trim()}`; + console.log(`Attempting to connect to WebSocket: ${wsUrl} with userId: ${currentUserId}`); + + const newWs = new WebSocket(wsUrl); + setWs(newWs); + + newWs.onopen = () => { + console.log(`Connected to room: ${roomId}`); + setIsConnected(true); + }; + + newWs.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + + // Dispatch as a custom event for room.ts to handle + if (message.emitter?.userId !== currentUserId) { // Basic check to avoid self-echo + const collabEvent = new CustomEvent('collabEvent', { detail: message }); + document.dispatchEvent(collabEvent); + } + } catch (error) { + console.error("Failed to parse incoming message or dispatch event:", error); + } + }; + + newWs.onerror = (error) => { + console.error("WebSocket error:", error); + alert(`WebSocket error. Check console.`); + setIsConnected(false); + }; + + newWs.onclose = (event) => { + console.log(`Disconnected from room: ${roomId}. Code: ${event.code}, Reason: ${event.reason}`); + setIsConnected(false); + setWs(null); + }; + }; + + const handleDisconnect = () => { + if (ws) { + ws.close(); + setWs(null); + } + setIsConnected(false); + }; + + // Listen to 'collabEvent' from room.ts and send it via WebSocket + useEffect(() => { + const handleSendMessage = (event: Event) => { + if (event instanceof CustomEvent && event.detail && isConnected && ws && ws.readyState === WebSocket.OPEN && currentUserId) { + // Only send if this client is the emitter + if (event.detail.emitter?.userId === currentUserId) { + const messageWithEmitter = { + ...event.detail, + emitter: event.detail.emitter || { userId: currentUserId } + }; + ws.send(JSON.stringify(messageWithEmitter)); + } + } + }; + + document.addEventListener('collabEvent', handleSendMessage); + return () => { + document.removeEventListener('collabEvent', handleSendMessage); + }; + }, [isConnected, currentUserId, ws]); + + // Function to create a random collaborator in the appstate + const createRandomCollaborator = () => { + if (!excalidrawAPI) { + alert('Excalidraw API not available'); + return; + } + + try { + // Get current appState + const currentState = excalidrawAPI.getAppState(); + + // Ensure collaborators is a Map + if (!currentState.collaborators || !(currentState.collaborators instanceof Map)) { + currentState.collaborators = new Map(); + } + + // Generate a random ID for the collaborator + const randomId = `test-collab-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + + // Create a random position for the collaborator + const randomX = Math.floor(Math.random() * 1000); + const randomY = Math.floor(Math.random() * 1000); + + // Create the collaborator object + const newCollaborator = { + userId: randomId, + displayName: `Test User ${collaboratorCount + 1}`, + x: randomX, + y: randomY, + isCurrentUser: false, + pointer: { x: randomX, y: randomY }, + button: 'up', + selectedElementIds: {}, + username: `testuser${collaboratorCount + 1}`, + userState: 'active' + }; + + // Add the collaborator to the map + currentState.collaborators.set(randomId, newCollaborator); + + // Update the scene with the new appState + excalidrawAPI.updateScene({ appState: currentState }); + + // Increment the collaborator count + setCollaboratorCount(prev => prev + 1); + + console.log('[DevTools] Created random collaborator:', newCollaborator); + } catch (error) { + console.error('[DevTools] Error creating collaborator:', error); + alert(`Error creating collaborator: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + return ( +
+
+
+ + + +
+ + {activeSection === 'collab' && ( +
+ + +
+ )} +
+ + {/* AppState Editor */} + {activeSection === 'appstate' && ( +
+
+
+

AppState Editor

+

View and modify the current Excalidraw AppState.

+
+ +
+ + + +
+
+ +
+
AppState (JSON)
+ setCurrentAppState(value || '{}')} + options={{ + readOnly: false, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + automaticLayout: true, + wordWrap: 'on' + }} + /> +
+
+ )} + + {/* Collaboration Events Content */} + {activeSection === 'collab' && activeTab === 'receive' && ( +
+
+
+ +
+
+ Cursor Positions (Canvas Coordinates) +
+
+
+ )} + +
+ {activeSection === 'collab' && activeTab === 'receive' ? ( +
+
+ {/* Sending Events List */} +
+
+ Sending Events +
+
+ {sendingLogs.map((log) => ( +
setSelectedLog(log)} + > +
+ {getCollabEventIcon(log.type)} +
+
+
+ {log.type} +
+
+ {new Date(log.timestamp).toLocaleTimeString()} +
+
+
+ ))} + {sendingLogs.length === 0 && ( +
+ No sending events yet. +
+ )} +
+
+ + {/* Received Events List */} +
+
+ Received Events +
+
+ {receivedLogs.map((log) => ( +
setSelectedLog(log)} + > +
+ {getCollabEventIcon(log.type)} +
+
+
+ {log.type} +
+
+ {new Date(log.timestamp).toLocaleTimeString()} +
+
+
+ ))} + {receivedLogs.length === 0 && ( +
+ No received events yet. +
+ )} +
+
+
+
+
Event Details
+ +
+
+ ) : activeSection === 'collab' && activeTab === 'emit' ? ( +
+
+
+

Emit Custom Event

+

Create and emit custom collaboration events to test your application.

+
+ +
+
+ + +
+ + +
+
+ +
+
Event Data (JSON)
+ setEmitEventData(value || '')} + options={{ + readOnly: false, // Explicitly set to false + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + automaticLayout: true, + wordWrap: 'on' + }} + /> +
+
+ ) : activeSection === 'testing' ? ( +
+
+
+

Testing Tools

+

Various tools for testing Excalidraw functionality.

+
+ +
+

Collaborator Tools

+ + + {collaboratorCount > 0 && ( +
+ {collaboratorCount} collaborator{collaboratorCount !== 1 ? 's' : ''} added +
+ )} + +
+

WebSocket Connection

+ +
+ setRoomId(e.target.value)} + placeholder="Room ID" + disabled={isConnected} + style={{ + width: '90%', + padding: '8px', + backgroundColor: '#2d2d2d', + border: '1px solid #444', + borderRadius: '4px', + color: '#e0e0e0', + fontSize: '12px', + marginBottom: '8px' + }} + /> + + {isConnected ? ( + + ) : ( + + )} +
+ +
+ {isConnected ? ( + Connected to room: {roomId} + ) : ( + Not connected + )} +
+
+
+
+ +
+
Testing Information
+
+

This section provides tools for testing Excalidraw functionality:

+
    +
  • Add Random Collaborator: Creates a random collaborator in the appstate with a random position.
  • +
  • WebSocket Connection: Connect to a collaboration room to send and receive events in real-time.
  • +
+

WebSocket Connection Usage:

+
    +
  1. Enter a room ID in the input field.
  2. +
  3. Click "Connect" to establish a WebSocket connection.
  4. +
  5. Once connected, all collaboration events will be sent to and received from the server.
  6. +
  7. Use the "Emit Custom Event" tab in the Collaboration Events section to send test events.
  8. +
  9. Click "Disconnect" to close the connection.
  10. +
+
+
+
+ ) : null} +
+
+ ); +}; + +export default DevTools; \ No newline at end of file From 74900ef9aba6d2d8d4288754f24c17c45763cb70 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 00:24:10 +0000 Subject: [PATCH 036/149] feat: integrate user authentication status in MainMenu component --- src/frontend/src/ui/MainMenu.tsx | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 00ef1a4..b9c8f64 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -8,6 +8,7 @@ import AccountDialog from './AccountDialog'; import md5 from 'crypto-js/md5'; // import { capture } from '../utils/posthog'; import { useLogout } from '../hooks/useLogout'; +import { useAuthStatus } from '../hooks/useAuthStatus'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/elementFactory'; import "./MainMenu.scss"; @@ -33,29 +34,17 @@ export const MainMenuConfig: React.FC = ({ }) => { const [showAccountModal, setShowAccountModal] = useState(false); const { mutate: logoutMutation, isPending: isLoggingOut } = useLogout(); - - const data = { // TODO - id: '1234567890', - email: 'test@example.com', - username: 'testuser', - name: 'Test User', - given_name: 'Test', - family_name: 'User', - email_verified: true, - } - - const isLoading = false; //TODO - const isError = false; //TODO + const { user, isLoading, isError } = useAuthStatus(); let username = ""; let email = ""; if (isLoading) { username = "Loading..."; - } else if (isError || !data?.username) { + } else if (isError || !user?.username) { username = "Unknown"; } else { - username = data.username; - email = data.email || ""; + username = user.username; + email = user.email || ""; } const handleDashboardButtonClick = () => { From 32d31f5454c9ab030371222c9c68daeadd813974 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 00:45:26 +0000 Subject: [PATCH 037/149] fix delete --- src/backend/domain/pad.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index b3e60c5..93ef598 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -82,13 +82,31 @@ async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad'] if await redis.exists(cache_key): cached_data = await redis.hgetall(cache_key) if cached_data: + pad_id = UUID(cached_data['id']) + owner_id = UUID(cached_data['owner_id']) + display_name = cached_data['display_name'] + data = json.loads(cached_data['data']) + created_at = datetime.fromisoformat(cached_data['created_at']) + updated_at = datetime.fromisoformat(cached_data['updated_at']) + + # Create a minimal PadStore instance + store = PadStore( + id=pad_id, + owner_id=owner_id, + display_name=display_name, + data=data, + created_at=created_at, + updated_at=updated_at + ) + pad_instance = cls( - id=UUID(cached_data['id']), - owner_id=UUID(cached_data['owner_id']), - display_name=cached_data['display_name'], - data=json.loads(cached_data['data']), - created_at=datetime.fromisoformat(cached_data['created_at']), - updated_at=datetime.fromisoformat(cached_data['updated_at']) + id=pad_id, + owner_id=owner_id, + display_name=display_name, + data=data, + created_at=created_at, + updated_at=updated_at, + store=store ) return pad_instance except (json.JSONDecodeError, KeyError, ValueError) as e: @@ -216,8 +234,11 @@ async def get_recent_events(self, count: int = 100) -> list[Dict[str, Any]]: async def delete(self, session: AsyncSession) -> bool: """Delete the pad from both database and cache""" + print(f"Deleting pad {self.id}", flush=True) + print(f"self._store: {self._store}", flush=True) if self._store: success = await self._store.delete(session) + if success: try: await self.invalidate_cache() From 6642c0550d383354b64581877b2c1da85d32d5c1 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 01:25:04 +0000 Subject: [PATCH 038/149] feat: enhance pad management with optimistic temporary pad creation and improved loading states --- src/frontend/src/App.tsx | 10 +- src/frontend/src/hooks/usePadData.ts | 34 +++-- src/frontend/src/hooks/usePadTabs.ts | 143 +++++++++++++++++++--- src/frontend/src/hooks/usePadWebSocket.ts | 26 ++-- src/frontend/src/pad/index.ts | 2 + src/frontend/src/ui/TabContextMenu.tsx | 5 - src/frontend/src/ui/Tabs.tsx | 79 ++++++------ 7 files changed, 205 insertions(+), 94 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 4e6f5a5..a1332db 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -22,20 +22,12 @@ import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRende import { debounce } from './lib/debounce'; import Tabs from "./ui/Tabs"; -const defaultInitialData = { +export const defaultInitialData = { elements: [], appState: { gridModeEnabled: true, gridSize: 20, gridStep: 5, - pad: { - moduleBorderOffset: { - top: 40, - right: 5, - bottom: 5, - left: 5, - }, - }, }, files: {}, }; diff --git a/src/frontend/src/hooks/usePadData.ts b/src/frontend/src/hooks/usePadData.ts index 395411f..663af1c 100644 --- a/src/frontend/src/hooks/usePadData.ts +++ b/src/frontend/src/hooks/usePadData.ts @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { ExcalidrawElement } from "@atyrode/excalidraw/element/types"; import { normalizeCanvasData } from '../lib/canvas'; - +import { defaultInitialData } from '../App'; interface PadData { elements?: readonly ExcalidrawElement[]; appState?: Pick; @@ -27,20 +27,40 @@ const fetchPadById = async (padId: string): Promise => { return response.json(); }; -export const usePad = (padId: string, excalidrawAPI: ExcalidrawImperativeAPI | null) => { +export const usePad = (padId: string | null, excalidrawAPI: ExcalidrawImperativeAPI | null) => { + const isTemporaryPad = padId?.startsWith('temp-'); + const { data, isLoading, error, isError } = useQuery({ queryKey: ['pad', padId], - queryFn: () => fetchPadById(padId), - enabled: !!padId, // Only run the query if padId is provided + queryFn: () => { + if (!padId) throw new Error("padId is required"); + return fetchPadById(padId); + }, + enabled: !!padId && !isTemporaryPad, }); useEffect(() => { - if (data && excalidrawAPI) { + if (isTemporaryPad && excalidrawAPI) { + console.log(`[pad.ws] Initializing new temporary pad ${padId}`); + excalidrawAPI.updateScene(defaultInitialData); + return; + } + + if (data && excalidrawAPI && !isTemporaryPad) { const normalizedData = normalizeCanvasData(data); console.log(`[pad.ws] Loading pad ${padId}`); excalidrawAPI.updateScene(normalizedData); } - }, [data, excalidrawAPI, padId]); + }, [data, excalidrawAPI, padId, isTemporaryPad]); + + if (isTemporaryPad) { + return { + padData: defaultInitialData, + isLoading: false, + error: null, + isError: false, + }; + } return { padData: data, @@ -48,4 +68,4 @@ export const usePad = (padId: string, excalidrawAPI: ExcalidrawImperativeAPI | n error, isError }; -}; \ No newline at end of file +}; diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 472a0d6..0cb5ac4 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useState, useEffect } from 'react'; -interface Tab { +export interface Tab { id: string; title: string; createdAt: string; @@ -122,24 +122,65 @@ export const usePadTabs = () => { }, [data, isLoading]); - const createPadMutation = useMutation({ // Result: Tab, Error, Variables: void - mutationFn: createNewPad, // createNewPad now returns Promise - onSuccess: (newlyCreatedTab) => { - // Optimistically update the cache and select the new tab - queryClient.setQueryData(['padTabs'], (oldData) => { - const newTabs = oldData ? [...oldData.tabs, newlyCreatedTab] : [newlyCreatedTab]; - // Determine the activeTabId for PadResponse. If oldData exists, use its activeTabId, - // otherwise, it's the first fetch, so newTab is the one. - // However, fetchUserPads sets activeTabId to tabs[0].id. - // For consistency, let's mimic that or just ensure tabs are updated. - const currentActiveId = oldData?.activeTabId || newlyCreatedTab.id; + const createPadMutation = useMutation({ + mutationFn: createNewPad, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ['padTabs'] }); + const previousTabsResponse = queryClient.getQueryData(['padTabs']); + + const tempTabId = `temp-${Date.now()}`; + const tempTab: Tab = { + id: tempTabId, + title: 'New pad', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + queryClient.setQueryData(['padTabs'], (old) => { + const newTabs = old ? [...old.tabs, tempTab] : [tempTab]; + return { + tabs: newTabs, + activeTabId: old?.activeTabId || tempTab.id, // Keep old active or use new if first + }; + }); + setSelectedTabId(tempTabId); + + return { previousTabsResponse, tempTabId }; + }, + onError: (err, variables, context) => { + if (context?.previousTabsResponse) { + queryClient.setQueryData(['padTabs'], context.previousTabsResponse); + } + // Revert selectedTabId if it was the temporary one + if (selectedTabId === context?.tempTabId && context?.previousTabsResponse?.activeTabId) { + setSelectedTabId(context.previousTabsResponse.activeTabId); + } else if (selectedTabId === context?.tempTabId && context?.previousTabsResponse?.tabs && context.previousTabsResponse.tabs.length > 0) { + setSelectedTabId(context.previousTabsResponse.tabs[0].id); + } else if (selectedTabId === context?.tempTabId) { + setSelectedTabId(''); + } + // Optionally: display error to user + }, + onSuccess: (newlyCreatedTab, variables, context) => { + queryClient.setQueryData(['padTabs'], (old) => { + if (!old) return { tabs: [newlyCreatedTab], activeTabId: newlyCreatedTab.id }; + const newTabs = old.tabs.map(tab => + tab.id === context?.tempTabId ? newlyCreatedTab : tab + ); + // If the temp tab wasn't found (e.g., cache was cleared), add the new tab + if (!newTabs.find(tab => tab.id === newlyCreatedTab.id)) { + newTabs.push(newlyCreatedTab); + } return { tabs: newTabs, - activeTabId: currentActiveId // This might not be strictly necessary if selectedTabId drives UI + activeTabId: old.activeTabId === context?.tempTabId ? newlyCreatedTab.id : old.activeTabId, }; }); - setSelectedTabId(newlyCreatedTab.id); - // Invalidate to ensure eventual consistency with the backend + if (selectedTabId === context?.tempTabId) { + setSelectedTabId(newlyCreatedTab.id); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['padTabs'] }); }, }); @@ -157,9 +198,33 @@ export const usePadTabs = () => { } }; - const renamePadMutation = useMutation({ + const renamePadMutation = useMutation({ mutationFn: renamePadAPI, - onSuccess: () => { + onMutate: async ({ padId, newName }) => { + await queryClient.cancelQueries({ queryKey: ['padTabs'] }); + const previousTabsResponse = queryClient.getQueryData(['padTabs']); + let oldName: string | undefined; + + queryClient.setQueryData(['padTabs'], (old) => { + if (!old) return undefined; + const newTabs = old.tabs.map(tab => { + if (tab.id === padId) { + oldName = tab.title; + return { ...tab, title: newName, updatedAt: new Date().toISOString() }; + } + return tab; + }); + return { ...old, tabs: newTabs }; + }); + return { previousTabsResponse, padId, oldName }; + }, + onError: (err, variables, context) => { + if (context?.previousTabsResponse) { + queryClient.setQueryData(['padTabs'], context.previousTabsResponse); + } + // Optionally: display error to user + }, + onSettled: (data, error, variables, context) => { queryClient.invalidateQueries({ queryKey: ['padTabs'] }); }, }); @@ -173,9 +238,47 @@ export const usePadTabs = () => { } }; - const deletePadMutation = useMutation({ - mutationFn: deletePadAPI, - onSuccess: () => { + const deletePadMutation = useMutation({ + mutationFn: deletePadAPI, // padId is the variable passed to mutate + onMutate: async (padIdToDelete) => { + await queryClient.cancelQueries({ queryKey: ['padTabs'] }); + const previousTabsResponse = queryClient.getQueryData(['padTabs']); + const previousSelectedTabId = selectedTabId; + let deletedTab: Tab | undefined; + + queryClient.setQueryData(['padTabs'], (old) => { + if (!old) return { tabs: [], activeTabId: '' }; + deletedTab = old.tabs.find(tab => tab.id === padIdToDelete); + const newTabs = old.tabs.filter(tab => tab.id !== padIdToDelete); + + let newSelectedTabId = selectedTabId; + if (selectedTabId === padIdToDelete) { + if (newTabs.length > 0) { + const currentIndex = old.tabs.findIndex(tab => tab.id === padIdToDelete); + newSelectedTabId = newTabs[Math.max(0, currentIndex -1)]?.id || newTabs[0]?.id; + } else { + newSelectedTabId = ''; + } + setSelectedTabId(newSelectedTabId); + } + + return { + tabs: newTabs, + activeTabId: newSelectedTabId, // Update activeTabId in cache as well + }; + }); + return { previousTabsResponse, previousSelectedTabId, deletedTab }; + }, + onError: (err, padId, context) => { + if (context?.previousTabsResponse) { + queryClient.setQueryData(['padTabs'], context.previousTabsResponse); + } + if (context?.previousSelectedTabId) { + setSelectedTabId(context.previousSelectedTabId); + } + // Optionally: display error to user + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['padTabs'] }); }, }); diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 89bf741..ff2f262 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -20,7 +20,7 @@ export const usePadWebSocket = (padId: string | null) => { wsRef.current.close(); // wsRef.current = null; // Let onclose handle this } - return; + return; } // Close existing connection if any (from a *different* padId) @@ -46,31 +46,31 @@ export const usePadWebSocket = (padId: string | null) => { wsRef.current = ws; ws.onopen = () => { - console.log(`[WebSocket] Connected to pad ${padId}`); + console.log(`[pad.ws] Connected to pad ${padId}`); }; ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data); - console.log(`[WebSocket] Received message:`, message); + console.log(`[pad.ws] Received message:`, message); // Handle different message types here switch (message.type) { case 'connected': - console.log(`[WebSocket] Successfully connected to pad ${message.pad_id}`); + console.log(`[pad.ws] Successfully connected to pad ${message.pad_id}`); break; case 'user_joined': - console.log(`[WebSocket] User ${message.user_id} joined pad ${message.pad_id}`); + console.log(`[pad.ws] User ${message.user_id} joined pad ${message.pad_id}`); break; case 'user_left': - console.log(`[WebSocket] User ${message.user_id} left pad ${message.pad_id}`); + console.log(`[pad.ws] User ${message.user_id} left pad ${message.pad_id}`); break; case 'pad_update': - console.log(`[WebSocket] Pad ${message.pad_id} updated by user ${message.user_id}`); + console.log(`[pad.ws] Pad ${message.pad_id} updated by user ${message.user_id}`); break; default: // Default handler for any message type - console.log(`[WebSocket] Received ${message.type} message:`, { + console.log(`[pad.ws] Received ${message.type} message:`, { pad_id: message.pad_id, user_id: message.user_id, timestamp: message.timestamp, @@ -78,23 +78,23 @@ export const usePadWebSocket = (padId: string | null) => { }); } } catch (error) { - console.error('[WebSocket] Error parsing message:', error); + console.error('[pad.ws] Error parsing message:', error); } }; ws.onerror = (error) => { - console.error('[WebSocket] Error:', error); + console.error('[pad.ws] Error:', error); }; ws.onclose = () => { // Removed event param as it's not used after removing logs - console.log(`[WebSocket] Disconnected from pad ${padId}`); + console.log(`[pad.ws] Disconnected from pad ${padId}`); // Only nullify if wsRef.current is THIS instance that just closed if (wsRef.current === ws) { wsRef.current = null; } }; - return () => { + return () => { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(); } @@ -102,7 +102,7 @@ export const usePadWebSocket = (padId: string | null) => { }, [padId, user]); useEffect(() => { - const cleanup = connect(); + const cleanup = connect(); return () => { cleanup?.(); }; diff --git a/src/frontend/src/pad/index.ts b/src/frontend/src/pad/index.ts index 4da81bb..0c2bb78 100644 --- a/src/frontend/src/pad/index.ts +++ b/src/frontend/src/pad/index.ts @@ -5,9 +5,11 @@ export * from './Dashboard'; export * from './Terminal'; export * from './buttons'; export * from './editors'; +export * from './DevTools'; // Default exports export { default as ControlButton } from './buttons/ControlButton'; export { default as StateIndicator } from './StateIndicator'; export { default as Dashboard } from './Dashboard'; export { default as Terminal } from './Terminal'; +export { default as DevTools } from './DevTools'; \ No newline at end of file diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index 9a3e671..fe625cf 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -270,12 +270,7 @@ const TabContextMenu: React.FC = ({ // Create a wrapper for onClose that handles the callback const handleClose = (callback?: () => void) => { - console.debug('[pad.ws] TabContextMenu handleClose called, has callback:', !!callback); - - // First call the original onClose onClose(); - - // Then execute the callback if provided if (callback) { callback(); } diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index 2d4bed8..048c57b 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -4,27 +4,19 @@ import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; import { FilePlus2, ChevronLeft, ChevronRight } from "lucide-react"; -// Removed: import { usePadTabs } from "../hooks/usePadTabs"; -import { usePad } from "../hooks/usePadData"; // Keep usePad for isPadLoading and padError +import { usePad } from "../hooks/usePadData"; +import type { Tab } from "../hooks/usePadTabs"; import { capture } from "../lib/posthog"; import TabContextMenu from "./TabContextMenu"; import "./Tabs.scss"; -// Define PadTab type if not already globally available or imported from usePadTabs -interface PadTab { - id: string; - title: string; - createdAt: string; - updatedAt: string; -} - interface TabsProps { excalidrawAPI: ExcalidrawImperativeAPI; - tabs: PadTab[]; - selectedTabId: string | null; // Can be null if no tab is selected - isLoading: boolean; // Loading state for the tab list + tabs: Tab[]; + selectedTabId: string | null; + isLoading: boolean; isCreatingPad: boolean; - createNewPadAsync: () => Promise; // Adjusted based on usePadTabs return + createNewPadAsync: () => Promise; renamePad: (args: { padId: string; newName: string }) => void; deletePad: (padId: string) => void; selectTab: (tabId: string) => void; @@ -41,9 +33,8 @@ const Tabs: React.FC = ({ deletePad, selectTab, }) => { - // Use the usePad hook to handle loading pad data when selectedTabId changes - // Note: selectedTabId comes from props now const { isLoading: isPadLoading, error: padError } = usePad(selectedTabId, excalidrawAPI); + const [displayPadLoadingIndicator, setDisplayPadLoadingIndicator] = useState(false); const appState = excalidrawAPI.getAppState(); const [startPadIndex, setStartPadIndex] = useState(0); @@ -63,7 +54,7 @@ const Tabs: React.FC = ({ padName: '' }); - const handlePadSelect = (pad: PadTab) => { + const handlePadSelect = (pad: Tab) => { selectTab(pad.id); }; @@ -79,16 +70,11 @@ const Tabs: React.FC = ({ padName: newPad.title }); - // Scroll to the new tab if it's off-screen - // The `tabs` list will be updated by react-query. Need to wait for that update. - // This logic might need to be in a useEffect that watches `tabs` and `selectedTabId`. - // For now, we assume `usePadTabs` handles selection, and scrolling might need adjustment. - const newPadIndex = tabs.findIndex(tab => tab.id === newPad.id); // This will be based on PREVIOUS tabs list - if (newPadIndex !== -1) { // This check might be problematic due to timing + const newPadIndex = tabs.findIndex((tab: { id: any; }) => tab.id === newPad.id); + if (newPadIndex !== -1) { const newStartIndex = Math.max(0, Math.min(newPadIndex - PADS_PER_PAGE + 1, tabs.length - PADS_PER_PAGE)); setStartPadIndex(newStartIndex); } else { - // If newPad is not in current `tabs` (due to async update), try to scroll to end if (tabs.length >= PADS_PER_PAGE) { setStartPadIndex(Math.max(0, tabs.length + 1 - PADS_PER_PAGE)); } @@ -99,7 +85,6 @@ const Tabs: React.FC = ({ } }; - // Adjust scrolling logic when tabs array changes (e.g. after delete) useEffect(() => { if (tabs && startPadIndex > 0 && startPadIndex + PADS_PER_PAGE > tabs.length) { const newIndex = Math.max(0, tabs.length - PADS_PER_PAGE); @@ -107,6 +92,25 @@ const Tabs: React.FC = ({ } }, [tabs, startPadIndex, PADS_PER_PAGE]); + useEffect(() => { + let timer: NodeJS.Timeout; + if (isPadLoading && selectedTabId) { + if (!displayPadLoadingIndicator) { + timer = setTimeout(() => { + if (isPadLoading) { + setDisplayPadLoadingIndicator(true); + } + }, 200); + } + } else { + setDisplayPadLoadingIndicator(false); + } + + return () => { + clearTimeout(timer); + }; + }, [isPadLoading, selectedTabId, displayPadLoadingIndicator]); + const showPreviousPads = () => { const newIndex = Math.max(0, startPadIndex - 1); @@ -190,16 +194,11 @@ const Tabs: React.FC = ({ Loading pads...
)} - {isPadLoading && ( -
- Loading pad content... -
- )} - {!isLoading && !isPadLoading && tabs && tabs.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((tab, index) => ( + {!isLoading && tabs && tabs.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((tab: Tab, index: any) => (
{ + onContextMenu={(e: { preventDefault: () => void; clientX: any; clientY: any; }) => { e.preventDefault(); setContextMenu({ visible: true, @@ -225,9 +224,9 @@ const Tabs: React.FC = ({ className={selectedTabId === tab.id ? "active-pad" : ""} children={
- {tab.title.length > 8 ? `${tab.title.substring(0, 11)}...` : tab.title} + {selectedTabId === tab.id && displayPadLoadingIndicator ? "..." : (tab.title.length > 8 ? `${tab.title.substring(0, 11)}...` : tab.title)} {/* Calculate position based on overall index in `tabs` if needed, or `startPadIndex + index + 1` */} - {tabs.findIndex(t => t.id === tab.id) + 1} + {tabs.findIndex((t: { id: any; }) => t.id === tab.id) + 1}
} /> @@ -240,7 +239,7 @@ const Tabs: React.FC = ({ children={
{tab.title} - {tabs.findIndex(t => t.id === tab.id) + 1} + {tabs.findIndex((t: { id: any; }) => t.id === tab.id) + 1}
} /> @@ -298,22 +297,22 @@ const Tabs: React.FC = ({ y={contextMenu.y} padId={contextMenu.padId} padName={contextMenu.padName} - onRename={(padId, newName) => { + onRename={(padId: any, newName: any) => { capture("pad_renamed", { padId, newName }); renamePad({ padId, newName }); }} - onDelete={(padId) => { + onDelete={(padId: any) => { if (tabs && tabs.length <= 1) { alert("Cannot delete the last pad"); return; } - const tabToDelete = tabs?.find(t => t.id === padId); + const tabToDelete = tabs?.find((t: { id: any; }) => t.id === padId); const padName = tabToDelete?.title || ""; capture("pad_deleted", { padId, padName }); if (padId === selectedTabId && tabs) { - const otherTab = tabs.find(t => t.id !== padId); + const otherTab = tabs.find((t: { id: any; }) => t.id !== padId); if (otherTab) { // Before deleting, select another tab. // The actual deletion will trigger a list update and selection adjustment in usePadTabs. @@ -325,7 +324,7 @@ const Tabs: React.FC = ({ deletePad(padId); }} onClose={() => { - setContextMenu(prev => ({ ...prev, visible: false })); + setContextMenu((prev: any) => ({ ...prev, visible: false })); }} /> )} From 906edf479cd2eb7d5f88cd620c9fc772e1b0ae4b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 01:30:05 +0000 Subject: [PATCH 039/149] feat: update page title and add DevTools option in MainMenu for enhanced user experience --- src/frontend/index.html | 2 +- src/frontend/src/ui/MainMenu.tsx | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/frontend/index.html b/src/frontend/index.html index 95645b3..808cf73 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -8,7 +8,7 @@ /> - Pad.ws + Whiteboard IDE — pad.ws diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index b9c8f64..02f0ac1 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types'; import type { MainMenu as MainMenuType } from '@atyrode/excalidraw'; -import { LogOut, SquarePlus, LayoutDashboard, User, Text, Settings, Terminal, FileText } from 'lucide-react'; +import { LogOut, SquarePlus, LayoutDashboard, User, Text, Settings, Terminal, FileText, FlaskConical } from 'lucide-react'; import AccountDialog from './AccountDialog'; import md5 from 'crypto-js/md5'; // import { capture } from '../utils/posthog'; @@ -63,6 +63,22 @@ export const MainMenuConfig: React.FC = ({ }); }; + const handleDevToolsClick = () => { + if (!excalidrawAPI) return; + + const devToolsElement = ExcalidrawElementFactory.createEmbeddableElement({ + link: "!dev", + width: 800, + height: 500 + }); + + ExcalidrawElementFactory.placeInScene(devToolsElement, excalidrawAPI, { + mode: PlacementMode.NEAR_VIEWPORT_CENTER, + bufferPercentage: 10, + scrollToView: true + }); + }; + const handleInsertButtonClick = () => { if (!excalidrawAPI) return; @@ -245,6 +261,12 @@ export const MainMenuConfig: React.FC = ({ > Action Button + } + onClick={handleDevToolsClick} + > + Dev. Tools + From 26be48c88876d0c336d91e07d324c452488d6f55 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 01:38:57 +0000 Subject: [PATCH 040/149] feat: add border offsets to customData in default.json and update package.json with lodash dependencies --- src/backend/templates/default.json | 8 +++++++- src/frontend/package.json | 4 +++- src/frontend/yarn.lock | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/backend/templates/default.json b/src/backend/templates/default.json index 0629611..17383ab 100644 --- a/src/backend/templates/default.json +++ b/src/backend/templates/default.json @@ -2180,7 +2180,13 @@ "backgroundColor": "#e9ecef", "customData": { "showHyperlinkIcon": false, - "showClickableHint": false + "showClickableHint": false, + "borderOffsets": { + "top": 40, + "right": 10, + "bottom": 10, + "left": 10 + } } } ] diff --git a/src/frontend/package.json b/src/frontend/package.json index 794d508..4f3e3b7 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "with-script-in-browser", + "name": "pad.ws", "version": "1.0.0", "private": true, "dependencies": { @@ -11,6 +11,8 @@ "browser-fs-access": "0.29.1", "clsx": "^2.1.1", "crypto-js": "^4.2.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", "lucide-react": "^0.488.0", "posthog-js": "^1.236.0", "react": "19.0.0", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 95cf347..68f0604 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1264,7 +1264,12 @@ lodash.debounce@4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.throttle@4.1.1: +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + +lodash.throttle@4.1.1, lodash.throttle@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== From 7e214d9e85b8b9991eda1410e81e68447e4af900 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 01:59:00 +0000 Subject: [PATCH 041/149] refactor: update type imports and clean up unused hooks in App component --- src/frontend/src/App.tsx | 8 ++------ src/frontend/src/hooks/usePadTabs.ts | 7 ++----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a1332db..3f5e103 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,13 +1,11 @@ import React, { useState, useEffect, useCallback } from "react"; import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; -import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; +import type { ExcalidrawEmbeddableElement, NonDeleted, NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; // Hooks import { useAuthStatus } from "./hooks/useAuthStatus"; -import { useAppConfig } from "./hooks/useAppConfig"; import { usePadTabs } from "./hooks/usePadTabs"; -import { usePad } from "./hooks/usePadData"; import { usePadWebSocket } from "./hooks/usePadWebSocket"; // Components @@ -34,7 +32,6 @@ export const defaultInitialData = { export default function App() { const { isAuthenticated } = useAuthStatus(); - const { config: appConfig, isLoadingConfig, configError } = useAppConfig(); const { tabs, selectedTabId, @@ -49,7 +46,6 @@ export default function App() { const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - const { padData } = usePad(selectedTabId, excalidrawAPI); const { sendMessage, isConnected } = usePadWebSocket(selectedTabId); const handleCloseSettingsModal = () => { @@ -103,7 +99,7 @@ export default function App() { name="Pad.ws" onScrollChange={handleOnScrollChange} validateEmbeddable={true} - renderEmbeddable={(element, appState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} + renderEmbeddable={(element: NonDeleted, appState: AppState) => renderCustomEmbeddable(element, appState, excalidrawAPI)} renderTopRightUI={() => (
diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 0cb5ac4..1a6547f 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -59,25 +59,22 @@ const fetchUserPads = async (): Promise => { }; }; -// Assuming the backend returns an object with these fields for a new pad interface NewPadApiResponse { id: string; display_name: string; created_at: string; updated_at: string; - // Potentially other fields like 'data' if the full pad object is returned } -const createNewPad = async (): Promise => { // Return type is Tab +const createNewPad = async (): Promise => { const response = await fetch('/api/pad/new', { method: 'POST', }); if (!response.ok) { - // Try to parse error message from backend let errorMessage = 'Failed to create new pad'; try { const errorData = await response.json(); - if (errorData && errorData.detail) { // FastAPI often uses 'detail' + if (errorData && errorData.detail) { errorMessage = errorData.detail; } else if (errorData && errorData.message) { errorMessage = errorData.message; From 524e27c9a295d23407e5dc1acb3523bea51bbf94 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 02:07:11 +0000 Subject: [PATCH 042/149] fix tabs rename --- src/backend/database/models/pad_model.py | 37 ++++++++++++++++------- src/backend/database/models/user_model.py | 2 +- src/backend/domain/pad.py | 14 --------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/backend/database/models/pad_model.py b/src/backend/database/models/pad_model.py index 86b181c..8d690d9 100644 --- a/src/backend/database/models/pad_model.py +++ b/src/backend/database/models/pad_model.py @@ -66,18 +66,33 @@ async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> List['PadS return result.scalars().all() async def save(self, session: AsyncSession) -> 'PadStore': - """Save the current pad state""" - if self.id is None: - session.add(self) - await session.commit() - await session.refresh(self) - return self - - async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'PadStore': - """Update the pad's data""" - self.data = data + """Update the pad in the database""" self.updated_at = datetime.now() - return await self.save(session) + try: + # Just execute the update statement without adding to session + stmt = update(self.__class__).where(self.__class__.id == self.id).values( + owner_id=self.owner_id, + display_name=self.display_name, + data=self.data, + updated_at=self.updated_at + ) + await session.execute(stmt) + await session.commit() + + # After update, get the fresh object from the database + refreshed = await self.get_by_id(session, self.id) + if refreshed: + # Update this object's attributes from the database + self.owner_id = refreshed.owner_id + self.display_name = refreshed.display_name + self.data = refreshed.data + self.created_at = refreshed.created_at + self.updated_at = refreshed.updated_at + + return self + except Exception as e: + print(f"Error saving pad {self.id}: {str(e)}", flush=True) + raise e async def delete(self, session: AsyncSession) -> bool: """Delete the pad""" diff --git a/src/backend/database/models/user_model.py b/src/backend/database/models/user_model.py index fccea57..50b35cc 100644 --- a/src/backend/database/models/user_model.py +++ b/src/backend/database/models/user_model.py @@ -111,7 +111,7 @@ async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[ PadStore.display_name, PadStore.created_at, PadStore.updated_at - ).where(PadStore.owner_id == user_id) + ).where(PadStore.owner_id == user_id).order_by(PadStore.created_at) result = await session.execute(stmt) pads = result.all() diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 93ef598..3347158 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -181,20 +181,6 @@ async def save(self, session: AsyncSession) -> 'Pad': return self - async def update_data(self, session: AsyncSession, data: Dict[str, Any]) -> 'Pad': - """Update the pad's data and refresh cache""" - self.data = data - self.updated_at = datetime.now() - if self._store: - self._store = await self._store.update_data(session, data) - - try: - await self.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {self.id} after update: {str(e)}") - - return self - async def broadcast_event(self, event_type: str, event_data: Dict[str, Any]) -> None: """Broadcast an event to all connected clients""" redis = await get_redis_client() From 02f0370a5d781fe96d8ad4e46c4bca8fe60a1815 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 02:27:30 +0000 Subject: [PATCH 043/149] feat: enhance WebSocket connection management with connection state tracking and user expiration handling --- src/frontend/src/hooks/usePadWebSocket.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index ff2f262..26163c8 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { useAuthStatus } from './useAuthStatus'; interface WebSocketMessage { @@ -11,7 +11,8 @@ interface WebSocketMessage { export const usePadWebSocket = (padId: string | null) => { const wsRef = useRef(null); - const { user } = useAuthStatus(); + const { user, expires_in } = useAuthStatus(); + const [isConnected, setIsConnected] = useState(false); const connect = useCallback(() => { if (!padId || !user) { @@ -20,6 +21,7 @@ export const usePadWebSocket = (padId: string | null) => { wsRef.current.close(); // wsRef.current = null; // Let onclose handle this } + setIsConnected(false); // Explicitly set to false return; } @@ -47,6 +49,7 @@ export const usePadWebSocket = (padId: string | null) => { ws.onopen = () => { console.log(`[pad.ws] Connected to pad ${padId}`); + setIsConnected(true); }; ws.onmessage = (event) => { @@ -54,7 +57,6 @@ export const usePadWebSocket = (padId: string | null) => { const message: WebSocketMessage = JSON.parse(event.data); console.log(`[pad.ws] Received message:`, message); - // Handle different message types here switch (message.type) { case 'connected': console.log(`[pad.ws] Successfully connected to pad ${message.pad_id}`); @@ -86,9 +88,9 @@ export const usePadWebSocket = (padId: string | null) => { console.error('[pad.ws] Error:', error); }; - ws.onclose = () => { // Removed event param as it's not used after removing logs + ws.onclose = () => { console.log(`[pad.ws] Disconnected from pad ${padId}`); - // Only nullify if wsRef.current is THIS instance that just closed + setIsConnected(false); if (wsRef.current === ws) { wsRef.current = null; } @@ -99,7 +101,7 @@ export const usePadWebSocket = (padId: string | null) => { ws.close(); } }; - }, [padId, user]); + }, [padId, user, expires_in]); useEffect(() => { const cleanup = connect(); @@ -121,6 +123,6 @@ export const usePadWebSocket = (padId: string | null) => { return { sendMessage, - isConnected: wsRef.current?.readyState === WebSocket.OPEN + isConnected }; }; From 8b9ce99931bab48b7b38ad136ead89989cd1ee72 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 02:42:13 +0000 Subject: [PATCH 044/149] ignore /dev folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 71b2312..2566b3e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ src/excalidraw .env src/frontend/.env.local .vscode/settings.json +dev/ From a407db73dc3a39c90a1747d4093d9e3f1cd0d9a8 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 02:46:06 +0000 Subject: [PATCH 045/149] feat: notify clients of user join events in WebSocket connections --- src/backend/routers/ws_router.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index 1550eeb..6f6d2a3 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -100,6 +100,14 @@ async def websocket_endpoint( field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v for k, v in join_message.items()} await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + + # Notify other clients about user joining + for connection in active_connections[pad_id].copy(): + if connection != websocket and connection.client_state.CONNECTED: + try: + await connection.send_json(join_message) + except Exception: + pass except Exception as e: print(f"Error broadcasting join message: {e}") From 6c44124fe5bd5079bae7b63be45f0b3df421e4c0 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 03:15:19 +0000 Subject: [PATCH 046/149] feat: integrate React Query Devtools for improved debugging and update dependencies --- src/frontend/index.tsx | 2 ++ src/frontend/yarn.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index 7fae3f1..1401655 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -1,6 +1,7 @@ import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; // import posthog from "./src/lib/posthog"; // import { PostHogProvider } from 'posthog-js/react'; @@ -25,6 +26,7 @@ async function initApp() { {/* */} + // , ); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 68f0604..74715f3 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -542,17 +542,17 @@ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.74.9.tgz#35d5b1075663072bea22aa3ce21508b195306ecd" integrity sha512-qmjXpWyigDw4SfqdSBy24FzRvpBPXlaSbl92N77lcrL+yvVQLQkf0T6bQNbTxl9IEB/SvVFhhVZoIlQvFnNuuw== -"@tanstack/query-devtools@5.74.7": - version "5.74.7" - resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.74.7.tgz#c9b022b386ac86e6395228b5d6912e6444b3b971" - integrity sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw== +"@tanstack/query-devtools@5.76.0": + version "5.76.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz#ba43754ed8d23a265ed72f17de618fa9f9c7649d" + integrity sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ== "@tanstack/react-query-devtools@^5.74.3": - version "5.74.11" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.74.11.tgz#81c078d4f202c51065de1735415360b80f2e1e12" - integrity sha512-vx8MzH4WUUk4ZW8uHq7T45XNDgePF5ecRoa7haWJZxDMQyAHM80GGMhEW/yRz6TeyS9UlfTUz2OLPvgGRvvVOA== + version "5.76.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.76.1.tgz#20157a5880df5fd4d4fe8fd4fca2c8663d8dfa3e" + integrity sha512-LFVWgk/VtXPkerNLfYIeuGHh0Aim/k9PFGA+JxLdRaUiroQ4j4eoEqBrUpQ1Pd/KXoG4AB9vVE/M6PUQ9vwxBQ== dependencies: - "@tanstack/query-devtools" "5.74.7" + "@tanstack/query-devtools" "5.76.0" "@tanstack/react-query@^5.74.3": version "5.74.11" From 91f866363f90b38c1626005cee1b103273368924 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 04:07:23 +0000 Subject: [PATCH 047/149] refactor: remove unused expires_in from WebSocket hook to streamline connection logic --- src/frontend/src/hooks/usePadWebSocket.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 26163c8..4567b8c 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -11,7 +11,7 @@ interface WebSocketMessage { export const usePadWebSocket = (padId: string | null) => { const wsRef = useRef(null); - const { user, expires_in } = useAuthStatus(); + const { user } = useAuthStatus(); const [isConnected, setIsConnected] = useState(false); const connect = useCallback(() => { @@ -101,7 +101,7 @@ export const usePadWebSocket = (padId: string | null) => { ws.close(); } }; - }, [padId, user, expires_in]); + }, [padId, user]); useEffect(() => { const cleanup = connect(); From 5f3694fd24dc0178eaa9e0442488f23e167eb86a Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 04:23:05 +0000 Subject: [PATCH 048/149] fix echoing authStatus --- src/frontend/src/hooks/useAuthStatus.ts | 114 ++++++++++++++---------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index 6a1b8dd..45698de 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient, QueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; interface UserInfo { @@ -42,14 +42,80 @@ const refreshSession = async (): Promise => { return response.json(); }; +// Singleton to track if we've already set up the global auth refresher +let isAuthRefresherInitialized = false; + +// Set up a global auth refresher that will be initialized only once +const setupGlobalAuthRefresher = (queryClient: QueryClient) => { + if (isAuthRefresherInitialized) { + return; + } + + console.debug('[pad.ws] Setting up global auth refresh mechanism'); + isAuthRefresherInitialized = true; + + // Function to refresh the session when needed + const refreshSessionIfNeeded = async () => { + // Get the current auth data from the cache + const authData = queryClient.getQueryData(['authStatus']); + + if (!authData?.authenticated || !authData?.expires_in) { + return; + } + + const expiresAt = new Date(Date.now() + authData.expires_in * 1000); + const currentTime = new Date(); + + // If the token has already expired, refetch the status + if (expiresAt < currentTime) { + queryClient.invalidateQueries({ queryKey: ['authStatus'] }); + return; + } + + // Refresh if less than 5 minutes remaining + const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60 * 1000); + if (expiresAt < fiveMinutesFromNow) { + try { + const newData = await refreshSession(); + queryClient.setQueryData(['authStatus'], newData); + console.debug('[pad.ws] Auth session refreshed successfully'); + } catch (error) { + console.error('[pad.ws] Failed to refresh session:', error); + queryClient.invalidateQueries({ queryKey: ['authStatus'] }); + } + } + }; + + // Global storage event listener (for auth popup) + const handleStorageChange = (event: StorageEvent) => { + if (event.key === 'auth_completed') { + console.debug('[pad.ws] Auth completed event detected, refreshing auth status'); + queryClient.invalidateQueries({ queryKey: ['authStatus'] }); + } + }; + + // Set up a single interval for the entire app + const intervalId = setInterval(refreshSessionIfNeeded, 60 * 1000); + window.addEventListener('storage', handleStorageChange); + + // No cleanup function - this is meant to last for the entire app lifecycle + // In a more complete solution, we'd store these and clean them up on app unmount +}; + export const useAuthStatus = () => { const queryClient = useQueryClient(); + // Set up the global auth refresher once + useEffect(() => { + setupGlobalAuthRefresher(queryClient); + }, [queryClient]); + const { data, isLoading, error, isError, refetch } = useQuery({ queryKey: ['authStatus'], queryFn: fetchAuthStatus, }); + // Still calculate expiresAt for components that might need it const expiresAt = useMemo(() => { if (data?.expires_in) { return new Date(Date.now() + data.expires_in * 1000); @@ -57,52 +123,6 @@ export const useAuthStatus = () => { return null; }, [data?.expires_in]); - useEffect(() => { - const refreshSessionIfNeeded = async () => { - if (!data?.authenticated || !expiresAt) { - return; - } - - const currentTime = new Date(); - - // If the token has already expired, refetch the status - if (expiresAt < currentTime) { - refetch(); - return; - } - - // Refresh if less than 5 minutes remaining - const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60 * 1000); - if (expiresAt < fiveMinutesFromNow) { - try { - const newData = await refreshSession(); - queryClient.setQueryData(['authStatus'], newData); - } catch (error) { - console.error('Failed to refresh session:', error); - refetch(); - } - } - }; - - // Handle auth completion from popup - const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'auth_completed') { - refetch(); - } - }; - - // Set up interval to check session expiry - const intervalId = setInterval(refreshSessionIfNeeded, 60 * 1000); - - // Add event listeners - window.addEventListener('storage', handleStorageChange); - - return () => { - clearInterval(intervalId); - window.removeEventListener('storage', handleStorageChange); - }; - }, [data, queryClient, refetch, expiresAt]); - return { isAuthenticated: data?.authenticated ?? undefined, user: data?.user, From fb8cf79b693c3a5ec0f91f649438257ef28b6492 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 04:51:14 +0000 Subject: [PATCH 049/149] keep websocket qlive betzeen refresh --- src/frontend/src/hooks/usePadWebSocket.ts | 121 ++++++++++------------ 1 file changed, 53 insertions(+), 68 deletions(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 4567b8c..61f3f8c 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -11,97 +11,80 @@ interface WebSocketMessage { export const usePadWebSocket = (padId: string | null) => { const wsRef = useRef(null); - const { user } = useAuthStatus(); + const { isAuthenticated, isLoading } = useAuthStatus(); const [isConnected, setIsConnected] = useState(false); + const currentPadIdRef = useRef(null); const connect = useCallback(() => { - if (!padId || !user) { - // Ensure any existing connection is closed if padId becomes null or user logs out + // Don't connect if auth is still loading or requirements aren't met + if (isLoading || !padId || !isAuthenticated) { if (wsRef.current) { wsRef.current.close(); - // wsRef.current = null; // Let onclose handle this + currentPadIdRef.current = null; } - setIsConnected(false); // Explicitly set to false + setIsConnected(false); return; } - // Close existing connection if any (from a *different* padId) - if (wsRef.current && !wsRef.current.url.endsWith(padId)) { - wsRef.current.close(); - // wsRef.current = null; // Let onclose handle setting wsRef.current to null - } else if (wsRef.current && wsRef.current.url.endsWith(padId)) { - // Already connected or connecting to the same padId, do nothing. - // The useEffect dependency on `connect` (which depends on `padId`) handles this. - return () => { // Return the existing cleanup if we're not making a new ws + // Don't reconnect if already connected to same pad + if (wsRef.current && currentPadIdRef.current === padId && wsRef.current.readyState === WebSocket.OPEN) { + return () => { if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { - // This is tricky, if connect is called but we don't make a new ws, - // what cleanup should be returned? The one for the existing ws. - // However, this path should ideally not be hit if deps are correct. - // For safety, we can return a no-op or the existing ws's close. + wsRef.current.close(); + currentPadIdRef.current = null; } }; } + // Close any existing connection before creating a new one + if (wsRef.current) { + wsRef.current.close(); + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/pad/${padId}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - ws.onopen = () => { - console.log(`[pad.ws] Connected to pad ${padId}`); - setIsConnected(true); - }; + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; - ws.onmessage = (event) => { - try { - const message: WebSocketMessage = JSON.parse(event.data); - console.log(`[pad.ws] Received message:`, message); + ws.onopen = () => { + setIsConnected(true); + currentPadIdRef.current = padId; + }; - switch (message.type) { - case 'connected': - console.log(`[pad.ws] Successfully connected to pad ${message.pad_id}`); - break; - case 'user_joined': - console.log(`[pad.ws] User ${message.user_id} joined pad ${message.pad_id}`); - break; - case 'user_left': - console.log(`[pad.ws] User ${message.user_id} left pad ${message.pad_id}`); - break; - case 'pad_update': - console.log(`[pad.ws] Pad ${message.pad_id} updated by user ${message.user_id}`); - break; - default: - // Default handler for any message type - console.log(`[pad.ws] Received ${message.type} message:`, { - pad_id: message.pad_id, - user_id: message.user_id, - timestamp: message.timestamp, - data: message.data - }); + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + // Process message if needed + } catch (error) { + console.error('[pad.ws] Error parsing message:', error); } - } catch (error) { - console.error('[pad.ws] Error parsing message:', error); - } - }; + }; - ws.onerror = (error) => { - console.error('[pad.ws] Error:', error); - }; + ws.onerror = (error) => { + console.error('[pad.ws] WebSocket error:', error); + }; - ws.onclose = () => { - console.log(`[pad.ws] Disconnected from pad ${padId}`); - setIsConnected(false); - if (wsRef.current === ws) { - wsRef.current = null; - } - }; + ws.onclose = () => { + setIsConnected(false); + if (wsRef.current === ws) { + wsRef.current = null; + currentPadIdRef.current = null; + } + }; - return () => { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - }; - }, [padId, user]); + return () => { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + currentPadIdRef.current = null; + } + }; + } catch (error) { + console.error('[pad.ws] Error creating WebSocket:', error); + return () => { }; + } + }, [padId, isAuthenticated, isLoading]); useEffect(() => { const cleanup = connect(); @@ -118,6 +101,8 @@ export const usePadWebSocket = (padId: string | null) => { data, timestamp: new Date().toISOString() })); + } else { + console.warn(`[pad.ws] Cannot send message: WebSocket not connected`); } }, [padId]); From 2b09a6b6f168627a9a9f0c617a5896dda0191a93 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 06:01:47 +0000 Subject: [PATCH 050/149] add reconect logic to websocket hook --- src/frontend/src/hooks/usePadWebSocket.ts | 236 ++++++++++++++++------ 1 file changed, 172 insertions(+), 64 deletions(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 61f3f8c..7afe780 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -9,101 +9,209 @@ interface WebSocketMessage { user_id?: string; } +// WebSocket connection states +enum ConnectionState { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting' +} + +// Connection state object to consolidate multiple refs +interface ConnectionStateRef { + ws: WebSocket | null; + reconnectTimeout: NodeJS.Timeout | null; + reconnectAttempts: number; + currentPadId: string | null; +} + export const usePadWebSocket = (padId: string | null) => { - const wsRef = useRef(null); const { isAuthenticated, isLoading } = useAuthStatus(); - const [isConnected, setIsConnected] = useState(false); - const currentPadIdRef = useRef(null); - - const connect = useCallback(() => { - // Don't connect if auth is still loading or requirements aren't met - if (isLoading || !padId || !isAuthenticated) { - if (wsRef.current) { - wsRef.current.close(); - currentPadIdRef.current = null; + const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED); + + // Consolidated connection state + const connStateRef = useRef({ + ws: null, + reconnectTimeout: null, + reconnectAttempts: 0, + currentPadId: null + }); + + const MAX_RECONNECT_ATTEMPTS = 5; + const INITIAL_RECONNECT_DELAY = 1000; // 1 second + + // Clear any reconnect timeout + const clearReconnectTimeout = useCallback(() => { + if (connStateRef.current.reconnectTimeout) { + clearTimeout(connStateRef.current.reconnectTimeout); + connStateRef.current.reconnectTimeout = null; + } + }, []); + + // Reset connection state + const resetConnection = useCallback(() => { + clearReconnectTimeout(); + connStateRef.current.reconnectAttempts = 0; + connStateRef.current.currentPadId = null; + setConnectionState(ConnectionState.DISCONNECTED); + }, [clearReconnectTimeout]); + + // Function to create and setup a WebSocket connection + const createWebSocket = useCallback((url: string, isReconnecting: boolean) => { + const ws = new WebSocket(url); + connStateRef.current.ws = ws; + + setConnectionState(isReconnecting ? ConnectionState.RECONNECTING : ConnectionState.CONNECTING); + + ws.onopen = () => { + console.log(`[pad.ws] Connection established to pad ${padId}`); + setConnectionState(ConnectionState.CONNECTED); + connStateRef.current.currentPadId = padId; + connStateRef.current.reconnectAttempts = 0; + }; + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + // Process message if needed + } catch (error) { + console.error('[pad.ws] Error parsing message:', error); + } + }; + + ws.onerror = (error) => { + console.error('[pad.ws] WebSocket error:', error); + }; + + ws.onclose = (event) => { + console.log(`[pad.ws] Connection closed with code ${event.code}`); + setConnectionState(ConnectionState.DISCONNECTED); + + if (connStateRef.current.ws === ws) { + connStateRef.current.ws = null; + + // Only attempt to reconnect if it wasn't a normal closure and we have a pad ID + const isAbnormalClosure = event.code !== 1000 && event.code !== 1001; + if (padId && isAbnormalClosure && connStateRef.current.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + attemptReconnect(); + } else if (!isAbnormalClosure) { + resetConnection(); + } + } + }; + + return ws; + }, [padId, resetConnection]); + + // Attempt to reconnect with exponential backoff + const attemptReconnect = useCallback(() => { + clearReconnectTimeout(); + + // Safety check if we've reached max attempts + if (connStateRef.current.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.warn('[pad.ws] Max reconnect attempts reached'); + resetConnection(); + return; + } + + // Calculate delay with exponential backoff + const attempt = connStateRef.current.reconnectAttempts + 1; + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, connStateRef.current.reconnectAttempts); + console.info(`[pad.ws] Reconnecting in ${delay}ms (attempt ${attempt}/${MAX_RECONNECT_ATTEMPTS})`); + + // Increment counter before scheduling reconnect + connStateRef.current.reconnectAttempts = attempt; + + // Schedule reconnect + connStateRef.current.reconnectTimeout = setTimeout(() => { + connectWebSocket(true); + }, delay); + }, [clearReconnectTimeout, resetConnection]); + + // Core connection function + const connectWebSocket = useCallback((isReconnecting = false) => { + // Check if we can/should connect + const canConnect = isAuthenticated && !isLoading && padId; + if (!canConnect) { + // Clean up existing connection if we can't connect now + if (connStateRef.current.ws) { + connStateRef.current.ws.close(); + connStateRef.current.ws = null; + } + setConnectionState(ConnectionState.DISCONNECTED); + + // Preserve reconnection sequence if needed + if (isReconnecting && + connStateRef.current.reconnectAttempts > 0 && + connStateRef.current.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + console.log(`[pad.ws] Can't connect now but preserving reconnection sequence, scheduling next attempt`); + attemptReconnect(); } - setIsConnected(false); return; } // Don't reconnect if already connected to same pad - if (wsRef.current && currentPadIdRef.current === padId && wsRef.current.readyState === WebSocket.OPEN) { - return () => { - if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { - wsRef.current.close(); - currentPadIdRef.current = null; - } - }; + if (connStateRef.current.ws && + connStateRef.current.currentPadId === padId && + connStateRef.current.ws.readyState === WebSocket.OPEN) { + return; } + console.log(`[pad.ws] ${isReconnecting ? 'Re' : ''}Connecting to pad ${padId} (attempt ${isReconnecting ? connStateRef.current.reconnectAttempts : 0}/${MAX_RECONNECT_ATTEMPTS})`); + // Close any existing connection before creating a new one - if (wsRef.current) { - wsRef.current.close(); + if (connStateRef.current.ws) { + connStateRef.current.ws.close(); } + // Create the WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/pad/${padId}`; try { - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - setIsConnected(true); - currentPadIdRef.current = padId; - }; - - ws.onmessage = (event) => { - try { - const message: WebSocketMessage = JSON.parse(event.data); - // Process message if needed - } catch (error) { - console.error('[pad.ws] Error parsing message:', error); - } - }; - - ws.onerror = (error) => { - console.error('[pad.ws] WebSocket error:', error); - }; - - ws.onclose = () => { - setIsConnected(false); - if (wsRef.current === ws) { - wsRef.current = null; - currentPadIdRef.current = null; - } - }; - - return () => { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - currentPadIdRef.current = null; - } - }; + createWebSocket(wsUrl, isReconnecting); } catch (error) { console.error('[pad.ws] Error creating WebSocket:', error); - return () => { }; + attemptReconnect(); } - }, [padId, isAuthenticated, isLoading]); + }, [padId, isAuthenticated, isLoading, createWebSocket, attemptReconnect]); + // Connect when dependencies change useEffect(() => { - const cleanup = connect(); + connectWebSocket(false); + + // Cleanup function - preserve reconnection attempts return () => { - cleanup?.(); + if (connStateRef.current.ws) { + // Only close if this is a normal unmount, not a reconnection attempt + if (connStateRef.current.reconnectAttempts === 0) { + connStateRef.current.ws.close(); + connStateRef.current.currentPadId = null; + } else { + console.log(`[pad.ws] Component unmounting but preserving connection attempt ${connStateRef.current.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + } + } + // Only clear the timeout, don't reset the counter or close active reconnection attempts + clearReconnectTimeout(); }; - }, [connect]); + }, [connectWebSocket, clearReconnectTimeout]); + + // Check if we're connected + const isConnected = connectionState === ConnectionState.CONNECTED; + // Send message over WebSocket const sendMessage = useCallback((type: string, data: any) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ + if (connStateRef.current.ws?.readyState === WebSocket.OPEN) { + connStateRef.current.ws.send(JSON.stringify({ type, pad_id: padId, data, timestamp: new Date().toISOString() })); - } else { - console.warn(`[pad.ws] Cannot send message: WebSocket not connected`); + return true; } + console.warn(`[pad.ws] Cannot send message: WebSocket not connected - changes will not be saved`); + return false; }, [padId]); return { From 1b9e7ac8091fb3b97fd7bf5bd6f92fc9915fda50 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 06:05:55 +0000 Subject: [PATCH 051/149] simplify sendmessage --- src/frontend/src/App.tsx | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 3f5e103..d851042 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -32,15 +32,15 @@ export const defaultInitialData = { export default function App() { const { isAuthenticated } = useAuthStatus(); - const { - tabs, - selectedTabId, - isLoading: isLoadingTabs, - createNewPadAsync, - isCreating: isCreatingPad, - renamePad, - deletePad, - selectTab + const { + tabs, + selectedTabId, + isLoading: isLoadingTabs, + createNewPadAsync, + isCreating: isCreatingPad, + renamePad, + deletePad, + selectTab } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); @@ -54,17 +54,9 @@ export default function App() { const debouncedSendMessage = useCallback( debounce((type: string, data: any) => { - if (isConnected && selectedTabId) { - sendMessage(type, data); - } else if (!isConnected && selectedTabId) { - setTimeout(() => { - if (!isConnected) { - console.log(`[pad.ws] WebSocket not connected - changes will not be saved`); - } - }, 100); - } + sendMessage(type, data); }, 250), - [isConnected, selectedTabId, sendMessage] + [sendMessage] ); const handleOnChange = useCallback((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { From 537c5eaf19427ce9a3022f8be9f1533933c66f5c Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 07:06:17 +0000 Subject: [PATCH 052/149] improved refresh and anthStatus --- src/frontend/src/hooks/useAuthStatus.ts | 144 +++++++-------------- src/frontend/src/lib/authRefreshManager.ts | 86 ++++++++++++ 2 files changed, 131 insertions(+), 99 deletions(-) create mode 100644 src/frontend/src/lib/authRefreshManager.ts diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index 45698de..c65170a 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -1,5 +1,6 @@ -import { useQuery, useQueryClient, QueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { scheduleTokenRefresh, AUTH_STATUS_KEY } from '../lib/authRefreshManager'; interface UserInfo { username?: string; @@ -14,117 +15,62 @@ interface AuthStatusResponse { message?: string; } -const fetchAuthStatus = async (): Promise => { +// API function for getting status +const getAuthStatus = async (): Promise => { const response = await fetch('/api/auth/status'); if (!response.ok) { - let errorMessage = 'Failed to fetch authentication status.'; - try { - const errorData = await response.json(); - if (errorData && errorData.message) { - errorMessage = errorData.message; - } - } catch (e) { - // Ignore if error response is not JSON or empty - } - throw new Error(errorMessage); + throw new Error('Failed to fetch authentication status'); } return response.json(); }; -const refreshSession = async (): Promise => { - const response = await fetch('/api/auth/refresh', { - method: 'POST', - credentials: 'include', - }); - if (!response.ok) { - throw new Error('Failed to refresh session'); - } - return response.json(); -}; - -// Singleton to track if we've already set up the global auth refresher -let isAuthRefresherInitialized = false; - -// Set up a global auth refresher that will be initialized only once -const setupGlobalAuthRefresher = (queryClient: QueryClient) => { - if (isAuthRefresherInitialized) { - return; - } - - console.debug('[pad.ws] Setting up global auth refresh mechanism'); - isAuthRefresherInitialized = true; - - // Function to refresh the session when needed - const refreshSessionIfNeeded = async () => { - // Get the current auth data from the cache - const authData = queryClient.getQueryData(['authStatus']); - - if (!authData?.authenticated || !authData?.expires_in) { - return; - } - - const expiresAt = new Date(Date.now() + authData.expires_in * 1000); - const currentTime = new Date(); - - // If the token has already expired, refetch the status - if (expiresAt < currentTime) { - queryClient.invalidateQueries({ queryKey: ['authStatus'] }); - return; - } - - // Refresh if less than 5 minutes remaining - const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60 * 1000); - if (expiresAt < fiveMinutesFromNow) { - try { - const newData = await refreshSession(); - queryClient.setQueryData(['authStatus'], newData); - console.debug('[pad.ws] Auth session refreshed successfully'); - } catch (error) { - console.error('[pad.ws] Failed to refresh session:', error); - queryClient.invalidateQueries({ queryKey: ['authStatus'] }); - } - } - }; - - // Global storage event listener (for auth popup) - const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'auth_completed') { - console.debug('[pad.ws] Auth completed event detected, refreshing auth status'); - queryClient.invalidateQueries({ queryKey: ['authStatus'] }); - } - }; - - // Set up a single interval for the entire app - const intervalId = setInterval(refreshSessionIfNeeded, 60 * 1000); - window.addEventListener('storage', handleStorageChange); - - // No cleanup function - this is meant to last for the entire app lifecycle - // In a more complete solution, we'd store these and clean them up on app unmount -}; - export const useAuthStatus = () => { const queryClient = useQueryClient(); - // Set up the global auth refresher once + // Main auth status query + const { + data, + isLoading, + error, + isError, + refetch + } = useQuery({ + queryKey: [AUTH_STATUS_KEY], + queryFn: getAuthStatus, + staleTime: 4 * 60 * 1000, // 4 minutes + }); + + // Schedule refresh when auth data changes useEffect(() => { - setupGlobalAuthRefresher(queryClient); - }, [queryClient]); + if (!data?.authenticated || !data?.expires_in) return; + + scheduleTokenRefresh( + data.expires_in, + // Success callback + (refreshedData) => { + queryClient.setQueryData([AUTH_STATUS_KEY], refreshedData); + }, + // Error callback + () => { + queryClient.invalidateQueries({ queryKey: [AUTH_STATUS_KEY] }); + } + ); + }, [data?.authenticated, data?.expires_in, queryClient]); - const { data, isLoading, error, isError, refetch } = useQuery({ - queryKey: ['authStatus'], - queryFn: fetchAuthStatus, - }); + // Handle auth events from popup windows + useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === 'auth_completed') { + queryClient.invalidateQueries({ queryKey: [AUTH_STATUS_KEY] }); + } + }; - // Still calculate expiresAt for components that might need it - const expiresAt = useMemo(() => { - if (data?.expires_in) { - return new Date(Date.now() + data.expires_in * 1000); - } - return null; - }, [data?.expires_in]); + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, [queryClient]); return { - isAuthenticated: data?.authenticated ?? undefined, + isAuthenticated: data?.authenticated, user: data?.user, expires_in: data?.expires_in, isLoading, diff --git a/src/frontend/src/lib/authRefreshManager.ts b/src/frontend/src/lib/authRefreshManager.ts new file mode 100644 index 0000000..d3a1a2e --- /dev/null +++ b/src/frontend/src/lib/authRefreshManager.ts @@ -0,0 +1,86 @@ +// Auth refresh singleton manager +interface UserInfo { + username?: string; + email?: string; + name?: string; +} + +interface AuthStatusResponse { + authenticated: boolean; + user?: UserInfo; + expires_in?: number; + message?: string; +} + +// Refresh API function +const refreshAuth = async (): Promise => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + throw new Error('Failed to refresh session'); + } + return response.json(); +}; + +// Singleton state +let refreshTimer: NodeJS.Timeout | null = null; +let isRefreshScheduled = false; + +/** + * Schedule a token refresh operation. + * Only one refresh will be scheduled at a time across the application. + */ +export const scheduleTokenRefresh = ( + expiresIn: number, + onRefresh: (data: AuthStatusResponse) => void, + onError: (err: Error) => void +): void => { + // Don't schedule if already scheduled + if (isRefreshScheduled) { + return; + } + + const msUntilExpiry = expiresIn * 1000; + const refreshTime = msUntilExpiry - (5 * 60 * 1000); // 5 minutes before expiry + + // Don't schedule if token expires too soon + if (refreshTime <= 0) { + return; + } + + isRefreshScheduled = true; + + // Clear any existing timer first + if (refreshTimer) { + clearTimeout(refreshTimer); + } + + // Set up new timer + refreshTimer = setTimeout(async () => { + try { + const refreshData = await refreshAuth(); + onRefresh(refreshData); + isRefreshScheduled = false; + } catch (err) { + console.error('[pad.ws] Auth refresh failed:', err); + onError(err instanceof Error ? err : new Error(String(err))); + isRefreshScheduled = false; + } + }, refreshTime); +}; + +/** + * Cancel any scheduled token refresh + */ +export const cancelTokenRefresh = (): void => { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + isRefreshScheduled = false; +}; + +// Export auth status query key for consistency +export const AUTH_STATUS_KEY = 'authStatus'; \ No newline at end of file From e5a00450c3b551fb087512f3a3c79332582d217f Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 15:39:03 +0000 Subject: [PATCH 053/149] feat: enhance authentication handling and improve WebSocket connection logic for temporary pads --- src/frontend/src/App.tsx | 4 ++-- src/frontend/src/hooks/usePadWebSocket.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index d851042..47812cb 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -31,7 +31,7 @@ export const defaultInitialData = { }; export default function App() { - const { isAuthenticated } = useAuthStatus(); + const { isAuthenticated, isLoading: isLoadingAuth } = useAuthStatus(); const { tabs, selectedTabId, @@ -105,7 +105,7 @@ export default function App() { setShowSettingsModal={setShowSettingsModal} /> - {!isAuthenticated && ( + {!isLoadingAuth && !isAuthenticated && ( { }} /> )} diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 7afe780..b8db042 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -150,6 +150,18 @@ export const usePadWebSocket = (padId: string | null) => { return; } + if (padId && padId.startsWith('temp-')) { + console.info(`[pad.ws] Holding WebSocket connection for temporary pad ID: ${padId}`); + if (connStateRef.current.ws) { + connStateRef.current.ws.close(); + connStateRef.current.ws = null; + } + clearReconnectTimeout(); + connStateRef.current.reconnectAttempts = 0; + setConnectionState(ConnectionState.DISCONNECTED); + return; + } + // Don't reconnect if already connected to same pad if (connStateRef.current.ws && connStateRef.current.currentPadId === padId && @@ -174,7 +186,7 @@ export const usePadWebSocket = (padId: string | null) => { console.error('[pad.ws] Error creating WebSocket:', error); attemptReconnect(); } - }, [padId, isAuthenticated, isLoading, createWebSocket, attemptReconnect]); + }, [padId, isAuthenticated, isLoading, createWebSocket, attemptReconnect, clearReconnectTimeout]); // Connect when dependencies change useEffect(() => { From 3c7a3ebccf1f067c82c0df18c7e5c5d91aa3f0e6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 16:49:39 +0000 Subject: [PATCH 054/149] dev: update default pad configuration to use dev.json for development environment --- src/backend/config.py | 2 +- src/backend/templates/dev.json | 164 +++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/backend/templates/dev.json diff --git a/src/backend/config.py b/src/backend/config.py index 938752f..5e62908 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -82,7 +82,7 @@ async def close_redis_client() -> None: RedisService._instance = None default_pad = {} -with open("templates/default.json", 'r') as f: +with open("templates/dev.json", 'r') as f: default_pad = json.load(f) # ===== Coder API Configuration ===== diff --git a/src/backend/templates/dev.json b/src/backend/templates/dev.json new file mode 100644 index 0000000..e18a716 --- /dev/null +++ b/src/backend/templates/dev.json @@ -0,0 +1,164 @@ +{ + "files": {}, + "appState": { + "pad": { + "displayName": "Welcome!" + }, + "name": "Pad.ws", + "zoom": { + "value": 1 + }, + "stats": { + "open": false, + "panels": 3 + }, + "theme": "dark", + "toast": null, + "width": 1920, + "height": 957, + "penMode": false, + "scrollX": 777.3333740234375, + "scrollY": 326.3333740234375, + "gridSize": 20, + "gridStep": 5, + "openMenu": null, + "isLoading": false, + "offsetTop": 0, + "openPopup": null, + "snapLines": [], + "activeTool": { + "type": "selection", + "locked": false, + "customType": null, + "lastActiveTool": { + "type": "selection", + "locked": false, + "customType": null, + "lastActiveTool": null + } + }, + "fileHandle": null, + "followedBy": {}, + "isCropping": false, + "isResizing": false, + "isRotating": false, + "newElement": null, + "offsetLeft": 0, + "openDialog": null, + "contextMenu": null, + "exportScale": 1, + "openSidebar": null, + "pasteDialog": { + "data": null, + "shown": false + }, + "penDetected": true, + "cursorButton": "up", + "editingFrame": null, + "errorMessage": null, + "multiElement": null, + "userToFollow": null, + "collaborators": {}, + "searchMatches": [], + "editingGroupId": null, + "frameRendering": { + "clip": true, + "name": true, + "enabled": true, + "outline": true + }, + "zenModeEnabled": false, + "gridModeEnabled": true, + "resizingElement": null, + "scrolledOutside": false, + "viewModeEnabled": false, + "activeEmbeddable": null, + "currentChartType": "bar", + "exportBackground": true, + "exportEmbedScene": false, + "frameToHighlight": null, + "isBindingEnabled": true, + "originSnapOffset": null, + "selectedGroupIds": {}, + "selectionElement": null, + "croppingElementId": null, + "hoveredElementIds": {}, + "showWelcomeScreen": true, + "startBoundElement": null, + "suggestedBindings": [], + "currentItemOpacity": 100, + "editingTextElement": null, + "exportWithDarkMode": false, + "selectedElementIds": {}, + "showHyperlinkPopup": false, + "currentItemFontSize": 20, + "elementsToHighlight": null, + "lastPointerDownWith": "mouse", + "viewBackgroundColor": "#ffffff", + "currentItemArrowType": "round", + "currentItemFillStyle": "solid", + "currentItemRoughness": 1, + "currentItemRoundness": "round", + "currentItemTextAlign": "left", + "editingLinearElement": null, + "currentItemFontFamily": 5, + "pendingImageElementId": null, + "selectedLinearElement": null, + "shouldCacheIgnoreZoom": false, + "currentItemStrokeColor": "#1e1e1e", + "currentItemStrokeStyle": "solid", + "currentItemStrokeWidth": 2, + "objectsSnapModeEnabled": false, + "currentItemEndArrowhead": "arrow", + "currentHoveredFontFamily": null, + "currentItemStartArrowhead": null, + "currentItemBackgroundColor": "transparent", + "previousSelectedElementIds": {}, + "defaultSidebarDockedPreference": false, + "selectedElementsAreBeingDragged": false + }, + "elements": [ + { + "x": -160, + "y": -200, + "id": "1igpgsvrsh3", + "link": "!dev", + "seed": 76441, + "type": "embeddable", + "angle": 0, + "index": "b0q", + "width": 700, + "height": 800, + "locked": false, + "frameId": null, + "opacity": 100, + "updated": 1747500403348, + "version": 2104, + "groupIds": [], + "fillStyle": "solid", + "isDeleted": false, + "roughness": 0, + "roundness": { + "type": 3 + }, + "customData": { + "borderOffsets": { + "top": 40, + "left": 10, + "right": 10, + "bottom": 10 + }, + "showClickableHint": false, + "showHyperlinkIcon": false + }, + "isSelected": false, + "strokeColor": "#ced4da", + "strokeStyle": "solid", + "strokeWidth": 2, + "versionNonce": 1736659225, + "boundElements": [], + "backgroundColor": "#e9ecef" + } + ] +} + From c50c0d1b20e1769cb891c3d2865974ff3a37c845 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 17:36:25 +0000 Subject: [PATCH 055/149] refactor: break down ws_router into separate method --- src/backend/routers/ws_router.py | 122 ++++++++++++++++--------------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index 6f6d2a3..e4916ce 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -1,11 +1,13 @@ -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Cookie -from typing import Dict, Set, Optional -from uuid import UUID import json -import asyncio +from uuid import UUID +from typing import Dict, Set, Optional +from datetime import datetime + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Cookie +from redis import asyncio as aioredis + from config import get_redis_client from dependencies import UserSession, get_session_domain -from datetime import datetime ws_router = APIRouter() @@ -57,6 +59,50 @@ async def cleanup_connection(pad_id: UUID, websocket: WebSocket): if "already completed" not in str(e) and "close message has been sent" not in str(e): print(f"Error closing WebSocket connection: {e}") +async def broadcast_message(pad_id: UUID, message: Dict, sender_websocket: WebSocket): + """Broadcasts a message to all connected clients in a pad, except the sender.""" + if pad_id in active_connections: + for connection in active_connections[pad_id].copy(): # Iterate over a copy for safe removal + if connection != sender_websocket and connection.client_state.CONNECTED: + try: + await connection.send_json(message) + except (WebSocketDisconnect, Exception) as e: + # Log error if it's not a standard "already closed" type + if "close message has been sent" not in str(e) and "already completed" not in str(e): + print(f"Error sending message to client during broadcast: {e}") + # Attempt to clean up the problematic connection + await cleanup_connection(pad_id, connection) + +async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, event_data: Dict): + """Formats event data and publishes it to a Redis stream.""" + field_value_dict = { + str(k): str(v) if not isinstance(v, (int, float)) else v + for k, v in event_data.items() + } + await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + +async def _handle_received_data( + raw_data: str, + pad_id: UUID, + user: UserSession, + redis_client: aioredis.Redis, + stream_key: str, + websocket: WebSocket +): + """Processes decoded message data, publishes to Redis, and broadcasts.""" + message_data = json.loads(raw_data) + + message_data.update({ + "user_id": str(user.id), + "pad_id": str(pad_id), + "timestamp": datetime.now().isoformat() + }) + print(f"Received message from {user.id} on pad {str(pad_id)[:5]}") + + if redis_client: + await publish_event_to_redis(redis_client, stream_key, message_data) + await broadcast_message(pad_id, message_data, websocket) + @ws_router.websocket("/ws/pad/{pad_id}") async def websocket_endpoint( websocket: WebSocket, @@ -96,60 +142,28 @@ async def websocket_endpoint( "user_id": str(user.id), "timestamp": datetime.now().isoformat() } - # Convert dict to proper format for Redis xadd - field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v - for k, v in join_message.items()} - await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + if redis_client: + await publish_event_to_redis(redis_client, stream_key, join_message) # Notify other clients about user joining - for connection in active_connections[pad_id].copy(): - if connection != websocket and connection.client_state.CONNECTED: - try: - await connection.send_json(join_message) - except Exception: - pass + await broadcast_message(pad_id, join_message, websocket) except Exception as e: print(f"Error broadcasting join message: {e}") # Wait for WebSocket disconnect while websocket.client_state.CONNECTED: try: - # Keep the connection alive and handle any incoming messages data = await websocket.receive_text() - message_data = json.loads(data) - - # Add user_id and timestamp to the message - message_data.update({ - "user_id": str(user.id), - "pad_id": str(pad_id), - "timestamp": datetime.now().isoformat() - }) - print(f"Received message from {user.id} on pad {str(pad_id)[:5]}") - - # Publish the message to Redis stream to be broadcasted to all clients - # Convert dict to proper format for Redis xadd (field-value pairs) - field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v - for k, v in message_data.items()} - await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) - - # Forward message to all connected clients for this pad - for connection in active_connections[pad_id].copy(): - try: - if connection.client_state.CONNECTED: - await connection.send_json(message_data) - except (WebSocketDisconnect, Exception) as e: - if "close message has been sent" not in str(e): - print(f"Error sending message to client: {e}") - await cleanup_connection(pad_id, connection) - + await _handle_received_data(data, pad_id, user, redis_client, stream_key, websocket) except WebSocketDisconnect: break except json.JSONDecodeError as e: print(f"Invalid JSON received: {e}") - await websocket.send_json({ - "type": "error", - "message": "Invalid message format" - }) + if websocket.client_state.CONNECTED: + await websocket.send_json({ + "type": "error", + "message": "Invalid message format" + }) except Exception as e: print(f"Error in WebSocket connection: {e}") break @@ -159,26 +173,18 @@ async def websocket_endpoint( finally: # Broadcast user left message before cleanup try: - if redis_client: + if redis_client: # Ensure redis_client was initialized leave_message = { "type": "user_left", "pad_id": str(pad_id), "user_id": str(user.id), "timestamp": datetime.now().isoformat() } - # Convert dict to proper format for Redis xadd - field_value_dict = {str(k): str(v) if not isinstance(v, (int, float)) else v - for k, v in leave_message.items()} - await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + await publish_event_to_redis(redis_client, stream_key, leave_message) # Notify other clients about user leaving - for connection in active_connections[pad_id].copy(): - if connection != websocket and connection.client_state.CONNECTED: - try: - await connection.send_json(leave_message) - except Exception: - pass + await broadcast_message(pad_id, leave_message, websocket) except Exception as e: print(f"Error broadcasting leave message: {e}") - await cleanup_connection(pad_id, websocket) \ No newline at end of file + await cleanup_connection(pad_id, websocket) From c187cb064d3804ede006722cb7f4d155bcda3e12 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 17:36:38 +0000 Subject: [PATCH 056/149] feat: include user ID in token data and update UserInfo interface to support optional ID --- src/backend/routers/users_router.py | 1 + src/frontend/src/hooks/useAuthStatus.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/backend/routers/users_router.py b/src/backend/routers/users_router.py index eb2dc60..d540bd8 100644 --- a/src/backend/routers/users_router.py +++ b/src/backend/routers/users_router.py @@ -24,6 +24,7 @@ async def get_user_info( # Create token data dictionary from UserSession properties token_data = { + "id": user.id, "username": user.username, "email": user.email, "email_verified": user.email_verified, diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index c65170a..39b0728 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { scheduleTokenRefresh, AUTH_STATUS_KEY } from '../lib/authRefreshManager'; interface UserInfo { + id?: string; username?: string; email?: string; name?: string; From 1eb564061597685b4c4fbf1b1965afc6ab05a6ca Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 18:26:41 +0000 Subject: [PATCH 057/149] feat: add logging for received WebSocket messages in usePadWebSocket hook --- src/frontend/src/hooks/usePadWebSocket.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index b8db042..efb4d82 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -73,6 +73,7 @@ export const usePadWebSocket = (padId: string | null) => { ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data); + console.log("[pad.ws] Received message", message); // Process message if needed } catch (error) { console.error('[pad.ws] Error parsing message:', error); From 93dc9afc8e1ea8fabc6c62e6db0b8cc3e76a354d Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 18:26:46 +0000 Subject: [PATCH 058/149] refactor: simplify DevTools component by removing unused state and UI elements --- src/frontend/src/pad/DevTools.scss | 417 +-------------- src/frontend/src/pad/DevTools.tsx | 814 +---------------------------- 2 files changed, 18 insertions(+), 1213 deletions(-) diff --git a/src/frontend/src/pad/DevTools.scss b/src/frontend/src/pad/DevTools.scss index 4c6d17e..faaa2f7 100644 --- a/src/frontend/src/pad/DevTools.scss +++ b/src/frontend/src/pad/DevTools.scss @@ -5,139 +5,6 @@ width: 100%; overflow: hidden; - &__header { - display: flex; - flex-direction: column; - padding: 8px 12px; - background-color: #1e1e1e; - border-bottom: 1px solid #333; - - h2 { - margin: 0; - font-size: 14px; - color: #e0e0e0; - } - } - - &__sections { - display: flex; - gap: 8px; - margin-bottom: 8px; - } - - &__section { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background-color: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #e0e0e0; - font-size: 13px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: #3a3a3a; - } - - &.active { - background-color: #3a3a3a; - border-color: #666; - } - - svg { - color: #4caf50; - } - } - - &__tabs { - display: flex; - gap: 8px; - justify-content: flex-end; - } - - &__tab { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background-color: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #e0e0e0; - font-size: 12px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: #3a3a3a; - } - - &.active { - background-color: #3a3a3a; - border-color: #666; - } - - svg { - color: #4caf50; - } - } - - &__pointer-tracker { - background-color: #2d2d2d; - border-bottom: 1px solid #444; - padding: 8px 12px; - display: flex; - flex-direction: column; - - &-header { - display: flex; - align-items: center; - margin-bottom: 4px; - } - - &-icon { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 4px; - background-color: #3a3a3a; - margin-right: 8px; - color: #4caf50; - } - - &-title { - font-size: 12px; - font-weight: 500; - color: #e0e0e0; - display: flex; - align-items: center; - } - - &-coords { - display: flex; - align-items: center; - font-family: monospace; - font-size: 12px; - color: #e0e0e0; - padding-left: 32px; - - span { - margin-right: 16px; - } - } - - &-time { - color: #a0a0a0; - font-size: 10px; - margin-left: auto; - } - } - &__content { flex: 1; overflow: hidden; @@ -151,116 +18,15 @@ gap: 8px; } - // Wrapper for the two event lists (Sending & Received) &__collab-events-wrapper { - display: flex; - flex-direction: column; // Arrange sending/received one on top of the other - flex: 1; // Takes 1 part of space in __collab-container (shares with __collab-details) - gap: 8px; // Gap between sending and received lists - overflow: hidden; // Prevent children from expanding this wrapper - } - - &__emit-container { - display: flex; - height: 100%; - width: 100%; - gap: 8px; - } - - &__emit-controls { - width: 250px; - border: 1px solid #333; - border-radius: 4px; display: flex; flex-direction: column; - background-color: #1e1e1e; - overflow: hidden; - } - - &__emit-header { - padding: 12px; - border-bottom: 1px solid #444; - - h3 { - margin: 0 0 8px 0; - font-size: 14px; - color: #e0e0e0; - } - - p { - margin: 0; - font-size: 12px; - color: #a0a0a0; - line-height: 1.4; - } - } - - &__emit-form { - padding: 12px; - display: flex; - flex-direction: column; - gap: 16px; - } - - &__emit-field { - display: flex; - flex-direction: column; - gap: 6px; - - label { - font-size: 12px; - color: #e0e0e0; - } - - select { - padding: 6px 8px; - background-color: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #e0e0e0; - font-size: 12px; - - &:focus { - outline: none; - border-color: #666; - } - } - } - - &__emit-button { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px 12px; - background-color: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #e0e0e0; - font-size: 12px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: #3a3a3a; - } - - svg { - color: #4caf50; - } - } - - &__emit-editor { flex: 1; - display: flex; - flex-direction: column; - border: 1px solid #333; - border-radius: 4px; + gap: 8px; overflow: hidden; } &__collab-events { - width: 250px; border: 1px solid #333; border-radius: 4px; display: flex; @@ -268,14 +34,12 @@ background-color: #1e1e1e; overflow: hidden; - // Styles for when __collab-events is a direct child of __collab-events-wrapper - // This ensures the two lists (Sending & Received) share space equally within their wrapper. .dev-tools__collab-events-wrapper > & { - width: auto; // Override the general fixed width below - flex-basis: 0; // Distribute space based on flex-grow - flex-grow: 1; // Each list will grow equally to fill half of the wrapper - flex-shrink: 1; // Allow shrinking if the wrapper itself is constrained - min-width: 0; // Crucial for allowing flex items to shrink below their content size + width: auto; + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; } } @@ -302,80 +66,6 @@ font-style: italic; } - &__collab-event-item { - display: flex; - align-items: center; - padding: 6px 8px; - border-radius: 4px; - margin-bottom: 4px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: #2a2a2a; - } - - &.active { - background-color: #3a3a3a; - } - } - - &__collab-event-icon { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 4px; - background-color: #2d2d2d; - margin-right: 8px; - color: #e0e0e0; - } - - &__collab-event-info { - flex: 1; - overflow: hidden; - } - - &__collab-event-type { - font-size: 12px; - font-weight: 500; - color: #e0e0e0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - display: flex; - align-items: center; - } - - &__collab-event-live-indicator { - width: 6px; - height: 6px; - border-radius: 50%; - background-color: #4caf50; - margin-left: 6px; - display: inline-block; - animation: pulse 1.5s infinite; - } - - @keyframes pulse { - 0% { - opacity: 0.4; - } - 50% { - opacity: 1; - } - 100% { - opacity: 0.4; - } - } - - &__collab-event-time { - font-size: 10px; - color: #a0a0a0; - } - &__collab-details { flex: 1; display: flex; @@ -393,97 +83,4 @@ font-weight: 500; color: #e0e0e0; } - - // AppState Editor Styles - &__appstate-container { - display: flex; - height: 100%; - width: 100%; - gap: 8px; - } - - &__appstate-controls { - width: 250px; - border: 1px solid #333; - border-radius: 4px; - display: flex; - flex-direction: column; - background-color: #1e1e1e; - overflow: hidden; - } - - &__appstate-header { - padding: 12px; - border-bottom: 1px solid #444; - - h3 { - margin: 0 0 8px 0; - font-size: 14px; - color: #e0e0e0; - } - - p { - margin: 0; - font-size: 12px; - color: #a0a0a0; - line-height: 1.4; - } - } - - &__appstate-actions { - padding: 12px; - display: flex; - flex-direction: column; - gap: 12px; - } - - &__appstate-button { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px 12px; - background-color: #2d2d2d; - border: 1px solid #444; - border-radius: 4px; - color: #e0e0e0; - font-size: 12px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover:not(:disabled) { - background-color: #3a3a3a; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - svg { - color: #4caf50; - - &.rotating { - animation: rotate 1.5s linear infinite; - } - } - } - - &__appstate-editor { - flex: 1; - display: flex; - flex-direction: column; - border: 1px solid #333; - border-radius: 4px; - overflow: hidden; - } - - @keyframes rotate { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } - } \ No newline at end of file +} diff --git a/src/frontend/src/pad/DevTools.tsx b/src/frontend/src/pad/DevTools.tsx index f5c6cca..d0080b6 100644 --- a/src/frontend/src/pad/DevTools.tsx +++ b/src/frontend/src/pad/DevTools.tsx @@ -1,663 +1,23 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React from 'react'; import MonacoEditor from '@monaco-editor/react'; -import { MousePointer, Edit, Clock, Move, Settings, Plus, Trash2, Radio, Send, RefreshCw, Save } from 'lucide-react'; import './DevTools.scss'; -import { CollabEvent, CollabEventType } from '../lib/collab'; -import { useAuthStatus } from '../hooks/useAuthStatus'; -interface DevToolsProps { - element?: any; // Excalidraw element - appState?: any; // Excalidraw app state - excalidrawAPI?: any; // Excalidraw API instance -} - -// Enum for DevTools sections and tabs -type DevToolsSection = 'collab' | 'appstate' | 'testing'; -type DevToolsTab = 'receive' | 'emit'; - -interface CollabLogData { - id: string; - timestamp: string; - type: CollabEventType; - data: CollabEvent; -} - -const DevTools: React.FC = ({ element, appState, excalidrawAPI }) => { - // Active section and tab states - const [activeSection, setActiveSection] = useState('collab'); - const [activeTab, setActiveTab] = useState('receive'); - - // AppState editor state - const [currentAppState, setCurrentAppState] = useState('{}'); - const [isAppStateLoading, setIsAppStateLoading] = useState(false); - - // Get user profile to determine own user ID - const { data: userProfile } = useAuthStatus(); - const currentUserId = userProfile?.id; - - // Store collaboration events - const [sendingLogs, setSendingLogs] = useState([]); - const [receivedLogs, setReceivedLogs] = useState([]); - // Current collab log to display - const [selectedLog, setSelectedLog] = useState(null); - - // Emit tab state - const [selectedEventType, setSelectedEventType] = useState('pointer_down'); - const [emitEventData, setEmitEventData] = useState('{\n "type": "pointer_down",\n "timestamp": 0,\n "pointer": {\n "x": 100,\n "y": 100\n },\n "button": "left"\n}'); - - // Testing section state - const [collaboratorCount, setCollaboratorCount] = useState(0); - const [roomId, setRoomId] = useState("test-room"); - const [isConnected, setIsConnected] = useState(false); - const [ws, setWs] = useState(null); - - - // Subscribe to all collaboration events for logging and local cursor updates - useEffect(() => { - const handleCollabEvent = (event: CustomEvent) => { - const collabEvent: CollabEvent = event.detail; - - // For all other event types, log them - const newCollabLog: CollabLogData = { - id: `collab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: new Date(collabEvent.timestamp).toISOString(), - type: collabEvent.type, - data: collabEvent, - }; - - const eventEmitterId = collabEvent.emitter?.userId; - if (currentUserId && eventEmitterId === currentUserId) { - setSendingLogs(prevLogs => [newCollabLog, ...prevLogs].slice(0, 50)); // Keep last 50 sent - } else { - setReceivedLogs(prevLogs => [newCollabLog, ...prevLogs].slice(0, 50)); // Keep last 50 received - } - // Auto-select the newest log for display in the JSON viewer - setSelectedLog(newCollabLog); - }; - - document.addEventListener('collabEvent', handleCollabEvent as EventListener); - return () => { - document.removeEventListener('collabEvent', handleCollabEvent as EventListener); - }; - }, [currentUserId]); // Dependencies: currentUserId. State setters are stable. - - // Format collaboration event data as pretty JSON - const formatCollabEventData = (log: CollabLogData | null) => { - if (!log) return "{}"; - - // Format based on event type - switch (log.type) { - case 'pointer_down': - case 'pointer_up': - return JSON.stringify({ - type: log.type, - timestamp: new Date(log.data.timestamp).toLocaleString(), - emitter: log.data.emitter, - pointer: log.data.pointer, - button: log.data.button - }, null, 2); - - case 'elements_added': - return JSON.stringify({ - type: log.type, - timestamp: new Date(log.data.timestamp).toLocaleString(), - emitter: log.data.emitter, - addedElements: log.data.elements, - count: log.data.elements?.length || 0 - }, null, 2); - - case 'elements_edited': - return JSON.stringify({ - type: log.type, - timestamp: new Date(log.data.timestamp).toLocaleString(), - emitter: log.data.emitter, - editedElements: log.data.elements, - count: log.data.elements?.length || 0 - }, null, 2); - - case 'elements_deleted': - return JSON.stringify({ - type: log.type, - timestamp: new Date(log.data.timestamp).toLocaleString(), - emitter: log.data.emitter, - deletedElements: log.data.elements, - count: log.data.elements?.length || 0 - }, null, 2); - - case 'appstate_changed': - return JSON.stringify({ - type: log.type, - timestamp: new Date(log.data.timestamp).toLocaleString(), - emitter: log.data.emitter, - appState: log.data.appState - }, null, 2); - - default: - // For pointer_move and any other types, include emitter if present - return JSON.stringify({ - ...log.data, - timestamp: new Date(log.data.timestamp).toLocaleString(), // Ensure timestamp is formatted - }, null, 2); - } - }; - - // Get icon for collaboration event type - const getCollabEventIcon = (type: CollabEventType) => { - switch (type) { - case 'pointer_down': - case 'pointer_up': - return ; - case 'pointer_move': - return ; - case 'elements_added': - return ; - case 'elements_edited': - return ; - case 'elements_deleted': - return ; - case 'appstate_changed': - return ; - default: - return ; - } - }; - - // Function to emit a custom event - const handleEmitEvent = () => { - try { - // Parse the event data from the editor - const eventData = JSON.parse(emitEventData); - - // Ensure the event has a timestamp - if (!eventData.timestamp) { - eventData.timestamp = Date.now(); - } - - // Ensure the event has the correct type - eventData.type = selectedEventType; - - // Dispatch the event - const collabEvent = new CustomEvent('collabEvent', { - detail: eventData - }); - document.dispatchEvent(collabEvent); - - // Show success message - console.log('[DevTools] Emitted event:', eventData); - } catch (error) { - console.error('[DevTools] Error emitting event:', error); - alert(`Error emitting event: ${error instanceof Error ? error.message : String(error)}`); - } - }; - - // Function to generate template event data based on selected type - const generateTemplateEventData = (type: CollabEventType): string => { - const timestamp = Date.now(); - - switch (type) { - case 'pointer_down': - case 'pointer_up': - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" }, - pointer: { x: 100, y: 100 }, - button: 'left' - }, null, 2); - - case 'pointer_move': - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" }, - pointer: { x: 100, y: 100 } - }, null, 2); - - case 'elements_added': - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" }, - elements: [{ - id: 'element-1', - type: 'rectangle', - x: 100, - y: 100, - width: 100, - height: 100, - version: 1 - }] - }, null, 2); - - case 'elements_edited': - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" }, - elements: [{ - id: 'element-1', - type: 'rectangle', - x: 150, - y: 150, - width: 100, - height: 100, - version: 2 - }] - }, null, 2); - - case 'elements_deleted': - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" }, - elements: [{ - id: 'element-1' - }] - }, null, 2); - - case 'appstate_changed': - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" }, - appState: { - viewBackgroundColor: '#ffffff' - } - }, null, 2); - - default: - return JSON.stringify({ - type, - timestamp, - emitter: { userId: "test" } - }, null, 2); - } - }; - - // Update event data template when event type changes - useEffect(() => { - setEmitEventData(generateTemplateEventData(selectedEventType)); - }, [selectedEventType]); - - // Effect to refresh emitEventData when Emit tab is selected - useEffect(() => { - if (activeTab === 'emit') { - setEmitEventData(generateTemplateEventData(selectedEventType)); - } - // Note: We don't need to do anything special for the 'receive' tab here, - // as its content is driven by `selectedLog` which updates independently. - }, [activeTab, selectedEventType]); // Re-run if activeTab or selectedEventType changes - - // Function to refresh the AppState - const refreshAppState = () => { - if (!excalidrawAPI) return; - - setIsAppStateLoading(true); - try { - const currentState = excalidrawAPI.getAppState(); - setCurrentAppState(JSON.stringify(currentState, null, 2)); - } catch (error) { - console.error('[DevTools] Error fetching AppState:', error); - } finally { - setIsAppStateLoading(false); - } - }; - - // Function to update the AppState - const updateAppState = () => { - if (!excalidrawAPI) return; - - try { - const newAppState = JSON.parse(currentAppState); - - // Fix collaborators issue (similar to the fix in canvas.ts) - // Check if collaborators is an empty object ({}) or undefined - const isEmptyObject = newAppState.collaborators && - Object.keys(newAppState.collaborators).length === 0 && - Object.getPrototypeOf(newAppState.collaborators) === Object.prototype; - - if (!newAppState.collaborators || isEmptyObject) { - // Apply the fix only if collaborators is empty or undefined - newAppState.collaborators = new Map(); - } - - excalidrawAPI.updateScene({ appState: newAppState }); - console.log('[DevTools] AppState updated successfully'); - } catch (error) { - console.error('[DevTools] Error updating AppState:', error); - alert(`Error updating AppState: ${error instanceof Error ? error.message : String(error)}`); - } - }; - - // Initialize AppState when component mounts or section changes to appstate - useEffect(() => { - if (activeSection === 'appstate' && excalidrawAPI) { - refreshAppState(); - } - }, [activeSection, excalidrawAPI]); - - // WebSocket connection functions - const handleConnect = () => { - if (!roomId.trim()) { - alert("Please enter a Room ID."); - return; - } - if (ws && ws.readyState === WebSocket.OPEN) { - console.log("Already connected."); - return; - } - - // Ensure userId is set - if (!currentUserId) { - console.error("User ID not set, cannot connect."); - alert("User ID not available. Please ensure you are logged in and try again."); - return; - } - - // Set emitter info for outgoing events - if (userProfile) { - // Import from lib/collab if needed - // setRoomEmitterInfo(currentUserId, userProfile.given_name, userProfile.username); - } - - const wsUrl = `wss://alex.pad.ws/ws/collab/${roomId.trim()}`; - console.log(`Attempting to connect to WebSocket: ${wsUrl} with userId: ${currentUserId}`); - - const newWs = new WebSocket(wsUrl); - setWs(newWs); - - newWs.onopen = () => { - console.log(`Connected to room: ${roomId}`); - setIsConnected(true); - }; - - newWs.onmessage = (event) => { - try { - const message = JSON.parse(event.data as string); - - // Dispatch as a custom event for room.ts to handle - if (message.emitter?.userId !== currentUserId) { // Basic check to avoid self-echo - const collabEvent = new CustomEvent('collabEvent', { detail: message }); - document.dispatchEvent(collabEvent); - } - } catch (error) { - console.error("Failed to parse incoming message or dispatch event:", error); - } - }; - - newWs.onerror = (error) => { - console.error("WebSocket error:", error); - alert(`WebSocket error. Check console.`); - setIsConnected(false); - }; - - newWs.onclose = (event) => { - console.log(`Disconnected from room: ${roomId}. Code: ${event.code}, Reason: ${event.reason}`); - setIsConnected(false); - setWs(null); - }; - }; - - const handleDisconnect = () => { - if (ws) { - ws.close(); - setWs(null); - } - setIsConnected(false); - }; - - // Listen to 'collabEvent' from room.ts and send it via WebSocket - useEffect(() => { - const handleSendMessage = (event: Event) => { - if (event instanceof CustomEvent && event.detail && isConnected && ws && ws.readyState === WebSocket.OPEN && currentUserId) { - // Only send if this client is the emitter - if (event.detail.emitter?.userId === currentUserId) { - const messageWithEmitter = { - ...event.detail, - emitter: event.detail.emitter || { userId: currentUserId } - }; - ws.send(JSON.stringify(messageWithEmitter)); - } - } - }; - - document.addEventListener('collabEvent', handleSendMessage); - return () => { - document.removeEventListener('collabEvent', handleSendMessage); - }; - }, [isConnected, currentUserId, ws]); - - // Function to create a random collaborator in the appstate - const createRandomCollaborator = () => { - if (!excalidrawAPI) { - alert('Excalidraw API not available'); - return; - } - - try { - // Get current appState - const currentState = excalidrawAPI.getAppState(); - - // Ensure collaborators is a Map - if (!currentState.collaborators || !(currentState.collaborators instanceof Map)) { - currentState.collaborators = new Map(); - } - - // Generate a random ID for the collaborator - const randomId = `test-collab-${Date.now()}-${Math.floor(Math.random() * 1000)}`; - - // Create a random position for the collaborator - const randomX = Math.floor(Math.random() * 1000); - const randomY = Math.floor(Math.random() * 1000); - - // Create the collaborator object - const newCollaborator = { - userId: randomId, - displayName: `Test User ${collaboratorCount + 1}`, - x: randomX, - y: randomY, - isCurrentUser: false, - pointer: { x: randomX, y: randomY }, - button: 'up', - selectedElementIds: {}, - username: `testuser${collaboratorCount + 1}`, - userState: 'active' - }; - - // Add the collaborator to the map - currentState.collaborators.set(randomId, newCollaborator); - - // Update the scene with the new appState - excalidrawAPI.updateScene({ appState: currentState }); - - // Increment the collaborator count - setCollaboratorCount(prev => prev + 1); - - console.log('[DevTools] Created random collaborator:', newCollaborator); - } catch (error) { - console.error('[DevTools] Error creating collaborator:', error); - alert(`Error creating collaborator: ${error instanceof Error ? error.message : String(error)}`); - } - }; +interface DevToolsProps {} +const DevTools: React.FC = () => { return (
-
-
- - - -
- - {activeSection === 'collab' && ( -
- - -
- )} -
- - {/* AppState Editor */} - {activeSection === 'appstate' && ( -
-
-
-

AppState Editor

-

View and modify the current Excalidraw AppState.

-
- -
- - - -
-
- -
-
AppState (JSON)
- setCurrentAppState(value || '{}')} - options={{ - readOnly: false, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 12, - automaticLayout: true, - wordWrap: 'on' - }} - /> -
-
- )} - - {/* Collaboration Events Content */} - {activeSection === 'collab' && activeTab === 'receive' && ( -
-
-
- -
-
- Cursor Positions (Canvas Coordinates) -
-
-
- )} -
- {activeSection === 'collab' && activeTab === 'receive' ? ( -
-
- {/* Sending Events List */} +
+
- Sending Events + Events
- {sendingLogs.map((log) => ( -
setSelectedLog(log)} - > -
- {getCollabEventIcon(log.type)} -
-
-
- {log.type} -
-
- {new Date(log.timestamp).toLocaleTimeString()} -
-
-
- ))} - {sendingLogs.length === 0 && ( -
- No sending events yet. -
- )} -
-
- - {/* Received Events List */} -
-
- Received Events -
-
- {receivedLogs.map((log) => ( -
setSelectedLog(log)} - > -
- {getCollabEventIcon(log.type)} -
-
-
- {log.type} -
-
- {new Date(log.timestamp).toLocaleTimeString()} -
-
-
- ))} - {receivedLogs.length === 0 && ( -
- No received events yet. -
- )} +
+ Event display area. +
@@ -667,7 +27,7 @@ const DevTools: React.FC = ({ element, appState, excalidrawAPI }) height="100%" language="json" theme="vs-dark" - value={formatCollabEventData(selectedLog)} + value={JSON.stringify({ message: "JSON representation of event data will appear here." }, null, 2)} options={{ readOnly: true, minimap: { enabled: false }, @@ -679,161 +39,9 @@ const DevTools: React.FC = ({ element, appState, excalidrawAPI }) />
- ) : activeSection === 'collab' && activeTab === 'emit' ? ( -
-
-
-

Emit Custom Event

-

Create and emit custom collaboration events to test your application.

-
- -
-
- - -
- - -
-
- -
-
Event Data (JSON)
- setEmitEventData(value || '')} - options={{ - readOnly: false, // Explicitly set to false - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 12, - automaticLayout: true, - wordWrap: 'on' - }} - /> -
-
- ) : activeSection === 'testing' ? ( -
-
-
-

Testing Tools

-

Various tools for testing Excalidraw functionality.

-
- -
-

Collaborator Tools

- - - {collaboratorCount > 0 && ( -
- {collaboratorCount} collaborator{collaboratorCount !== 1 ? 's' : ''} added -
- )} - -
-

WebSocket Connection

- -
- setRoomId(e.target.value)} - placeholder="Room ID" - disabled={isConnected} - style={{ - width: '90%', - padding: '8px', - backgroundColor: '#2d2d2d', - border: '1px solid #444', - borderRadius: '4px', - color: '#e0e0e0', - fontSize: '12px', - marginBottom: '8px' - }} - /> - - {isConnected ? ( - - ) : ( - - )} -
- -
- {isConnected ? ( - Connected to room: {roomId} - ) : ( - Not connected - )} -
-
-
-
- -
-
Testing Information
-
-

This section provides tools for testing Excalidraw functionality:

-
    -
  • Add Random Collaborator: Creates a random collaborator in the appstate with a random position.
  • -
  • WebSocket Connection: Connect to a collaboration room to send and receive events in real-time.
  • -
-

WebSocket Connection Usage:

-
    -
  1. Enter a room ID in the input field.
  2. -
  3. Click "Connect" to establish a WebSocket connection.
  4. -
  5. Once connected, all collaboration events will be sent to and received from the server.
  6. -
  7. Use the "Emit Custom Event" tab in the Collaboration Events section to send test events.
  8. -
  9. Click "Disconnect" to close the connection.
  10. -
-
-
-
- ) : null}
); }; -export default DevTools; \ No newline at end of file +export default DevTools; From 370f33c1d16c20358554caadd5168d0ddf2f245a Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 18:52:05 +0000 Subject: [PATCH 059/149] simplify websocket handling --- src/backend/routers/ws_router.py | 165 ++++++++++++++++--------------- 1 file changed, 88 insertions(+), 77 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index e4916ce..cd1c722 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -1,9 +1,10 @@ import json +import asyncio from uuid import UUID -from typing import Dict, Set, Optional +from typing import Optional from datetime import datetime -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Cookie +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends from redis import asyncio as aioredis from config import get_redis_client @@ -11,9 +12,6 @@ ws_router = APIRouter() -# Store active WebSocket connections -active_connections: Dict[UUID, Set[WebSocket]] = {} - async def get_ws_user(websocket: WebSocket) -> Optional[UserSession]: """WebSocket-specific authentication dependency""" try: @@ -42,38 +40,7 @@ async def get_ws_user(websocket: WebSocket) -> Optional[UserSession]: print(f"Error in WebSocket authentication: {str(e)}") return None -async def cleanup_connection(pad_id: UUID, websocket: WebSocket): - """Clean up WebSocket connection and remove from active connections""" - # Remove from active connections first to prevent any race conditions - if pad_id in active_connections: - active_connections[pad_id].discard(websocket) - if not active_connections[pad_id]: - del active_connections[pad_id] - - # Only try to close if the connection is still open - try: - if websocket.client_state.CONNECTED: - await websocket.close() - except Exception as e: - # Ignore "connection already closed" errors - if "already completed" not in str(e) and "close message has been sent" not in str(e): - print(f"Error closing WebSocket connection: {e}") - -async def broadcast_message(pad_id: UUID, message: Dict, sender_websocket: WebSocket): - """Broadcasts a message to all connected clients in a pad, except the sender.""" - if pad_id in active_connections: - for connection in active_connections[pad_id].copy(): # Iterate over a copy for safe removal - if connection != sender_websocket and connection.client_state.CONNECTED: - try: - await connection.send_json(message) - except (WebSocketDisconnect, Exception) as e: - # Log error if it's not a standard "already closed" type - if "close message has been sent" not in str(e) and "already completed" not in str(e): - print(f"Error sending message to client during broadcast: {e}") - # Attempt to clean up the problematic connection - await cleanup_connection(pad_id, connection) - -async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, event_data: Dict): +async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, event_data: dict): """Formats event data and publishes it to a Redis stream.""" field_value_dict = { str(k): str(v) if not isinstance(v, (int, float)) else v @@ -86,10 +53,9 @@ async def _handle_received_data( pad_id: UUID, user: UserSession, redis_client: aioredis.Redis, - stream_key: str, - websocket: WebSocket + stream_key: str ): - """Processes decoded message data, publishes to Redis, and broadcasts.""" + """Processes decoded message data and publishes to Redis.""" message_data = json.loads(raw_data) message_data.update({ @@ -99,9 +65,43 @@ async def _handle_received_data( }) print(f"Received message from {user.id} on pad {str(pad_id)[:5]}") - if redis_client: - await publish_event_to_redis(redis_client, stream_key, message_data) - await broadcast_message(pad_id, message_data, websocket) + await publish_event_to_redis(redis_client, stream_key, message_data) + +async def consume_redis_stream(redis_client: aioredis.Redis, stream_key: str, websocket: WebSocket, last_id: str = '$'): + """Consumes messages from Redis stream and forwards them to the WebSocket""" + try: + while websocket.client_state.CONNECTED: + # Read new messages from the stream + streams = await redis_client.xread({stream_key: last_id}, count=5, block=1000) + + # Process received messages + if streams: + stream_name, stream_messages = streams[0] + for message_id, message_data in stream_messages: + # Convert message data to a format suitable for WebSocket + formatted_message = {} + for k, v in message_data.items(): + # Handle key - could be bytes or string + key = k.decode() if isinstance(k, bytes) else k + + # Handle value - could be bytes or string + if isinstance(v, bytes): + value = v.decode() + else: + value = v + + formatted_message[key] = value + + # Send to WebSocket + await websocket.send_json(formatted_message) + + # Update last_id to get newer messages next time + last_id = message_id + + # Brief pause to prevent CPU hogging + await asyncio.sleep(0.01) + except Exception as e: + print(f"Error in Redis stream consumer: {e}") @ws_router.websocket("/ws/pad/{pad_id}") async def websocket_endpoint( @@ -115,11 +115,6 @@ async def websocket_endpoint( return await websocket.accept() - - if pad_id not in active_connections: - active_connections[pad_id] = set() - active_connections[pad_id].add(websocket) - redis_client = None try: @@ -134,7 +129,7 @@ async def websocket_endpoint( "user_id": str(user.id) }) - # Broadcast user joined message + # Publish user joined message try: join_message = { "type": "user_joined", @@ -144,36 +139,49 @@ async def websocket_endpoint( } if redis_client: await publish_event_to_redis(redis_client, stream_key, join_message) - - # Notify other clients about user joining - await broadcast_message(pad_id, join_message, websocket) except Exception as e: - print(f"Error broadcasting join message: {e}") + print(f"Error publishing join message: {e}") - # Wait for WebSocket disconnect - while websocket.client_state.CONNECTED: - try: - data = await websocket.receive_text() - await _handle_received_data(data, pad_id, user, redis_client, stream_key, websocket) - except WebSocketDisconnect: - break - except json.JSONDecodeError as e: - print(f"Invalid JSON received: {e}") - if websocket.client_state.CONNECTED: - await websocket.send_json({ - "type": "error", - "message": "Invalid message format" - }) - except Exception as e: - print(f"Error in WebSocket connection: {e}") - break + # Create tasks for WebSocket message handling and Redis stream reading + async def handle_websocket_messages(): + while websocket.client_state.CONNECTED: + try: + data = await websocket.receive_text() + await _handle_received_data(data, pad_id, user, redis_client, stream_key) + except WebSocketDisconnect: + break + except json.JSONDecodeError as e: + print(f"Invalid JSON received: {e}") + if websocket.client_state.CONNECTED: + await websocket.send_json({ + "type": "error", + "message": "Invalid message format" + }) + except Exception as e: + print(f"Error in WebSocket connection: {e}") + break + + # Run both tasks concurrently + ws_task = asyncio.create_task(handle_websocket_messages()) + # Start from current - only get new messages + redis_task = asyncio.create_task(consume_redis_stream(redis_client, stream_key, websocket, last_id='$')) + + # Wait for either task to complete + done, pending = await asyncio.wait( + [ws_task, redis_task], + return_when=asyncio.FIRST_COMPLETED + ) + + # Cancel any pending tasks + for task in pending: + task.cancel() except Exception as e: print(f"Error in WebSocket endpoint: {e}") finally: - # Broadcast user left message before cleanup + # Send user left message before cleanup try: - if redis_client: # Ensure redis_client was initialized + if redis_client: leave_message = { "type": "user_left", "pad_id": str(pad_id), @@ -181,10 +189,13 @@ async def websocket_endpoint( "timestamp": datetime.now().isoformat() } await publish_event_to_redis(redis_client, stream_key, leave_message) - - # Notify other clients about user leaving - await broadcast_message(pad_id, leave_message, websocket) except Exception as e: - print(f"Error broadcasting leave message: {e}") + print(f"Error publishing leave message: {e}") - await cleanup_connection(pad_id, websocket) + # Close websocket if still connected + try: + if websocket.client_state.CONNECTED: + await websocket.close() + except Exception as e: + if "already completed" not in str(e) and "close message has been sent" not in str(e): + print(f"Error closing WebSocket connection: {e}") From 1bed55a4eeaf0605161308660c4872615f915f2f Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 20:01:27 +0000 Subject: [PATCH 060/149] fix: reference the correct state of connectWebsocket using useRef to provide correct variable states --- src/frontend/src/hooks/usePadWebSocket.ts | 24 ++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index efb4d82..7f14074 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -26,7 +26,7 @@ interface ConnectionStateRef { } export const usePadWebSocket = (padId: string | null) => { - const { isAuthenticated, isLoading } = useAuthStatus(); + const { isAuthenticated, isLoading, refetchAuthStatus } = useAuthStatus(); const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED); // Consolidated connection state @@ -104,6 +104,9 @@ export const usePadWebSocket = (padId: string | null) => { return ws; }, [padId, resetConnection]); + // Forward declaration for connectWebSocket to be used in attemptReconnect + const connectWebSocketRef = useRef<((isReconnecting?: boolean) => void) | null>(null); + // Attempt to reconnect with exponential backoff const attemptReconnect = useCallback(() => { clearReconnectTimeout(); @@ -125,7 +128,9 @@ export const usePadWebSocket = (padId: string | null) => { // Schedule reconnect connStateRef.current.reconnectTimeout = setTimeout(() => { - connectWebSocket(true); + if (connectWebSocketRef.current) { + connectWebSocketRef.current(true); + } }, delay); }, [clearReconnectTimeout, resetConnection]); @@ -133,6 +138,7 @@ export const usePadWebSocket = (padId: string | null) => { const connectWebSocket = useCallback((isReconnecting = false) => { // Check if we can/should connect const canConnect = isAuthenticated && !isLoading && padId; + console.log('[pad.ws] trying to connect', canConnect, isAuthenticated, !isLoading, padId); if (!canConnect) { // Clean up existing connection if we can't connect now if (connStateRef.current.ws) { @@ -145,6 +151,13 @@ export const usePadWebSocket = (padId: string | null) => { if (isReconnecting && connStateRef.current.reconnectAttempts > 0 && connStateRef.current.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + + // If auth status is unknown and not loading, try to refetch it + if (isAuthenticated === undefined && !isLoading) { + console.log('[pad.ws] Auth status is unknown and not loading, attempting to refetch auth status.'); + refetchAuthStatus(); + } + console.log(`[pad.ws] Can't connect now but preserving reconnection sequence, scheduling next attempt`); attemptReconnect(); } @@ -187,7 +200,12 @@ export const usePadWebSocket = (padId: string | null) => { console.error('[pad.ws] Error creating WebSocket:', error); attemptReconnect(); } - }, [padId, isAuthenticated, isLoading, createWebSocket, attemptReconnect, clearReconnectTimeout]); + }, [padId, isAuthenticated, isLoading, refetchAuthStatus, createWebSocket, attemptReconnect, clearReconnectTimeout]); + + // Assign the connectWebSocket function to the ref after its definition + useEffect(() => { + connectWebSocketRef.current = connectWebSocket; + }, [connectWebSocket]); // Connect when dependencies change useEffect(() => { From 6f41c4eb1398fd704e3fcb20db6287ccfea33f69 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 20:09:13 +0000 Subject: [PATCH 061/149] refactor: change console.log to console.debug for WebSocket message logging in usePadWebSocket hook --- src/frontend/src/hooks/usePadWebSocket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 7f14074..36fccfd 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -73,7 +73,7 @@ export const usePadWebSocket = (padId: string | null) => { ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data); - console.log("[pad.ws] Received message", message); + console.debug("[pad.ws] Received message", message); // Process message if needed } catch (error) { console.error('[pad.ws] Error parsing message:', error); From b811b18c0a14ae49f70bc4b7b10df7afb583206f Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 23:09:41 +0000 Subject: [PATCH 062/149] refactor: enhance message handling in WebSocket router by ensuring metadata consistency and preventing echo for user connections --- src/backend/routers/ws_router.py | 56 ++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index cd1c722..c1c77e8 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -52,50 +52,57 @@ async def _handle_received_data( raw_data: str, pad_id: UUID, user: UserSession, - redis_client: aioredis.Redis, + redis_client: aioredis.Redis, stream_key: str ): """Processes decoded message data and publishes to Redis.""" - message_data = json.loads(raw_data) + message_data = json.loads(raw_data) - message_data.update({ - "user_id": str(user.id), - "pad_id": str(pad_id), - "timestamp": datetime.now().isoformat() - }) - print(f"Received message from {user.id} on pad {str(pad_id)[:5]}") + if 'user_id' not in message_data: # Should not happen if client sends it, but as a safeguard + message_data['user_id'] = str(user.id) + + # Add other metadata if not present or to ensure consistency + message_data.setdefault("pad_id", str(pad_id)) + message_data.setdefault("timestamp", datetime.now().isoformat()) + + print(f"Received message from {user.id} (event user_id: {message_data.get('user_id')}) on pad {str(pad_id)[:5]}") await publish_event_to_redis(redis_client, stream_key, message_data) -async def consume_redis_stream(redis_client: aioredis.Redis, stream_key: str, websocket: WebSocket, last_id: str = '$'): - """Consumes messages from Redis stream and forwards them to the WebSocket""" +async def consume_redis_stream( + redis_client: aioredis.Redis, + stream_key: str, + websocket: WebSocket, + current_connection_user_id: str, # Added to identify the user of this specific WebSocket connection + last_id: str = '$' +): + """Consumes messages from Redis stream and forwards them to the WebSocket, avoiding echo.""" try: while websocket.client_state.CONNECTED: - # Read new messages from the stream streams = await redis_client.xread({stream_key: last_id}, count=5, block=1000) - # Process received messages if streams: stream_name, stream_messages = streams[0] - for message_id, message_data in stream_messages: - # Convert message data to a format suitable for WebSocket + for message_id, message_data_raw in stream_messages: formatted_message = {} - for k, v in message_data.items(): - # Handle key - could be bytes or string + for k, v in message_data_raw.items(): key = k.decode() if isinstance(k, bytes) else k - - # Handle value - could be bytes or string if isinstance(v, bytes): value = v.decode() else: value = v - formatted_message[key] = value - # Send to WebSocket - await websocket.send_json(formatted_message) + message_origin_user_id = formatted_message.get('user_id') + + # Only send if the message did not originate from this same user connection + if message_origin_user_id != current_connection_user_id: + await websocket.send_json(formatted_message) + else: + # Optional: log that an echo was prevented + # print(f"Echo prevented for user {current_connection_user_id} on pad {formatted_message.get('pad_id', 'N/A')[:5]}") + pass - # Update last_id to get newer messages next time last_id = message_id # Brief pause to prevent CPU hogging @@ -163,8 +170,9 @@ async def handle_websocket_messages(): # Run both tasks concurrently ws_task = asyncio.create_task(handle_websocket_messages()) - # Start from current - only get new messages - redis_task = asyncio.create_task(consume_redis_stream(redis_client, stream_key, websocket, last_id='$')) + redis_task = asyncio.create_task( + consume_redis_stream(redis_client, stream_key, websocket, str(user.id), last_id='$') + ) # Wait for either task to complete done, pending = await asyncio.wait( From 0ea061c273fa0462e53cea6219bc168254b885ea Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 23:10:01 +0000 Subject: [PATCH 063/149] feat: integrate react-use-websocket and zod for improved WebSocket message handling and validation in usePadWebSocket hook --- src/frontend/package.json | 4 +- src/frontend/src/hooks/usePadWebSocket.ts | 402 ++++++++++------------ src/frontend/yarn.lock | 10 + 3 files changed, 191 insertions(+), 225 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 4f3e3b7..ca1f4ab 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -16,7 +16,9 @@ "lucide-react": "^0.488.0", "posthog-js": "^1.236.0", "react": "19.0.0", - "react-dom": "19.0.0" + "react-dom": "19.0.0", + "react-use-websocket": "^4.13.0", + "zod": "^3.24.4" }, "resolutions": { "cytoscape": "3.31.2", diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 36fccfd..bb54d2c 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -1,252 +1,206 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; +import { useCallback, useMemo, useEffect, useState } from 'react'; +import useWebSocket, { ReadyState } from 'react-use-websocket'; +import { z } from 'zod'; // Import Zod import { useAuthStatus } from './useAuthStatus'; -interface WebSocketMessage { - type: string; - pad_id: string; - data: any; - timestamp: string; - user_id?: string; -} - -// WebSocket connection states -enum ConnectionState { - DISCONNECTED = 'disconnected', - CONNECTING = 'connecting', - CONNECTED = 'connected', - RECONNECTING = 'reconnecting' -} - -// Connection state object to consolidate multiple refs -interface ConnectionStateRef { - ws: WebSocket | null; - reconnectTimeout: NodeJS.Timeout | null; - reconnectAttempts: number; - currentPadId: string | null; -} +// 1. Define Zod Schema for your WebSocketMessage +// Adjust the 'data' part of the schema based on the actual structure of your messages. +// Using z.any() for data is a placeholder; more specific schemas are better. +// If 'data' can have different structures based on 'type', consider Zod's discriminated unions. +const WebSocketMessageSchema = z.object({ + type: z.string(), + pad_id: z.string().nullable(), + data: z.any().optional(), // Make 'data' optional + timestamp: z.string().datetime({ message: "Invalid timestamp format, expected ISO 8601" }).optional(), + user_id: z.string().optional(), +}); -export const usePadWebSocket = (padId: string | null) => { - const { isAuthenticated, isLoading, refetchAuthStatus } = useAuthStatus(); - const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED); - - // Consolidated connection state - const connStateRef = useRef({ - ws: null, - reconnectTimeout: null, - reconnectAttempts: 0, - currentPadId: null - }); - - const MAX_RECONNECT_ATTEMPTS = 5; - const INITIAL_RECONNECT_DELAY = 1000; // 1 second - - // Clear any reconnect timeout - const clearReconnectTimeout = useCallback(() => { - if (connStateRef.current.reconnectTimeout) { - clearTimeout(connStateRef.current.reconnectTimeout); - connStateRef.current.reconnectTimeout = null; - } - }, []); - - // Reset connection state - const resetConnection = useCallback(() => { - clearReconnectTimeout(); - connStateRef.current.reconnectAttempts = 0; - connStateRef.current.currentPadId = null; - setConnectionState(ConnectionState.DISCONNECTED); - }, [clearReconnectTimeout]); - - // Function to create and setup a WebSocket connection - const createWebSocket = useCallback((url: string, isReconnecting: boolean) => { - const ws = new WebSocket(url); - connStateRef.current.ws = ws; - - setConnectionState(isReconnecting ? ConnectionState.RECONNECTING : ConnectionState.CONNECTING); - - ws.onopen = () => { - console.log(`[pad.ws] Connection established to pad ${padId}`); - setConnectionState(ConnectionState.CONNECTED); - connStateRef.current.currentPadId = padId; - connStateRef.current.reconnectAttempts = 0; - }; +// Type inferred from the Zod schema +type WebSocketMessage = z.infer; - ws.onmessage = (event) => { - try { - const message: WebSocketMessage = JSON.parse(event.data); - console.debug("[pad.ws] Received message", message); - // Process message if needed - } catch (error) { - console.error('[pad.ws] Error parsing message:', error); - } - }; +const MAX_RECONNECT_ATTEMPTS = 5; +const INITIAL_RECONNECT_DELAY = 1000; // 1 second - ws.onerror = (error) => { - console.error('[pad.ws] WebSocket error:', error); - }; - - ws.onclose = (event) => { - console.log(`[pad.ws] Connection closed with code ${event.code}`); - setConnectionState(ConnectionState.DISCONNECTED); - - if (connStateRef.current.ws === ws) { - connStateRef.current.ws = null; - - // Only attempt to reconnect if it wasn't a normal closure and we have a pad ID - const isAbnormalClosure = event.code !== 1000 && event.code !== 1001; - if (padId && isAbnormalClosure && connStateRef.current.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - attemptReconnect(); - } else if (!isAbnormalClosure) { - resetConnection(); - } - } - }; +// For user-friendly connection status +type ConnectionStatus = 'Uninstantiated' | 'Connecting' | 'Open' | 'Closing' | 'Closed' | 'Reconnecting' | 'Failed'; - return ws; - }, [padId, resetConnection]); - - // Forward declaration for connectWebSocket to be used in attemptReconnect - const connectWebSocketRef = useRef<((isReconnecting?: boolean) => void) | null>(null); - - // Attempt to reconnect with exponential backoff - const attemptReconnect = useCallback(() => { - clearReconnectTimeout(); +export const usePadWebSocket = (padId: string | null) => { + const { isAuthenticated, isLoading, refetchAuthStatus } = useAuthStatus(); + const [isPermanentlyDisconnected, setIsPermanentlyDisconnected] = useState(false); + const [reconnectAttemptCount, setReconnectAttemptCount] = useState(0); - // Safety check if we've reached max attempts - if (connStateRef.current.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - console.warn('[pad.ws] Max reconnect attempts reached'); - resetConnection(); - return; + const getSocketUrl = useCallback((): string | null => { + if (!padId || padId.startsWith('temp-')) { + console.debug(`[pad.ws] getSocketUrl: Invalid padId (${padId}), returning null.`); + return null; } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${window.location.host}/ws/pad/${padId}`; + console.debug(`[pad.ws] getSocketUrl: Generated URL: ${url} for padId: ${padId}`); + return url; + }, [padId]); - // Calculate delay with exponential backoff - const attempt = connStateRef.current.reconnectAttempts + 1; - const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, connStateRef.current.reconnectAttempts); - console.info(`[pad.ws] Reconnecting in ${delay}ms (attempt ${attempt}/${MAX_RECONNECT_ATTEMPTS})`); - - // Increment counter before scheduling reconnect - connStateRef.current.reconnectAttempts = attempt; - - // Schedule reconnect - connStateRef.current.reconnectTimeout = setTimeout(() => { - if (connectWebSocketRef.current) { - connectWebSocketRef.current(true); - } - }, delay); - }, [clearReconnectTimeout, resetConnection]); - - // Core connection function - const connectWebSocket = useCallback((isReconnecting = false) => { - // Check if we can/should connect - const canConnect = isAuthenticated && !isLoading && padId; - console.log('[pad.ws] trying to connect', canConnect, isAuthenticated, !isLoading, padId); - if (!canConnect) { - // Clean up existing connection if we can't connect now - if (connStateRef.current.ws) { - connStateRef.current.ws.close(); - connStateRef.current.ws = null; - } - setConnectionState(ConnectionState.DISCONNECTED); - - // Preserve reconnection sequence if needed - if (isReconnecting && - connStateRef.current.reconnectAttempts > 0 && - connStateRef.current.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - - // If auth status is unknown and not loading, try to refetch it + const memoizedSocketUrl = useMemo(() => getSocketUrl(), [getSocketUrl]); + + const shouldBeConnected = useMemo(() => { + const conditionsMet = !!memoizedSocketUrl && isAuthenticated && !isLoading && !isPermanentlyDisconnected; + console.debug( + `[pad.ws] shouldBeConnected for pad ${padId}: ${conditionsMet} (URL: ${!!memoizedSocketUrl}, Auth: ${isAuthenticated}, Loading: ${isLoading}, PermanentDisconnect: ${isPermanentlyDisconnected})` + ); + return conditionsMet; + }, [memoizedSocketUrl, isAuthenticated, isLoading, isPermanentlyDisconnected, padId]); + + const { + sendMessage: librarySendMessage, + lastMessage: rawLastMessage, + readyState, + } = useWebSocket( + memoizedSocketUrl, + { + onOpen: () => { + console.log(`[pad.ws] Connection established for pad: ${padId}`); + setIsPermanentlyDisconnected(false); + setReconnectAttemptCount(0); + }, + onClose: (event: CloseEvent) => { + console.log(`[pad.ws] Connection closed for pad: ${padId}. Code: ${event.code}, Reason: '${event.reason}'`); if (isAuthenticated === undefined && !isLoading) { - console.log('[pad.ws] Auth status is unknown and not loading, attempting to refetch auth status.'); + console.log('[pad.ws] Auth status unknown on close, attempting to refetch auth status.'); + refetchAuthStatus(); + } + }, + onError: (event: Event) => { + console.error(`[pad.ws] WebSocket error for pad: ${padId}:`, event); + }, + shouldReconnect: (closeEvent: CloseEvent) => { + const isAbnormalClosure = closeEvent.code !== 1000 && closeEvent.code !== 1001; + const conditionsStillMetForConnection = !!getSocketUrl() && isAuthenticated && !isLoading; + + if (isAbnormalClosure && !conditionsStillMetForConnection && isAuthenticated === undefined && !isLoading) { + console.log('[pad.ws] Abnormal closure for pad ${padId}, auth status unknown. Refetching auth before deciding on reconnect.'); refetchAuthStatus(); } - console.log(`[pad.ws] Can't connect now but preserving reconnection sequence, scheduling next attempt`); - attemptReconnect(); - } - return; - } - - if (padId && padId.startsWith('temp-')) { - console.info(`[pad.ws] Holding WebSocket connection for temporary pad ID: ${padId}`); - if (connStateRef.current.ws) { - connStateRef.current.ws.close(); - connStateRef.current.ws = null; + const decision = isAbnormalClosure && conditionsStillMetForConnection && !isPermanentlyDisconnected; + if (decision) { + setReconnectAttemptCount(prev => prev + 1); + } + console.log( + `[pad.ws] shouldReconnect for pad ${padId}: ${decision} (Abnormal: ${isAbnormalClosure}, ConditionsMet: ${conditionsStillMetForConnection}, PermanentDisconnect: ${isPermanentlyDisconnected}, Code: ${closeEvent.code})` + ); + return decision; + }, + reconnectAttempts: MAX_RECONNECT_ATTEMPTS, + reconnectInterval: (attemptNumber: number) => { + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attemptNumber); + console.info( + `[pad.ws] Reconnecting attempt ${attemptNumber + 1}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms for pad: ${padId}` + ); + return delay; + }, + onReconnectStop: (numAttempts: number) => { + console.warn(`[pad.ws] Failed to reconnect to pad ${padId} after ${numAttempts} attempts. Stopping.`); + setIsPermanentlyDisconnected(true); + setReconnectAttemptCount(numAttempts); // Store the final attempt count + }, + }, + shouldBeConnected + ); + + const lastJsonMessage = useMemo((): WebSocketMessage | null => { + if (rawLastMessage && rawLastMessage.data) { + try { + const parsedData = JSON.parse(rawLastMessage.data as string); + const validationResult = WebSocketMessageSchema.safeParse(parsedData); + if (validationResult.success) { + return validationResult.data; + } else { + console.error(`[pad.ws] Incoming message validation failed for pad ${padId}:`, validationResult.error.issues); + return null; + } + } catch (error) { + console.error(`[pad.ws] Error parsing incoming JSON message for pad ${padId}:`, error); + return null; } - clearReconnectTimeout(); - connStateRef.current.reconnectAttempts = 0; - setConnectionState(ConnectionState.DISCONNECTED); - return; } - - // Don't reconnect if already connected to same pad - if (connStateRef.current.ws && - connStateRef.current.currentPadId === padId && - connStateRef.current.ws.readyState === WebSocket.OPEN) { + return null; + }, [rawLastMessage, padId]); + + const sendJsonMessage = useCallback((payload: WebSocketMessage) => { + // Validate outgoing message structure (optional, but good practice) + const validationResult = WebSocketMessageSchema.safeParse(payload); + if (!validationResult.success) { + console.error(`[pad.ws] Outgoing message validation failed for pad ${padId}:`, validationResult.error.issues); + // Decide if you want to throw an error or just log and not send return; } - console.log(`[pad.ws] ${isReconnecting ? 'Re' : ''}Connecting to pad ${padId} (attempt ${isReconnecting ? connStateRef.current.reconnectAttempts : 0}/${MAX_RECONNECT_ATTEMPTS})`); - - // Close any existing connection before creating a new one - if (connStateRef.current.ws) { - connStateRef.current.ws.close(); + if (readyState === ReadyState.OPEN) { + console.debug(`[pad.ws] Sending message for pad ${padId}:`, payload); + librarySendMessage(JSON.stringify(payload)); + } else { + console.warn(`[pad.ws] WebSocket not open for pad ${padId}. State: ${readyState}. Message not sent:`, payload); + // react-use-websocket queues messages if sent before open, but this explicit check can be useful for logging. + // If you rely purely on the library's queueing, you might remove this check. + // However, for critical messages, knowing it wasn't sent immediately can be good. + // For now, let's assume librarySendMessage handles queueing if not open. + librarySendMessage(JSON.stringify(payload)); } + }, [padId, librarySendMessage, readyState]); - // Create the WebSocket URL - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/pad/${padId}`; - - try { - createWebSocket(wsUrl, isReconnecting); - } catch (error) { - console.error('[pad.ws] Error creating WebSocket:', error); - attemptReconnect(); - } - }, [padId, isAuthenticated, isLoading, refetchAuthStatus, createWebSocket, attemptReconnect, clearReconnectTimeout]); - - // Assign the connectWebSocket function to the ref after its definition - useEffect(() => { - connectWebSocketRef.current = connectWebSocket; - }, [connectWebSocket]); + // Wrapper to maintain the original `sendMessage(type, data)` signature if preferred by consuming components + const sendMessage = useCallback((type: string, data: any) => { + const messagePayload: WebSocketMessage = { + type, + pad_id: padId, + data, + timestamp: new Date().toISOString(), + // user_id: can be added here if available and needed from context + }; + sendJsonMessage(messagePayload); + }, [padId, sendJsonMessage]); - // Connect when dependencies change useEffect(() => { - connectWebSocket(false); - - // Cleanup function - preserve reconnection attempts - return () => { - if (connStateRef.current.ws) { - // Only close if this is a normal unmount, not a reconnection attempt - if (connStateRef.current.reconnectAttempts === 0) { - connStateRef.current.ws.close(); - connStateRef.current.currentPadId = null; - } else { - console.log(`[pad.ws] Component unmounting but preserving connection attempt ${connStateRef.current.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + if (lastJsonMessage) { + console.debug(`[pad.ws] Validated JSON message received for pad ${padId}:`, lastJsonMessage); + // TODO: Dispatch to a store, update context, or trigger other side effects based on the message + } + }, [lastJsonMessage, padId]); + + const connectionStatus = useMemo((): ConnectionStatus => { + if (isPermanentlyDisconnected) return 'Failed'; + + switch (readyState) { + case ReadyState.UNINSTANTIATED: + return 'Uninstantiated'; + case ReadyState.CONNECTING: + // Differentiate between initial connecting and reconnecting + if (reconnectAttemptCount > 0 && reconnectAttemptCount < MAX_RECONNECT_ATTEMPTS && !isPermanentlyDisconnected) { + return 'Reconnecting'; } - } - // Only clear the timeout, don't reset the counter or close active reconnection attempts - clearReconnectTimeout(); - }; - }, [connectWebSocket, clearReconnectTimeout]); - - // Check if we're connected - const isConnected = connectionState === ConnectionState.CONNECTED; - - // Send message over WebSocket - const sendMessage = useCallback((type: string, data: any) => { - if (connStateRef.current.ws?.readyState === WebSocket.OPEN) { - connStateRef.current.ws.send(JSON.stringify({ - type, - pad_id: padId, - data, - timestamp: new Date().toISOString() - })); - return true; + return 'Connecting'; + case ReadyState.OPEN: + return 'Open'; + case ReadyState.CLOSING: + return 'Closing'; + case ReadyState.CLOSED: + // If it's closed but not permanently, and shouldBeConnected is true, it might be about to reconnect + if (shouldBeConnected && reconnectAttemptCount > 0 && reconnectAttemptCount < MAX_RECONNECT_ATTEMPTS && !isPermanentlyDisconnected) { + return 'Reconnecting'; + } + return 'Closed'; + default: + return 'Uninstantiated'; } - console.warn(`[pad.ws] Cannot send message: WebSocket not connected - changes will not be saved`); - return false; - }, [padId]); + }, [readyState, isPermanentlyDisconnected, reconnectAttemptCount, shouldBeConnected]); return { - sendMessage, - isConnected + sendMessage, // Original simple signature + sendJsonMessage, // For sending pre-formed WebSocketMessage objects + lastJsonMessage, // Validated JSON message + rawLastMessage, // Raw message, for debugging or non-JSON cases + readyState, // Numerical readyState + connectionStatus,// User-friendly status string + isPermanentlyDisconnected, }; }; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 74715f3..c00abf4 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1719,6 +1719,11 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: get-nonce "^1.0.0" tslib "^2.0.0" +react-use-websocket@^4.13.0: + version "4.13.0" + resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.13.0.tgz#9db1dbac6dc8ba2fdc02a5bba06205fbf6406736" + integrity sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw== + react@19.0.0: version "19.0.0" resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" @@ -1947,6 +1952,11 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +zod@^3.24.4: + version "3.24.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.4.tgz#e2e2cca5faaa012d76e527d0d36622e0a90c315f" + integrity sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg== + zustand@^4.3.2: version "4.5.6" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.6.tgz#6857d52af44874a79fb3408c9473f78367255c96" From 0f22b4400e75c9b427a8b1e43e61544130fcf26b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 23:16:30 +0000 Subject: [PATCH 064/149] refactor: enhance logging format for received WebSocket messages in ws_router to include timestamp and message type --- src/backend/routers/ws_router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index c1c77e8..ca21344 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -64,8 +64,8 @@ async def _handle_received_data( # Add other metadata if not present or to ensure consistency message_data.setdefault("pad_id", str(pad_id)) message_data.setdefault("timestamp", datetime.now().isoformat()) - - print(f"Received message from {user.id} (event user_id: {message_data.get('user_id')}) on pad {str(pad_id)[:5]}") + + print(f"[WS] {datetime.now().strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(user.id)[:5]}] on pad ({str(pad_id)[:5]})") await publish_event_to_redis(redis_client, stream_key, message_data) From ab9ee8afa4092827f3f9c4b77e80c6c931f29caf Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sat, 17 May 2025 23:21:38 +0000 Subject: [PATCH 065/149] refactor: replace debouncedSendMessage with useMemo for improved performance in pad updates --- src/frontend/src/App.tsx | 28 +++++++++++------------ src/frontend/src/hooks/usePadWebSocket.ts | 14 ++---------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 47812cb..64dfd3e 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { ExcalidrawEmbeddableElement, NonDeleted, NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -46,25 +46,25 @@ export default function App() { const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - const { sendMessage, isConnected } = usePadWebSocket(selectedTabId); + const { sendMessage } = usePadWebSocket(selectedTabId); + + // Memoized debounced function for pad updates + const debouncedPadUpdate = useMemo(() => { + return debounce((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { + if (sendMessage && selectedTabId) { + // console.log('[pad.ws] Debounced pad_update sending:', { elements, state }); + sendMessage("pad_update", { elements, state }); + } + }, 250); // 250ms delay + }, [sendMessage, selectedTabId]); // Dependencies for useMemo const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; - const debouncedSendMessage = useCallback( - debounce((type: string, data: any) => { - sendMessage(type, data); - }, 250), - [sendMessage] - ); - const handleOnChange = useCallback((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - debouncedSendMessage('pad_update', { - elements, - appState: state - }); - }, [debouncedSendMessage]); + debouncedPadUpdate(elements, state); + }, [debouncedPadUpdate]); // Dependency for useCallback is now the debounced function const handleOnScrollChange = (scrollX: number, scrollY: number) => { lockEmbeddables(excalidrawAPI?.getAppState()); diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index bb54d2c..5adf843 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -135,18 +135,8 @@ export const usePadWebSocket = (padId: string | null) => { return; } - if (readyState === ReadyState.OPEN) { - console.debug(`[pad.ws] Sending message for pad ${padId}:`, payload); - librarySendMessage(JSON.stringify(payload)); - } else { - console.warn(`[pad.ws] WebSocket not open for pad ${padId}. State: ${readyState}. Message not sent:`, payload); - // react-use-websocket queues messages if sent before open, but this explicit check can be useful for logging. - // If you rely purely on the library's queueing, you might remove this check. - // However, for critical messages, knowing it wasn't sent immediately can be good. - // For now, let's assume librarySendMessage handles queueing if not open. - librarySendMessage(JSON.stringify(payload)); - } - }, [padId, librarySendMessage, readyState]); + librarySendMessage(JSON.stringify(payload)); + }, [padId, librarySendMessage]); // Wrapper to maintain the original `sendMessage(type, data)` signature if preferred by consuming components const sendMessage = useCallback((type: string, data: any) => { From 6425627e7dc7a73a7e1f5f4b488e05a1ab555bc3 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 23:46:02 +0000 Subject: [PATCH 066/149] use connection_id rather than user_id for multiple tabs --- src/backend/routers/ws_router.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index ca21344..c33805a 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -1,5 +1,6 @@ import json import asyncio +import uuid from uuid import UUID from typing import Optional from datetime import datetime @@ -53,7 +54,8 @@ async def _handle_received_data( pad_id: UUID, user: UserSession, redis_client: aioredis.Redis, - stream_key: str + stream_key: str, + connection_id: str ): """Processes decoded message data and publishes to Redis.""" message_data = json.loads(raw_data) @@ -64,6 +66,7 @@ async def _handle_received_data( # Add other metadata if not present or to ensure consistency message_data.setdefault("pad_id", str(pad_id)) message_data.setdefault("timestamp", datetime.now().isoformat()) + message_data.setdefault("connection_id", connection_id) print(f"[WS] {datetime.now().strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(user.id)[:5]}] on pad ({str(pad_id)[:5]})") @@ -73,7 +76,7 @@ async def consume_redis_stream( redis_client: aioredis.Redis, stream_key: str, websocket: WebSocket, - current_connection_user_id: str, # Added to identify the user of this specific WebSocket connection + current_connection_id: str, # Changed to identify the specific connection last_id: str = '$' ): """Consumes messages from Redis stream and forwards them to the WebSocket, avoiding echo.""" @@ -93,14 +96,15 @@ async def consume_redis_stream( value = v formatted_message[key] = value - message_origin_user_id = formatted_message.get('user_id') + # print(f"Received message from {formatted_message}") + message_origin_connection_id = formatted_message.get('connection_id') - # Only send if the message did not originate from this same user connection - if message_origin_user_id != current_connection_user_id: + # Only send if the message did not originate from this same connection + if message_origin_connection_id != current_connection_id: await websocket.send_json(formatted_message) else: # Optional: log that an echo was prevented - # print(f"Echo prevented for user {current_connection_user_id} on pad {formatted_message.get('pad_id', 'N/A')[:5]}") + # print(f"Echo prevented for connection {current_connection_id} on pad {formatted_message.get('pad_id', 'N/A')[:5]}") pass last_id = message_id @@ -128,12 +132,16 @@ async def websocket_endpoint( redis_client = await get_redis_client() stream_key = f"pad:stream:{pad_id}" + # Generate a unique connection ID for this WebSocket session + connection_id = str(uuid.uuid4()) + # Send initial connection success if websocket.client_state.CONNECTED: await websocket.send_json({ "type": "connected", "pad_id": str(pad_id), - "user_id": str(user.id) + "user_id": str(user.id), + "connection_id": connection_id }) # Publish user joined message @@ -142,6 +150,7 @@ async def websocket_endpoint( "type": "user_joined", "pad_id": str(pad_id), "user_id": str(user.id), + "connection_id": connection_id, "timestamp": datetime.now().isoformat() } if redis_client: @@ -154,7 +163,7 @@ async def handle_websocket_messages(): while websocket.client_state.CONNECTED: try: data = await websocket.receive_text() - await _handle_received_data(data, pad_id, user, redis_client, stream_key) + await _handle_received_data(data, pad_id, user, redis_client, stream_key, connection_id) except WebSocketDisconnect: break except json.JSONDecodeError as e: @@ -171,7 +180,7 @@ async def handle_websocket_messages(): # Run both tasks concurrently ws_task = asyncio.create_task(handle_websocket_messages()) redis_task = asyncio.create_task( - consume_redis_stream(redis_client, stream_key, websocket, str(user.id), last_id='$') + consume_redis_stream(redis_client, stream_key, websocket, connection_id, last_id='$') ) # Wait for either task to complete @@ -194,6 +203,7 @@ async def handle_websocket_messages(): "type": "user_left", "pad_id": str(pad_id), "user_id": str(user.id), + "connection_id": connection_id, "timestamp": datetime.now().isoformat() } await publish_event_to_redis(redis_client, stream_key, leave_message) From e5b0abb8ef73497bfa05c97e338734d15cc43866 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sat, 17 May 2025 23:47:06 +0000 Subject: [PATCH 067/149] cleanup --- src/backend/routers/ws_router.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index c33805a..aa77346 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -68,7 +68,7 @@ async def _handle_received_data( message_data.setdefault("timestamp", datetime.now().isoformat()) message_data.setdefault("connection_id", connection_id) - print(f"[WS] {datetime.now().strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(user.id)[:5]}] on pad ({str(pad_id)[:5]})") + print(f"[WS] {datetime.now().strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(connection_id)[:5]}] on pad ({str(pad_id)[:5]})") await publish_event_to_redis(redis_client, stream_key, message_data) @@ -96,21 +96,17 @@ async def consume_redis_stream( value = v formatted_message[key] = value - # print(f"Received message from {formatted_message}") message_origin_connection_id = formatted_message.get('connection_id') - # Only send if the message did not originate from this same connection if message_origin_connection_id != current_connection_id: await websocket.send_json(formatted_message) else: - # Optional: log that an echo was prevented - # print(f"Echo prevented for connection {current_connection_id} on pad {formatted_message.get('pad_id', 'N/A')[:5]}") pass last_id = message_id - # Brief pause to prevent CPU hogging - await asyncio.sleep(0.01) + # Release asyncio lock to prevent CPU hogging + await asyncio.sleep(0) except Exception as e: print(f"Error in Redis stream consumer: {e}") @@ -145,18 +141,14 @@ async def websocket_endpoint( }) # Publish user joined message - try: - join_message = { - "type": "user_joined", - "pad_id": str(pad_id), - "user_id": str(user.id), - "connection_id": connection_id, - "timestamp": datetime.now().isoformat() - } - if redis_client: - await publish_event_to_redis(redis_client, stream_key, join_message) - except Exception as e: - print(f"Error publishing join message: {e}") + join_message = { + "type": "user_joined", + "pad_id": str(pad_id), + "user_id": str(user.id), + "connection_id": connection_id, + "timestamp": datetime.now().isoformat() + } + await publish_event_to_redis(redis_client, stream_key, join_message) # Create tasks for WebSocket message handling and Redis stream reading async def handle_websocket_messages(): From ad79eebbbe6ae297f8b04d3c91464e7e658061fd Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 18 May 2025 00:32:37 +0000 Subject: [PATCH 068/149] refactor: update timestamp handling in WebSocket router and validation schema to use UTC and improve error messaging --- src/backend/routers/ws_router.py | 16 +++++++++------- src/frontend/src/hooks/usePadWebSocket.ts | 12 +++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index aa77346..c382d1b 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -3,7 +3,7 @@ import uuid from uuid import UUID from typing import Optional -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends from redis import asyncio as aioredis @@ -65,10 +65,10 @@ async def _handle_received_data( # Add other metadata if not present or to ensure consistency message_data.setdefault("pad_id", str(pad_id)) - message_data.setdefault("timestamp", datetime.now().isoformat()) + message_data.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) message_data.setdefault("connection_id", connection_id) - print(f"[WS] {datetime.now().strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(connection_id)[:5]}] on pad ({str(pad_id)[:5]})") + print(f"[WS] {datetime.now(timezone.utc).strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(connection_id)[:5]}] on pad ({str(pad_id)[:5]})") await publish_event_to_redis(redis_client, stream_key, message_data) @@ -137,7 +137,8 @@ async def websocket_endpoint( "type": "connected", "pad_id": str(pad_id), "user_id": str(user.id), - "connection_id": connection_id + "connection_id": connection_id, + "timestamp": datetime.now(timezone.utc).isoformat() }) # Publish user joined message @@ -146,7 +147,7 @@ async def websocket_endpoint( "pad_id": str(pad_id), "user_id": str(user.id), "connection_id": connection_id, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now(timezone.utc).isoformat() } await publish_event_to_redis(redis_client, stream_key, join_message) @@ -163,7 +164,8 @@ async def handle_websocket_messages(): if websocket.client_state.CONNECTED: await websocket.send_json({ "type": "error", - "message": "Invalid message format" + "message": "Invalid message format", + "timestamp": datetime.now(timezone.utc).isoformat() }) except Exception as e: print(f"Error in WebSocket connection: {e}") @@ -196,7 +198,7 @@ async def handle_websocket_messages(): "pad_id": str(pad_id), "user_id": str(user.id), "connection_id": connection_id, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now(timezone.utc).isoformat() } await publish_event_to_redis(redis_client, stream_key, leave_message) except Exception as e: diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 5adf843..67b8c37 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -11,7 +11,7 @@ const WebSocketMessageSchema = z.object({ type: z.string(), pad_id: z.string().nullable(), data: z.any().optional(), // Make 'data' optional - timestamp: z.string().datetime({ message: "Invalid timestamp format, expected ISO 8601" }).optional(), + timestamp: z.string().datetime({ offset: true, precision: 6, message: "Invalid timestamp format, expected ISO 8601 with offset and 6 fractional seconds" }).optional(), user_id: z.string().optional(), }); @@ -31,12 +31,10 @@ export const usePadWebSocket = (padId: string | null) => { const getSocketUrl = useCallback((): string | null => { if (!padId || padId.startsWith('temp-')) { - console.debug(`[pad.ws] getSocketUrl: Invalid padId (${padId}), returning null.`); return null; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${protocol}//${window.location.host}/ws/pad/${padId}`; - console.debug(`[pad.ws] getSocketUrl: Generated URL: ${url} for padId: ${padId}`); return url; }, [padId]); @@ -44,9 +42,6 @@ export const usePadWebSocket = (padId: string | null) => { const shouldBeConnected = useMemo(() => { const conditionsMet = !!memoizedSocketUrl && isAuthenticated && !isLoading && !isPermanentlyDisconnected; - console.debug( - `[pad.ws] shouldBeConnected for pad ${padId}: ${conditionsMet} (URL: ${!!memoizedSocketUrl}, Auth: ${isAuthenticated}, Loading: ${isLoading}, PermanentDisconnect: ${isPermanentlyDisconnected})` - ); return conditionsMet; }, [memoizedSocketUrl, isAuthenticated, isLoading, isPermanentlyDisconnected, padId]); @@ -116,6 +111,7 @@ export const usePadWebSocket = (padId: string | null) => { return validationResult.data; } else { console.error(`[pad.ws] Incoming message validation failed for pad ${padId}:`, validationResult.error.issues); + console.error(`[pad.ws] Raw message: ${rawLastMessage.data}`); return null; } } catch (error) { @@ -147,12 +143,14 @@ export const usePadWebSocket = (padId: string | null) => { timestamp: new Date().toISOString(), // user_id: can be added here if available and needed from context }; + console.debug(`[pad.ws] Sending message`, messagePayload.type); sendJsonMessage(messagePayload); }, [padId, sendJsonMessage]); useEffect(() => { if (lastJsonMessage) { - console.debug(`[pad.ws] Validated JSON message received for pad ${padId}:`, lastJsonMessage); + // console.debug(`[pad.ws] Validated JSON message received for pad ${padId}:`, lastJsonMessage); + console.debug(`[pad.ws] Received message`, lastJsonMessage?.type); // TODO: Dispatch to a store, update context, or trigger other side effects based on the message } }, [lastJsonMessage, padId]); From afd8ef7e990b9ea661ad4c12905b1e665ccbc6e6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 18 May 2025 00:36:18 +0000 Subject: [PATCH 069/149] refactor: remove precision requirement in websocket message schema --- src/frontend/src/hooks/usePadWebSocket.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 67b8c37..963ed5e 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -11,7 +11,7 @@ const WebSocketMessageSchema = z.object({ type: z.string(), pad_id: z.string().nullable(), data: z.any().optional(), // Make 'data' optional - timestamp: z.string().datetime({ offset: true, precision: 6, message: "Invalid timestamp format, expected ISO 8601 with offset and 6 fractional seconds" }).optional(), + timestamp: z.string().datetime({ offset: true, message: "Invalid timestamp format, expected ISO 8601 with offset" }).optional(), user_id: z.string().optional(), }); @@ -140,7 +140,7 @@ export const usePadWebSocket = (padId: string | null) => { type, pad_id: padId, data, - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString().replace('Z', '+00:00'), // user_id: can be added here if available and needed from context }; console.debug(`[pad.ws] Sending message`, messagePayload.type); From 1a1b054c2f05d714840b16f6508f72f59b48a5e1 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 18 May 2025 14:12:35 +0000 Subject: [PATCH 070/149] refactor: replace debounced function for pad updates with improved logging and state comparison to reduce unnecessary WebSocket messages --- src/frontend/src/App.tsx | 42 ++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 64dfd3e..283eed1 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; import type { ExcalidrawEmbeddableElement, NonDeleted, NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -48,24 +48,36 @@ export default function App() { const { sendMessage } = usePadWebSocket(selectedTabId); - // Memoized debounced function for pad updates - const debouncedPadUpdate = useMemo(() => { - return debounce((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - if (sendMessage && selectedTabId) { - // console.log('[pad.ws] Debounced pad_update sending:', { elements, state }); - sendMessage("pad_update", { elements, state }); - } - }, 250); // 250ms delay - }, [sendMessage, selectedTabId]); // Dependencies for useMemo + const lastSentCanvasDataRef = useRef(""); + + const debouncedLogChange = useCallback( + debounce( + (elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => { + if (!isAuthenticated || !selectedTabId) return; + + const canvasData = { + elements, + appState: state, + files + }; + + const serialized = JSON.stringify(canvasData); + + if (serialized !== lastSentCanvasDataRef.current) { + lastSentCanvasDataRef.current = serialized; + + sendMessage("pad_update", canvasData); + } + }, + 1200 + ), + [sendMessage, isAuthenticated] + ); const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; - const handleOnChange = useCallback((elements: readonly NonDeletedExcalidrawElement[], state: AppState) => { - debouncedPadUpdate(elements, state); - }, [debouncedPadUpdate]); // Dependency for useCallback is now the debounced function - const handleOnScrollChange = (scrollX: number, scrollY: number) => { lockEmbeddables(excalidrawAPI?.getAppState()); }; @@ -87,7 +99,7 @@ export default function App() { excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)} theme="dark" initialData={defaultInitialData} - onChange={handleOnChange} + onChange={debouncedLogChange} name="Pad.ws" onScrollChange={handleOnScrollChange} validateEmbeddable={true} From c51e8fcd143f43053baaa7e93937bcf255b11d11 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sun, 18 May 2025 20:24:46 +0000 Subject: [PATCH 071/149] basic pad monitoring script --- src/backend/scripts/ws_listener.py | 249 +++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/backend/scripts/ws_listener.py diff --git a/src/backend/scripts/ws_listener.py b/src/backend/scripts/ws_listener.py new file mode 100644 index 0000000..71f4b63 --- /dev/null +++ b/src/backend/scripts/ws_listener.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +import json +import asyncio +import argparse +import signal +import os +from uuid import UUID +from datetime import datetime +import sys +from pathlib import Path +from dotenv import load_dotenv + +# Add the parent directory to the path so we can import from modules +sys.path.append(str(Path(__file__).parent.parent)) + +from redis import asyncio as aioredis +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.syntax import Syntax +from rich.markdown import Markdown +from rich import box + +# Load environment variables from .env file +env_path = Path(__file__).parent.parent.parent.parent / '.env' +load_dotenv(dotenv_path=env_path) + +console = Console() + +# Handle graceful shutdown +shutdown_event = asyncio.Event() + +def handle_exit(): + console.print("[yellow]Shutting down...[/yellow]") + shutdown_event.set() + +async def get_redis_client(): + """Get Redis client using configuration from .env file.""" + redis_password = os.getenv("REDIS_PASSWORD", "pad") + redis_host = os.getenv("REDIS_HOST", "localhost") + redis_port = int(os.getenv("REDIS_PORT", 6379)) + + redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/0" + console.print(f"[dim]Connecting to Redis at {redis_host}:{redis_port}[/dim]") + + try: + redis_client = await aioredis.from_url(redis_url) + # Test connection + await redis_client.ping() + console.print("[green]Redis connection established[/green]") + return redis_client + except Exception as e: + console.print(f"[red]Failed to connect to Redis:[/red] {str(e)}") + raise + +# Store pad content globally to track changes +pad_content = {} + +async def connect_to_pad_stream(pad_id: UUID, from_start: bool = False): + """Connect to Redis and listen for pad events.""" + stream_key = f"pad:stream:{pad_id}" + console.print(f"[bold]Listening to Redis stream:[/bold] {stream_key}") + + try: + redis_client = await get_redis_client() + # Start from the beginning of the stream if requested, otherwise start from latest + last_id = "0" if from_start else "$" + + # Set up signal handlers + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_exit) + + # Store the current pad content + pad_content[str(pad_id)] = "" + + while not shutdown_event.is_set(): + try: + # Read messages from the Redis stream + streams = await redis_client.xread({stream_key: last_id}, count=5, block=1000) + + if streams: + stream_name, stream_messages = streams[0] + for message_id, message_data_raw in stream_messages: + # Convert raw Redis data to a formatted dictionary + formatted_message = {} + for k, v in message_data_raw.items(): + key = k.decode() if isinstance(k, bytes) else k + if isinstance(v, bytes): + value = v.decode() + else: + value = v + formatted_message[key] = value + + # If this is a pad_update, update our stored content + if formatted_message.get("type") == "pad_update" and "content" in formatted_message: + pad_content[str(pad_id)] = formatted_message["content"] + + await handle_message(formatted_message, str(pad_id)) + last_id = message_id + + # Release asyncio lock to prevent CPU hogging + await asyncio.sleep(0) + except asyncio.CancelledError: + break + except Exception as e: + console.print(f"[red]Error reading from Redis stream:[/red] {str(e)}") + await asyncio.sleep(1) # Wait before reconnecting + + # Close Redis connection + await redis_client.close() + console.print("[yellow]Redis connection closed[/yellow]") + + except Exception as e: + console.print(f"[red]Error in Redis connection:[/red] {str(e)}") + +def detect_content_type(content): + """Try to detect content type for syntax highlighting.""" + if content.startswith("```") and "\n" in content: + # Possible markdown code block + lang_line = content.split("\n", 1)[0].strip("`").strip() + if lang_line in ["python", "javascript", "typescript", "html", "css", "json", "bash", "markdown"]: + return lang_line + + # Look for common patterns + if "" in content: + return "html" + if "function" in content and ("=>" in content or "{" in content): + return "javascript" + if "import " in content and "from " in content and "def " in content: + return "python" + if content.strip().startswith("{") and content.strip().endswith("}"): + try: + json.loads(content) + return "json" + except: + pass + + # Default to plain text + return "text" + +def format_content_for_display(content, content_type=None): + """Format content appropriately based on detected type.""" + if not content_type: + content_type = detect_content_type(content) + + # If content looks like markdown, render it as markdown + if content.startswith("#") or "**" in content or "*" in content or "##" in content: + try: + return Markdown(content) + except: + pass + + # Otherwise use syntax highlighting + return Syntax(content, content_type, theme="monokai", line_numbers=True, word_wrap=True) + +async def handle_message(message, pad_id): + """Process and display received Redis stream messages.""" + try: + # Extract message type and other common fields + msg_type = message.get("type", "unknown") + timestamp = message.get("timestamp", datetime.now().isoformat()) + connection_id = message.get("connection_id", "unknown")[:5] # First 5 chars + user_id = message.get("user_id", "unknown")[:5] + + # Format timestamp for display + timestamp_display = timestamp.split('T')[1].split('.')[0] if 'T' in timestamp else timestamp + + # Format title based on message type + title = f"{msg_type} at {timestamp_display} [connection: {connection_id}, user: {user_id}]" + + # Create different styles for different event types + if msg_type == "user_joined": + title_style = "bold green" + content = f"User {user_id} joined the pad" + + elif msg_type == "user_left": + title_style = "bold red" + content = f"User {user_id} left the pad" + + elif msg_type == "pad_update": + title_style = "bold blue" + if "content" in message: + # Show the formatted content + content_text = message["content"] + + # Display formatted content + console.print(Panel( + f"User {user_id} updated the pad content", + title=Text(title, style=title_style), + border_style="blue" + )) + + # Display full content with syntax highlighting + content_type = detect_content_type(content_text) + formatted_content = format_content_for_display(content_text, content_type) + + console.print(Panel( + formatted_content, + title=Text(f"Current Pad Content ({content_type})", style="bold cyan"), + border_style="cyan", + box=box.ROUNDED + )) + return + else: + content = f"Content updated by user {user_id} (no content provided in event)" + + elif msg_type == "connected": + title_style = "bold cyan" + content = f"Successfully connected with connection ID: {connection_id}" + + else: + title_style = "bold yellow" + content = json.dumps(message, indent=2) + + # Create and display the panel with message details + title_text = Text(title, style=title_style) + console.print(Panel(content, title=title_text, border_style="dim")) + + except Exception as e: + console.print(f"[red]Error handling message:[/red] {str(e)}") + +async def main(): + """Main entry point for the pad events listener script.""" + parser = argparse.ArgumentParser(description="Listen to events for a specific pad directly from Redis") + parser.add_argument("pad_id", help="UUID of the pad to listen to") + parser.add_argument("--from-start", "-f", action="store_true", + help="Read from the beginning of the stream history") + + args = parser.parse_args() + + # Validate the pad_id is a valid UUID + try: + pad_uuid = UUID(args.pad_id) + except ValueError: + console.print("[red]Invalid pad ID. Must be a valid UUID.[/red]") + return + + console.print(f"[bold]Pad Event Listener[/bold] - Connecting to pad: {pad_uuid}") + + try: + await connect_to_pad_stream(pad_uuid, from_start=args.from_start) + except KeyboardInterrupt: + handle_exit() + finally: + console.print("[yellow]Listener stopped[/yellow]") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 65e0a5c636d79dbc7e9c58298e2df1a78efdcd4a Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sun, 18 May 2025 21:16:41 +0000 Subject: [PATCH 072/149] imrpoved script a bit --- src/backend/scripts/ws_listener.py | 495 ++++++++++++++++++++--------- 1 file changed, 349 insertions(+), 146 deletions(-) diff --git a/src/backend/scripts/ws_listener.py b/src/backend/scripts/ws_listener.py index 71f4b63..e5d8ade 100644 --- a/src/backend/scripts/ws_listener.py +++ b/src/backend/scripts/ws_listener.py @@ -9,6 +9,7 @@ import sys from pathlib import Path from dotenv import load_dotenv +import io # Add the parent directory to the path so we can import from modules sys.path.append(str(Path(__file__).parent.parent)) @@ -20,6 +21,15 @@ from rich.syntax import Syntax from rich.markdown import Markdown from rich import box +from rich.tree import Tree +from rich.json import JSON +from rich.console import RenderableType + +from textual.app import App, ComposeResult +from textual.containers import Container, ScrollableContainer +from textual.widgets import Header, Footer, Button, Static +from textual.reactive import reactive +from textual import work # Load environment variables from .env file env_path = Path(__file__).parent.parent.parent.parent / '.env' @@ -27,13 +37,336 @@ console = Console() -# Handle graceful shutdown -shutdown_event = asyncio.Event() +# For non-Textual output during setup +setup_console = Console() -def handle_exit(): - console.print("[yellow]Shutting down...[/yellow]") - shutdown_event.set() +class PadUpdateWidget(Static): + """A widget to display an individual pad update with expandable content.""" + + def __init__(self, message, **kwargs): + super().__init__("", **kwargs) + self.message = message + # Parse data field if it exists and appears to be JSON + if "data" in self.message and isinstance(self.message["data"], str): + try: + if self.message["data"].startswith("{") or self.message["data"].startswith("["): + self.message["data"] = json.loads(self.message["data"]) + except json.JSONDecodeError: + pass # Keep as string if it can't be parsed + self.expanded = False + self.update_display() + + def render_rich_tree(self, tree: Tree) -> str: + """Properly render a Rich Tree to a string.""" + # Create a string buffer and a console to render into it + string_io = io.StringIO() + temp_console = Console(file=string_io, width=100) + + # Render the tree to the string buffer + temp_console.print(tree) + + # Return the contents of the buffer + return string_io.getvalue() + + def update_display(self): + """Update the widget display based on expanded state.""" + msg_type = self.message.get("type", "unknown") + timestamp = self.message.get("timestamp", datetime.now().isoformat()) + connection_id = self.message.get("connection_id", "unknown")[:5] + user_id = self.message.get("user_id", "unknown")[:5] + + timestamp_display = timestamp.split('T')[1].split('.')[0] if 'T' in timestamp else timestamp + title = f"{msg_type} at {timestamp_display} [connection: {connection_id}, user: {user_id}]" + + content = "" + border_style = "dim" + title_style = "white" + box_type = box.SIMPLE + + if msg_type == "user_joined": + title_style = "bold green" + border_style = "green" + box_type = box.ROUNDED + content = f"User {user_id} joined the pad" + + elif msg_type == "user_left": + title_style = "bold red" + border_style = "red" + box_type = box.ROUNDED + content = f"User {user_id} left the pad" + + elif msg_type == "pad_update": + title_style = "bold blue" + border_style = "blue" + box_type = box.ROUNDED + + # Check for data field containing Excalidraw content + has_excalidraw_data = ( + "data" in self.message and + isinstance(self.message["data"], dict) and + ("elements" in self.message["data"] or "appState" in self.message["data"] or "files" in self.message["data"]) + ) + + if has_excalidraw_data: + button_text = "[▼] Show Excalidraw data" if not self.expanded else "[▲] Hide Excalidraw data" + content = f"User {user_id} updated the pad\n\n{button_text}" + + if self.expanded: + content = f"User {user_id} updated the pad\n\n{button_text}\n\n" + data = self.message["data"] + + # Create a tree to display the structure + excalidraw_tree = Tree("Excalidraw Data") + + # Elements (drawing objects) + if "elements" in data: + element_count = len(data["elements"]) + elements_branch = excalidraw_tree.add(f"[bold cyan]Elements[/] ({element_count})") + + # Show a preview of a few elements + max_elements = 3 + for i, element in enumerate(data["elements"][:max_elements]): + element_type = element.get("type", "unknown") + element_id = element.get("id", "unknown")[:8] + elements_branch.add(f"[cyan]{element_type}[/] (id: {element_id})") + + if element_count > max_elements: + elements_branch.add(f"... and {element_count - max_elements} more elements") + + # AppState (view state, settings) + if "appState" in data: + app_state = data["appState"] + app_state_branch = excalidraw_tree.add("[bold green]AppState[/]") + + # Show important appState properties + important_props = ["viewBackgroundColor", "gridSize", "zoom", "scrollX", "scrollY"] + for prop in important_props: + if prop in app_state: + app_state_branch.add(f"[green]{prop}[/]: {app_state[prop]}") + + # Show count of other properties + other_props_count = len(app_state) - len([p for p in important_props if p in app_state]) + if other_props_count > 0: + app_state_branch.add(f"... and {other_props_count} more properties") + + # Files (attached files/images) + if "files" in data: + files = data["files"] + files_count = len(files) + if files_count > 0: + files_branch = excalidraw_tree.add(f"[bold yellow]Files[/] ({files_count})") + for file_id, file_data in list(files.items())[:3]: + files_branch.add(f"[yellow]{file_id[:8]}...[/]") + + if files_count > 3: + files_branch.add(f"... and {files_count - 3} more files") + else: + excalidraw_tree.add("[bold yellow]Files[/] (none)") + + # Properly render the tree to a string + content += self.render_rich_tree(excalidraw_tree) + + self.update(Panel( + content, + title=Text(title, style=title_style), + border_style=border_style, + box=box_type + )) + return + else: + content = f"Content updated by user {user_id} (no Excalidraw data found in message)" + if "data" in self.message: + # Try to display raw data if available + if isinstance(self.message["data"], str) and len(self.message["data"]) > 0: + content += "\n\nData appears to be a string, not parsed JSON" + if self.expanded: + # Show preview of the raw data string + preview = self.message["data"][:200] + "..." if len(self.message["data"]) > 200 else self.message["data"] + content += f"\n\n{preview}" + + elif msg_type == "connected": + title_style = "bold cyan" + content = f"Successfully connected with connection ID: {connection_id}" + + elif msg_type == "welcome": + title_style = "bold magenta" + border_style = "magenta" + box_type = box.DOUBLE + content = self.message.get("message", "Welcome to pad listener!") + + else: + title_style = "bold yellow" + if self.expanded: + content = json.dumps(self.message, indent=2) + else: + content = f"Unknown event type: {msg_type} [▼] Show details" + + self.update(Panel( + content, + title=Text(title, style=title_style), + border_style=border_style, + box=box_type + )) + + def on_click(self): + """Toggle expanded state when clicked.""" + if self.message.get("type") in ["pad_update", "unknown"]: + self.expanded = not self.expanded + self.update_display() + +class PadEventApp(App): + """Main application for monitoring pad events.""" + CSS = """ + #events-container { + width: 100%; + height: 100%; + overflow-y: auto; + } + + PadUpdateWidget { + margin: 0 0 1 0; + } + + #status-bar { + dock: bottom; + height: 1; + background: $surface; + color: $text; + } + """ + + BINDINGS = [ + ("q", "quit", "Quit"), + ("c", "clear", "Clear Events"), + ] + + pad_id = reactive("") + connection_status = reactive("Disconnected") + event_count = reactive(0) + + def __init__(self, pad_id): + super().__init__() + self.pad_id = str(pad_id) + self.redis_client = None + + def compose(self) -> ComposeResult: + """Create UI components.""" + yield Header(show_clock=True) + yield ScrollableContainer(id="events-container") + yield Static(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}", id="status-bar") + yield Footer() + + def on_mount(self) -> None: + """Set up the application when it starts.""" + self.update_status("Connecting...") + # Starting the worker method directly - Textual handles the task creation + self.start_redis_listener() + + def update_status(self, status: str) -> None: + """Update the connection status and status bar.""" + self.connection_status = status + status_bar = self.query_one("#status-bar") + status_bar.update(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}") + + @work(thread=False) + async def start_redis_listener(self): + """Connect to Redis and start listening for events. + This uses Textual's work decorator to run as a background task. + """ + try: + # Connect to Redis + self.redis_client = await get_redis_client() + stream_key = f"pad:stream:{self.pad_id}" + + self.update_status("Connected") + + # Add a welcome message + welcome_message = { + "type": "welcome", + "timestamp": datetime.now().isoformat(), + "connection_id": "system", + "user_id": "system", + "message": f"Connected to pad stream: {self.pad_id}" + } + self.add_message(welcome_message) + + # Listen for events + last_id = "$" # Start from latest messages + + while True: + try: + # Read messages from the Redis stream + streams = await self.redis_client.xread({stream_key: last_id}, count=5, block=1000) + + if streams: + stream_name, stream_messages = streams[0] + for message_id, message_data_raw in stream_messages: + # Convert raw Redis data to a formatted dictionary + formatted_message = {} + for k, v in message_data_raw.items(): + key = k.decode() if isinstance(k, bytes) else k + + # Try to parse JSON values + if isinstance(v, bytes): + string_value = v.decode() + else: + string_value = str(v) + + formatted_message[key] = string_value + + # Add the message to the UI + self.add_message(formatted_message) + last_id = message_id + + # Prevent CPU hogging + await asyncio.sleep(0.1) + + except Exception as e: + self.update_status(f"Error: {str(e)}") + await asyncio.sleep(1) + + except Exception as e: + self.update_status(f"Connection failed: {str(e)}") + + def add_message(self, message): + """Add a new message to the UI.""" + container = self.query_one("#events-container") + update_widget = PadUpdateWidget(message) + container.mount(update_widget) + + # Scroll to the new message + container.scroll_end(animate=False) + + # Update event count + self.event_count += 1 + status_bar = self.query_one("#status-bar") + status_bar.update(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}") + + async def action_clear(self) -> None: + """Clear all events from the container.""" + container = self.query_one("#events-container") + container.remove_children() + self.event_count = 0 + status_bar = self.query_one("#status-bar") + status_bar.update(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}") + + async def action_quit(self) -> None: + """Quit the application cleanly.""" + if self.redis_client: + try: + await self.redis_client.close() + except: + # Handle deprecated close() method + try: + await self.redis_client.aclose() + except: + pass + + # Wait a moment to ensure clean shutdown + await asyncio.sleep(0.1) + self.exit() +# Reuse the helper functions from the previous script async def get_redis_client(): """Get Redis client using configuration from .env file.""" redis_password = os.getenv("REDIS_PASSWORD", "pad") @@ -41,79 +374,18 @@ async def get_redis_client(): redis_port = int(os.getenv("REDIS_PORT", 6379)) redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/0" - console.print(f"[dim]Connecting to Redis at {redis_host}:{redis_port}[/dim]") + setup_console.print(f"[dim]Connecting to Redis at {redis_host}:{redis_port}[/dim]") try: redis_client = await aioredis.from_url(redis_url) # Test connection await redis_client.ping() - console.print("[green]Redis connection established[/green]") + setup_console.print("[green]Redis connection established[/green]") return redis_client except Exception as e: - console.print(f"[red]Failed to connect to Redis:[/red] {str(e)}") + setup_console.print(f"[red]Failed to connect to Redis:[/red] {str(e)}") raise -# Store pad content globally to track changes -pad_content = {} - -async def connect_to_pad_stream(pad_id: UUID, from_start: bool = False): - """Connect to Redis and listen for pad events.""" - stream_key = f"pad:stream:{pad_id}" - console.print(f"[bold]Listening to Redis stream:[/bold] {stream_key}") - - try: - redis_client = await get_redis_client() - # Start from the beginning of the stream if requested, otherwise start from latest - last_id = "0" if from_start else "$" - - # Set up signal handlers - loop = asyncio.get_running_loop() - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, handle_exit) - - # Store the current pad content - pad_content[str(pad_id)] = "" - - while not shutdown_event.is_set(): - try: - # Read messages from the Redis stream - streams = await redis_client.xread({stream_key: last_id}, count=5, block=1000) - - if streams: - stream_name, stream_messages = streams[0] - for message_id, message_data_raw in stream_messages: - # Convert raw Redis data to a formatted dictionary - formatted_message = {} - for k, v in message_data_raw.items(): - key = k.decode() if isinstance(k, bytes) else k - if isinstance(v, bytes): - value = v.decode() - else: - value = v - formatted_message[key] = value - - # If this is a pad_update, update our stored content - if formatted_message.get("type") == "pad_update" and "content" in formatted_message: - pad_content[str(pad_id)] = formatted_message["content"] - - await handle_message(formatted_message, str(pad_id)) - last_id = message_id - - # Release asyncio lock to prevent CPU hogging - await asyncio.sleep(0) - except asyncio.CancelledError: - break - except Exception as e: - console.print(f"[red]Error reading from Redis stream:[/red] {str(e)}") - await asyncio.sleep(1) # Wait before reconnecting - - # Close Redis connection - await redis_client.close() - console.print("[yellow]Redis connection closed[/yellow]") - - except Exception as e: - console.print(f"[red]Error in Redis connection:[/red] {str(e)}") - def detect_content_type(content): """Try to detect content type for syntax highlighting.""" if content.startswith("```") and "\n" in content: @@ -154,75 +426,9 @@ def format_content_for_display(content, content_type=None): # Otherwise use syntax highlighting return Syntax(content, content_type, theme="monokai", line_numbers=True, word_wrap=True) -async def handle_message(message, pad_id): - """Process and display received Redis stream messages.""" - try: - # Extract message type and other common fields - msg_type = message.get("type", "unknown") - timestamp = message.get("timestamp", datetime.now().isoformat()) - connection_id = message.get("connection_id", "unknown")[:5] # First 5 chars - user_id = message.get("user_id", "unknown")[:5] - - # Format timestamp for display - timestamp_display = timestamp.split('T')[1].split('.')[0] if 'T' in timestamp else timestamp - - # Format title based on message type - title = f"{msg_type} at {timestamp_display} [connection: {connection_id}, user: {user_id}]" - - # Create different styles for different event types - if msg_type == "user_joined": - title_style = "bold green" - content = f"User {user_id} joined the pad" - - elif msg_type == "user_left": - title_style = "bold red" - content = f"User {user_id} left the pad" - - elif msg_type == "pad_update": - title_style = "bold blue" - if "content" in message: - # Show the formatted content - content_text = message["content"] - - # Display formatted content - console.print(Panel( - f"User {user_id} updated the pad content", - title=Text(title, style=title_style), - border_style="blue" - )) - - # Display full content with syntax highlighting - content_type = detect_content_type(content_text) - formatted_content = format_content_for_display(content_text, content_type) - - console.print(Panel( - formatted_content, - title=Text(f"Current Pad Content ({content_type})", style="bold cyan"), - border_style="cyan", - box=box.ROUNDED - )) - return - else: - content = f"Content updated by user {user_id} (no content provided in event)" - - elif msg_type == "connected": - title_style = "bold cyan" - content = f"Successfully connected with connection ID: {connection_id}" - - else: - title_style = "bold yellow" - content = json.dumps(message, indent=2) - - # Create and display the panel with message details - title_text = Text(title, style=title_style) - console.print(Panel(content, title=title_text, border_style="dim")) - - except Exception as e: - console.print(f"[red]Error handling message:[/red] {str(e)}") - -async def main(): +def main(): """Main entry point for the pad events listener script.""" - parser = argparse.ArgumentParser(description="Listen to events for a specific pad directly from Redis") + parser = argparse.ArgumentParser(description="Interactive viewer for pad events from Redis") parser.add_argument("pad_id", help="UUID of the pad to listen to") parser.add_argument("--from-start", "-f", action="store_true", help="Read from the beginning of the stream history") @@ -233,17 +439,14 @@ async def main(): try: pad_uuid = UUID(args.pad_id) except ValueError: - console.print("[red]Invalid pad ID. Must be a valid UUID.[/red]") + setup_console.print("[red]Invalid pad ID. Must be a valid UUID.[/red]") return - console.print(f"[bold]Pad Event Listener[/bold] - Connecting to pad: {pad_uuid}") + setup_console.print(f"[bold]Interactive Pad Event Listener[/bold] - Connecting to pad: {pad_uuid}") - try: - await connect_to_pad_stream(pad_uuid, from_start=args.from_start) - except KeyboardInterrupt: - handle_exit() - finally: - console.print("[yellow]Listener stopped[/yellow]") + # Start the Textual app + app = PadEventApp(pad_uuid) + app.run() if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + main() \ No newline at end of file From c3aa7f8d3ca4b729a12c3c786230642ff61767f0 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 19 May 2025 15:04:09 +0000 Subject: [PATCH 073/149] refactor: include user ID in auth status response --- src/backend/routers/auth_router.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/routers/auth_router.py b/src/backend/routers/auth_router.py index a0416d8..8aa83c0 100644 --- a/src/backend/routers/auth_router.py +++ b/src/backend/routers/auth_router.py @@ -145,10 +145,11 @@ async def auth_status( try: expires_in = user_session.token_data.get('exp') - time.time() - + return JSONResponse({ "authenticated": True, "user": { + "id": str(user_session.id), "username": user_session.username, "email": user_session.email, "name": user_session.name From 8027d89bd832e4dc767ec1cd3577d8330ba4be56 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 19 May 2025 15:13:13 +0000 Subject: [PATCH 074/149] refactor: replace console.log with console.debug for improved logging in usePadData hook --- src/frontend/src/hooks/usePadData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/hooks/usePadData.ts b/src/frontend/src/hooks/usePadData.ts index 663af1c..90a4b9f 100644 --- a/src/frontend/src/hooks/usePadData.ts +++ b/src/frontend/src/hooks/usePadData.ts @@ -41,14 +41,14 @@ export const usePad = (padId: string | null, excalidrawAPI: ExcalidrawImperative useEffect(() => { if (isTemporaryPad && excalidrawAPI) { - console.log(`[pad.ws] Initializing new temporary pad ${padId}`); + console.debug(`[pad.ws] Initializing new temporary pad ${padId}`); excalidrawAPI.updateScene(defaultInitialData); return; } if (data && excalidrawAPI && !isTemporaryPad) { const normalizedData = normalizeCanvasData(data); - console.log(`[pad.ws] Loading pad ${padId}`); + console.debug(`[pad.ws] Loading pad ${padId}`); excalidrawAPI.updateScene(normalizedData); } }, [data, excalidrawAPI, padId, isTemporaryPad]); From dfae77f59f2b16391c731c492f0d863974cb6b16 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 19 May 2025 15:24:21 +0000 Subject: [PATCH 075/149] refactor: separate collaboration logic into Collab.tsx and add user_joined/user_left handling --- src/backend/routers/ws_router.py | 242 ++++++++++++++-------- src/frontend/src/App.tsx | 17 +- src/frontend/src/Collab.tsx | 108 ++++++++++ src/frontend/src/hooks/usePadWebSocket.ts | 77 ++++--- 4 files changed, 316 insertions(+), 128 deletions(-) create mode 100644 src/frontend/src/Collab.tsx diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index c382d1b..e7f87d3 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -2,10 +2,11 @@ import asyncio import uuid from uuid import UUID -from typing import Optional +from typing import Optional, Any from datetime import datetime, timezone from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends +from pydantic import BaseModel, Field, field_validator from redis import asyncio as aioredis from config import get_redis_client @@ -13,6 +14,30 @@ ws_router = APIRouter() +class WebSocketMessage(BaseModel): + type: str + pad_id: Optional[str] = None + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + user_id: Optional[str] = None # ID of the user related to the event or sending the message + connection_id: Optional[str] = None # Connection ID related to the event or sending the message + data: Optional[Any] = None # Payload; structure depends entirely on 'type' + + @field_validator('timestamp', mode='before') + @classmethod + def ensure_datetime_object(cls, v): + if isinstance(v, str): + if v.endswith('Z'): + return datetime.fromisoformat(v[:-1] + '+00:00') + return datetime.fromisoformat(v) + if isinstance(v, datetime): + return v + raise ValueError("Timestamp must be a datetime object or an ISO 8601 string") + + class Config: + json_encoders = { + datetime: lambda dt: dt.isoformat().replace('+00:00', 'Z') if dt.tzinfo else dt.replace(tzinfo=timezone.utc).isoformat().replace('+00:00', 'Z') + } + async def get_ws_user(websocket: WebSocket) -> Optional[UserSession]: """WebSocket-specific authentication dependency""" try: @@ -41,74 +66,117 @@ async def get_ws_user(websocket: WebSocket) -> Optional[UserSession]: print(f"Error in WebSocket authentication: {str(e)}") return None -async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, event_data: dict): - """Formats event data and publishes it to a Redis stream.""" - field_value_dict = { - str(k): str(v) if not isinstance(v, (int, float)) else v - for k, v in event_data.items() - } +async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, event_model: WebSocketMessage): + """Formats event data from WebSocketMessage model and publishes it to a Redis stream.""" + message_dict = event_model.model_dump() + + # Ensure all values are suitable for Redis stream (mostly strings, or numbers) + field_value_dict = {} + for k, v in message_dict.items(): + if isinstance(v, datetime): + field_value_dict[k] = v.isoformat().replace('+00:00', 'Z') + elif isinstance(v, (dict, list)): # Serialize complex data field to JSON string + field_value_dict[k] = json.dumps(v) + elif v is None: + continue # Optionally skip None values or convert to empty string + else: + field_value_dict[k] = str(v) + await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + async def _handle_received_data( raw_data: str, pad_id: UUID, - user: UserSession, + user: UserSession, # Authenticated user session redis_client: aioredis.Redis, stream_key: str, - connection_id: str + current_connection_id: str # This specific connection's ID ): - """Processes decoded message data and publishes to Redis.""" - message_data = json.loads(raw_data) + """Processes decoded message data, wraps it in WebSocketMessage, and publishes to Redis.""" + try: + client_message_dict = json.loads(raw_data) + + # Create a WebSocketMessage instance from the client's data. + # The client might not send user_id or connection_id, or we might want to override them. + processed_message = WebSocketMessage( + type=client_message_dict.get("type", "unknown_client_message"), # Get type from client + pad_id=str(pad_id), # Server sets/overrides pad_id + user_id=str(user.id), # Server sets/overrides user_id from authenticated session + connection_id=current_connection_id, # Server sets/overrides connection_id + timestamp=datetime.now(timezone.utc), # Server sets timestamp + data=client_message_dict.get("data") # Pass through client's data payload + ) - if 'user_id' not in message_data: # Should not happen if client sends it, but as a safeguard - message_data['user_id'] = str(user.id) - - # Add other metadata if not present or to ensure consistency - message_data.setdefault("pad_id", str(pad_id)) - message_data.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) - message_data.setdefault("connection_id", connection_id) + print(f"[WS] {processed_message.timestamp.strftime('%H:%M:%S')} - Type: {processed_message.type} from User: {processed_message.user_id[:5]} Conn: [{processed_message.connection_id[:5]}] on Pad: ({processed_message.pad_id[:5]})") - print(f"[WS] {datetime.now(timezone.utc).strftime('%H:%M:%S')} - {message_data.get('type', 'Unknown')} from [{str(connection_id)[:5]}] on pad ({str(pad_id)[:5]})") + await publish_event_to_redis(redis_client, stream_key, processed_message) + + except json.JSONDecodeError: + print(f"Invalid JSON received from {current_connection_id[:5]}") + # Optionally send an error message back to this client + error_msg = WebSocketMessage( + type="error", + pad_id=str(pad_id), + data={"message": "Invalid JSON format received."} + ) + # This send is tricky as _handle_received_data doesn't have websocket object. + # Error handling might need to be closer to websocket.receive_text() + except Exception as e: + print(f"Error processing message from {current_connection_id[:5]}: {e}") - await publish_event_to_redis(redis_client, stream_key, message_data) async def consume_redis_stream( redis_client: aioredis.Redis, stream_key: str, websocket: WebSocket, - current_connection_id: str, # Changed to identify the specific connection + current_connection_id: str, last_id: str = '$' ): - """Consumes messages from Redis stream and forwards them to the WebSocket, avoiding echo.""" + """Consumes messages from Redis stream, parses to WebSocketMessage, and forwards them.""" try: while websocket.client_state.CONNECTED: streams = await redis_client.xread({stream_key: last_id}, count=5, block=1000) if streams: stream_name, stream_messages = streams[0] - for message_id, message_data_raw in stream_messages: - formatted_message = {} - for k, v in message_data_raw.items(): + for message_id, message_data_raw_redis in stream_messages: + # Convert raw Redis data to a standard dict, handling bytes or str + redis_dict = {} + for k, v in message_data_raw_redis.items(): key = k.decode() if isinstance(k, bytes) else k - if isinstance(v, bytes): - value = v.decode() + value_str = v.decode() if isinstance(v, bytes) else v + + # Attempt to parse 'data' field if it was stored as JSON string + if key == 'data': + try: + redis_dict[key] = json.loads(value_str) + except json.JSONDecodeError: + redis_dict[key] = value_str # Keep as string if not valid JSON + elif key == 'pad_id' and value_str == 'None': # Handle 'None' string for nullable fields + redis_dict[key] = None else: - value = v - formatted_message[key] = value + redis_dict[key] = value_str - message_origin_connection_id = formatted_message.get('connection_id') + try: + # Parse the dictionary from Redis into our Pydantic model + message_to_send = WebSocketMessage(**redis_dict) + + # Avoid echoing messages to the sender + if message_to_send.connection_id != current_connection_id: + await websocket.send_text(message_to_send.model_dump_json()) + else: + pass # Message originated from this connection, don't echo + + except Exception as pydantic_error: + print(f"Error parsing message from Redis stream {stream_key} (ID: {message_id}): {pydantic_error}. Data: {redis_dict}") - if message_origin_connection_id != current_connection_id: - await websocket.send_json(formatted_message) - else: - pass - last_id = message_id - # Release asyncio lock to prevent CPU hogging - await asyncio.sleep(0) + await asyncio.sleep(0.01) # Small sleep to yield control except Exception as e: - print(f"Error in Redis stream consumer: {e}") + print(f"Error in Redis stream consumer for {stream_key}: {e}") + @ws_router.websocket("/ws/pad/{pad_id}") async def websocket_endpoint( @@ -116,98 +184,104 @@ async def websocket_endpoint( pad_id: UUID, user: Optional[UserSession] = Depends(get_ws_user) ): - """WebSocket endpoint for real-time pad updates""" if not user: await websocket.close(code=4001, reason="Authentication required") return await websocket.accept() redis_client = None + connection_id = str(uuid.uuid4()) # Unique ID for this WebSocket connection + stream_key = f"pad:stream:{pad_id}" try: redis_client = await get_redis_client() - stream_key = f"pad:stream:{pad_id}" - - # Generate a unique connection ID for this WebSocket session - connection_id = str(uuid.uuid4()) - # Send initial connection success + # Send initial connection success message if websocket.client_state.CONNECTED: - await websocket.send_json({ - "type": "connected", - "pad_id": str(pad_id), - "user_id": str(user.id), - "connection_id": connection_id, - "timestamp": datetime.now(timezone.utc).isoformat() - }) + connected_msg = WebSocketMessage( + type="connected", + pad_id=str(pad_id), + user_id=str(user.id), + connection_id=connection_id, + data={"message": f"Successfully connected to pad {str(pad_id)}."} + ) + + await websocket.send_text(connected_msg.model_dump_json()) - # Publish user joined message - join_message = { - "type": "user_joined", - "pad_id": str(pad_id), - "user_id": str(user.id), - "connection_id": connection_id, - "timestamp": datetime.now(timezone.utc).isoformat() - } + # Publish user joined message to Redis + join_event_data = {"displayName": getattr(user, 'displayName', str(user.id))} # Adapt if user model has display name + join_message = WebSocketMessage( + type="user_joined", + pad_id=str(pad_id), + user_id=str(user.id), + connection_id=connection_id, + data=join_event_data + ) await publish_event_to_redis(redis_client, stream_key, join_message) - # Create tasks for WebSocket message handling and Redis stream reading async def handle_websocket_messages(): while websocket.client_state.CONNECTED: try: data = await websocket.receive_text() await _handle_received_data(data, pad_id, user, redis_client, stream_key, connection_id) except WebSocketDisconnect: + print(f"WebSocket disconnected for user {str(user.id)[:5]} conn {connection_id[:5]} from pad {str(pad_id)[:5]}") break except json.JSONDecodeError as e: - print(f"Invalid JSON received: {e}") + print(f"Invalid JSON received from {connection_id[:5]}: {e}") if websocket.client_state.CONNECTED: - await websocket.send_json({ - "type": "error", - "message": "Invalid message format", - "timestamp": datetime.now(timezone.utc).isoformat() - }) + error_msg = WebSocketMessage( + type="error", + pad_id=str(pad_id), + data={"message": "Invalid message format. Please send valid JSON."} + ) + await websocket.send_text(error_msg.model_dump_json()) except Exception as e: - print(f"Error in WebSocket connection: {e}") + print(f"Error in WebSocket connection for {connection_id[:5]}: {e}") break - # Run both tasks concurrently ws_task = asyncio.create_task(handle_websocket_messages()) redis_task = asyncio.create_task( consume_redis_stream(redis_client, stream_key, websocket, connection_id, last_id='$') ) - # Wait for either task to complete done, pending = await asyncio.wait( [ws_task, redis_task], return_when=asyncio.FIRST_COMPLETED ) - # Cancel any pending tasks for task in pending: task.cancel() + try: + await task # Await cancellation + except asyncio.CancelledError: + pass except Exception as e: - print(f"Error in WebSocket endpoint: {e}") + print(f"Error in WebSocket endpoint for pad {str(pad_id)}: {e}") finally: - # Send user left message before cleanup + print(f"Cleaning up connection for user {str(user.id)[:5]} conn {connection_id[:5]} from pad {str(pad_id)[:5]}") try: - if redis_client: - leave_message = { - "type": "user_left", - "pad_id": str(pad_id), - "user_id": str(user.id), - "connection_id": connection_id, - "timestamp": datetime.now(timezone.utc).isoformat() - } + if redis_client and user: # Ensure user is not None + leave_event_data = {} # No specific data needed for leave, user_id is top-level + leave_message = WebSocketMessage( + type="user_left", + pad_id=str(pad_id), + user_id=str(user.id), + connection_id=connection_id, + data=leave_event_data + ) await publish_event_to_redis(redis_client, stream_key, leave_message) except Exception as e: - print(f"Error publishing leave message: {e}") + print(f"Error publishing leave message for {connection_id[:5]}: {e}") - # Close websocket if still connected try: if websocket.client_state.CONNECTED: await websocket.close() except Exception as e: - if "already completed" not in str(e) and "close message has been sent" not in str(e): - print(f"Error closing WebSocket connection: {e}") + # Suppress common errors on close if already handled or connection is gone + if "already completed" not in str(e) and "close message has been sent" not in str(e) and "no connection" not in str(e).lower(): + print(f"Error closing WebSocket connection for {connection_id[:5]}: {e}") + + if redis_client: + await redis_client.close() # Ensure Redis client is closed if it was opened diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 283eed1..41ce302 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Excalidraw, MainMenu, Footer } from "@atyrode/excalidraw"; -import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; +import type { ExcalidrawImperativeAPI, AppState, Collaborator as ExcalidrawCollaborator } from "@atyrode/excalidraw/types"; // Added Collaborator import type { ExcalidrawEmbeddableElement, NonDeleted, NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; // Hooks @@ -13,6 +13,7 @@ import DiscordButton from './ui/DiscordButton'; import { MainMenuConfig } from './ui/MainMenu'; import AuthDialog from './ui/AuthDialog'; import SettingsDialog from './ui/SettingsDialog'; +import Collab from './Collab'; // Utils // import { initializePostHog } from "./lib/posthog"; @@ -31,7 +32,8 @@ export const defaultInitialData = { }; export default function App() { - const { isAuthenticated, isLoading: isLoadingAuth } = useAuthStatus(); + const { isAuthenticated, isLoading: isLoadingAuth, user } = useAuthStatus(); // Changed userProfile to user + const { tabs, selectedTabId, @@ -46,7 +48,7 @@ export default function App() { const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - const { sendMessage } = usePadWebSocket(selectedTabId); + const { sendMessage, lastJsonMessage } = usePadWebSocket(selectedTabId); // Added lastJsonMessage const lastSentCanvasDataRef = useRef(""); @@ -143,6 +145,15 @@ export default function App() { /> )} + + {excalidrawAPI && user && ( + + )} ); diff --git a/src/frontend/src/Collab.tsx b/src/frontend/src/Collab.tsx new file mode 100644 index 0000000..e6f4d9d --- /dev/null +++ b/src/frontend/src/Collab.tsx @@ -0,0 +1,108 @@ +import React, { useEffect } from 'react'; +import type { ExcalidrawImperativeAPI, AppState, Collaborator as ExcalidrawCollaborator } from "@atyrode/excalidraw/types"; +import { WebSocketMessage } from "./hooks/usePadWebSocket"; // Assuming this is the correct path and type + +// Moved Types +export interface Collaborator { + id: string; + pointer?: { x: number; y: number }; + button?: "up" | "down"; + selectedElementIds?: AppState["selectedElementIds"]; + username?: string | null; + userState?: "active" | "away" | "idle"; + color?: { background: string; stroke: string }; + avatarUrl?: string | null; +} + +// Specific data payload for 'user_joined' WebSocket messages +// These types reflect the direct properties on lastJsonMessage for these events +export interface UserJoinedMessage extends WebSocketMessage { // Extend or use a more specific type if available + type: "user_joined"; + user_id: string; + connection_id: string; + displayName?: string; +} + +// Specific data payload for 'user_left' WebSocket messages +export interface UserLeftMessage extends WebSocketMessage { // Extend or use a more specific type if available + type: "user_left"; + user_id: string; + connection_id: string; +} + +// ConnectedData is also moved here, though not directly used in the primary effect. +export interface ConnectedData { + pad_id: string; + user_id: string; + connection_id: string; + timestamp: string; +} + +// Props for the Collab component +interface CollabProps { + excalidrawAPI: ExcalidrawImperativeAPI | null; + lastJsonMessage: WebSocketMessage | null; + userId?: string; // Current user's ID + sendMessage: (message: WebSocketMessage) => void; // Keep if other collab features might need it +} + +// Moved Helper Function +const getRandomCollaboratorColor = () => { + const colors = [ + { background: "#FFC9C9", stroke: "#A61E1E" }, // Light Red + { background: "#B2F2BB", stroke: "#1E7E34" }, // Light Green + { background: "#A5D8FF", stroke: "#1C63A6" }, // Light Blue + { background: "#FFEC99", stroke: "#A67900" }, // Light Yellow + { background: "#E6C9FF", stroke: "#6A1E9A" }, // Light Purple + { background: "#FFD8A8", stroke: "#A65E00" }, // Light Orange + { background: "#C3FAFB", stroke: "#008083" }, // Light Cyan + { background: "#F0B9DD", stroke: "#A21E6F" }, // Light Pink + ]; + return colors[Math.floor(Math.random() * colors.length)]; +}; + +export default function Collab({ excalidrawAPI, lastJsonMessage, userId, sendMessage }: CollabProps) { + useEffect(() => { + if (!lastJsonMessage || !excalidrawAPI || !userId) { + return; + } + + const currentAppState = excalidrawAPI.getAppState(); + // Ensure collaborators is a Map. Excalidraw might initialize it as an object. + const collaboratorsMap = currentAppState.collaborators instanceof Map + ? new Map(currentAppState.collaborators) + : new Map(); + + + if (lastJsonMessage.type === "user_joined") { + + const { connection_id: joinedUserId, displayName } = lastJsonMessage as UserJoinedMessage; + + if (!collaboratorsMap.has(joinedUserId)) { + const newCollaborator: Collaborator = { + id: joinedUserId, + username: displayName || joinedUserId, + pointer: { x: 0, y: 0 }, + button: "up", + selectedElementIds: {}, + userState: "active", + color: getRandomCollaboratorColor(), + }; + collaboratorsMap.set(joinedUserId, newCollaborator as ExcalidrawCollaborator); + excalidrawAPI.updateScene({ appState: { ...currentAppState, collaborators: collaboratorsMap } }); + console.log(`[Collab.tsx] User joined: ${joinedUserId}, collaborators:`, collaboratorsMap); + } + } else if (lastJsonMessage.type === "user_left") { + + const { connection_id: leftUserId } = lastJsonMessage as UserLeftMessage; + + if (collaboratorsMap.has(leftUserId)) { + collaboratorsMap.delete(leftUserId); + excalidrawAPI.updateScene({ appState: { ...currentAppState, collaborators: collaboratorsMap } }); + console.log(`[Collab.tsx] User left: ${leftUserId}, collaborators:`, collaboratorsMap); + } + } + }, [lastJsonMessage, excalidrawAPI, userId]); + + return null; +} diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 963ed5e..6c3884e 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -1,22 +1,19 @@ import { useCallback, useMemo, useEffect, useState } from 'react'; import useWebSocket, { ReadyState } from 'react-use-websocket'; -import { z } from 'zod'; // Import Zod +import { z } from 'zod'; import { useAuthStatus } from './useAuthStatus'; -// 1. Define Zod Schema for your WebSocketMessage -// Adjust the 'data' part of the schema based on the actual structure of your messages. -// Using z.any() for data is a placeholder; more specific schemas are better. -// If 'data' can have different structures based on 'type', consider Zod's discriminated unions. -const WebSocketMessageSchema = z.object({ - type: z.string(), +export const WebSocketMessageSchema = z.object({ + type: z.string(), // e.g., "connected", "user_joined", "user_left", "pad_update", "error" pad_id: z.string().nullable(), - data: z.any().optional(), // Make 'data' optional - timestamp: z.string().datetime({ offset: true, message: "Invalid timestamp format, expected ISO 8601 with offset" }).optional(), - user_id: z.string().optional(), + timestamp: z.string().datetime({ offset: true, message: "Invalid timestamp format" }), + user_id: z.string().optional(), // ID of the user related to the event or sending the message + connection_id: z.string().optional(), // Connection ID related to the event or sending the message + data: z.any().optional(), // Payload; structure depends entirely on 'type' }); -// Type inferred from the Zod schema -type WebSocketMessage = z.infer; +// TypeScript type inferred from the Zod schema +export type WebSocketMessage = z.infer; const MAX_RECONNECT_ATTEMPTS = 5; const INITIAL_RECONNECT_DELAY = 1000; // 1 second @@ -25,7 +22,7 @@ const INITIAL_RECONNECT_DELAY = 1000; // 1 second type ConnectionStatus = 'Uninstantiated' | 'Connecting' | 'Open' | 'Closing' | 'Closed' | 'Reconnecting' | 'Failed'; export const usePadWebSocket = (padId: string | null) => { - const { isAuthenticated, isLoading, refetchAuthStatus } = useAuthStatus(); + const { isAuthenticated, isLoading, refetchAuthStatus, user } = useAuthStatus(); // Assuming user object has id const [isPermanentlyDisconnected, setIsPermanentlyDisconnected] = useState(false); const [reconnectAttemptCount, setReconnectAttemptCount] = useState(0); @@ -43,7 +40,7 @@ export const usePadWebSocket = (padId: string | null) => { const shouldBeConnected = useMemo(() => { const conditionsMet = !!memoizedSocketUrl && isAuthenticated && !isLoading && !isPermanentlyDisconnected; return conditionsMet; - }, [memoizedSocketUrl, isAuthenticated, isLoading, isPermanentlyDisconnected, padId]); + }, [memoizedSocketUrl, isAuthenticated, isLoading, isPermanentlyDisconnected]); const { sendMessage: librarySendMessage, @@ -53,14 +50,14 @@ export const usePadWebSocket = (padId: string | null) => { memoizedSocketUrl, { onOpen: () => { - console.log(`[pad.ws] Connection established for pad: ${padId}`); + console.debug(`[pad.ws] Connection established for pad: ${padId}`); setIsPermanentlyDisconnected(false); setReconnectAttemptCount(0); }, onClose: (event: CloseEvent) => { - console.log(`[pad.ws] Connection closed for pad: ${padId}. Code: ${event.code}, Reason: '${event.reason}'`); + console.debug(`[pad.ws] Connection closed for pad: ${padId}. Code: ${event.code}, Reason: '${event.reason}'`); if (isAuthenticated === undefined && !isLoading) { - console.log('[pad.ws] Auth status unknown on close, attempting to refetch auth status.'); + console.debug('[pad.ws] Auth status unknown on close, attempting to refetch auth status.'); refetchAuthStatus(); } }, @@ -72,7 +69,7 @@ export const usePadWebSocket = (padId: string | null) => { const conditionsStillMetForConnection = !!getSocketUrl() && isAuthenticated && !isLoading; if (isAbnormalClosure && !conditionsStillMetForConnection && isAuthenticated === undefined && !isLoading) { - console.log('[pad.ws] Abnormal closure for pad ${padId}, auth status unknown. Refetching auth before deciding on reconnect.'); + console.debug(`[pad.ws] Abnormal closure for pad ${padId}, auth status unknown. Refetching auth before deciding on reconnect.`); refetchAuthStatus(); } @@ -80,7 +77,7 @@ export const usePadWebSocket = (padId: string | null) => { if (decision) { setReconnectAttemptCount(prev => prev + 1); } - console.log( + console.debug( `[pad.ws] shouldReconnect for pad ${padId}: ${decision} (Abnormal: ${isAbnormalClosure}, ConditionsMet: ${conditionsStillMetForConnection}, PermanentDisconnect: ${isPermanentlyDisconnected}, Code: ${closeEvent.code})` ); return decision; @@ -88,7 +85,7 @@ export const usePadWebSocket = (padId: string | null) => { reconnectAttempts: MAX_RECONNECT_ATTEMPTS, reconnectInterval: (attemptNumber: number) => { const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attemptNumber); - console.info( + console.debug( `[pad.ws] Reconnecting attempt ${attemptNumber + 1}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms for pad: ${padId}` ); return delay; @@ -123,35 +120,35 @@ export const usePadWebSocket = (padId: string | null) => { }, [rawLastMessage, padId]); const sendJsonMessage = useCallback((payload: WebSocketMessage) => { - // Validate outgoing message structure (optional, but good practice) const validationResult = WebSocketMessageSchema.safeParse(payload); if (!validationResult.success) { console.error(`[pad.ws] Outgoing message validation failed for pad ${padId}:`, validationResult.error.issues); - // Decide if you want to throw an error or just log and not send return; } - librarySendMessage(JSON.stringify(payload)); }, [padId, librarySendMessage]); - // Wrapper to maintain the original `sendMessage(type, data)` signature if preferred by consuming components - const sendMessage = useCallback((type: string, data: any) => { + // Simplified sendMessage wrapper. + // The 'type' parameter dictates the message type. + // The 'data' parameter is the payload for the 'data' field in WebSocketMessage. + const sendMessage = useCallback((type: string, messageData?: any) => { const messagePayload: WebSocketMessage = { type, pad_id: padId, - data, - timestamp: new Date().toISOString().replace('Z', '+00:00'), - // user_id: can be added here if available and needed from context + timestamp: new Date().toISOString(), // Ensure ISO 8601 with Z or offset + user_id: user?.id, // Add sender's user_id if available + // connection_id is typically set by the server or known on connection, + // client might not need to send it explicitly unless for specific cases. + data: messageData, }; - console.debug(`[pad.ws] Sending message`, messagePayload.type); + + console.debug(`[pad.ws] Sending message of type: ${messagePayload.type}`); sendJsonMessage(messagePayload); - }, [padId, sendJsonMessage]); + }, [padId, sendJsonMessage, user?.id]); useEffect(() => { if (lastJsonMessage) { - // console.debug(`[pad.ws] Validated JSON message received for pad ${padId}:`, lastJsonMessage); - console.debug(`[pad.ws] Received message`, lastJsonMessage?.type); - // TODO: Dispatch to a store, update context, or trigger other side effects based on the message + console.debug(`[pad.ws] Received message of type: ${lastJsonMessage?.type} for pad ${padId}:`, lastJsonMessage); } }, [lastJsonMessage, padId]); @@ -162,7 +159,6 @@ export const usePadWebSocket = (padId: string | null) => { case ReadyState.UNINSTANTIATED: return 'Uninstantiated'; case ReadyState.CONNECTING: - // Differentiate between initial connecting and reconnecting if (reconnectAttemptCount > 0 && reconnectAttemptCount < MAX_RECONNECT_ATTEMPTS && !isPermanentlyDisconnected) { return 'Reconnecting'; } @@ -172,7 +168,6 @@ export const usePadWebSocket = (padId: string | null) => { case ReadyState.CLOSING: return 'Closing'; case ReadyState.CLOSED: - // If it's closed but not permanently, and shouldBeConnected is true, it might be about to reconnect if (shouldBeConnected && reconnectAttemptCount > 0 && reconnectAttemptCount < MAX_RECONNECT_ATTEMPTS && !isPermanentlyDisconnected) { return 'Reconnecting'; } @@ -183,12 +178,12 @@ export const usePadWebSocket = (padId: string | null) => { }, [readyState, isPermanentlyDisconnected, reconnectAttemptCount, shouldBeConnected]); return { - sendMessage, // Original simple signature - sendJsonMessage, // For sending pre-formed WebSocketMessage objects - lastJsonMessage, // Validated JSON message - rawLastMessage, // Raw message, for debugging or non-JSON cases - readyState, // Numerical readyState - connectionStatus,// User-friendly status string + sendMessage, + sendJsonMessage, // Kept for sending pre-formed WebSocketMessage objects + lastJsonMessage, + rawLastMessage, + readyState, + connectionStatus, isPermanentlyDisconnected, }; }; From a86c2e7110bec575d9a95c69106fec3c0d8fae20 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 19 May 2025 16:16:25 +0000 Subject: [PATCH 076/149] refactor: export UserInfo interface in useAuthStatus hook --- src/frontend/src/hooks/useAuthStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index 39b0728..52d2e61 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; import { scheduleTokenRefresh, AUTH_STATUS_KEY } from '../lib/authRefreshManager'; -interface UserInfo { +export interface UserInfo { id?: string; username?: string; email?: string; From ceba047906886d3689296b8bcb0d0c3da8a3169b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 19 May 2025 17:11:21 +0000 Subject: [PATCH 077/149] refactor: simplify debug logging in usePadWebSocket by removing pad ID from message --- src/frontend/src/hooks/usePadWebSocket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts index 6c3884e..73a4afd 100644 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ b/src/frontend/src/hooks/usePadWebSocket.ts @@ -148,7 +148,7 @@ export const usePadWebSocket = (padId: string | null) => { useEffect(() => { if (lastJsonMessage) { - console.debug(`[pad.ws] Received message of type: ${lastJsonMessage?.type} for pad ${padId}:`, lastJsonMessage); + console.debug(`[pad.ws] Received message of type: ${lastJsonMessage?.type}`); } }, [lastJsonMessage, padId]); From 009552f6976323618576941dddb2245b86277a17 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 18:28:07 +0000 Subject: [PATCH 078/149] killed dead code --- src/backend/database/models/pad_model.py | 7 --- src/backend/domain/pad.py | 78 +++++------------------- src/backend/routers/pad_router.py | 24 -------- 3 files changed, 14 insertions(+), 95 deletions(-) diff --git a/src/backend/database/models/pad_model.py b/src/backend/database/models/pad_model.py index 8d690d9..2924506 100644 --- a/src/backend/database/models/pad_model.py +++ b/src/backend/database/models/pad_model.py @@ -58,13 +58,6 @@ async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['PadSt result = await session.execute(stmt) return result.scalars().first() - @classmethod - async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> List['PadStore']: - """Get all pads for a specific owner""" - stmt = select(cls).where(cls.owner_id == owner_id).order_by(cls.created_at) - result = await session.execute(stmt) - return result.scalars().all() - async def save(self, session: AsyncSession) -> 'PadStore': """Update the pad in the database""" self.updated_at = datetime.now() diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 3347158..3c5a3a1 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -125,21 +125,6 @@ async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad'] return pad return None - @classmethod - async def get_by_owner(cls, session: AsyncSession, owner_id: UUID) -> list['Pad']: - """Get all pads for a specific owner""" - stores = await PadStore.get_by_owner(session, owner_id) - pads = [cls.from_store(store) for store in stores] - - # Cache all pads, handling errors for each individually - for pad in pads: - try: - await pad.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {pad.id}: {str(e)}") - - return pads - @classmethod def from_store(cls, store: PadStore) -> 'Pad': """Create a Pad instance from a store""" @@ -181,42 +166,21 @@ async def save(self, session: AsyncSession) -> 'Pad': return self - async def broadcast_event(self, event_type: str, event_data: Dict[str, Any]) -> None: - """Broadcast an event to all connected clients""" - redis = await get_redis_client() - stream_key = f"pad:stream:{self.id}" - message = { - "type": event_type, - "pad_id": str(self.id), - "data": event_data, - "timestamp": datetime.now().isoformat() - } - try: - await redis.xadd(stream_key, message) - except Exception as e: - print(f"Error broadcasting event to pad {self.id}: {str(e)}") - - async def get_stream_position(self) -> str: - """Get the current position in the pad's stream""" - redis = await get_redis_client() - stream_key = f"pad:stream:{self.id}" - try: - info = await redis.xinfo_stream(stream_key) - return info.get("last-generated-id", "0-0") - except Exception as e: - print(f"Error getting stream position for pad {self.id}: {str(e)}") - return "0-0" - - async def get_recent_events(self, count: int = 100) -> list[Dict[str, Any]]: - """Get recent events from the pad's stream""" - redis = await get_redis_client() - stream_key = f"pad:stream:{self.id}" + async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': + """Rename the pad by updating its display name""" + self.display_name = new_display_name + self.updated_at = datetime.now() + if self._store: + self._store.display_name = new_display_name + self._store.updated_at = self.updated_at + self._store = await self._store.save(session) + try: - messages = await redis.xrevrange(stream_key, count=count) - return [msg[1] for msg in messages] + await self.cache() except Exception as e: - print(f"Error getting recent events for pad {self.id}: {str(e)}") - return [] + print(f"Warning: Failed to cache pad {self.id} after rename: {str(e)}") + + return self async def delete(self, session: AsyncSession) -> bool: """Delete the pad from both database and cache""" @@ -269,21 +233,7 @@ def to_dict(self) -> Dict[str, Any]: "updated_at": self.updated_at.isoformat() } - async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': - """Rename the pad by updating its display name""" - self.display_name = new_display_name - self.updated_at = datetime.now() - if self._store: - self._store.display_name = new_display_name - self._store.updated_at = self.updated_at - self._store = await self._store.save(session) - - try: - await self.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {self.id} after rename: {str(e)}") - - return self + \ No newline at end of file diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index 9afa207..f27dcb9 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -12,30 +12,6 @@ pad_router = APIRouter() -@pad_router.get("/") -async def initialize_pad( - user: UserSession = Depends(require_auth), - session: AsyncSession = Depends(get_session) -) -> Dict[str, Any]: - # First try to get any existing pads for the user - existing_pads = await PadStore.get_by_owner(session, user.id) - - if existing_pads: - # User already has pads, load the first one - pad = await Pad.get_by_id(session, existing_pads[0].id) - - else: - # Create a new pad for first-time user - pad = await Pad.create( - session=session, - owner_id=user.id, - display_name="My First Pad" - ) - - pad_dict = pad.to_dict() - user_app_state = pad_dict["data"]["appState"].get(str(user.id), {}) - pad_dict["data"]["appState"] = user_app_state - return pad_dict["data"] @pad_router.post("/new") async def create_new_pad( From b3ce3cb09eff97fe40206fc9b2fdecce210b4518 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 18:31:20 +0000 Subject: [PATCH 079/149] simplify cache error handling --- src/backend/domain/pad.py | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 3c5a3a1..733500c 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -65,10 +65,7 @@ async def create( ) pad = cls.from_store(store) - try: - await pad.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {pad.id}: {str(e)}") + await pad.cache() return pad @@ -118,10 +115,7 @@ async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad'] store = await PadStore.get_by_id(session, pad_id) if store: pad = cls.from_store(store) - try: - await pad.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {pad.id}: {str(e)}") + await pad.cache() return pad return None @@ -159,10 +153,7 @@ async def save(self, session: AsyncSession) -> 'Pad': self.created_at = self._store.created_at self.updated_at = self._store.updated_at - try: - await self.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {self.id} after save: {str(e)}") + await self.cache() return self @@ -175,17 +166,13 @@ async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': self._store.updated_at = self.updated_at self._store = await self._store.save(session) - try: - await self.cache() - except Exception as e: - print(f"Warning: Failed to cache pad {self.id} after rename: {str(e)}") + await self.cache() return self async def delete(self, session: AsyncSession) -> bool: """Delete the pad from both database and cache""" - print(f"Deleting pad {self.id}", flush=True) - print(f"self._store: {self._store}", flush=True) + print(f"Deleting pad {self.id}") if self._store: success = await self._store.delete(session) @@ -210,11 +197,13 @@ async def cache(self) -> None: 'created_at': self.created_at.isoformat(), 'updated_at': self.updated_at.isoformat() } - - async with redis.pipeline() as pipe: - await pipe.hset(cache_key, mapping=cache_data) - await pipe.expire(cache_key, self.CACHE_EXPIRY) - await pipe.execute() + try: + async with redis.pipeline() as pipe: + await pipe.hset(cache_key, mapping=cache_data) + await pipe.expire(cache_key, self.CACHE_EXPIRY) + await pipe.execute() + except Exception as e: + print(f"Error caching pad {self.id}: {str(e)}") async def invalidate_cache(self) -> None: """Remove the pad from Redis cache""" From ff8cfb0ed2f4297bb2dbf61de2762aa3a72a8dc3 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 21:12:30 +0000 Subject: [PATCH 080/149] use Redis singleton in pad --- src/backend/domain/pad.py | 151 ++++++++++++++++--------------- src/backend/routers/ws_router.py | 24 ++--- 2 files changed, 84 insertions(+), 91 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 733500c..8b0ea9c 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -2,7 +2,7 @@ from typing import Dict, Any, Optional from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession -from config import default_pad, get_redis_client +from config import default_pad, RedisService import json from database.models.pad_model import PadStore @@ -26,10 +26,11 @@ def __init__( id: UUID, owner_id: UUID, display_name: str, + created_at: datetime, + updated_at: datetime, + store: PadStore, + redis: AsyncRedis, data: Dict[str, Any] = None, - created_at: datetime = None, - updated_at: datetime = None, - store: PadStore = None ): self.id = id self.owner_id = owner_id @@ -38,6 +39,7 @@ def __init__( self.created_at = created_at or datetime.now() self.updated_at = updated_at or datetime.now() self._store = store + self._redis = redis @classmethod async def create( @@ -63,64 +65,80 @@ async def create( display_name=display_name, data=pad_data ) - pad = cls.from_store(store) + redis = await RedisService.get_instance() + pad = cls.from_store(store, redis) await pad.cache() return pad @classmethod - async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad']: - """Get a pad by ID, first trying Redis cache then falling back to database""" - redis = await get_redis_client() + async def from_redis(cls, redis: AsyncRedis, pad_id: UUID) -> Optional['Pad']: + """Create a Pad instance from Redis cache data""" cache_key = f"pad:{pad_id}" try: - if await redis.exists(cache_key): - cached_data = await redis.hgetall(cache_key) - if cached_data: - pad_id = UUID(cached_data['id']) - owner_id = UUID(cached_data['owner_id']) - display_name = cached_data['display_name'] - data = json.loads(cached_data['data']) - created_at = datetime.fromisoformat(cached_data['created_at']) - updated_at = datetime.fromisoformat(cached_data['updated_at']) - - # Create a minimal PadStore instance - store = PadStore( - id=pad_id, - owner_id=owner_id, - display_name=display_name, - data=data, - created_at=created_at, - updated_at=updated_at - ) - - pad_instance = cls( - id=pad_id, - owner_id=owner_id, - display_name=display_name, - data=data, - created_at=created_at, - updated_at=updated_at, - store=store - ) - return pad_instance + if not await redis.exists(cache_key): + return None + + cached_data = await redis.hgetall(cache_key) + if not cached_data: + return None + + pad_id = UUID(cached_data['id']) + owner_id = UUID(cached_data['owner_id']) + display_name = cached_data['display_name'] + data = json.loads(cached_data['data']) + created_at = datetime.fromisoformat(cached_data['created_at']) + updated_at = datetime.fromisoformat(cached_data['updated_at']) + + # Create a minimal PadStore instance + store = PadStore( + id=pad_id, + owner_id=owner_id, + display_name=display_name, + data=data, + created_at=created_at, + updated_at=updated_at + ) + + return cls( + id=pad_id, + owner_id=owner_id, + display_name=display_name, + data=data, + created_at=created_at, + updated_at=updated_at, + store=store, + redis=redis + ) except (json.JSONDecodeError, KeyError, ValueError) as e: print(f"Error parsing cached pad data for id {pad_id}: {str(e)}") + return None except Exception as e: print(f"Unexpected error retrieving pad from cache: {str(e)}") + return None + + @classmethod + async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad']: + """Get a pad by ID, first trying Redis cache then falling back to database""" + redis = await RedisService.get_instance() + # Try to get from cache first + pad = await cls.from_redis(redis, pad_id) + if pad: + return pad + # Fall back to database store = await PadStore.get_by_id(session, pad_id) if store: - pad = cls.from_store(store) + pad = cls.from_store(store, redis) await pad.cache() return pad return None @classmethod - def from_store(cls, store: PadStore) -> 'Pad': + def from_store(cls, store: PadStore, redis: AsyncRedis) -> 'Pad': """Create a Pad instance from a store""" return cls( id=store.id, @@ -129,32 +147,18 @@ def from_store(cls, store: PadStore) -> 'Pad': data=store.data, created_at=store.created_at, updated_at=store.updated_at, - store=store + store=store, + redis=redis ) async def save(self, session: AsyncSession) -> 'Pad': """Save the pad to the database and update cache""" - if not self._store: - self._store = PadStore( - id=self.id, - owner_id=self.owner_id, - display_name=self.display_name, - data=self.data, - created_at=self.created_at, - updated_at=self.updated_at - ) - else: - self._store.display_name = self.display_name - self._store.data = self.data - self._store.updated_at = datetime.now() - + self._store.display_name = self.display_name + self._store.data = self.data + self._store.updated_at = datetime.now() self._store = await self._store.save(session) - self.id = self._store.id - self.created_at = self._store.created_at - self.updated_at = self._store.updated_at - + await self.cache() - return self async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': @@ -172,21 +176,19 @@ async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': async def delete(self, session: AsyncSession) -> bool: """Delete the pad from both database and cache""" - print(f"Deleting pad {self.id}") - if self._store: - success = await self._store.delete(session) - - if success: - try: - await self.invalidate_cache() - except Exception as e: - print(f"Warning: Failed to invalidate cache for pad {self.id}: {str(e)}") - return success - return False + success = await self._store.delete(session) + if success: + await self.invalidate_cache() + else: + print(f"Failed to delete pad {self.id} from database") + return False + + print(f"Deleted pad {self.id} from database and cache") + return success async def cache(self) -> None: """Cache the pad data in Redis using hash structure""" - redis = await get_redis_client() + cache_key = f"pad:{self.id}" cache_data = { @@ -198,7 +200,7 @@ async def cache(self) -> None: 'updated_at': self.updated_at.isoformat() } try: - async with redis.pipeline() as pipe: + async with self._redis.pipeline() as pipe: await pipe.hset(cache_key, mapping=cache_data) await pipe.expire(cache_key, self.CACHE_EXPIRY) await pipe.execute() @@ -207,9 +209,8 @@ async def cache(self) -> None: async def invalidate_cache(self) -> None: """Remove the pad from Redis cache""" - redis = await get_redis_client() cache_key = f"pad:{self.id}" - await redis.delete(cache_key) + await self._redis.delete(cache_key) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary representation""" diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index e7f87d3..c8e5121 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -88,10 +88,10 @@ async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, async def _handle_received_data( raw_data: str, pad_id: UUID, - user: UserSession, # Authenticated user session + user: UserSession, redis_client: aioredis.Redis, stream_key: str, - current_connection_id: str # This specific connection's ID + connection_id: str ): """Processes decoded message data, wraps it in WebSocketMessage, and publishes to Redis.""" try: @@ -103,7 +103,7 @@ async def _handle_received_data( type=client_message_dict.get("type", "unknown_client_message"), # Get type from client pad_id=str(pad_id), # Server sets/overrides pad_id user_id=str(user.id), # Server sets/overrides user_id from authenticated session - connection_id=current_connection_id, # Server sets/overrides connection_id + connection_id=connection_id, # Server sets/overrides connection_id timestamp=datetime.now(timezone.utc), # Server sets timestamp data=client_message_dict.get("data") # Pass through client's data payload ) @@ -113,24 +113,16 @@ async def _handle_received_data( await publish_event_to_redis(redis_client, stream_key, processed_message) except json.JSONDecodeError: - print(f"Invalid JSON received from {current_connection_id[:5]}") - # Optionally send an error message back to this client - error_msg = WebSocketMessage( - type="error", - pad_id=str(pad_id), - data={"message": "Invalid JSON format received."} - ) - # This send is tricky as _handle_received_data doesn't have websocket object. - # Error handling might need to be closer to websocket.receive_text() + print(f"Invalid JSON received from {connection_id[:5]}") except Exception as e: - print(f"Error processing message from {current_connection_id[:5]}: {e}") + print(f"Error processing message from {connection_id[:5]}: {e}") async def consume_redis_stream( redis_client: aioredis.Redis, stream_key: str, websocket: WebSocket, - current_connection_id: str, + connection_id: str, last_id: str = '$' ): """Consumes messages from Redis stream, parses to WebSocketMessage, and forwards them.""" @@ -163,7 +155,7 @@ async def consume_redis_stream( message_to_send = WebSocketMessage(**redis_dict) # Avoid echoing messages to the sender - if message_to_send.connection_id != current_connection_id: + if message_to_send.connection_id != connection_id: await websocket.send_text(message_to_send.model_dump_json()) else: pass # Message originated from this connection, don't echo @@ -173,7 +165,7 @@ async def consume_redis_stream( last_id = message_id - await asyncio.sleep(0.01) # Small sleep to yield control + await asyncio.sleep(0) except Exception as e: print(f"Error in Redis stream consumer for {stream_key}: {e}") From 99aa19cecf59d56e3ee183a11b6c7c32b75f2350 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 21:25:47 +0000 Subject: [PATCH 081/149] moved redis to dedicated cache submodule --- src/backend/cache/__init__.py | 3 ++ src/backend/cache/redis_client.py | 44 ++++++++++++++++++++++ src/backend/config.py | 57 ++--------------------------- src/backend/dependencies.py | 4 +- src/backend/domain/pad.py | 7 ++-- src/backend/main.py | 6 +-- src/backend/routers/users_router.py | 5 ++- src/backend/routers/ws_router.py | 5 +-- src/frontend/src/App.tsx | 4 +- 9 files changed, 67 insertions(+), 68 deletions(-) create mode 100644 src/backend/cache/__init__.py create mode 100644 src/backend/cache/redis_client.py diff --git a/src/backend/cache/__init__.py b/src/backend/cache/__init__.py new file mode 100644 index 0000000..bbb771d --- /dev/null +++ b/src/backend/cache/__init__.py @@ -0,0 +1,3 @@ +from .redis_client import RedisClient + +__all__ = ["RedisClient"] \ No newline at end of file diff --git a/src/backend/cache/redis_client.py b/src/backend/cache/redis_client.py new file mode 100644 index 0000000..3dbce07 --- /dev/null +++ b/src/backend/cache/redis_client.py @@ -0,0 +1,44 @@ +import os +from redis import asyncio as aioredis +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Redis Configuration +REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') +REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) +REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}" + +class RedisClient: + """Service for managing Redis connections with proper lifecycle management.""" + + _instance = None + + @classmethod + async def get_instance(cls) -> aioredis.Redis: + """Get or create a Redis client instance.""" + if cls._instance is None: + cls._instance = cls() + await cls._instance.initialize() + return cls._instance.client + + def __init__(self): + self.client = None + + async def initialize(self) -> None: + """Initialize the Redis client.""" + self.client = aioredis.from_url( + REDIS_URL, + password=REDIS_PASSWORD, + decode_responses=True, + health_check_interval=30 + ) + + async def close(self) -> None: + """Close the Redis client connection.""" + if self.client: + await self.client.close() + self.client = None + print("Redis client closed.") \ No newline at end of file diff --git a/src/backend/config.py b/src/backend/config.py index 5e62908..fdd9312 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -2,11 +2,11 @@ import json import time import httpx -from redis import asyncio as aioredis import jwt from jwt.jwks_client import PyJWKClient from typing import Optional, Dict, Any, Tuple from dotenv import load_dotenv +from cache import RedisClient # Load environment variables once load_dotenv() @@ -32,55 +32,6 @@ OIDC_REALM = os.getenv('OIDC_REALM') OIDC_REDIRECT_URI = os.getenv('REDIRECT_URI') -# ===== Redis Configuration ===== -REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') -REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) -REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) -REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}" - -class RedisService: - """Service for managing Redis connections with proper lifecycle management.""" - - _instance = None - - @classmethod - async def get_instance(cls) -> aioredis.Redis: - """Get or create a Redis client instance.""" - if cls._instance is None: - cls._instance = cls() - await cls._instance.initialize() - return cls._instance.client - - def __init__(self): - self.client = None - - async def initialize(self) -> None: - """Initialize the Redis client.""" - self.client = aioredis.from_url( - REDIS_URL, - password=REDIS_PASSWORD, - decode_responses=True, - health_check_interval=30 - ) - - async def close(self) -> None: - """Close the Redis client connection.""" - if self.client: - await self.client.close() - self.client = None - print("Redis client closed.") - -# Simplified functions to maintain backwards compatibility -async def get_redis_client() -> aioredis.Redis: - """Get a Redis client. Creates one if it doesn't exist.""" - return await RedisService.get_instance() - -async def close_redis_client() -> None: - """Close the Redis client connection.""" - if RedisService._instance: - await RedisService._instance.close() - RedisService._instance = None - default_pad = {} with open("templates/dev.json", 'r') as f: default_pad = json.load(f) @@ -98,7 +49,7 @@ async def close_redis_client() -> None: # Session management functions async def get_session(session_id: str) -> Optional[Dict[str, Any]]: """Get session data from Redis""" - client = await get_redis_client() + client = await RedisClient.get_instance() session_data = await client.get(f"session:{session_id}") if session_data: return json.loads(session_data) @@ -106,7 +57,7 @@ async def get_session(session_id: str) -> Optional[Dict[str, Any]]: async def set_session(session_id: str, data: Dict[str, Any], expiry: int) -> None: """Store session data in Redis with expiry in seconds""" - client = await get_redis_client() + client = await RedisClient.get_instance() await client.setex( f"session:{session_id}", expiry, @@ -115,7 +66,7 @@ async def set_session(session_id: str, data: Dict[str, Any], expiry: int) -> Non async def delete_session(session_id: str) -> None: """Delete session data from Redis""" - client = await get_redis_client() + client = await RedisClient.get_instance() await client.delete(f"session:{session_id}") def get_auth_url() -> str: diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 079f60b..90edacc 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -5,7 +5,7 @@ from fastapi import Request, HTTPException -from config import get_redis_client +from cache import RedisClient from domain.session import Session from coder import CoderAPI @@ -20,7 +20,7 @@ async def get_session_domain() -> Session: """Get a Session domain instance for the current request.""" - redis_client = await get_redis_client() + redis_client = await RedisClient.get_instance() return Session(redis_client, oidc_config) class UserSession: diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 8b0ea9c..bebb627 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -2,9 +2,10 @@ from typing import Dict, Any, Optional from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession -from config import default_pad, RedisService +from config import default_pad import json +from cache import RedisClient from database.models.pad_model import PadStore from redis.asyncio import Redis as AsyncRedis @@ -65,7 +66,7 @@ async def create( display_name=display_name, data=pad_data ) - redis = await RedisService.get_instance() + redis = await RedisClient.get_instance() pad = cls.from_store(store, redis) await pad.cache() @@ -122,7 +123,7 @@ async def from_redis(cls, redis: AsyncRedis, pad_id: UUID) -> Optional['Pad']: @classmethod async def get_by_id(cls, session: AsyncSession, pad_id: UUID) -> Optional['Pad']: """Get a pad by ID, first trying Redis cache then falling back to database""" - redis = await RedisService.get_instance() + redis = await RedisClient.get_instance() # Try to get from cache first pad = await cls.from_redis(redis, pad_id) diff --git a/src/backend/main.py b/src/backend/main.py index bfcbcca..ae703ce 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -12,8 +12,8 @@ from database import init_db from config import ( STATIC_DIR, ASSETS_DIR, POSTHOG_API_KEY, POSTHOG_HOST, - get_redis_client, close_redis_client ) +from cache import RedisClient from dependencies import UserSession, optional_auth from routers.auth_router import auth_router from routers.users_router import users_router @@ -34,14 +34,14 @@ async def lifespan(_: FastAPI): print("Database connection established successfully") # Initialize Redis client and verify connection - redis = await get_redis_client() + redis = await RedisClient.get_instance() await redis.ping() print("Redis connection established successfully") yield # Clean up connections when shutting down - await close_redis_client() + await RedisClient.close(redis) app = FastAPI(lifespan=lifespan) diff --git a/src/backend/routers/users_router.py b/src/backend/routers/users_router.py index d540bd8..6ee8697 100644 --- a/src/backend/routers/users_router.py +++ b/src/backend/routers/users_router.py @@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from config import get_redis_client, get_jwks_client, OIDC_CLIENT_ID, FRONTEND_URL +from cache import RedisClient +from config import get_jwks_client, OIDC_CLIENT_ID, FRONTEND_URL from dependencies import UserSession, require_admin, require_auth from database.database import get_session from domain.user import User @@ -56,7 +57,7 @@ async def get_online_users( ): """Get all online users with their information (admin only)""" try: - client = await get_redis_client() + client = await RedisClient.get_instance() # Get all session keys session_keys = await client.keys("session:*") diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index c8e5121..9ce04de 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -9,9 +9,8 @@ from pydantic import BaseModel, Field, field_validator from redis import asyncio as aioredis -from config import get_redis_client from dependencies import UserSession, get_session_domain - +from cache import RedisClient ws_router = APIRouter() class WebSocketMessage(BaseModel): @@ -186,7 +185,7 @@ async def websocket_endpoint( stream_key = f"pad:stream:{pad_id}" try: - redis_client = await get_redis_client() + redis_client = await RedisClient.get_instance() # Send initial connection success message if websocket.client_state.CONNECTED: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 41ce302..a8f5698 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -67,11 +67,11 @@ export default function App() { if (serialized !== lastSentCanvasDataRef.current) { lastSentCanvasDataRef.current = serialized; - + sendMessage("pad_update", canvasData); } }, - 1200 + 200 ), [sendMessage, isAuthenticated] ); From 1f623d8f22364a3c767990fea5f40b0705db0daf Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 22:11:46 +0000 Subject: [PATCH 082/149] improved cleanup after ws connection closed --- src/backend/database/__init__.py | 4 +++- src/backend/domain/pad.py | 7 +++---- src/backend/main.py | 9 ++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/backend/database/__init__.py b/src/backend/database/__init__.py index c1b7a13..1a166c5 100644 --- a/src/backend/database/__init__.py +++ b/src/backend/database/__init__.py @@ -6,10 +6,12 @@ from .database import ( init_db, - get_session, + get_session, + engine, ) __all__ = [ 'init_db', 'get_session', + 'engine', ] diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index bebb627..72ba3a7 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -1,6 +1,7 @@ from uuid import UUID from typing import Dict, Any, Optional from datetime import datetime +from redis import RedisError from sqlalchemy.ext.asyncio import AsyncSession from config import default_pad import json @@ -9,7 +10,6 @@ from database.models.pad_model import PadStore from redis.asyncio import Redis as AsyncRedis - class Pad: """ Domain entity representing a collaborative pad. @@ -36,11 +36,11 @@ def __init__( self.id = id self.owner_id = owner_id self.display_name = display_name - self.data = data or {} self.created_at = created_at or datetime.now() self.updated_at = updated_at or datetime.now() self._store = store self._redis = redis + self.data = data or {} @classmethod async def create( @@ -113,8 +113,7 @@ async def from_redis(cls, redis: AsyncRedis, pad_id: UUID) -> Optional['Pad']: store=store, redis=redis ) - except (json.JSONDecodeError, KeyError, ValueError) as e: - print(f"Error parsing cached pad data for id {pad_id}: {str(e)}") + except (json.JSONDecodeError, KeyError, ValueError, RedisError) as e: return None except Exception as e: print(f"Unexpected error retrieving pad from cache: {str(e)}") diff --git a/src/backend/main.py b/src/backend/main.py index ae703ce..d4a5f2c 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from database import init_db +from database import init_db, engine from config import ( STATIC_DIR, ASSETS_DIR, POSTHOG_API_KEY, POSTHOG_HOST, ) @@ -41,11 +41,11 @@ async def lifespan(_: FastAPI): yield # Clean up connections when shutting down - await RedisClient.close(redis) + await redis.close() + await engine.dispose() app = FastAPI(lifespan=lifespan) -# CORS middleware setup app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -61,13 +61,12 @@ async def lifespan(_: FastAPI): async def read_root(request: Request, auth: Optional[UserSession] = Depends(optional_auth)): return FileResponse(os.path.join(STATIC_DIR, "index.html")) -# Include routers in the main app with the /api prefix app.include_router(auth_router, prefix="/api/auth") app.include_router(users_router, prefix="/api/users") app.include_router(workspace_router, prefix="/api/workspace") app.include_router(pad_router, prefix="/api/pad") app.include_router(app_router, prefix="/api/app") -app.include_router(ws_router) # WebSocket router doesn't need /api prefix +app.include_router(ws_router) if __name__ == "__main__": import uvicorn From 3efd12fa2c130ea1a40b928ceef769d5c7f59a40 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 22:22:06 +0000 Subject: [PATCH 083/149] add expiry to redis stream --- src/backend/routers/ws_router.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index 9ce04de..2b64bcf 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -13,6 +13,8 @@ from cache import RedisClient ws_router = APIRouter() +STREAM_EXPIRY = 3600 + class WebSocketMessage(BaseModel): type: str pad_id: Optional[str] = None @@ -81,7 +83,15 @@ async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, else: field_value_dict[k] = str(v) - await redis_client.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + try: + async with redis_client.pipeline() as pipe: + # Add message to stream + await pipe.xadd(stream_key, field_value_dict, maxlen=100, approximate=True) + # Set expiration on the stream key + await pipe.expire(stream_key, STREAM_EXPIRY) + await pipe.execute() + except Exception as e: + print(f"Error publishing event to Redis stream {stream_key}: {str(e)}") async def _handle_received_data( From c194f402a6e1a4fe5af2c1090239bb13483a76fe Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Mon, 19 May 2025 22:56:47 +0000 Subject: [PATCH 084/149] remove dead code --- src/backend/config.py | 107 ------------------------------------------ 1 file changed, 107 deletions(-) diff --git a/src/backend/config.py b/src/backend/config.py index fdd9312..a5140f8 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -46,113 +46,6 @@ # Cache for JWKS client _jwks_client = None -# Session management functions -async def get_session(session_id: str) -> Optional[Dict[str, Any]]: - """Get session data from Redis""" - client = await RedisClient.get_instance() - session_data = await client.get(f"session:{session_id}") - if session_data: - return json.loads(session_data) - return None - -async def set_session(session_id: str, data: Dict[str, Any], expiry: int) -> None: - """Store session data in Redis with expiry in seconds""" - client = await RedisClient.get_instance() - await client.setex( - f"session:{session_id}", - expiry, - json.dumps(data) - ) - -async def delete_session(session_id: str) -> None: - """Delete session data from Redis""" - client = await RedisClient.get_instance() - await client.delete(f"session:{session_id}") - -def get_auth_url() -> str: - """Generate the authentication URL for Keycloak login""" - auth_url = f"{OIDC_SERVER_URL}/realms/{OIDC_REALM}/protocol/openid-connect/auth" - params = { - 'client_id': OIDC_CLIENT_ID, - 'response_type': 'code', - 'redirect_uri': OIDC_REDIRECT_URI, - 'scope': 'openid profile email' - } - return f"{auth_url}?{'&'.join(f'{k}={v}' for k,v in params.items())}" - -def get_token_url() -> str: - """Get the token endpoint URL""" - return f"{OIDC_SERVER_URL}/realms/{OIDC_REALM}/protocol/openid-connect/token" - -def is_token_expired(token_data: Dict[str, Any], buffer_seconds: int = 30) -> bool: - if not token_data or 'access_token' not in token_data: - return True - - try: - # Get the signing key - jwks_client = get_jwks_client() - signing_key = jwks_client.get_signing_key_from_jwt(token_data['access_token']) - - # Decode with verification - decoded = jwt.decode( - token_data['access_token'], - signing_key.key, - algorithms=["RS256"], # Common algorithm for OIDC - audience=OIDC_CLIENT_ID, - ) - - # Check expiration - exp_time = decoded.get('exp', 0) - current_time = time.time() - return current_time + buffer_seconds >= exp_time - except jwt.ExpiredSignatureError: - return True - except Exception as e: - print(f"Error checking token expiration: {str(e)}") - return True - -async def refresh_token(session_id: str, token_data: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]: - """ - Refresh the access token using the refresh token - - Args: - session_id: The session ID - token_data: The current token data containing the refresh token - - Returns: - Tuple[bool, Dict[str, Any]]: Success status and updated token data - """ - if not token_data or 'refresh_token' not in token_data: - return False, token_data - - try: - async with httpx.AsyncClient() as client: - refresh_response = await client.post( - get_token_url(), - data={ - 'grant_type': 'refresh_token', - 'client_id': OIDC_CLIENT_ID, - 'client_secret': OIDC_CLIENT_SECRET, - 'refresh_token': token_data['refresh_token'] - } - ) - - if refresh_response.status_code != 200: - print(f"Token refresh failed: {refresh_response.text}") - return False, token_data - - # Get new token data - new_token_data = refresh_response.json() - - # Update session with new tokens - expiry = new_token_data['refresh_expires_in'] - await set_session(session_id, new_token_data, expiry) - - return True, new_token_data - except Exception as e: - print(f"Error refreshing token: {str(e)}") - return False, token_data - def get_jwks_client(): """Get or create a PyJWKClient for token verification""" global _jwks_client From d51cf2b1e2f7f132225346dcb46a7eeb5dcde59b Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 01:31:16 +0000 Subject: [PATCH 085/149] add sharing policy and whitelist --- src/backend/database/models/pad_model.py | 24 ++++++++++++--- src/backend/database/models/user_model.py | 36 ++++++++++++++++++----- src/backend/domain/user.py | 18 ------------ 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/backend/database/models/pad_model.py b/src/backend/database/models/pad_model.py index 2924506..3bc5367 100644 --- a/src/backend/database/models/pad_model.py +++ b/src/backend/database/models/pad_model.py @@ -2,7 +2,7 @@ from uuid import UUID from datetime import datetime -from sqlalchemy import Column, String, ForeignKey, Index, UUID as SQLUUID, select, update, delete +from sqlalchemy import Column, String, ForeignKey, Index, UUID as SQLUUID, select, update, delete, ARRAY from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import relationship, Mapped from sqlalchemy.ext.asyncio import AsyncSession @@ -29,12 +29,14 @@ class PadStore(Base, BaseModel): ) display_name = Column(String(100), nullable=False) data = Column(JSONB, nullable=False) + sharing_policy = Column(String(20), nullable=False, default="private") + whitelist = Column(ARRAY(SQLUUID(as_uuid=True)), nullable=True, default=[]) # Relationships owner: Mapped["UserStore"] = relationship("UserStore", back_populates="pads") def __repr__(self) -> str: - return f"" + return f"" @classmethod async def create_pad( @@ -42,10 +44,18 @@ async def create_pad( session: AsyncSession, owner_id: UUID, display_name: str, - data: Dict[str, Any] + data: Dict[str, Any], + sharing_policy: str = "private", + whitelist: List[UUID] = [] ) -> 'PadStore': """Create a new pad""" - pad = cls(owner_id=owner_id, display_name=display_name, data=data) + pad = cls( + owner_id=owner_id, + display_name=display_name, + data=data, + sharing_policy=sharing_policy, + whitelist=whitelist + ) session.add(pad) await session.commit() await session.refresh(pad) @@ -67,6 +77,8 @@ async def save(self, session: AsyncSession) -> 'PadStore': owner_id=self.owner_id, display_name=self.display_name, data=self.data, + sharing_policy=self.sharing_policy, + whitelist=self.whitelist, updated_at=self.updated_at ) await session.execute(stmt) @@ -79,6 +91,8 @@ async def save(self, session: AsyncSession) -> 'PadStore': self.owner_id = refreshed.owner_id self.display_name = refreshed.display_name self.data = refreshed.data + self.sharing_policy = refreshed.sharing_policy + self.whitelist = refreshed.whitelist self.created_at = refreshed.created_at self.updated_at = refreshed.updated_at @@ -101,6 +115,8 @@ def to_dict(self) -> Dict[str, Any]: "owner_id": str(self.owner_id), "display_name": self.display_name, "data": self.data, + "sharing_policy": self.sharing_policy, + "whitelist": [str(uid) for uid in self.whitelist] if self.whitelist else [], "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat() } diff --git a/src/backend/database/models/user_model.py b/src/backend/database/models/user_model.py index 50b35cc..75cbcb4 100644 --- a/src/backend/database/models/user_model.py +++ b/src/backend/database/models/user_model.py @@ -2,8 +2,8 @@ from uuid import UUID from datetime import datetime -from sqlalchemy import Column, Index, String, UUID as SQLUUID, Boolean, select, update, delete, func -from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import Column, Index, String, UUID as SQLUUID, Boolean, select, update, delete, func, ARRAY, and_, or_, text +from sqlalchemy.dialects.postgresql import JSONB, array from sqlalchemy.orm import relationship, Mapped from sqlalchemy.ext.asyncio import AsyncSession @@ -32,6 +32,7 @@ class UserStore(Base, BaseModel): given_name = Column(String(254), nullable=True) family_name = Column(String(254), nullable=True) roles = Column(JSONB, nullable=False, default=[]) + open_pads = Column(ARRAY(SQLUUID(as_uuid=True)), nullable=False, default=[]) # Relationships pads: Mapped[List["PadStore"]] = relationship( @@ -55,7 +56,8 @@ async def create_user( name: Optional[str] = None, given_name: Optional[str] = None, family_name: Optional[str] = None, - roles: List[str] = None + roles: List[str] = None, + open_pads: List[UUID] = None ) -> 'UserStore': """Create a new user""" user = cls( @@ -66,7 +68,8 @@ async def create_user( name=name, given_name=given_name, family_name=family_name, - roles=roles or [] + roles=roles or [], + open_pads=open_pads or [] ) session.add(user) await session.commit() @@ -103,15 +106,30 @@ async def get_all(cls, session: AsyncSession) -> List['UserStore']: @classmethod async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[str, Any]]: - """Get just the metadata of pads owned by the user without loading full pad data""" + """Get all pad IDs that the user has access to (both open pads and owned pads)""" from .pad_model import PadStore # Import here to avoid circular imports + # Get the user to access their open_pads + user = await cls.get_by_id(session, user_id) + if not user: + return [] + + # Get both open_pads and owned pad IDs + open_pad_ids = user.open_pads or [] + owned_pad_ids = [pad.id for pad in user.pads] + + # Combine and deduplicate pad IDs + all_pad_ids = list(set(open_pad_ids + owned_pad_ids)) + stmt = select( PadStore.id, PadStore.display_name, PadStore.created_at, - PadStore.updated_at - ).where(PadStore.owner_id == user_id).order_by(PadStore.created_at) + PadStore.updated_at, + PadStore.sharing_policy + ).where( + PadStore.id.in_(all_pad_ids) + ).order_by(PadStore.created_at) result = await session.execute(stmt) pads = result.all() @@ -120,7 +138,8 @@ async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[ "id": str(pad.id), "display_name": pad.display_name, "created_at": pad.created_at.isoformat(), - "updated_at": pad.updated_at.isoformat() + "updated_at": pad.updated_at.isoformat(), + "sharing_policy": pad.sharing_policy } for pad in pads] async def save(self, session: AsyncSession) -> 'UserStore': @@ -156,6 +175,7 @@ def to_dict(self) -> Dict[str, Any]: "given_name": self.given_name, "family_name": self.family_name, "roles": self.roles, + "open_pads": [str(pid) for pid in self.open_pads] if self.open_pads else [], "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat() } diff --git a/src/backend/domain/user.py b/src/backend/domain/user.py index 7247ff0..428399a 100644 --- a/src/backend/domain/user.py +++ b/src/backend/domain/user.py @@ -73,24 +73,6 @@ async def get_by_id(cls, session: AsyncSession, user_id: UUID) -> Optional['User store = await UserStore.get_by_id(session, user_id) return cls.from_store(store) if store else None - @classmethod - async def get_by_username(cls, session: AsyncSession, username: str) -> Optional['User']: - """Get a user by username""" - store = await UserStore.get_by_username(session, username) - return cls.from_store(store) if store else None - - @classmethod - async def get_by_email(cls, session: AsyncSession, email: str) -> Optional['User']: - """Get a user by email""" - store = await UserStore.get_by_email(session, email) - return cls.from_store(store) if store else None - - @classmethod - async def get_all(cls, session: AsyncSession) -> List['User']: - """Get all users""" - stores = await UserStore.get_all(session) - return [cls.from_store(store) for store in stores] - @classmethod def from_store(cls, store: UserStore) -> 'User': """Create a User instance from a store""" From bc977f5de90f4cb4944aba9f9defc74102eeb038 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 02:47:05 +0000 Subject: [PATCH 086/149] sharing/whitelist API --- src/backend/domain/pad.py | 78 +++++++++++++++++++-- src/backend/routers/pad_router.py | 112 ++++++++++++++++++++++++++++-- src/backend/routers/ws_router.py | 20 ++++++ 3 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index 72ba3a7..f7cd99d 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -1,5 +1,5 @@ from uuid import UUID -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List from datetime import datetime from redis import RedisError from sqlalchemy.ext.asyncio import AsyncSession @@ -31,7 +31,9 @@ def __init__( updated_at: datetime, store: PadStore, redis: AsyncRedis, - data: Dict[str, Any] = None, + data: Dict[str, Any] = None, + sharing_policy: str = "private", + whitelist: List[UUID] = None, ): self.id = id self.owner_id = owner_id @@ -41,6 +43,8 @@ def __init__( self._store = store self._redis = redis self.data = data or {} + self.sharing_policy = sharing_policy or "private" + self.whitelist = whitelist or [] @classmethod async def create( @@ -48,7 +52,9 @@ async def create( session: AsyncSession, owner_id: UUID, display_name: str, - data: Dict[str, Any] = default_pad + data: Dict[str, Any] = default_pad, + sharing_policy: str = "private", + whitelist: List[UUID] = None, ) -> 'Pad': """Create a new pad with multi-user app state support""" # Create a deep copy of the default template @@ -64,7 +70,9 @@ async def create( session=session, owner_id=owner_id, display_name=display_name, - data=pad_data + data=pad_data, + sharing_policy=sharing_policy or "private", + whitelist=whitelist or [] ) redis = await RedisClient.get_instance() pad = cls.from_store(store, redis) @@ -148,13 +156,17 @@ def from_store(cls, store: PadStore, redis: AsyncRedis) -> 'Pad': created_at=store.created_at, updated_at=store.updated_at, store=store, - redis=redis + redis=redis, + sharing_policy=store.sharing_policy or "private", + whitelist=store.whitelist or [] ) async def save(self, session: AsyncSession) -> 'Pad': """Save the pad to the database and update cache""" self._store.display_name = self.display_name self._store.data = self.data + self._store.sharing_policy = self.sharing_policy + self._store.whitelist = self.whitelist self._store.updated_at = datetime.now() self._store = await self._store.save(session) @@ -168,6 +180,8 @@ async def rename(self, session: AsyncSession, new_display_name: str) -> 'Pad': if self._store: self._store.display_name = new_display_name self._store.updated_at = self.updated_at + self._store.sharing_policy = self.sharing_policy + self._store.whitelist = self.whitelist self._store = await self._store.save(session) await self.cache() @@ -212,6 +226,58 @@ async def invalidate_cache(self) -> None: cache_key = f"pad:{self.id}" await self._redis.delete(cache_key) + async def set_sharing_policy(self, session: AsyncSession, policy: str) -> 'Pad': + """Update the sharing policy of the pad""" + if policy not in ["private", "whitelist", "public"]: + raise ValueError("Invalid sharing policy") + + print(f"Changing sharing policy for pad {self.id} from {self.sharing_policy} to {policy}") + self.sharing_policy = policy + self._store.sharing_policy = policy + self.updated_at = datetime.now() + self._store.updated_at = self.updated_at + + await self._store.save(session) + await self.cache() + + return self + + async def add_to_whitelist(self, session: AsyncSession, user_id: UUID) -> 'Pad': + """Add a user to the pad's whitelist""" + if user_id not in self.whitelist: + self.whitelist.append(user_id) + self._store.whitelist = self.whitelist + self.updated_at = datetime.now() + self._store.updated_at = self.updated_at + + await self._store.save(session) + await self.cache() + + return self + + async def remove_from_whitelist(self, session: AsyncSession, user_id: UUID) -> 'Pad': + """Remove a user from the pad's whitelist""" + if user_id in self.whitelist: + self.whitelist.remove(user_id) + self._store.whitelist = self.whitelist + self.updated_at = datetime.now() + self._store.updated_at = self.updated_at + + await self._store.save(session) + await self.cache() + + return self + + def can_access(self, user_id: UUID) -> bool: + """Check if a user can access the pad""" + if self.owner_id == user_id: + return True + if self.sharing_policy == "public": + return True + if self.sharing_policy == "whitelist": + return user_id in self.whitelist + return False + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary representation""" return { @@ -219,6 +285,8 @@ def to_dict(self) -> Dict[str, Any]: "owner_id": str(self.owner_id), "display_name": self.display_name, "data": self.data, + "sharing_policy": self.sharing_policy, + "whitelist": [str(uid) for uid in self.whitelist], "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat() } diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index f27dcb9..cfb9b5a 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -12,6 +12,15 @@ pad_router = APIRouter() +# Request models +class RenameRequest(BaseModel): + display_name: str + +class SharingPolicyUpdate(BaseModel): + policy: str # "private", "whitelist", or "public" + +class WhitelistUpdate(BaseModel): + user_id: UUID @pad_router.post("/new") async def create_new_pad( @@ -40,7 +49,6 @@ async def get_pad( ) -> Dict[str, Any]: """Get a specific pad for the authenticated user""" try: - # Get the pad using the domain class pad = await Pad.get_by_id(session, pad_id) if not pad: raise HTTPException( @@ -48,8 +56,8 @@ async def get_pad( detail="Pad not found" ) - # Check if the user owns the pad - if pad.owner_id != user.id: + # Check access permissions + if not pad.can_access(user.id): raise HTTPException( status_code=403, detail="Not authorized to access this pad" @@ -68,9 +76,6 @@ async def get_pad( detail=f"Failed to get pad: {str(e)}" ) -class RenameRequest(BaseModel): - display_name: str - @pad_router.put("/{pad_id}/rename") async def rename_pad( pad_id: UUID, @@ -145,3 +150,98 @@ async def delete_pad( status_code=500, detail=f"Failed to delete pad: {str(e)}" ) + +@pad_router.put("/{pad_id}/sharing") +async def update_sharing_policy( + pad_id: UUID, + policy_update: SharingPolicyUpdate, + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + """Update the sharing policy of a pad""" + try: + pad = await Pad.get_by_id(session, pad_id) + if not pad: + raise HTTPException( + status_code=404, + detail="Pad not found" + ) + + if pad.owner_id != user.id: + raise HTTPException( + status_code=403, + detail="Not authorized to modify sharing settings" + ) + + await pad.set_sharing_policy(session, policy_update.policy) + return pad.to_dict() + except ValueError as e: + raise HTTPException( + status_code=400, + detail=str(e) + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to update sharing policy: {str(e)}" + ) + +@pad_router.post("/{pad_id}/whitelist") +async def add_to_whitelist( + pad_id: UUID, + whitelist_update: WhitelistUpdate, + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + """Add a user to the pad's whitelist""" + try: + pad = await Pad.get_by_id(session, pad_id) + if not pad: + raise HTTPException( + status_code=404, + detail="Pad not found" + ) + + if pad.owner_id != user.id: + raise HTTPException( + status_code=403, + detail="Not authorized to modify whitelist" + ) + + await pad.add_to_whitelist(session, whitelist_update.user_id) + return pad.to_dict() + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to add user to whitelist: {str(e)}" + ) + +@pad_router.delete("/{pad_id}/whitelist/{user_id}") +async def remove_from_whitelist( + pad_id: UUID, + user_id: UUID, + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +) -> Dict[str, Any]: + """Remove a user from the pad's whitelist""" + try: + pad = await Pad.get_by_id(session, pad_id) + if not pad: + raise HTTPException( + status_code=404, + detail="Pad not found" + ) + + if pad.owner_id != user.id: + raise HTTPException( + status_code=403, + detail="Not authorized to modify whitelist" + ) + + await pad.remove_from_whitelist(session, user_id) + return pad.to_dict() + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to remove user from whitelist: {str(e)}" + ) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index 2b64bcf..bea40e0 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -8,9 +8,12 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends from pydantic import BaseModel, Field, field_validator from redis import asyncio as aioredis +from sqlalchemy.ext.asyncio import AsyncSession from dependencies import UserSession, get_session_domain from cache import RedisClient +from domain.pad import Pad +from database import get_session ws_router = APIRouter() STREAM_EXPIRY = 3600 @@ -189,6 +192,23 @@ async def websocket_endpoint( await websocket.close(code=4001, reason="Authentication required") return + # Get pad and check access + async for session in get_session(): + try: + pad = await Pad.get_by_id(session, pad_id) + if not pad: + await websocket.close(code=4004, reason="Pad not found") + return + + if not pad.can_access(user.id): + await websocket.close(code=4003, reason="Access denied") + return + break + except Exception as e: + print(f"Error checking pad access: {e}") + await websocket.close(code=4000, reason="Internal server error") + return + await websocket.accept() redis_client = None connection_id = str(uuid.uuid4()) # Unique ID for this WebSocket connection From f0fc1d3a627640cc8862c987a04aff418ef145b7 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 03:24:35 +0000 Subject: [PATCH 087/149] basic sharing_policy selection in context menu --- src/frontend/src/App.tsx | 4 +- src/frontend/src/hooks/usePadTabs.ts | 55 ++++++- src/frontend/src/ui/TabContextMenu.tsx | 134 +++++++++-------- src/frontend/src/ui/Tabs.tsx | 194 +++++++++++++------------ 4 files changed, 227 insertions(+), 160 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a8f5698..db3abfb 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -42,7 +42,8 @@ export default function App() { isCreating: isCreatingPad, renamePad, deletePad, - selectTab + selectTab, + updateSharingPolicy } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); @@ -141,6 +142,7 @@ export default function App() { createNewPadAsync={createNewPadAsync} renamePad={renamePad} deletePad={deletePad} + updateSharingPolicy={updateSharingPolicy} selectTab={selectTab} /> diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 1a6547f..99ef15f 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -105,7 +105,7 @@ export const usePadTabs = () => { // Effect to manage tab selection based on data changes and selectedTabId validity useEffect(() => { if (isLoading) { - return; + return; } if (data?.tabs && data.tabs.length > 0) { @@ -124,7 +124,7 @@ export const usePadTabs = () => { onMutate: async () => { await queryClient.cancelQueries({ queryKey: ['padTabs'] }); const previousTabsResponse = queryClient.getQueryData(['padTabs']); - + const tempTabId = `temp-${Date.now()}`; const tempTab: Tab = { id: tempTabId, @@ -150,7 +150,7 @@ export const usePadTabs = () => { } // Revert selectedTabId if it was the temporary one if (selectedTabId === context?.tempTabId && context?.previousTabsResponse?.activeTabId) { - setSelectedTabId(context.previousTabsResponse.activeTabId); + setSelectedTabId(context.previousTabsResponse.activeTabId); } else if (selectedTabId === context?.tempTabId && context?.previousTabsResponse?.tabs && context.previousTabsResponse.tabs.length > 0) { setSelectedTabId(context.previousTabsResponse.tabs[0].id); } else if (selectedTabId === context?.tempTabId) { @@ -161,7 +161,7 @@ export const usePadTabs = () => { onSuccess: (newlyCreatedTab, variables, context) => { queryClient.setQueryData(['padTabs'], (old) => { if (!old) return { tabs: [newlyCreatedTab], activeTabId: newlyCreatedTab.id }; - const newTabs = old.tabs.map(tab => + const newTabs = old.tabs.map(tab => tab.id === context?.tempTabId ? newlyCreatedTab : tab ); // If the temp tab wasn't found (e.g., cache was cleared), add the new tab @@ -247,12 +247,12 @@ export const usePadTabs = () => { if (!old) return { tabs: [], activeTabId: '' }; deletedTab = old.tabs.find(tab => tab.id === padIdToDelete); const newTabs = old.tabs.filter(tab => tab.id !== padIdToDelete); - + let newSelectedTabId = selectedTabId; if (selectedTabId === padIdToDelete) { if (newTabs.length > 0) { const currentIndex = old.tabs.findIndex(tab => tab.id === padIdToDelete); - newSelectedTabId = newTabs[Math.max(0, currentIndex -1)]?.id || newTabs[0]?.id; + newSelectedTabId = newTabs[Math.max(0, currentIndex - 1)]?.id || newTabs[0]?.id; } else { newSelectedTabId = ''; } @@ -280,6 +280,47 @@ export const usePadTabs = () => { }, }); + const updateSharingPolicyAPI = async ({ padId, policy }: { padId: string, policy: string }): Promise => { + const response = await fetch(`/api/pad/${padId}/sharing`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ policy }), + }); + if (!response.ok) { + throw new Error('Failed to update sharing policy'); + } + }; + + const updateSharingPolicyMutation = useMutation({ + mutationFn: updateSharingPolicyAPI, + onMutate: async ({ padId, policy }) => { + await queryClient.cancelQueries({ queryKey: ['padTabs'] }); + const previousTabsResponse = queryClient.getQueryData(['padTabs']); + + queryClient.setQueryData(['padTabs'], (old) => { + if (!old) return undefined; + const newTabs = old.tabs.map(tab => { + if (tab.id === padId) { + return { ...tab, updatedAt: new Date().toISOString() }; + } + return tab; + }); + return { ...old, tabs: newTabs }; + }); + return { previousTabsResponse }; + }, + onError: (err, variables, context) => { + if (context?.previousTabsResponse) { + queryClient.setQueryData(['padTabs'], context.previousTabsResponse); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['padTabs'] }); + }, + }); + const selectTab = async (tabId: string) => { setSelectedTabId(tabId); }; @@ -297,6 +338,8 @@ export const usePadTabs = () => { isRenaming: renamePadMutation.isPending, deletePad: deletePadMutation.mutate, isDeleting: deletePadMutation.isPending, + updateSharingPolicy: updateSharingPolicyMutation.mutate, + isUpdatingSharingPolicy: updateSharingPolicyMutation.isPending, selectTab }; }; diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index fe625cf..9889b69 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -38,6 +38,7 @@ interface TabContextMenuProps { padName: string; onRename: (padId: string, newName: string) => void; onDelete: (padId: string) => void; + onUpdateSharingPolicy: (padId: string, policy: string) => void; onClose: () => void; } @@ -52,69 +53,69 @@ const Popover: React.FC<{ viewportWidth?: number; viewportHeight?: number; children: React.ReactNode; -}> = ({ - onCloseRequest, - top, - left, - children, +}> = ({ + onCloseRequest, + top, + left, + children, fitInViewport = false, offsetLeft = 0, offsetTop = 0, viewportWidth = window.innerWidth, viewportHeight = window.innerHeight }) => { - const popoverRef = useRef(null); - - // Handle clicks outside the popover to close it - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { - onCloseRequest(); - } - }; + const popoverRef = useRef(null); + + // Handle clicks outside the popover to close it + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + onCloseRequest(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onCloseRequest]); - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [onCloseRequest]); + // Adjust position if needed to fit in viewport + useEffect(() => { + if (fitInViewport && popoverRef.current) { + const rect = popoverRef.current.getBoundingClientRect(); + const adjustedLeft = Math.min(left, viewportWidth - rect.width); + const adjustedTop = Math.min(top, viewportHeight - rect.height); - // Adjust position if needed to fit in viewport - useEffect(() => { - if (fitInViewport && popoverRef.current) { - const rect = popoverRef.current.getBoundingClientRect(); - const adjustedLeft = Math.min(left, viewportWidth - rect.width); - const adjustedTop = Math.min(top, viewportHeight - rect.height); - - if (popoverRef.current) { - popoverRef.current.style.left = `${adjustedLeft}px`; - popoverRef.current.style.top = `${adjustedTop}px`; + if (popoverRef.current) { + popoverRef.current.style.left = `${adjustedLeft}px`; + popoverRef.current.style.top = `${adjustedTop}px`; + } } - } - }, [fitInViewport, left, top, viewportWidth, viewportHeight]); + }, [fitInViewport, left, top, viewportWidth, viewportHeight]); - return ( -
- {children} -
- ); -}; + return ( +
+ {children} +
+ ); + }; // ContextMenu component -const ContextMenu: React.FC = ({ - actionManager, - items, - top, - left, - onClose +const ContextMenu: React.FC = ({ + actionManager, + items, + top, + left, + onClose }) => { // Filter items based on predicate const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { @@ -172,12 +173,12 @@ const ContextMenu: React.FC = ({ onClick={() => { // Log the click console.debug('[pad.ws] Menu item clicked:', item.name); - + // Store the callback to execute after closing const callback = () => { actionManager.executeAction(item, "contextMenu"); }; - + // Close the menu and execute the callback onClose(callback); }} @@ -206,24 +207,27 @@ class TabActionManager implements ActionManager { padName: string; onRename: (padId: string, newName: string) => void; onDelete: (padId: string) => void; + onUpdateSharingPolicy: (padId: string, policy: string) => void; app: any; constructor( padId: string, padName: string, onRename: (padId: string, newName: string) => void, - onDelete: (padId: string) => void + onDelete: (padId: string) => void, + onUpdateSharingPolicy: (padId: string, policy: string) => void ) { this.padId = padId; this.padName = padName; this.onRename = onRename; this.onDelete = onDelete; + this.onUpdateSharingPolicy = onUpdateSharingPolicy; this.app = { props: {} }; } executeAction(action: Action, source: string) { console.debug('[pad.ws] Executing action:', action.name, 'from source:', source); - + if (action.name === 'rename') { const newName = window.prompt('Rename pad', this.padName); if (newName && newName.trim() !== '') { @@ -235,6 +239,10 @@ class TabActionManager implements ActionManager { console.debug('[pad.ws] User confirmed delete, calling onDelete'); this.onDelete(this.padId); } + } else if (action.name === 'setPublic') { + this.onUpdateSharingPolicy(this.padId, 'public'); + } else if (action.name === 'setPrivate') { + this.onUpdateSharingPolicy(this.padId, 'private'); } } } @@ -247,10 +255,11 @@ const TabContextMenu: React.FC = ({ padName, onRename, onDelete, + onUpdateSharingPolicy, onClose }) => { // Create an action manager instance - const actionManager = new TabActionManager(padId, padName, onRename, onDelete); + const actionManager = new TabActionManager(padId, padName, onRename, onDelete, onUpdateSharingPolicy); // Define menu items const menuItems = [ @@ -259,7 +268,18 @@ const TabContextMenu: React.FC = ({ label: 'Rename', predicate: () => true, }, - CONTEXT_MENU_SEPARATOR, // Add separator between rename and delete + CONTEXT_MENU_SEPARATOR, + { + name: 'setPublic', + label: 'Set Public', + predicate: () => true, + }, + { + name: 'setPrivate', + label: 'Set Private', + predicate: () => true, + }, + CONTEXT_MENU_SEPARATOR, { name: 'delete', label: 'Delete', diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index 048c57b..6735a8f 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -11,27 +11,29 @@ import TabContextMenu from "./TabContextMenu"; import "./Tabs.scss"; interface TabsProps { - excalidrawAPI: ExcalidrawImperativeAPI; - tabs: Tab[]; - selectedTabId: string | null; - isLoading: boolean; - isCreatingPad: boolean; - createNewPadAsync: () => Promise; - renamePad: (args: { padId: string; newName: string }) => void; - deletePad: (padId: string) => void; - selectTab: (tabId: string) => void; + excalidrawAPI: ExcalidrawImperativeAPI; + tabs: Tab[]; + selectedTabId: string | null; + isLoading: boolean; + isCreatingPad: boolean; + createNewPadAsync: () => Promise; + renamePad: (args: { padId: string; newName: string }) => void; + deletePad: (padId: string) => void; + updateSharingPolicy: (args: { padId: string; policy: string }) => void; + selectTab: (tabId: string) => void; } const Tabs: React.FC = ({ - excalidrawAPI, - tabs, - selectedTabId, - isLoading, - isCreatingPad, - createNewPadAsync, - renamePad, - deletePad, - selectTab, + excalidrawAPI, + tabs, + selectedTabId, + isLoading, + isCreatingPad, + createNewPadAsync, + renamePad, + deletePad, + updateSharingPolicy, + selectTab, }) => { const { isLoading: isPadLoading, error: padError } = usePad(selectedTabId, excalidrawAPI); const [displayPadLoadingIndicator, setDisplayPadLoadingIndicator] = useState(false); @@ -60,23 +62,23 @@ const Tabs: React.FC = ({ const handleCreateNewPad = async () => { if (isCreatingPad) return; - + try { const newPad = await createNewPadAsync(); - + if (newPad) { capture("pad_created", { padId: newPad.id, padName: newPad.title }); - + const newPadIndex = tabs.findIndex((tab: { id: any; }) => tab.id === newPad.id); if (newPadIndex !== -1) { const newStartIndex = Math.max(0, Math.min(newPadIndex - PADS_PER_PAGE + 1, tabs.length - PADS_PER_PAGE)); setStartPadIndex(newStartIndex); } else { if (tabs.length >= PADS_PER_PAGE) { - setStartPadIndex(Math.max(0, tabs.length + 1 - PADS_PER_PAGE)); + setStartPadIndex(Math.max(0, tabs.length + 1 - PADS_PER_PAGE)); } } } @@ -127,18 +129,18 @@ const Tabs: React.FC = ({ const tabsWrapperRef = useRef(null); const lastWheelTimeRef = useRef(0); const wheelThrottleMs = 70; - + useLayoutEffect(() => { const handleWheel = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); - + const now = Date.now(); if (now - lastWheelTimeRef.current < wheelThrottleMs) { return; } lastWheelTimeRef.current = now; - + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { if (e.deltaX > 0 && tabs && startPadIndex < tabs.length - PADS_PER_PAGE) { showNextPads(); @@ -153,7 +155,7 @@ const Tabs: React.FC = ({ } } }; - + const localTabsWrapperRef = tabsWrapperRef.current; if (localTabsWrapperRef) { localTabsWrapperRef.addEventListener('wheel', handleWheel, { passive: false }); @@ -170,14 +172,14 @@ const Tabs: React.FC = ({
{!appState.viewModeEnabled && ( <> -
{} : handleCreateNewPad} + onSelect={isCreatingPad ? () => { } : handleCreateNewPad} className={isCreatingPad ? "creating-pad" : ""} children={
@@ -187,74 +189,74 @@ const Tabs: React.FC = ({ /> } />
- +
- {isLoading && !isPadLoading && ( -
- Loading pads... -
- )} - - {!isLoading && tabs && tabs.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((tab: Tab, index: any) => ( -
void; clientX: any; clientY: any; }) => { - e.preventDefault(); - setContextMenu({ - visible: true, - x: e.clientX, - y: e.clientY, - padId: tab.id, - padName: tab.title - }); - }} - > - {(selectedTabId === tab.id || tab.title.length > 11) ? ( - 11 - ? `${tab.title} (current pad)` - : "Current pad") - : tab.title - } - children={ + {isLoading && !isPadLoading && ( +
+ Loading pads... +
+ )} + + {!isLoading && tabs && tabs.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((tab: Tab, index: any) => ( +
void; clientX: any; clientY: any; }) => { + e.preventDefault(); + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + padId: tab.id, + padName: tab.title + }); + }} + > + {(selectedTabId === tab.id || tab.title.length > 11) ? ( + 11 + ? `${tab.title} (current pad)` + : "Current pad") + : tab.title + } + children={ +
- ))} - + )} +
+ ))} +
- + {tabs && tabs.length > PADS_PER_PAGE && ( - 0 ? `\n(${startPadIndex} more)` : ''}`} + 0 ? `\n(${startPadIndex} more)` : ''}`} children={ - - } + } /> )} - + {tabs && tabs.length > PADS_PER_PAGE && ( - 0 ? `\n(${Math.max(0, tabs.length - (startPadIndex + PADS_PER_PAGE))} more)` : ''}`} + 0 ? `\n(${Math.max(0, tabs.length - (startPadIndex + PADS_PER_PAGE))} more)` : ''}`} children={ - - } + } /> )} @@ -290,7 +292,7 @@ const Tabs: React.FC = ({
- + {contextMenu.visible && ( = ({ alert("Cannot delete the last pad"); return; } - + const tabToDelete = tabs?.find((t: { id: any; }) => t.id === padId); const padName = tabToDelete?.title || ""; capture("pad_deleted", { padId, padName }); - + if (padId === selectedTabId && tabs) { const otherTab = tabs.find((t: { id: any; }) => t.id !== padId); if (otherTab) { - // Before deleting, select another tab. - // The actual deletion will trigger a list update and selection adjustment in usePadTabs. - selectTab(otherTab.id); - // It might be better to let usePadTabs handle selection after delete. - // For now, explicitly select, then delete. + selectTab(otherTab.id); } } deletePad(padId); }} + onUpdateSharingPolicy={(padId: string, policy: string) => { + capture("pad_sharing_policy_updated", { padId, policy }); + updateSharingPolicy({ padId, policy }); + }} onClose={() => { setContextMenu((prev: any) => ({ ...prev, visible: false })); }} From f2925ccc365d2a02828c2f303f0f90f429f13360 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 03:25:44 +0000 Subject: [PATCH 088/149] remove console.log --- src/frontend/src/ui/TabContextMenu.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index 9889b69..fa47bbb 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -171,9 +171,6 @@ const ContextMenu: React.FC = ({ key={idx} data-testid={actionName} onClick={() => { - // Log the click - console.debug('[pad.ws] Menu item clicked:', item.name); - // Store the callback to execute after closing const callback = () => { actionManager.executeAction(item, "contextMenu"); @@ -226,8 +223,6 @@ class TabActionManager implements ActionManager { } executeAction(action: Action, source: string) { - console.debug('[pad.ws] Executing action:', action.name, 'from source:', source); - if (action.name === 'rename') { const newName = window.prompt('Rename pad', this.padName); if (newName && newName.trim() !== '') { From f47605c4a00fdb896182945dca2f9400482817fd Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 04:46:14 +0000 Subject: [PATCH 089/149] ensure user exists --- src/backend/dependencies.py | 18 ++++++++++++++++++ src/backend/domain/user.py | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 90edacc..d61a4fa 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -2,12 +2,16 @@ from typing import Optional, Dict, Any from uuid import UUID import os +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession from fastapi import Request, HTTPException from cache import RedisClient from domain.session import Session +from domain.user import User from coder import CoderAPI +from database.database import async_session # oidc_config for session creation and user sessions oidc_config = { @@ -45,6 +49,20 @@ def __init__(self, access_token: str, token_data: dict, session_domain: Session, audience=oidc_config['client_id'] ) + # Ensure user exists in database + async def ensure_user(): + async with async_session() as session: + await User.ensure_exists(session, self.token_data) + + # Run the async function and wait for it to complete + loop = asyncio.get_event_loop() + if loop.is_running(): + # If we're in an async context, create a task + asyncio.create_task(ensure_user()) + else: + # If we're in a sync context, run it directly + loop.run_until_complete(ensure_user()) + except jwt.InvalidTokenError as e: # Log the error and raise an appropriate exception print(f"Invalid token: {str(e)}") diff --git a/src/backend/domain/user.py b/src/backend/domain/user.py index 428399a..803573e 100644 --- a/src/backend/domain/user.py +++ b/src/backend/domain/user.py @@ -155,3 +155,25 @@ def to_dict(self) -> Dict[str, Any]: async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[str, Any]]: """Get just the metadata of pads owned by the user without loading full pad data""" return await UserStore.get_open_pads(session, user_id) + + @classmethod + async def ensure_exists(cls, session: AsyncSession, user_info: dict) -> 'User': + """Ensure a user exists in the database, creating them if they don't""" + user_id = UUID(user_info['sub']) + user = await cls.get_by_id(session, user_id) + + if not user: + print(f"Creating user {user_id}, {user_info.get('preferred_username', '')}") + user = await cls.create( + session=session, + id=user_id, + username=user_info.get('preferred_username', ''), + email=user_info.get('email', ''), + email_verified=user_info.get('email_verified', False), + name=user_info.get('name'), + given_name=user_info.get('given_name'), + family_name=user_info.get('family_name'), + roles=user_info.get('realm_access', {}).get('roles', []) + ) + + return user From 1f60fe0832a3a817bcf647b6f9fd6274faf0a5ce Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 04:46:45 +0000 Subject: [PATCH 090/149] dynamic join route --- src/backend/main.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/backend/main.py b/src/backend/main.py index d4a5f2c..aedc837 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -2,6 +2,8 @@ import json from contextlib import asynccontextmanager from typing import Optional +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession import posthog from fastapi import FastAPI, Request, Depends @@ -21,6 +23,9 @@ from routers.pad_router import pad_router from routers.app_router import app_router from routers.ws_router import ws_router +from database.database import get_session +from database.models.user_model import UserStore +from domain.pad import Pad # Initialize PostHog if API key is available if POSTHOG_API_KEY: @@ -61,6 +66,35 @@ async def lifespan(_: FastAPI): async def read_root(request: Request, auth: Optional[UserSession] = Depends(optional_auth)): return FileResponse(os.path.join(STATIC_DIR, "index.html")) +@app.get("/pad/{pad_id}") +async def read_pad( + pad_id: UUID, + request: Request, + user: Optional[UserSession] = Depends(optional_auth), + session: AsyncSession = Depends(get_session) +): + if not user: + return FileResponse(os.path.join(STATIC_DIR, "index.html")) + + try: + pad = await Pad.get_by_id(session, pad_id) + if not pad: + return FileResponse(os.path.join(STATIC_DIR, "index.html")) + + # Check access permissions + if not pad.can_access(user.id): + return FileResponse(os.path.join(STATIC_DIR, "index.html")) + + # Add pad to user's open pads if not already there + user_store = await UserStore.get_by_id(session, user.id) + if user_store and pad_id not in user_store.open_pads: + user_store.open_pads = list(set(user_store.open_pads + [pad_id])) + await user_store.save(session) + + return FileResponse(os.path.join(STATIC_DIR, "index.html")) + except Exception: + return FileResponse(os.path.join(STATIC_DIR, "index.html")) + app.include_router(auth_router, prefix="/api/auth") app.include_router(users_router, prefix="/api/users") app.include_router(workspace_router, prefix="/api/workspace") From 1a9dedbb1a31cad41daf87ea3a22188a0acb2ad7 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 04:58:14 +0000 Subject: [PATCH 091/149] improve context menu for url copying --- src/frontend/src/ui/TabContextMenu.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index fa47bbb..e0dc59a 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -238,6 +238,13 @@ class TabActionManager implements ActionManager { this.onUpdateSharingPolicy(this.padId, 'public'); } else if (action.name === 'setPrivate') { this.onUpdateSharingPolicy(this.padId, 'private'); + } else if (action.name === 'copyUrl') { + const url = `${window.location.origin}/pad/${this.padId}`; + navigator.clipboard.writeText(url).then(() => { + console.debug('[pad.ws] URL copied to clipboard:', url); + }).catch(err => { + console.error('[pad.ws] Failed to copy URL:', err); + }); } } } @@ -274,6 +281,11 @@ const TabContextMenu: React.FC = ({ label: 'Set Private', predicate: () => true, }, + { + name: 'copyUrl', + label: 'Copy URL', + predicate: () => true, + }, CONTEXT_MENU_SEPARATOR, { name: 'delete', From a9280a43b4924bf33983a9eabc67da1314744d7a Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 06:07:32 +0000 Subject: [PATCH 092/149] remove unecessary cleanup --- src/backend/routers/ws_router.py | 225 +++++++++++++++---------------- 1 file changed, 107 insertions(+), 118 deletions(-) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index bea40e0..e0e02f9 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -97,97 +97,86 @@ async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, print(f"Error publishing event to Redis stream {stream_key}: {str(e)}") -async def _handle_received_data( - raw_data: str, - pad_id: UUID, - user: UserSession, - redis_client: aioredis.Redis, - stream_key: str, - connection_id: str -): +async def _handle_received_data(raw_data: str, pad_id: UUID, user: UserSession, + redis_client: aioredis.Redis, stream_key: str, connection_id: str): """Processes decoded message data, wraps it in WebSocketMessage, and publishes to Redis.""" try: client_message_dict = json.loads(raw_data) - # Create a WebSocketMessage instance from the client's data. - # The client might not send user_id or connection_id, or we might want to override them. + # Create a WebSocketMessage instance from the client's data processed_message = WebSocketMessage( - type=client_message_dict.get("type", "unknown_client_message"), # Get type from client - pad_id=str(pad_id), # Server sets/overrides pad_id - user_id=str(user.id), # Server sets/overrides user_id from authenticated session - connection_id=connection_id, # Server sets/overrides connection_id - timestamp=datetime.now(timezone.utc), # Server sets timestamp - data=client_message_dict.get("data") # Pass through client's data payload + type=client_message_dict.get("type", "unknown_client_message"), + pad_id=str(pad_id), + user_id=str(user.id), + connection_id=connection_id, + timestamp=datetime.now(timezone.utc), + data=client_message_dict.get("data") ) print(f"[WS] {processed_message.timestamp.strftime('%H:%M:%S')} - Type: {processed_message.type} from User: {processed_message.user_id[:5]} Conn: [{processed_message.connection_id[:5]}] on Pad: ({processed_message.pad_id[:5]})") await publish_event_to_redis(redis_client, stream_key, processed_message) - except json.JSONDecodeError: print(f"Invalid JSON received from {connection_id[:5]}") except Exception as e: print(f"Error processing message from {connection_id[:5]}: {e}") -async def consume_redis_stream( - redis_client: aioredis.Redis, - stream_key: str, - websocket: WebSocket, - connection_id: str, - last_id: str = '$' -): +async def consume_redis_stream(redis_client: aioredis.Redis, stream_key: str, + websocket: WebSocket, connection_id: str, last_id: str = '$'): """Consumes messages from Redis stream, parses to WebSocketMessage, and forwards them.""" - try: - while websocket.client_state.CONNECTED: + while websocket.client_state.CONNECTED: + try: + # Read from Redis stream streams = await redis_client.xread({stream_key: last_id}, count=5, block=1000) - if streams: - stream_name, stream_messages = streams[0] - for message_id, message_data_raw_redis in stream_messages: - # Convert raw Redis data to a standard dict, handling bytes or str - redis_dict = {} - for k, v in message_data_raw_redis.items(): - key = k.decode() if isinstance(k, bytes) else k - value_str = v.decode() if isinstance(v, bytes) else v - - # Attempt to parse 'data' field if it was stored as JSON string - if key == 'data': - try: - redis_dict[key] = json.loads(value_str) - except json.JSONDecodeError: - redis_dict[key] = value_str # Keep as string if not valid JSON - elif key == 'pad_id' and value_str == 'None': # Handle 'None' string for nullable fields - redis_dict[key] = None - else: + if not streams: + await asyncio.sleep(0) + continue + + stream_name, stream_messages = streams[0] + for message_id, message_data_raw_redis in stream_messages: + if not websocket.client_state.CONNECTED: + return + + # Convert raw Redis data to a standard dict + redis_dict = {} + for k, v in message_data_raw_redis.items(): + key = k.decode() if isinstance(k, bytes) else k + value_str = v.decode() if isinstance(v, bytes) else v + + # Parse 'data' field if it's JSON + if key == 'data': + try: + redis_dict[key] = json.loads(value_str) + except json.JSONDecodeError: redis_dict[key] = value_str + elif key == 'pad_id' and value_str == 'None': + redis_dict[key] = None + else: + redis_dict[key] = value_str + + try: + # Create WebSocketMessage and send to client (if not from this connection) + message_to_send = WebSocketMessage(**redis_dict) - try: - # Parse the dictionary from Redis into our Pydantic model - message_to_send = WebSocketMessage(**redis_dict) - - # Avoid echoing messages to the sender - if message_to_send.connection_id != connection_id: - await websocket.send_text(message_to_send.model_dump_json()) - else: - pass # Message originated from this connection, don't echo - - except Exception as pydantic_error: - print(f"Error parsing message from Redis stream {stream_key} (ID: {message_id}): {pydantic_error}. Data: {redis_dict}") - - last_id = message_id - - await asyncio.sleep(0) - except Exception as e: - print(f"Error in Redis stream consumer for {stream_key}: {e}") + if message_to_send.connection_id != connection_id and websocket.client_state.CONNECTED: + await websocket.send_text(message_to_send.model_dump_json()) + except Exception as e: + print(f"Error sending message from Redis: {e}") + + last_id = message_id + + except Exception as e: + if websocket.client_state.CONNECTED: + print(f"Error in Redis stream consumer for {stream_key}: {e}") + return @ws_router.websocket("/ws/pad/{pad_id}") -async def websocket_endpoint( - websocket: WebSocket, - pad_id: UUID, - user: Optional[UserSession] = Depends(get_ws_user) -): +async def websocket_endpoint(websocket: WebSocket, pad_id: UUID, + user: Optional[UserSession] = Depends(get_ws_user)): + """WebSocket endpoint for pad collaboration.""" if not user: await websocket.close(code=4001, reason="Authentication required") return @@ -209,100 +198,100 @@ async def websocket_endpoint( await websocket.close(code=4000, reason="Internal server error") return + # Accept the connection and set up await websocket.accept() - redis_client = None - connection_id = str(uuid.uuid4()) # Unique ID for this WebSocket connection + connection_id = str(uuid.uuid4()) stream_key = f"pad:stream:{pad_id}" + redis_client = None try: + # Get Redis client and send initial messages redis_client = await RedisClient.get_instance() - # Send initial connection success message - if websocket.client_state.CONNECTED: - connected_msg = WebSocketMessage( - type="connected", - pad_id=str(pad_id), - user_id=str(user.id), - connection_id=connection_id, - data={"message": f"Successfully connected to pad {str(pad_id)}."} - ) - - await websocket.send_text(connected_msg.model_dump_json()) - - # Publish user joined message to Redis - join_event_data = {"displayName": getattr(user, 'displayName', str(user.id))} # Adapt if user model has display name - join_message = WebSocketMessage( - type="user_joined", - pad_id=str(pad_id), - user_id=str(user.id), - connection_id=connection_id, - data=join_event_data - ) - await publish_event_to_redis(redis_client, stream_key, join_message) + # Send connected message to client + connected_msg = WebSocketMessage( + type="connected", + pad_id=str(pad_id), + user_id=str(user.id), + connection_id=connection_id, + data={"message": f"Successfully connected to pad {str(pad_id)}."} + ) + await websocket.send_text(connected_msg.model_dump_json()) + + # Broadcast user joined message + join_event_data = {"displayName": getattr(user, 'displayName', str(user.id))} + join_message = WebSocketMessage( + type="user_joined", + pad_id=str(pad_id), + user_id=str(user.id), + connection_id=connection_id, + data=join_event_data + ) + await publish_event_to_redis(redis_client, stream_key, join_message) + # Handle incoming messages from client async def handle_websocket_messages(): while websocket.client_state.CONNECTED: try: data = await websocket.receive_text() await _handle_received_data(data, pad_id, user, redis_client, stream_key, connection_id) except WebSocketDisconnect: - print(f"WebSocket disconnected for user {str(user.id)[:5]} conn {connection_id[:5]} from pad {str(pad_id)[:5]}") + print(f"WebSocket disconnected for user {str(user.id)[:5]} conn {connection_id[:5]}") break except json.JSONDecodeError as e: print(f"Invalid JSON received from {connection_id[:5]}: {e}") - if websocket.client_state.CONNECTED: - error_msg = WebSocketMessage( - type="error", - pad_id=str(pad_id), - data={"message": "Invalid message format. Please send valid JSON."} - ) - await websocket.send_text(error_msg.model_dump_json()) + await websocket.send_text(WebSocketMessage( + type="error", + pad_id=str(pad_id), + data={"message": "Invalid message format. Please send valid JSON."} + ).model_dump_json()) except Exception as e: print(f"Error in WebSocket connection for {connection_id[:5]}: {e}") break + # Set up tasks for message handling ws_task = asyncio.create_task(handle_websocket_messages()) redis_task = asyncio.create_task( consume_redis_stream(redis_client, stream_key, websocket, connection_id, last_id='$') ) + # Wait for either task to complete done, pending = await asyncio.wait( [ws_task, redis_task], return_when=asyncio.FIRST_COMPLETED ) + # Cancel pending tasks for task in pending: task.cancel() try: - await task # Await cancellation - except asyncio.CancelledError: + await task + except (asyncio.CancelledError, Exception): pass except Exception as e: - print(f"Error in WebSocket endpoint for pad {str(pad_id)}: {e}") + print(f"Error in WebSocket connection: {e}") + finally: print(f"Cleaning up connection for user {str(user.id)[:5]} conn {connection_id[:5]} from pad {str(pad_id)[:5]}") - try: - if redis_client and user: # Ensure user is not None - leave_event_data = {} # No specific data needed for leave, user_id is top-level + + # Send user left message + if redis_client: + try: leave_message = WebSocketMessage( type="user_left", pad_id=str(pad_id), user_id=str(user.id), connection_id=connection_id, - data=leave_event_data + data={} ) await publish_event_to_redis(redis_client, stream_key, leave_message) - except Exception as e: - print(f"Error publishing leave message for {connection_id[:5]}: {e}") - - try: - if websocket.client_state.CONNECTED: - await websocket.close() - except Exception as e: - # Suppress common errors on close if already handled or connection is gone - if "already completed" not in str(e) and "close message has been sent" not in str(e) and "no connection" not in str(e).lower(): - print(f"Error closing WebSocket connection for {connection_id[:5]}: {e}") + except Exception as e: + print(f"Error publishing leave message: {e}") - if redis_client: - await redis_client.close() # Ensure Redis client is closed if it was opened + # Close the WebSocket if still connected + if websocket.client_state.CONNECTED: + try: + await websocket.close() + except Exception: + pass \ No newline at end of file From 054a13a8b61603dd181294fe5a227124efddcf3d Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 06:46:23 +0000 Subject: [PATCH 093/149] cache sharing_policy and whitelist --- src/backend/domain/pad.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/backend/domain/pad.py b/src/backend/domain/pad.py index f7cd99d..1f8a213 100644 --- a/src/backend/domain/pad.py +++ b/src/backend/domain/pad.py @@ -101,6 +101,11 @@ async def from_redis(cls, redis: AsyncRedis, pad_id: UUID) -> Optional['Pad']: created_at = datetime.fromisoformat(cached_data['created_at']) updated_at = datetime.fromisoformat(cached_data['updated_at']) + # Get sharing_policy and whitelist (or use defaults if not in cache) + sharing_policy = cached_data.get('sharing_policy', 'private') + whitelist_str = cached_data.get('whitelist', '[]') + whitelist = [UUID(uid) for uid in json.loads(whitelist_str)] if whitelist_str else [] + # Create a minimal PadStore instance store = PadStore( id=pad_id, @@ -108,7 +113,9 @@ async def from_redis(cls, redis: AsyncRedis, pad_id: UUID) -> Optional['Pad']: display_name=display_name, data=data, created_at=created_at, - updated_at=updated_at + updated_at=updated_at, + sharing_policy=sharing_policy, + whitelist=whitelist ) return cls( @@ -119,7 +126,9 @@ async def from_redis(cls, redis: AsyncRedis, pad_id: UUID) -> Optional['Pad']: created_at=created_at, updated_at=updated_at, store=store, - redis=redis + redis=redis, + sharing_policy=sharing_policy, + whitelist=whitelist ) except (json.JSONDecodeError, KeyError, ValueError, RedisError) as e: return None @@ -211,7 +220,9 @@ async def cache(self) -> None: 'display_name': self.display_name, 'data': json.dumps(self.data), 'created_at': self.created_at.isoformat(), - 'updated_at': self.updated_at.isoformat() + 'updated_at': self.updated_at.isoformat(), + 'sharing_policy': self.sharing_policy, + 'whitelist': json.dumps([str(uid) for uid in self.whitelist]) if self.whitelist else '[]' } try: async with self._redis.pipeline() as pipe: From e6387ae934b9a58ebfd28b80d27e37ab0d17cda9 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Tue, 20 May 2025 06:56:19 +0000 Subject: [PATCH 094/149] =?UTF-8?q?join=20public=20rooms=20via=20url=20?= =?UTF-8?q?=F0=9F=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/main.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/backend/main.py b/src/backend/main.py index aedc837..03ca5cf 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -43,6 +43,7 @@ async def lifespan(_: FastAPI): await redis.ping() print("Redis connection established successfully") + yield # Clean up connections when shutting down @@ -62,9 +63,7 @@ async def lifespan(_: FastAPI): app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") -@app.get("/") -async def read_root(request: Request, auth: Optional[UserSession] = Depends(optional_auth)): - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + @app.get("/pad/{pad_id}") async def read_pad( @@ -74,27 +73,44 @@ async def read_pad( session: AsyncSession = Depends(get_session) ): if not user: + print("No user found") return FileResponse(os.path.join(STATIC_DIR, "index.html")) try: + pad = await Pad.get_by_id(session, pad_id) if not pad: + print("No pad found") return FileResponse(os.path.join(STATIC_DIR, "index.html")) # Check access permissions if not pad.can_access(user.id): + print("No access to pad") return FileResponse(os.path.join(STATIC_DIR, "index.html")) # Add pad to user's open pads if not already there user_store = await UserStore.get_by_id(session, user.id) - if user_store and pad_id not in user_store.open_pads: - user_store.open_pads = list(set(user_store.open_pads + [pad_id])) - await user_store.save(session) - + if user_store: + # Convert all UUIDs to strings for comparison + open_pads_str = [str(pid) for pid in user_store.open_pads] + if str(pad_id) not in open_pads_str: + # Convert back to UUIDs for storage + user_store.open_pads = [UUID(pid) for pid in open_pads_str] + [pad_id] + try: + await user_store.save(session) + except Exception as e: + print(f"Error updating user's open pads: {e}") + # Continue even if update fails - don't block pad access + return FileResponse(os.path.join(STATIC_DIR, "index.html")) - except Exception: + except Exception as e: + print(f"Error in read_pad endpoint: {e}") return FileResponse(os.path.join(STATIC_DIR, "index.html")) +@app.get("/") +async def read_root(request: Request, auth: Optional[UserSession] = Depends(optional_auth)): + return FileResponse(os.path.join(STATIC_DIR, "index.html")) + app.include_router(auth_router, prefix="/api/auth") app.include_router(users_router, prefix="/api/users") app.include_router(workspace_router, prefix="/api/workspace") From 6b013446858227ef9b59d7394fa9d68819411c89 Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Wed, 21 May 2025 20:35:56 +0000 Subject: [PATCH 095/149] fix dev env by enabling /pad to serve index from a proxy --- src/backend/config.py | 2 ++ src/backend/main.py | 52 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/backend/config.py b/src/backend/config.py index a5140f8..7043512 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -15,6 +15,8 @@ STATIC_DIR = os.getenv("STATIC_DIR") ASSETS_DIR = os.getenv("ASSETS_DIR") FRONTEND_URL = os.getenv('FRONTEND_URL') +PAD_DEV_MODE = os.getenv('PAD_DEV_MODE', 'false').lower() == 'true' +DEV_FRONTEND_URL = os.getenv('DEV_FRONTEND_URL', 'http://localhost:3003') MAX_BACKUPS_PER_USER = 10 # Maximum number of backups to keep per user MIN_INTERVAL_MINUTES = 5 # Minimum interval in minutes between backups diff --git a/src/backend/main.py b/src/backend/main.py index 03ca5cf..0599d72 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -6,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession import posthog -from fastapi import FastAPI, Request, Depends +import httpx +from fastapi import FastAPI, Request, Depends, Response from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -14,6 +15,7 @@ from database import init_db, engine from config import ( STATIC_DIR, ASSETS_DIR, POSTHOG_API_KEY, POSTHOG_HOST, + PAD_DEV_MODE, DEV_FRONTEND_URL ) from cache import RedisClient from dependencies import UserSession, optional_auth @@ -34,6 +36,10 @@ @asynccontextmanager async def lifespan(_: FastAPI): + + if PAD_DEV_MODE: + print("Starting in dev mode") + # Initialize database await init_db() print("Database connection established successfully") @@ -63,7 +69,34 @@ async def lifespan(_: FastAPI): app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") - +async def serve_index_html(request: Request = None): + """ + Helper function to serve the index.html file or proxy to dev server based on PAD_DEV_MODE. + """ + if PAD_DEV_MODE: + try: + # Proxy the request to the development server's root URL + url = f"{DEV_FRONTEND_URL}/" + # If request path is available, use it for proxying + if request and str(request.url).replace(str(request.base_url), ""): + url = f"{DEV_FRONTEND_URL}{request.url.path}" + + async with httpx.AsyncClient() as client: + response = await client.get(url) + return Response( + content=response.content, + status_code=response.status_code, + media_type=response.headers.get("content-type") + ) + except Exception as e: + error_message = f"Error proxying to dev server: {e}" + print(error_message) + return Response( + status_code=500, + ) + else: + # Serve the static build + return FileResponse(os.path.join(STATIC_DIR, "index.html")) @app.get("/pad/{pad_id}") async def read_pad( @@ -74,19 +107,18 @@ async def read_pad( ): if not user: print("No user found") - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + return await serve_index_html(request) try: - pad = await Pad.get_by_id(session, pad_id) if not pad: print("No pad found") - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + return await serve_index_html(request) # Check access permissions if not pad.can_access(user.id): print("No access to pad") - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + return await serve_index_html(request) # Add pad to user's open pads if not already there user_store = await UserStore.get_by_id(session, user.id) @@ -102,14 +134,16 @@ async def read_pad( print(f"Error updating user's open pads: {e}") # Continue even if update fails - don't block pad access - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + return await serve_index_html(request) except Exception as e: print(f"Error in read_pad endpoint: {e}") - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + return await serve_index_html(request) @app.get("/") async def read_root(request: Request, auth: Optional[UserSession] = Depends(optional_auth)): - return FileResponse(os.path.join(STATIC_DIR, "index.html")) + return await serve_index_html(request) + + app.include_router(auth_router, prefix="/api/auth") app.include_router(users_router, prefix="/api/users") From 9a68e48e3fb7372575ff2e1da9952bf56f73a695 Mon Sep 17 00:00:00 2001 From: Romain Courtois Date: Thu, 22 May 2025 04:39:32 +0200 Subject: [PATCH 096/149] Multiplayer collab frontend fixes (#96) * refactor: implement collaboration management with useCollabManager hook and update Collab component to utilize it * refactor: remove unused useCollabManager import and commented-out collaboration logic from App component * refactor: restructure collaboration logic by moving Collab component and Portal into separate files, update import paths, and enhance collaboration state management * refactor: update WebSocket logging to exclude pointer updates, adjust default scroll and element positions in dev.json, and remove unused Collab component * refactor: remove WebSocket integration and related hooks, streamline collaboration logic in Collab and Portal components, and update props handling for improved authentication state management * refactor: simplify collaboration logic in Collab component by removing previousElements tracking, updating scene change handling, and enhancing element reconciliation for improved performance * refactor: enhance authentication state management in useAuthStatus hook by updating queryClient data handling to properly reflect authenticated status and expiration * chore: remove useless comments * refactor: update user display name handling in WebSocket events and adjust pointer move throttle for improved performance * refactor: improve code readability in Collab component by formatting object properties for better clarity * wip: implement user follow/unfollow functionality in WebSocket events and enhance viewport broadcasting in Collab component * refactor: remove useless LLM story telling * refactor: re-organize and cleanup Collab.tsx * handle a list of connected users in redis * fix: properly handle list of collaborators on 'connected' and properly delete collaborator on 'user_left' * feat: implement alternative message deletion for shared pad * moved pointer updates to a pub/sub channel * fix: Portal initialization to prevent no-op setState on (yet) unmounted component * add owner_id to get_open_pads * feat: add sharing policy to tabs and update styles accordingly * close pad endpoint * feat: enhance TabContextMenu to be modular based on tab properties (public/private) * feat: add functionality to leave shared pads and update user interface accordingly --------- Co-authored-by: Alex TYRODE --- src/backend/database/models/user_model.py | 12 + src/backend/domain/user.py | 6 + src/backend/routers/users_router.py | 26 + src/backend/routers/ws_router.py | 204 +++++++- src/backend/templates/dev.json | 19 +- src/frontend/src/App.tsx | 45 +- src/frontend/src/Collab.tsx | 108 ----- src/frontend/src/hooks/useAuthStatus.ts | 12 +- src/frontend/src/hooks/usePadTabs.ts | 88 ++++ src/frontend/src/hooks/usePadWebSocket.ts | 189 -------- src/frontend/src/lib/collab/Collab.tsx | 561 ++++++++++++++++++++++ src/frontend/src/lib/collab/Portal.tsx | 362 ++++++++++++++ src/frontend/src/ui/TabContextMenu.tsx | 122 +++-- src/frontend/src/ui/Tabs.scss | 16 +- src/frontend/src/ui/Tabs.tsx | 18 +- 15 files changed, 1384 insertions(+), 404 deletions(-) delete mode 100644 src/frontend/src/Collab.tsx delete mode 100644 src/frontend/src/hooks/usePadWebSocket.ts create mode 100644 src/frontend/src/lib/collab/Collab.tsx create mode 100644 src/frontend/src/lib/collab/Portal.tsx diff --git a/src/backend/database/models/user_model.py b/src/backend/database/models/user_model.py index 75cbcb4..eec4626 100644 --- a/src/backend/database/models/user_model.py +++ b/src/backend/database/models/user_model.py @@ -123,6 +123,7 @@ async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[ stmt = select( PadStore.id, + PadStore.owner_id, PadStore.display_name, PadStore.created_at, PadStore.updated_at, @@ -136,6 +137,7 @@ async def get_open_pads(cls, session: AsyncSession, user_id: UUID) -> List[Dict[ return [{ "id": str(pad.id), + "owner_id": str(pad.owner_id), "display_name": pad.display_name, "created_at": pad.created_at.isoformat(), "updated_at": pad.updated_at.isoformat(), @@ -179,3 +181,13 @@ def to_dict(self) -> Dict[str, Any]: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat() } + + async def remove_open_pad(self, session: AsyncSession, pad_id: UUID) -> 'UserStore': + """Remove a pad from the user's open_pads list""" + + if pad_id in self.open_pads: + pads = self.open_pads.copy() + pads.pop(pads.index(pad_id)) + await self.update(session, {"open_pads": pads}) + + return self diff --git a/src/backend/domain/user.py b/src/backend/domain/user.py index 803573e..174b944 100644 --- a/src/backend/domain/user.py +++ b/src/backend/domain/user.py @@ -177,3 +177,9 @@ async def ensure_exists(cls, session: AsyncSession, user_info: dict) -> 'User': ) return user + + async def remove_open_pad(self, session: AsyncSession, pad_id: UUID) -> 'User': + """Remove a pad from the user's open_pads list""" + if self._store and pad_id in self._store.open_pads: + self._store = await self._store.remove_open_pad(session, pad_id) + return self diff --git a/src/backend/routers/users_router.py b/src/backend/routers/users_router.py index 6ee8697..1503211 100644 --- a/src/backend/routers/users_router.py +++ b/src/backend/routers/users_router.py @@ -111,3 +111,29 @@ async def get_online_users( except Exception as e: print(f"Error getting online users: {str(e)}") raise HTTPException(status_code=500, detail="Failed to retrieve online users") + +@users_router.delete("/close/{pad_id}") +async def close_pad( + pad_id: UUID, + user: UserSession = Depends(require_auth), + session: AsyncSession = Depends(get_session) +): + """Remove a pad from the user's open_pads list""" + try: + # Get the user + user_obj = await User.get_by_id(session, user.id) + if not user_obj: + raise HTTPException( + status_code=404, + detail="User not found" + ) + await user_obj.remove_open_pad(session, pad_id) + + return {"success": True, "message": "Pad removed from open pads"} + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to remove pad from open pads: {str(e)}" + ) diff --git a/src/backend/routers/ws_router.py b/src/backend/routers/ws_router.py index e0e02f9..f13e33c 100644 --- a/src/backend/routers/ws_router.py +++ b/src/backend/routers/ws_router.py @@ -2,7 +2,7 @@ import asyncio import uuid from uuid import UUID -from typing import Optional, Any +from typing import Optional, Any, Dict, List from datetime import datetime, timezone from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends @@ -17,6 +17,8 @@ ws_router = APIRouter() STREAM_EXPIRY = 3600 +PAD_USERS_EXPIRY = 3600 # Expiry time for the pad users hash +POINTER_CHANNEL_PREFIX = "pad:pointer:updates:" # Prefix for pointer update pub/sub channels class WebSocketMessage(BaseModel): type: str @@ -96,6 +98,20 @@ async def publish_event_to_redis(redis_client: aioredis.Redis, stream_key: str, except Exception as e: print(f"Error publishing event to Redis stream {stream_key}: {str(e)}") +async def publish_pointer_update(redis_client: aioredis.Redis, pad_id: UUID, message: WebSocketMessage): + """ + Publish pointer updates through Redis pub/sub instead of streams. + Since we don't care about persistence or consuming history for pointer updates, + pub/sub is more efficient than streams for this high-frequency data. + """ + try: + channel = f"{POINTER_CHANNEL_PREFIX}{pad_id}" + # Serialize the message and publish it + message_json = message.model_dump_json() + await redis_client.publish(channel, message_json) + except Exception as e: + print(f"Error publishing pointer update to Redis pub/sub {pad_id}: {str(e)}") + async def _handle_received_data(raw_data: str, pad_id: UUID, user: UserSession, redis_client: aioredis.Redis, stream_key: str, connection_id: str): @@ -113,9 +129,43 @@ async def _handle_received_data(raw_data: str, pad_id: UUID, user: UserSession, data=client_message_dict.get("data") ) - print(f"[WS] {processed_message.timestamp.strftime('%H:%M:%S')} - Type: {processed_message.type} from User: {processed_message.user_id[:5]} Conn: [{processed_message.connection_id[:5]}] on Pad: ({processed_message.pad_id[:5]})") + if processed_message.type != "pointer_update": + print(f"[WS] {processed_message.timestamp.strftime('%H:%M:%S')} - Type: {processed_message.type} from User: {processed_message.user_id[:5]} Conn: [{processed_message.connection_id[:5]}] on Pad: ({processed_message.pad_id[:5]})") - await publish_event_to_redis(redis_client, stream_key, processed_message) + # Handle specific follow/unfollow requests by transforming them into broadcastable events + if processed_message.type == 'user_follow_request': + user_to_follow_id = processed_message.data.get('userToFollowId') if isinstance(processed_message.data, dict) else None + if user_to_follow_id: + follow_event = WebSocketMessage( + type='user_started_following', + pad_id=str(pad_id), + user_id=str(user.id), # The user who initiated the follow (the follower) + connection_id=connection_id, + timestamp=datetime.now(timezone.utc), + data={'followerId': str(user.id), 'followedUserId': user_to_follow_id} + ) + await publish_event_to_redis(redis_client, stream_key, follow_event) + # Do not publish the original 'user_follow_request' itself + elif processed_message.type == 'user_unfollow_request': + user_to_unfollow_id = processed_message.data.get('userToUnfollowId') if isinstance(processed_message.data, dict) else None + if user_to_unfollow_id: + unfollow_event = WebSocketMessage( + type='user_stopped_following', + pad_id=str(pad_id), + user_id=str(user.id), # The user who initiated the unfollow + connection_id=connection_id, + timestamp=datetime.now(timezone.utc), + data={'unfollowerId': str(user.id), 'unfollowedUserId': user_to_unfollow_id} + ) + await publish_event_to_redis(redis_client, stream_key, unfollow_event) + # Do not publish the original 'user_unfollow_request' itself + elif processed_message.type == 'pointer_update': + # Use pub/sub for pointer updates instead of Redis streams + await publish_pointer_update(redis_client, pad_id, processed_message) + else: + # For all other message types, publish them as they are + await publish_event_to_redis(redis_client, stream_key, processed_message) + except json.JSONDecodeError: print(f"Invalid JSON received from {connection_id[:5]}") except Exception as e: @@ -173,6 +223,123 @@ async def consume_redis_stream(redis_client: aioredis.Redis, stream_key: str, return +async def consume_pointer_updates(redis_client: aioredis.Redis, pad_id: UUID, + websocket: WebSocket, connection_id: str): + """Consumes pointer updates from Redis pub/sub channel and forwards them to the client.""" + channel = f"{POINTER_CHANNEL_PREFIX}{pad_id}" + pubsub = redis_client.pubsub() + + try: + await pubsub.subscribe(channel) + + # Process messages as they arrive + while websocket.client_state.CONNECTED: + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) + + if message and message["type"] == "message": + try: + # Parse the message data + message_data = json.loads(message["data"]) + pointer_message = WebSocketMessage(**message_data) + + # Only forward messages from other connections + if pointer_message.connection_id != connection_id and websocket.client_state.CONNECTED: + await websocket.send_text(message["data"]) + except Exception as e: + print(f"Error processing pointer update: {e}") + + # Prevent CPU hogging + await asyncio.sleep(0) + + except Exception as e: + if websocket.client_state.CONNECTED: + print(f"Error in pointer update consumer for {pad_id}: {e}") + finally: + # Clean up the subscription + try: + await pubsub.unsubscribe(channel) + await pubsub.close() + except Exception: + pass + + +async def add_connection(redis_client: aioredis.Redis, pad_id: UUID, user_id: str, + username: str, connection_id: str) -> None: + """Add a user connection to the pad users hash in Redis.""" + key = f"pad:users:{pad_id}" + try: + # Get existing user data if any + user_data_str = await redis_client.hget(key, user_id) + + if user_data_str: + user_data = json.loads(user_data_str) + # Add the connection ID if it doesn't exist + if connection_id not in user_data["connections"]: + user_data["connections"].append(connection_id) + else: + # Create new user data + user_data = { + "username": username, + "connections": [connection_id] + } + + # Update the hash in Redis + await redis_client.hset(key, user_id, json.dumps(user_data)) + # Set expiry on the hash + await redis_client.expire(key, PAD_USERS_EXPIRY) + except Exception as e: + print(f"Error adding connection to Redis: {e}") + +async def remove_connection(redis_client: aioredis.Redis, pad_id: UUID, user_id: str, + connection_id: str) -> None: + """Remove a user connection from the pad users hash in Redis.""" + key = f"pad:users:{pad_id}" + try: + # Get existing user data + user_data_str = await redis_client.hget(key, user_id) + + if user_data_str: + user_data = json.loads(user_data_str) + + # Remove the connection + if connection_id in user_data["connections"]: + user_data["connections"].remove(connection_id) + + # If there are still connections, update the user data + if user_data["connections"]: + await redis_client.hset(key, user_id, json.dumps(user_data)) + else: + # If no connections left, remove the user from the hash + await redis_client.hdel(key, user_id) + + # Refresh expiry on the hash if it still exists + if await redis_client.exists(key): + await redis_client.expire(key, PAD_USERS_EXPIRY) + except Exception as e: + print(f"Error removing connection from Redis: {e}") + +async def get_connected_users(redis_client: aioredis.Redis, pad_id: UUID) -> List[Dict[str, str]]: + """Get all connected users from the pad users hash as a list of dicts with user_id and username.""" + key = f"pad:users:{pad_id}" + try: + # Get all users from the hash + all_users = await redis_client.hgetall(key) + + # Convert to list of dicts with user_id and username + connected_users = [] + for user_id, user_data_str in all_users.items(): + user_id_str = user_id.decode() if isinstance(user_id, bytes) else user_id + user_data = json.loads(user_data_str.decode() if isinstance(user_data_str, bytes) else user_data_str) + connected_users.append({ + "user_id": user_id_str, + "username": user_data["username"] + }) + + return connected_users + except Exception as e: + print(f"Error getting connected users from Redis: {e}") + return [] + @ws_router.websocket("/ws/pad/{pad_id}") async def websocket_endpoint(websocket: WebSocket, pad_id: UUID, user: Optional[UserSession] = Depends(get_ws_user)): @@ -205,21 +372,25 @@ async def websocket_endpoint(websocket: WebSocket, pad_id: UUID, redis_client = None try: - # Get Redis client and send initial messages redis_client = await RedisClient.get_instance() - - # Send connected message to client + + await add_connection(redis_client, pad_id, str(user.id), user.username, connection_id) + connected_users = await get_connected_users(redis_client, pad_id) + + # Send connected message to client with connected users info connected_msg = WebSocketMessage( type="connected", pad_id=str(pad_id), user_id=str(user.id), connection_id=connection_id, - data={"message": f"Successfully connected to pad {str(pad_id)}."} + data={ + "collaboratorsList": connected_users + } ) await websocket.send_text(connected_msg.model_dump_json()) # Broadcast user joined message - join_event_data = {"displayName": getattr(user, 'displayName', str(user.id))} + join_event_data = {"username": user.username} join_message = WebSocketMessage( type="user_joined", pad_id=str(pad_id), @@ -254,10 +425,13 @@ async def handle_websocket_messages(): redis_task = asyncio.create_task( consume_redis_stream(redis_client, stream_key, websocket, connection_id, last_id='$') ) + pointer_task = asyncio.create_task( + consume_pointer_updates(redis_client, pad_id, websocket, connection_id) + ) - # Wait for either task to complete + # Wait for any task to complete done, pending = await asyncio.wait( - [ws_task, redis_task], + [ws_task, redis_task, pointer_task], return_when=asyncio.FIRST_COMPLETED ) @@ -275,8 +449,14 @@ async def handle_websocket_messages(): finally: print(f"Cleaning up connection for user {str(user.id)[:5]} conn {connection_id[:5]} from pad {str(pad_id)[:5]}") - # Send user left message + # Remove the connection from Redis if redis_client: + try: + await remove_connection(redis_client, pad_id, str(user.id), connection_id) + except Exception as e: + print(f"Error removing connection from Redis: {e}") + + # Send user left message try: leave_message = WebSocketMessage( type="user_left", @@ -294,4 +474,4 @@ async def handle_websocket_messages(): try: await websocket.close() except Exception: - pass \ No newline at end of file + pass diff --git a/src/backend/templates/dev.json b/src/backend/templates/dev.json index e18a716..0a6e58d 100644 --- a/src/backend/templates/dev.json +++ b/src/backend/templates/dev.json @@ -17,8 +17,8 @@ "width": 1920, "height": 957, "penMode": false, - "scrollX": 777.3333740234375, - "scrollY": 326.3333740234375, + "scrollX": 220, + "scrollY": 220, "gridSize": 20, "gridStep": 5, "openMenu": null, @@ -119,8 +119,8 @@ }, "elements": [ { - "x": -160, - "y": -200, + "x": 0, + "y": 0, "id": "1igpgsvrsh3", "link": "!dev", "seed": 76441, @@ -128,12 +128,12 @@ "angle": 0, "index": "b0q", "width": 700, - "height": 800, + "height": 420, "locked": false, "frameId": null, "opacity": 100, - "updated": 1747500403348, - "version": 2104, + "updated": 1747831905652, + "version": 2127, "groupIds": [], "fillStyle": "solid", "isDeleted": false, @@ -155,10 +155,9 @@ "strokeColor": "#ced4da", "strokeStyle": "solid", "strokeWidth": 2, - "versionNonce": 1736659225, + "versionNonce": 253042044, "boundElements": [], "backgroundColor": "#e9ecef" } ] -} - +} \ No newline at end of file diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index db3abfb..5a26d0b 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -6,19 +6,17 @@ import type { ExcalidrawEmbeddableElement, NonDeleted, NonDeletedExcalidrawEleme // Hooks import { useAuthStatus } from "./hooks/useAuthStatus"; import { usePadTabs } from "./hooks/usePadTabs"; -import { usePadWebSocket } from "./hooks/usePadWebSocket"; // Components import DiscordButton from './ui/DiscordButton'; import { MainMenuConfig } from './ui/MainMenu'; import AuthDialog from './ui/AuthDialog'; import SettingsDialog from './ui/SettingsDialog'; -import Collab from './Collab'; +import Collab from './lib/collab/Collab'; // Updated import path // Utils // import { initializePostHog } from "./lib/posthog"; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; -import { debounce } from './lib/debounce'; import Tabs from "./ui/Tabs"; export const defaultInitialData = { @@ -43,40 +41,13 @@ export default function App() { renamePad, deletePad, selectTab, - updateSharingPolicy + updateSharingPolicy, + leaveSharedPad } = usePadTabs(); const [showSettingsModal, setShowSettingsModal] = useState(false); const [excalidrawAPI, setExcalidrawAPI] = useState(null); - const { sendMessage, lastJsonMessage } = usePadWebSocket(selectedTabId); // Added lastJsonMessage - - const lastSentCanvasDataRef = useRef(""); - - const debouncedLogChange = useCallback( - debounce( - (elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => { - if (!isAuthenticated || !selectedTabId) return; - - const canvasData = { - elements, - appState: state, - files - }; - - const serialized = JSON.stringify(canvasData); - - if (serialized !== lastSentCanvasDataRef.current) { - lastSentCanvasDataRef.current = serialized; - - sendMessage("pad_update", canvasData); - } - }, - 200 - ), - [sendMessage, isAuthenticated] - ); - const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; @@ -102,7 +73,6 @@ export default function App() { excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)} theme="dark" initialData={defaultInitialData} - onChange={debouncedLogChange} name="Pad.ws" onScrollChange={handleOnScrollChange} validateEmbeddable={true} @@ -142,18 +112,19 @@ export default function App() { createNewPadAsync={createNewPadAsync} renamePad={renamePad} deletePad={deletePad} + leaveSharedPad={leaveSharedPad} updateSharingPolicy={updateSharingPolicy} selectTab={selectTab} /> )} - {excalidrawAPI && user && ( )} diff --git a/src/frontend/src/Collab.tsx b/src/frontend/src/Collab.tsx deleted file mode 100644 index e6f4d9d..0000000 --- a/src/frontend/src/Collab.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useEffect } from 'react'; -import type { ExcalidrawImperativeAPI, AppState, Collaborator as ExcalidrawCollaborator } from "@atyrode/excalidraw/types"; -import { WebSocketMessage } from "./hooks/usePadWebSocket"; // Assuming this is the correct path and type - -// Moved Types -export interface Collaborator { - id: string; - pointer?: { x: number; y: number }; - button?: "up" | "down"; - selectedElementIds?: AppState["selectedElementIds"]; - username?: string | null; - userState?: "active" | "away" | "idle"; - color?: { background: string; stroke: string }; - avatarUrl?: string | null; -} - -// Specific data payload for 'user_joined' WebSocket messages -// These types reflect the direct properties on lastJsonMessage for these events -export interface UserJoinedMessage extends WebSocketMessage { // Extend or use a more specific type if available - type: "user_joined"; - user_id: string; - connection_id: string; - displayName?: string; -} - -// Specific data payload for 'user_left' WebSocket messages -export interface UserLeftMessage extends WebSocketMessage { // Extend or use a more specific type if available - type: "user_left"; - user_id: string; - connection_id: string; -} - -// ConnectedData is also moved here, though not directly used in the primary effect. -export interface ConnectedData { - pad_id: string; - user_id: string; - connection_id: string; - timestamp: string; -} - -// Props for the Collab component -interface CollabProps { - excalidrawAPI: ExcalidrawImperativeAPI | null; - lastJsonMessage: WebSocketMessage | null; - userId?: string; // Current user's ID - sendMessage: (message: WebSocketMessage) => void; // Keep if other collab features might need it -} - -// Moved Helper Function -const getRandomCollaboratorColor = () => { - const colors = [ - { background: "#FFC9C9", stroke: "#A61E1E" }, // Light Red - { background: "#B2F2BB", stroke: "#1E7E34" }, // Light Green - { background: "#A5D8FF", stroke: "#1C63A6" }, // Light Blue - { background: "#FFEC99", stroke: "#A67900" }, // Light Yellow - { background: "#E6C9FF", stroke: "#6A1E9A" }, // Light Purple - { background: "#FFD8A8", stroke: "#A65E00" }, // Light Orange - { background: "#C3FAFB", stroke: "#008083" }, // Light Cyan - { background: "#F0B9DD", stroke: "#A21E6F" }, // Light Pink - ]; - return colors[Math.floor(Math.random() * colors.length)]; -}; - -export default function Collab({ excalidrawAPI, lastJsonMessage, userId, sendMessage }: CollabProps) { - useEffect(() => { - if (!lastJsonMessage || !excalidrawAPI || !userId) { - return; - } - - const currentAppState = excalidrawAPI.getAppState(); - // Ensure collaborators is a Map. Excalidraw might initialize it as an object. - const collaboratorsMap = currentAppState.collaborators instanceof Map - ? new Map(currentAppState.collaborators) - : new Map(); - - - if (lastJsonMessage.type === "user_joined") { - - const { connection_id: joinedUserId, displayName } = lastJsonMessage as UserJoinedMessage; - - if (!collaboratorsMap.has(joinedUserId)) { - const newCollaborator: Collaborator = { - id: joinedUserId, - username: displayName || joinedUserId, - pointer: { x: 0, y: 0 }, - button: "up", - selectedElementIds: {}, - userState: "active", - color: getRandomCollaboratorColor(), - }; - collaboratorsMap.set(joinedUserId, newCollaborator as ExcalidrawCollaborator); - excalidrawAPI.updateScene({ appState: { ...currentAppState, collaborators: collaboratorsMap } }); - console.log(`[Collab.tsx] User joined: ${joinedUserId}, collaborators:`, collaboratorsMap); - } - } else if (lastJsonMessage.type === "user_left") { - - const { connection_id: leftUserId } = lastJsonMessage as UserLeftMessage; - - if (collaboratorsMap.has(leftUserId)) { - collaboratorsMap.delete(leftUserId); - excalidrawAPI.updateScene({ appState: { ...currentAppState, collaborators: collaboratorsMap } }); - console.log(`[Collab.tsx] User left: ${leftUserId}, collaborators:`, collaboratorsMap); - } - } - }, [lastJsonMessage, excalidrawAPI, userId]); - - return null; -} diff --git a/src/frontend/src/hooks/useAuthStatus.ts b/src/frontend/src/hooks/useAuthStatus.ts index 52d2e61..cdfb400 100644 --- a/src/frontend/src/hooks/useAuthStatus.ts +++ b/src/frontend/src/hooks/useAuthStatus.ts @@ -49,7 +49,17 @@ export const useAuthStatus = () => { data.expires_in, // Success callback (refreshedData) => { - queryClient.setQueryData([AUTH_STATUS_KEY], refreshedData); + queryClient.setQueryData([AUTH_STATUS_KEY], (initialData: AuthStatusResponse | undefined) => { + if (refreshedData.authenticated) { + return { + ...initialData, + authenticated: refreshedData.authenticated, + expires_in: refreshedData.expires_in, + }; + } + // If refresh resulted in not authenticated, return the new (unauthenticated) data. + return refreshedData; + }); }, // Error callback () => { diff --git a/src/frontend/src/hooks/usePadTabs.ts b/src/frontend/src/hooks/usePadTabs.ts index 99ef15f..8ab0a8c 100644 --- a/src/frontend/src/hooks/usePadTabs.ts +++ b/src/frontend/src/hooks/usePadTabs.ts @@ -1,9 +1,18 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useState, useEffect } from 'react'; +import { capture } from "../lib/posthog"; + +export enum SharingPolicy { + PRIVATE = 'private', + WHITELIST = 'whitelist', + PUBLIC = 'public', +} export interface Tab { id: string; title: string; + ownerId: string; + sharingPolicy: SharingPolicy; createdAt: string; updatedAt: string; } @@ -24,6 +33,8 @@ interface UserResponse { pads: { id: string; display_name: string; + owner_id: string; + sharing_policy: string; created_at: string; updated_at: string; }[]; @@ -49,6 +60,8 @@ const fetchUserPads = async (): Promise => { const tabs = userData.pads.map(pad => ({ id: pad.id, title: pad.display_name, + ownerId: pad.owner_id, + sharingPolicy: pad.sharing_policy as SharingPolicy, createdAt: pad.created_at, updatedAt: pad.updated_at })); @@ -62,6 +75,8 @@ const fetchUserPads = async (): Promise => { interface NewPadApiResponse { id: string; display_name: string; + owner_id: string; + sharing_policy: SharingPolicy; created_at: string; updated_at: string; } @@ -88,6 +103,8 @@ const createNewPad = async (): Promise => { return { id: newPadResponse.id, title: newPadResponse.display_name, + ownerId: newPadResponse.owner_id, + sharingPolicy: newPadResponse.sharing_policy as SharingPolicy, createdAt: newPadResponse.created_at, updatedAt: newPadResponse.updated_at, }; @@ -129,6 +146,8 @@ export const usePadTabs = () => { const tempTab: Tab = { id: tempTabId, title: 'New pad', + ownerId: '', + sharingPolicy: SharingPolicy.PRIVATE, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -321,6 +340,73 @@ export const usePadTabs = () => { }, }); + const leaveSharedPadAPI = async (padId: string): Promise => { + const response = await fetch(`/api/users/close/${padId}`, { + method: 'DELETE', + // Add headers if necessary, e.g., Authorization + }); + if (!response.ok) { + let errorMessage = 'Failed to leave shared pad.'; + try { + const errorData = await response.json(); + if (errorData && (errorData.detail || errorData.message)) { + errorMessage = errorData.detail || errorData.message; + } + } catch (e) { /* Ignore if response is not JSON */ } + throw new Error(errorMessage); + } + }; + + const leaveSharedPadMutation = useMutation({ + mutationFn: leaveSharedPadAPI, + onMutate: async (padIdToLeave) => { + await queryClient.cancelQueries({ queryKey: ['padTabs'] }); + const previousTabsResponse = queryClient.getQueryData(['padTabs']); + const previousSelectedTabId = selectedTabId; + + queryClient.setQueryData(['padTabs'], (old) => { + if (!old) return { tabs: [], activeTabId: '' }; + const newTabs = old.tabs.filter(tab => tab.id !== padIdToLeave); + + let newSelectedTabId = selectedTabId; + if (selectedTabId === padIdToLeave) { + if (newTabs.length > 0) { + const currentIndex = old.tabs.findIndex(tab => tab.id === padIdToLeave); + newSelectedTabId = newTabs[Math.max(0, currentIndex - 1)]?.id || newTabs[0]?.id; + } else { + newSelectedTabId = ''; + } + setSelectedTabId(newSelectedTabId); + } + + return { + tabs: newTabs, + activeTabId: newSelectedTabId, + }; + }); + return { previousTabsResponse, previousSelectedTabId, leftPadId: padIdToLeave }; + }, + onSuccess: (data, padId, context) => { + const tabLeft = context?.previousTabsResponse?.tabs.find(t => t.id === context.leftPadId); + if (typeof capture !== 'undefined') { + capture("pad_left", { padId: context.leftPadId, padName: tabLeft?.title || "" }); + } + console.log(`Pad ${context.leftPadId} left successfully.`); + }, + onError: (err, padId, context) => { + if (context?.previousTabsResponse) { + queryClient.setQueryData(['padTabs'], context.previousTabsResponse); + } + if (context?.previousSelectedTabId) { + setSelectedTabId(context.previousSelectedTabId); + } + alert(`Error leaving pad: ${err.message}`); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['padTabs'] }); + }, + }); + const selectTab = async (tabId: string) => { setSelectedTabId(tabId); }; @@ -338,6 +424,8 @@ export const usePadTabs = () => { isRenaming: renamePadMutation.isPending, deletePad: deletePadMutation.mutate, isDeleting: deletePadMutation.isPending, + leaveSharedPad: leaveSharedPadMutation.mutate, + isLeavingSharedPad: leaveSharedPadMutation.isPending, updateSharingPolicy: updateSharingPolicyMutation.mutate, isUpdatingSharingPolicy: updateSharingPolicyMutation.isPending, selectTab diff --git a/src/frontend/src/hooks/usePadWebSocket.ts b/src/frontend/src/hooks/usePadWebSocket.ts deleted file mode 100644 index 73a4afd..0000000 --- a/src/frontend/src/hooks/usePadWebSocket.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { useCallback, useMemo, useEffect, useState } from 'react'; -import useWebSocket, { ReadyState } from 'react-use-websocket'; -import { z } from 'zod'; -import { useAuthStatus } from './useAuthStatus'; - -export const WebSocketMessageSchema = z.object({ - type: z.string(), // e.g., "connected", "user_joined", "user_left", "pad_update", "error" - pad_id: z.string().nullable(), - timestamp: z.string().datetime({ offset: true, message: "Invalid timestamp format" }), - user_id: z.string().optional(), // ID of the user related to the event or sending the message - connection_id: z.string().optional(), // Connection ID related to the event or sending the message - data: z.any().optional(), // Payload; structure depends entirely on 'type' -}); - -// TypeScript type inferred from the Zod schema -export type WebSocketMessage = z.infer; - -const MAX_RECONNECT_ATTEMPTS = 5; -const INITIAL_RECONNECT_DELAY = 1000; // 1 second - -// For user-friendly connection status -type ConnectionStatus = 'Uninstantiated' | 'Connecting' | 'Open' | 'Closing' | 'Closed' | 'Reconnecting' | 'Failed'; - -export const usePadWebSocket = (padId: string | null) => { - const { isAuthenticated, isLoading, refetchAuthStatus, user } = useAuthStatus(); // Assuming user object has id - const [isPermanentlyDisconnected, setIsPermanentlyDisconnected] = useState(false); - const [reconnectAttemptCount, setReconnectAttemptCount] = useState(0); - - const getSocketUrl = useCallback((): string | null => { - if (!padId || padId.startsWith('temp-')) { - return null; - } - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${protocol}//${window.location.host}/ws/pad/${padId}`; - return url; - }, [padId]); - - const memoizedSocketUrl = useMemo(() => getSocketUrl(), [getSocketUrl]); - - const shouldBeConnected = useMemo(() => { - const conditionsMet = !!memoizedSocketUrl && isAuthenticated && !isLoading && !isPermanentlyDisconnected; - return conditionsMet; - }, [memoizedSocketUrl, isAuthenticated, isLoading, isPermanentlyDisconnected]); - - const { - sendMessage: librarySendMessage, - lastMessage: rawLastMessage, - readyState, - } = useWebSocket( - memoizedSocketUrl, - { - onOpen: () => { - console.debug(`[pad.ws] Connection established for pad: ${padId}`); - setIsPermanentlyDisconnected(false); - setReconnectAttemptCount(0); - }, - onClose: (event: CloseEvent) => { - console.debug(`[pad.ws] Connection closed for pad: ${padId}. Code: ${event.code}, Reason: '${event.reason}'`); - if (isAuthenticated === undefined && !isLoading) { - console.debug('[pad.ws] Auth status unknown on close, attempting to refetch auth status.'); - refetchAuthStatus(); - } - }, - onError: (event: Event) => { - console.error(`[pad.ws] WebSocket error for pad: ${padId}:`, event); - }, - shouldReconnect: (closeEvent: CloseEvent) => { - const isAbnormalClosure = closeEvent.code !== 1000 && closeEvent.code !== 1001; - const conditionsStillMetForConnection = !!getSocketUrl() && isAuthenticated && !isLoading; - - if (isAbnormalClosure && !conditionsStillMetForConnection && isAuthenticated === undefined && !isLoading) { - console.debug(`[pad.ws] Abnormal closure for pad ${padId}, auth status unknown. Refetching auth before deciding on reconnect.`); - refetchAuthStatus(); - } - - const decision = isAbnormalClosure && conditionsStillMetForConnection && !isPermanentlyDisconnected; - if (decision) { - setReconnectAttemptCount(prev => prev + 1); - } - console.debug( - `[pad.ws] shouldReconnect for pad ${padId}: ${decision} (Abnormal: ${isAbnormalClosure}, ConditionsMet: ${conditionsStillMetForConnection}, PermanentDisconnect: ${isPermanentlyDisconnected}, Code: ${closeEvent.code})` - ); - return decision; - }, - reconnectAttempts: MAX_RECONNECT_ATTEMPTS, - reconnectInterval: (attemptNumber: number) => { - const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attemptNumber); - console.debug( - `[pad.ws] Reconnecting attempt ${attemptNumber + 1}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms for pad: ${padId}` - ); - return delay; - }, - onReconnectStop: (numAttempts: number) => { - console.warn(`[pad.ws] Failed to reconnect to pad ${padId} after ${numAttempts} attempts. Stopping.`); - setIsPermanentlyDisconnected(true); - setReconnectAttemptCount(numAttempts); // Store the final attempt count - }, - }, - shouldBeConnected - ); - - const lastJsonMessage = useMemo((): WebSocketMessage | null => { - if (rawLastMessage && rawLastMessage.data) { - try { - const parsedData = JSON.parse(rawLastMessage.data as string); - const validationResult = WebSocketMessageSchema.safeParse(parsedData); - if (validationResult.success) { - return validationResult.data; - } else { - console.error(`[pad.ws] Incoming message validation failed for pad ${padId}:`, validationResult.error.issues); - console.error(`[pad.ws] Raw message: ${rawLastMessage.data}`); - return null; - } - } catch (error) { - console.error(`[pad.ws] Error parsing incoming JSON message for pad ${padId}:`, error); - return null; - } - } - return null; - }, [rawLastMessage, padId]); - - const sendJsonMessage = useCallback((payload: WebSocketMessage) => { - const validationResult = WebSocketMessageSchema.safeParse(payload); - if (!validationResult.success) { - console.error(`[pad.ws] Outgoing message validation failed for pad ${padId}:`, validationResult.error.issues); - return; - } - librarySendMessage(JSON.stringify(payload)); - }, [padId, librarySendMessage]); - - // Simplified sendMessage wrapper. - // The 'type' parameter dictates the message type. - // The 'data' parameter is the payload for the 'data' field in WebSocketMessage. - const sendMessage = useCallback((type: string, messageData?: any) => { - const messagePayload: WebSocketMessage = { - type, - pad_id: padId, - timestamp: new Date().toISOString(), // Ensure ISO 8601 with Z or offset - user_id: user?.id, // Add sender's user_id if available - // connection_id is typically set by the server or known on connection, - // client might not need to send it explicitly unless for specific cases. - data: messageData, - }; - - console.debug(`[pad.ws] Sending message of type: ${messagePayload.type}`); - sendJsonMessage(messagePayload); - }, [padId, sendJsonMessage, user?.id]); - - useEffect(() => { - if (lastJsonMessage) { - console.debug(`[pad.ws] Received message of type: ${lastJsonMessage?.type}`); - } - }, [lastJsonMessage, padId]); - - const connectionStatus = useMemo((): ConnectionStatus => { - if (isPermanentlyDisconnected) return 'Failed'; - - switch (readyState) { - case ReadyState.UNINSTANTIATED: - return 'Uninstantiated'; - case ReadyState.CONNECTING: - if (reconnectAttemptCount > 0 && reconnectAttemptCount < MAX_RECONNECT_ATTEMPTS && !isPermanentlyDisconnected) { - return 'Reconnecting'; - } - return 'Connecting'; - case ReadyState.OPEN: - return 'Open'; - case ReadyState.CLOSING: - return 'Closing'; - case ReadyState.CLOSED: - if (shouldBeConnected && reconnectAttemptCount > 0 && reconnectAttemptCount < MAX_RECONNECT_ATTEMPTS && !isPermanentlyDisconnected) { - return 'Reconnecting'; - } - return 'Closed'; - default: - return 'Uninstantiated'; - } - }, [readyState, isPermanentlyDisconnected, reconnectAttemptCount, shouldBeConnected]); - - return { - sendMessage, - sendJsonMessage, // Kept for sending pre-formed WebSocketMessage objects - lastJsonMessage, - rawLastMessage, - readyState, - connectionStatus, - isPermanentlyDisconnected, - }; -}; diff --git a/src/frontend/src/lib/collab/Collab.tsx b/src/frontend/src/lib/collab/Collab.tsx new file mode 100644 index 0000000..872e6aa --- /dev/null +++ b/src/frontend/src/lib/collab/Collab.tsx @@ -0,0 +1,561 @@ +import React, { PureComponent } from 'react'; +import type { ExcalidrawImperativeAPI, AppState, SocketId, Collaborator as ExcalidrawCollaboratorType } from '@atyrode/excalidraw/types'; +import type { ExcalidrawElement as ExcalidrawElementType } from '@atyrode/excalidraw/element/types'; +import { + viewportCoordsToSceneCoords, + getSceneVersion, + reconcileElements, + restoreElements, + getVisibleSceneBounds, + zoomToFitBounds +} from '@atyrode/excalidraw'; +import throttle from 'lodash.throttle'; + +import Portal from './Portal'; +import type { WebSocketMessage, ConnectionStatus } from './Portal'; +import type { UserInfo } from '../../hooks/useAuthStatus'; + +interface PointerData { + x: number; + y: number; + tool: 'laser' | 'pointer'; + button?: 'up' | 'down'; +} + +export interface Collaborator { + id: SocketId; + pointer?: PointerData; + button?: 'up' | 'down'; + selectedElementIds?: AppState['selectedElementIds']; + username?: string; + userState?: 'active' | 'away' | 'idle'; + color?: { background: string; stroke: string }; + avatarUrl?: string; +} + +const getRandomCollaboratorColor = () => { + const colors = [ + { background: "#5C2323", stroke: "#FF6B6B" }, { background: "#1E4620", stroke: "#6BCB77" }, + { background: "#1A3A5F", stroke: "#4F9CF9" }, { background: "#5F4D1C", stroke: "#FFC83D" }, + { background: "#3A1E5C", stroke: "#C56CF0" }, { background: "#5F3A1C", stroke: "#FF9F43" }, + { background: "#1E4647", stroke: "#5ECED4" }, { background: "#4E1A3A", stroke: "#F368BC" }, + ]; + return colors[Math.floor(Math.random() * colors.length)]; +}; + +interface CollabProps { + excalidrawAPI: ExcalidrawImperativeAPI | null; + user: UserInfo | null; + isOnline: boolean; + isLoadingAuth: boolean; + padId: string | null; +} + +interface CollabState { + errorMessage: string | null; + connectionStatus: ConnectionStatus; + username: string; + collaborators: Map; + lastProcessedSceneVersion: number; +} + +const POINTER_MOVE_THROTTLE_MS = 20; +const RELAY_VIEWPORT_BOUNDS_THROTTLE_MS = 50; + +class Collab extends PureComponent { + [x: string]: any; + readonly state: CollabState; + private portal: Portal; + + private throttledOnPointerMove: any; + private unsubExcalidrawPointerDown: (() => void) | null = null; + private unsubExcalidrawPointerUp: (() => void) | null = null; + private unsubExcalidrawSceneChange: (() => void) | null = null; + private unsubExcalidrawScrollChange: (() => void) | null = null; + private unsubExcalidrawUserFollow: (() => void) | null = null; + private throttledRelayViewportBounds: any; + private lastBroadcastedSceneVersion: number = -1; + + props: any; + + constructor(props: CollabProps) { + super(props); + this.state = { + errorMessage: null, + connectionStatus: 'Uninstantiated', + username: props.user?.username || props.user?.id || '', + collaborators: new Map(), + lastProcessedSceneVersion: -1, // Version of the scene after applying remote changes + }; + + this.portal = new Portal( + this, + props.padId, + props.user, + props.isOnline, // Passing isOnline as isAuthenticated + props.isLoadingAuth, + this.handlePortalStatusChange, + this.handlePortalMessage + ); + + this.throttledOnPointerMove = throttle((event: PointerEvent) => { + this.handlePointerMove(event); + }, POINTER_MOVE_THROTTLE_MS); + + this.throttledRelayViewportBounds = throttle(() => { + this.relayViewportBounds(); + }, RELAY_VIEWPORT_BOUNDS_THROTTLE_MS); // Throttle time 50ms, adjust as needed + } + + /* Component Lifecycle */ + + componentDidMount() { + if (this.portal) { + this.portal.initiate(); + } + + if (this.props.user) { + this.updateUsername(this.props.user); + } + this.updateExcalidrawCollaborators(); // Initial update for collaborators + this.addPointerEventListeners(); + this.addSceneChangeListeners(); + this.addScrollChangeListener(); + this.addFollowListener(); + + // Initialize lastBroadcastedSceneVersion + if (this.props.excalidrawAPI) { + const initialElements = this.props.excalidrawAPI.getSceneElementsIncludingDeleted(); + // Set initial broadcast version. + this.lastBroadcastedSceneVersion = getSceneVersion(initialElements); + // Also set the initial processed version from local state + this.setState({lastProcessedSceneVersion: this.lastBroadcastedSceneVersion}); + } + if (this.props.isOnline && this.props.padId) { + // Potentially call a method to broadcast initial scene if this client is the first or needs to sync + // this.broadcastFullSceneUpdate(true); // Example: true for SCENE_INIT + } + } + + componentDidUpdate(prevProps: CollabProps, prevState: CollabState) { + if ( + this.props.user !== prevProps.user || + this.props.isOnline !== prevProps.isOnline || + this.props.isLoadingAuth !== prevProps.isLoadingAuth + ) { + this.updateUsername(this.props.user); // Update username if user object changed + this.portal.updateAuthInfo(this.props.user, this.props.isOnline, this.props.isLoadingAuth); + } + + if (this.props.padId !== prevProps.padId) { + // Portal's updatePadId will handle disconnection from old and connection to new + this.portal.updatePadId(this.props.padId); + this.setState({ + collaborators: new Map(), + lastProcessedSceneVersion: -1, + username: this.props.user?.username || this.props.user?.id || '', + // connectionStatus will be updated by portal's callbacks + }); + this.lastBroadcastedSceneVersion = -1; + + // Listeners might need to be re-evaluated if excalidrawAPI instance changes with padId + // For now, assuming excalidrawAPI is stable or re-bound by parent + if (this.props.excalidrawAPI) { + const initialElements = this.props.excalidrawAPI.getSceneElementsIncludingDeleted(); + this.lastBroadcastedSceneVersion = getSceneVersion(initialElements); + this.setState({ lastProcessedSceneVersion: this.lastBroadcastedSceneVersion }); + } + } + + if (this.state.collaborators !== prevState.collaborators) { + if (this.updateExcalidrawCollaborators) this.updateExcalidrawCollaborators(); + } + } + + componentWillUnmount() { + this.portal.closePortal(); // Changed from close() + this.removePointerEventListeners(); + if (this.throttledOnPointerMove && typeof this.throttledOnPointerMove.cancel === 'function') { + this.throttledOnPointerMove.cancel(); + } + if (this.throttledRelayViewportBounds && typeof this.throttledRelayViewportBounds.cancel === 'function') { + this.throttledRelayViewportBounds.cancel(); + } + this.removeSceneChangeListeners(); + this.removeScrollChangeListener(); + this.removeFollowListener(); + } + + /* Pointer */ + + private addPointerEventListeners = () => { + if (!this.props.excalidrawAPI) return; + document.addEventListener('pointermove', this.throttledOnPointerMove); + this.unsubExcalidrawPointerDown = this.props.excalidrawAPI.onPointerDown( + (_activeTool, _pointerDownState, event) => this.handlePointerInteraction('down', event) + ); + this.unsubExcalidrawPointerUp = this.props.excalidrawAPI.onPointerUp( + (_activeTool, _pointerUpState, event) => this.handlePointerInteraction('up', event) + ); + }; + + private removePointerEventListeners = () => { + document.removeEventListener('pointermove', this.throttledOnPointerMove); + if (this.unsubExcalidrawPointerDown) this.unsubExcalidrawPointerDown(); + if (this.unsubExcalidrawPointerUp) this.unsubExcalidrawPointerUp(); + this.unsubExcalidrawPointerDown = null; + this.unsubExcalidrawPointerUp = null; + }; + + private handlePointerInteraction = (button: 'down' | 'up', event: MouseEvent | PointerEvent) => { + if (!this.props.excalidrawAPI || !this.portal.isOpen() || !this.props.isOnline) return; + const appState = this.props.excalidrawAPI.getAppState(); + const sceneCoords = viewportCoordsToSceneCoords({ clientX: event.clientX, clientY: event.clientY }, appState); + const currentTool = appState.activeTool.type; + const displayTool: 'laser' | 'pointer' = currentTool === 'laser' ? 'laser' : 'pointer'; + const pointerData: PointerData = { x: sceneCoords.x, y: sceneCoords.y, tool: displayTool, button: button }; + this.portal.broadcastMouseLocation(pointerData, button); + }; + + private handlePointerMove = (event: PointerEvent) => { + if (!this.props.excalidrawAPI || !this.portal.isOpen() || !this.props.isOnline) return; + const appState = this.props.excalidrawAPI.getAppState(); + const sceneCoords = viewportCoordsToSceneCoords({ clientX: event.clientX, clientY: event.clientY }, appState); + const currentTool = appState.activeTool.type; + const displayTool: 'laser' | 'pointer' = currentTool === 'laser' ? 'laser' : 'pointer'; + const pointerData: PointerData = { x: sceneCoords.x, y: sceneCoords.y, tool: displayTool }; + this.portal.broadcastMouseLocation(pointerData, appState.cursorButton || 'up'); + }; + + /* Followers */ + + private addFollowListener = () => { + if (!this.props.excalidrawAPI) return; + this.unsubExcalidrawUserFollow = this.props.excalidrawAPI.onUserFollow( + (payload) => { + const socketIdToFollow = payload.userToFollow.socketId; + const action = payload.action; + console.log(`[pad.ws] Request to ${action} socket id: ${socketIdToFollow}`); + + if (action === 'FOLLOW' && socketIdToFollow && this.portal.isOpen()) { + this.portal.requestFollowUser(socketIdToFollow); + } else if (action === 'UNFOLLOW' && socketIdToFollow && this.portal.isOpen()) { + this.portal.requestUnfollowUser(socketIdToFollow); + } + } + ); + }; + + private removeFollowListener = () => { + if (this.unsubExcalidrawUserFollow) { + this.unsubExcalidrawUserFollow(); + this.unsubExcalidrawUserFollow = null; + } + }; + + private addScrollChangeListener = () => { + if (!this.props.excalidrawAPI) return; + this.unsubExcalidrawScrollChange = this.props.excalidrawAPI.onScrollChange( + this.throttledRelayViewportBounds + ); + }; + + private removeScrollChangeListener = () => { + if (this.unsubExcalidrawScrollChange) { + this.unsubExcalidrawScrollChange(); + this.unsubExcalidrawScrollChange = null; + } + }; + + private isBeingFollowed = (): boolean => { + // This is a placeholder. Actual implementation depends on how `followedBy` is managed. + // For Excalidraw, it's often `this.props.excalidrawAPI?.getAppState().followedBy.size > 0` + // You need to ensure `appState.followedBy` is populated correctly. + // For now, let's assume if there's a user to follow, someone might be following this client. + // This logic needs to be robust based on your app's state management for `followedBy`. + const appState = this.props.excalidrawAPI?.getAppState(); + return !!(appState && appState.followedBy && appState.followedBy.size > 0); + }; + + private relayViewportBounds = () => { + if (!this.props.excalidrawAPI || !this.portal.isOpen() || !this.props.isOnline) { + return; + } + const appState = this.props.excalidrawAPI.getAppState(); + + if (this.isBeingFollowed()) { + const bounds = getVisibleSceneBounds(appState); + this.portal.broadcastUserViewportUpdate(bounds); + } + }; + + /* Scene */ + + private addSceneChangeListeners = () => { + if (!this.props.excalidrawAPI) return; + // The onChange callback from Excalidraw provides elements and appState, + // but we'll fetch the latest scene directly to ensure we have deleted elements for versioning. + this.unsubExcalidrawSceneChange = this.props.excalidrawAPI.onChange( + () => { + this.handleSceneChange(); + } + ); + }; + + private removeSceneChangeListeners = () => { + if (this.unsubExcalidrawSceneChange) { + this.unsubExcalidrawSceneChange(); + this.unsubExcalidrawSceneChange = null; + } + }; + + private handleSceneChange = () => { + if (!this.props.excalidrawAPI || !this.portal.isOpen() || !this.props.isOnline) { + return; + } + + const allCurrentElements = this.props.excalidrawAPI.getSceneElementsIncludingDeleted(); + const currentSceneVersion = getSceneVersion(allCurrentElements); + + // Avoid broadcasting if: + // 1. The scene version hasn't actually increased from what this client last broadcasted. + // 2. The scene version isn't newer than what this client last processed from a remote update (prevents echo). + if (currentSceneVersion > this.lastBroadcastedSceneVersion && currentSceneVersion > this.state.lastProcessedSceneVersion) { + // Send all elements (including deleted) as Excalidraw's reconcile function handles this. + // The `false` indicates it's not a full sync (SCENE_INIT), but a regular update. + this.portal.broadcastSceneUpdate('SCENE_UPDATE', allCurrentElements, false); + this.lastBroadcastedSceneVersion = currentSceneVersion; + } else if (currentSceneVersion <= this.lastBroadcastedSceneVersion && currentSceneVersion > this.state.lastProcessedSceneVersion) { + // This case can happen if an undo/redo operation results in a scene version that was previously broadcasted + // but is newer than the last processed remote scene. We should still broadcast. + // Or, if a remote update was processed, and then a local action (like selection) triggers onChange + // without changing element versions, but we want to ensure our state is robust. + // For simplicity now, we only broadcast if strictly newer than last broadcast. + // More nuanced logic could be added if specific scenarios require it. + // console.log("Scene version not strictly greater than last broadcasted, but greater than last processed. Potentially an echo or no new element changes to broadcast."); + } + }; + + /* Collaborators */ + + private updateUsername = (user: UserInfo | null) => { + const newUsername = user?.username || user?.id || ""; + if (this.state.username !== newUsername) { + this.setState({ username: newUsername }); + } + }; + + private updateExcalidrawCollaborators = () => { + if (!this.props.excalidrawAPI) return; + const excalidrawCollaborators = new Map(); + if (this.props.isOnline) { + this.state.collaborators.forEach((collab, id) => { + if (this.props.user && this.props.user.id === collab.id) return; + + excalidrawCollaborators.set(id, { + id: collab.id, + pointer: collab.pointer, + username: collab.username, + button: collab.button, + selectedElementIds: + collab.selectedElementIds, + color: collab.color, + avatarUrl: collab.avatarUrl, + }); + }); + } + this.props.excalidrawAPI.updateScene({ collaborators: excalidrawCollaborators }); + }; + + /* Portal & Core logic */ + + handlePortalStatusChange = (status: ConnectionStatus, message?: string) => { + this.setState({ connectionStatus: status }); + // Potentially update UI or take actions based on status + if (status === 'Failed' || (status === 'Closed' && !this.portal.isOpen())) { + // Clear collaborators if connection is definitively lost + this.setState({ collaborators: new Map() }, () => { + if (this.updateExcalidrawCollaborators) this.updateExcalidrawCollaborators(); + }); + } + }; + + public handlePortalMessage = (message: WebSocketMessage) => { + const { type, connection_id, user_id, data: messageData } = message; + const senderIdString = connection_id || user_id; + + if (this.props.user?.id && senderIdString === this.props.user.id) return; + if (!senderIdString) return; + const senderId = senderIdString as SocketId; + + switch (type) { + case 'user_joined': { + const username = messageData?.username || senderIdString; + console.debug(`[pad.ws] User joined: ${username}`); + this.setState(prevState => { + if (prevState.collaborators.has(senderId) || (this.props.user?.id && senderIdString === this.props.user.id)) return null; + const newCollaborator: Collaborator = { + id: user_id as SocketId, + username: username, + pointer: { x: 0, y: 0, tool: 'pointer' }, + color: getRandomCollaboratorColor(), + userState: 'active', + }; + const newCollaborators = new Map(prevState.collaborators); + newCollaborators.set(user_id as SocketId, newCollaborator); + return { collaborators: newCollaborators }; + }); + break; + } + case 'user_left': { + console.debug(`[pad.ws] User left: ${user_id}`); + this.setState(prevState => { + if (!prevState.collaborators.has(user_id as SocketId) || (this.props.user?.id && user_id === this.props.user.id)) return null; + const newCollaborators = new Map(prevState.collaborators); + newCollaborators.delete(user_id as SocketId); + return { collaborators: newCollaborators }; + }); + break; + } + case 'pointer_update': { + if (!messageData?.pointer) return; + const pointerDataIn = messageData.pointer as PointerData; + if (messageData.button) pointerDataIn.button = messageData.button; + this.setState(prevState => { + const newCollaborators = new Map(prevState.collaborators); + const existing = newCollaborators.get(user_id); + const updatedCollaborator: Collaborator = { + ...(existing as Collaborator), + pointer: pointerDataIn, + button: pointerDataIn.button + }; + newCollaborators.set(user_id, updatedCollaborator); + return { collaborators: newCollaborators }; + }); + break; + } + case 'scene_update': { + const remoteElements = messageData?.elements as ExcalidrawElementType[] | undefined; + + if (remoteElements !== undefined && this.props.excalidrawAPI) { + const localElements = this.props.excalidrawAPI.getSceneElementsIncludingDeleted(); + const currentAppState = this.props.excalidrawAPI.getAppState(); + + // Ensure elements are properly restored (e.g., if they are plain objects from JSON) + const restoredRemoteElements = restoreElements(remoteElements, null); + + const reconciled = reconcileElements( + localElements, + restoredRemoteElements as any[], // Cast as any if type conflicts, ensure it matches Excalidraw's expected RemoteExcalidrawElement[] + currentAppState + ); + + this.props.excalidrawAPI.updateScene({ elements: reconciled as ExcalidrawElementType[], commitToHistory: false }); + this.setState({ lastProcessedSceneVersion: getSceneVersion(reconciled) }); + } + break; + } + case 'viewport_update': { + if (!messageData?.bounds || !this.props.excalidrawAPI) return; + + const remoteBounds = messageData.bounds; + const currentAppState = this.props.excalidrawAPI.getAppState(); + + // Ensure userToFollow and its id property exist + // Assuming senderId is the user_id of the one whose viewport is being sent + if (currentAppState.userToFollow && typeof currentAppState.userToFollow.id === 'string' && currentAppState.userToFollow.id === senderId) { + const newAppStateResult = zoomToFitBounds({ + appState: currentAppState, + bounds: remoteBounds, + fitToViewport: true, + viewportZoomFactor: currentAppState.zoom.value, + }); + + this.props.excalidrawAPI.updateScene({ + appState: newAppStateResult.appState, + commitToHistory: false, // Viewport changes usually don't go into history + }); + } + break; + } + case 'user_started_following': { + if (!messageData || !this.props.excalidrawAPI || !this.props.user) return; + const { followerId, followedUserId } = messageData as { followerId: string, followedUserId: string }; + + console.log(`[pad.ws] User ${followerId} started following ${followedUserId}`); + console.log(`[pad.ws] My user profile`, this.props.user); + console.log(`[pad.ws] My socket id: ${this.props.connection_id}, my user id: ${this.props.user.id}, my connection id: ${this.props.user.connection_id}`); + console.log(`[pad.ws] Am I the one being followed? ${this.props.socketId === followedUserId}`); + + if (this.props.connection_id === followedUserId) { + const currentAppState = this.props.excalidrawAPI.getAppState(); + const newFollowedBy = new Set(currentAppState.followedBy || []); + newFollowedBy.add(followerId); + this.props.excalidrawAPI.updateScene({ + appState: { ...currentAppState, followedBy: newFollowedBy }, + commitToHistory: false + }); + // If this client is the one being followed, immediately send its viewport + this.relayViewportBounds(); + } + break; + } + case 'user_stopped_following': { + if (!messageData || !this.props.excalidrawAPI || !this.props.user) return; + const { unfollowerId, unfollowedUserId } = messageData as { unfollowerId: string, unfollowedUserId: string }; + console.log(`[pad.ws] User ${unfollowerId} stopped following ${unfollowedUserId}`); + if (this.props.connection_id === unfollowedUserId) { + const currentAppState = this.props.excalidrawAPI.getAppState(); + const newFollowedBy = new Set(currentAppState.followedBy || []); + newFollowedBy.delete(unfollowerId); + this.props.excalidrawAPI.updateScene({ + appState: { ...currentAppState, followedBy: newFollowedBy }, + commitToHistory: false + }); + } + break; + } + case 'connected': { + const collaboratorsList = messageData?.collaboratorsList as any | undefined; + + if (collaboratorsList && Array.isArray(collaboratorsList)) { + console.debug(`[pad.ws] Received 'connected' message with ${collaboratorsList.length} collaborators.`); + this.setState(prevState => { + const newCollaborators = new Map(); + collaboratorsList.forEach(collabData => { + + console.debug(`[pad.ws] Collaborator data: ${JSON.stringify(collabData)}`); + if (collabData.user_id && collabData.user_id !== this.props.user?.id) { + + const newCollaborator: Collaborator = { + id: collabData.user_id as SocketId, + username: collabData.username, + pointer: collabData.pointer || { x: 0, y: 0, tool: 'pointer' }, + button: collabData.button || 'up', + selectedElementIds: collabData.selectedElementIds || {}, + userState: collabData.userState || 'active', + color: collabData.color || getRandomCollaboratorColor(), + avatarUrl: collabData.avatarUrl || '', + }; + newCollaborators.set(collabData.user_id as SocketId, newCollaborator); + } + }); + + return { collaborators: newCollaborators }; + }); + } else { + console.warn("[pad.ws] 'connected' message received without valid collaboratorsList.", messageData); + } + break; + } + default: + console.warn(`Unknown message type received: ${type}`, messageData); + } + }; + + render() { + return null; + } +} + +export default Collab; diff --git a/src/frontend/src/lib/collab/Portal.tsx b/src/frontend/src/lib/collab/Portal.tsx new file mode 100644 index 0000000..b1f9ab2 --- /dev/null +++ b/src/frontend/src/lib/collab/Portal.tsx @@ -0,0 +1,362 @@ +import { z } from 'zod'; +import type Collab from './Collab'; +import type { OrderedExcalidrawElement } from '@atyrode/excalidraw/element/types'; +import type { UserInfo } from '../../hooks/useAuthStatus'; // For user details + +export const WebSocketMessageSchema = z.object({ + type: z.string(), + pad_id: z.string().nullable(), + timestamp: z.string().datetime({ offset: true, message: "Invalid timestamp format" }), + user_id: z.string().optional(), + connection_id: z.string().optional(), + data: z.any().optional(), +}); +export type WebSocketMessage = z.infer; + +export type ConnectionStatus = 'Uninstantiated' | 'Connecting' | 'Open' | 'Closing' | 'Closed' | 'Reconnecting' | 'Failed'; + +const MAX_RECONNECT_ATTEMPTS = 5; +const INITIAL_RECONNECT_DELAY = 1000; // 1 second + +class Portal { + private collab: Collab; + private socket: WebSocket | null = null; + private roomId: string | null = null; // This will be the padId + + // Auth and connection state + private user: UserInfo | null = null; + private isAuthenticated: boolean = false; + private isLoadingAuth: boolean = true; // Start with true until first auth update + + private reconnectAttemptCount: number = 0; + private isPermanentlyDisconnected: boolean = false; + private reconnectTimeoutId: ReturnType | null = null; + private currentConnectionStatus: ConnectionStatus = 'Uninstantiated'; + + // Callback for Collab to react to status changes + private onStatusChange: ((status: ConnectionStatus, message?: string) => void) | null = null; + private onMessage: ((message: WebSocketMessage) => void) | null = null; + + + broadcastedElementVersions: Map = new Map(); + + constructor( + collab: Collab, + padId: string | null, + user: UserInfo | null, + isAuthenticated: boolean, + isLoadingAuth: boolean, + onStatusChange?: (status: ConnectionStatus, message?: string) => void, + onMessage?: (message: WebSocketMessage) => void, + ) { + this.collab = collab; + this.roomId = padId; + this.user = user; + this.isAuthenticated = isAuthenticated; + this.isLoadingAuth = isLoadingAuth; + if (onStatusChange) this.onStatusChange = onStatusChange; + if (onMessage) this.onMessage = onMessage; } + + public initiate(): void { + if (!this.onStatusChange && (this.roomId || this.currentConnectionStatus !== 'Uninstantiated')) { + console.warn("[Portal] initiate called before onStatusChange callback was set, or logic error."); + } + + if (this.roomId) { + this.connect(); + } else { + // If Collab's initial state.connectionStatus is 'Uninstantiated' + // and Portal's this.currentConnectionStatus is also 'Uninstantiated' (its default), + // this call to _updateStatus will not trigger onStatusChange due to the + // "if (this.currentConnectionStatus !== status)" check within _updateStatus. + // This is safe and ensures status consistency if padId is null. + this._updateStatus('Uninstantiated'); + } + } + + private _updateStatus(status: ConnectionStatus, message?: string) { + if (this.currentConnectionStatus !== status) { + this.currentConnectionStatus = status; + console.debug(`[pad.ws] Status changed to: ${status}${message ? ` (${message})` : ''}`); + if (this.onStatusChange) { + this.onStatusChange(status, message); + } + } + } + + public getStatus(): ConnectionStatus { + return this.currentConnectionStatus; + } + + private getSocketUrl(): string | null { + if (!this.roomId || this.roomId.startsWith('temp-')) { + return null; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws/pad/${this.roomId}`; + } + + private shouldBeConnected(): boolean { + return !!this.getSocketUrl() && this.isAuthenticated && !this.isLoadingAuth && !this.isPermanentlyDisconnected; + } + + public connect(): void { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + console.debug('[pad.ws] Already connected.'); + return; + } + if (this.socket && this.socket.readyState === WebSocket.CONNECTING) { + console.debug('[pad.ws] Already connecting.'); + return; + } + + if (this.reconnectTimeoutId) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = null; + } + + if (!this.shouldBeConnected()) { + console.debug('[pad.ws] Conditions not met for connection.'); + this._updateStatus(this.isPermanentlyDisconnected ? 'Failed' : 'Closed'); + return; + } + + const socketUrl = this.getSocketUrl(); + if (!socketUrl) { + console.error('[pad.ws] Cannot connect: Socket URL is invalid.'); + this._updateStatus('Failed', 'Invalid URL'); + return; + } + + this._updateStatus(this.reconnectAttemptCount > 0 ? 'Reconnecting' : 'Connecting'); + console.debug(`[pad.ws] Attempting to connect to: ${socketUrl}`); + this.socket = new WebSocket(socketUrl); + + this.socket.onopen = () => { + console.debug(`[pad.ws] Connection established for pad: ${this.roomId}`); + this.isPermanentlyDisconnected = false; + this.reconnectAttemptCount = 0; + if (this.reconnectTimeoutId) clearTimeout(this.reconnectTimeoutId); + this._updateStatus('Open'); + }; + + this.socket.onmessage = (event: MessageEvent) => { + try { + const parsedData = JSON.parse(event.data as string); + const validationResult = WebSocketMessageSchema.safeParse(parsedData); + if (validationResult.success) { + if (this.onMessage) { + this.onMessage(validationResult.data); + } else { + // Fallback to direct call if onMessage prop not set by Collab + this.collab.handlePortalMessage(validationResult.data); + } + } else { + console.error(`[pad.ws] Incoming message validation failed for pad ${this.roomId}:`, validationResult.error.issues); + console.error(`[pad.ws] Raw message: ${event.data}`); + } + } catch (error) { + console.error(`[pad.ws] Error parsing incoming JSON message for pad ${this.roomId}:`, error); + } + }; + + this.socket.onclose = (event: CloseEvent) => { + console.debug(`[pad.ws] Connection closed for pad: ${this.roomId}. Code: ${event.code}, Reason: '${event.reason}'`); + this.socket = null; // Clear the socket instance + + const isAbnormalClosure = event.code !== 1000 && event.code !== 1001; // 1000 = Normal, 1001 = Going Away + + if (this.isPermanentlyDisconnected) { + this._updateStatus('Failed', `Permanently disconnected. Code: ${event.code}`); + return; + } + + if (isAbnormalClosure && this.shouldBeConnected()) { + this.reconnectAttemptCount++; + if (this.reconnectAttemptCount > MAX_RECONNECT_ATTEMPTS) { + console.warn(`[pad.ws] Failed to reconnect to pad ${this.roomId} after ${this.reconnectAttemptCount -1} attempts. Stopping.`); + this.isPermanentlyDisconnected = true; + this._updateStatus('Failed', `Max reconnect attempts reached.`); + } else { + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttemptCount -1); + console.debug(`[pad.ws] Reconnecting attempt ${this.reconnectAttemptCount}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms for pad: ${this.roomId}`); + this._updateStatus('Reconnecting', `Attempt ${this.reconnectAttemptCount}`); + this.reconnectTimeoutId = setTimeout(() => this.connect(), delay); + } + } else { + this._updateStatus('Closed', `Code: ${event.code}`); + } + }; + + this.socket.onerror = (event: Event) => { + console.error(`[pad.ws] WebSocket error for pad: ${this.roomId}:`, event); + this._updateStatus('Failed', 'WebSocket error'); + }; + } + + public disconnect(): void { + console.debug(`[pad.ws] Disconnecting from pad: ${this.roomId}`); + this.isPermanentlyDisconnected = true; // Mark intent to disconnect this session + + if (this.reconnectTimeoutId) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = null; + } + + const socketToClose = this.socket; // Capture the current socket reference + this.socket = null; // Nullify the instance's main socket reference immediately + + if (socketToClose) { + // Detach all handlers from the old socket. + // This is crucial to prevent its onclose (and other) handlers from + // executing and potentially interfering with the state of the Portal instance, + // which is now focused on a new connection or a definitive closed state. + socketToClose.onopen = null; + socketToClose.onmessage = null; + socketToClose.onclose = null; // <--- Key change: prevent our generic onclose + socketToClose.onerror = null; + + // Only attempt to close if it's in a state that can be closed. + if (socketToClose.readyState === WebSocket.OPEN || socketToClose.readyState === WebSocket.CONNECTING) { + try { + socketToClose.close(1000, 'Client initiated disconnect'); + } catch (e) { + console.warn(`[pad.ws] Error while closing socket for pad ${this.roomId}:`, e); + } + } else { + console.debug(`[pad.ws] Socket for pad ${this.roomId} was not OPEN or CONNECTING. Current state: ${socketToClose.readyState}. No explicit close call needed.`); + } + } + + // This status update reflects the client's *action* to disconnect. + // The actual closure of the socket on the wire is handled by socketToClose.close(). + this._updateStatus('Closed', 'Client initiated disconnect'); + } + + public closePortal(): void { // Renamed from 'close' to avoid conflict with WebSocket.close + this.disconnect(); // For now, closing the portal means disconnecting. + this.roomId = null; + this.broadcastedElementVersions.clear(); + this.onStatusChange = null; + this.onMessage = null; + } + + public updatePadId(padId: string | null): void { + if (this.roomId === padId) return; + + this.disconnect(); // Disconnect from the old pad + this.roomId = padId; + this.isPermanentlyDisconnected = false; // Reset for new pad + this.reconnectAttemptCount = 0; + + if (this.roomId) { + this.connect(); + } else { + this._updateStatus('Uninstantiated'); + } + } + + public updateAuthInfo(user: UserInfo | null, isAuthenticated: boolean, isLoadingAuth: boolean): void { + const oldShouldBeConnected = this.shouldBeConnected(); + this.user = user; + this.isAuthenticated = isAuthenticated; + this.isLoadingAuth = isLoadingAuth; + const newShouldBeConnected = this.shouldBeConnected(); + + if (oldShouldBeConnected !== newShouldBeConnected) { + if (newShouldBeConnected) { + console.debug('[pad.ws] Auth state changed, attempting to connect/reconnect.'); + this.isPermanentlyDisconnected = false; // Allow reconnection attempts if auth is now valid + this.reconnectAttemptCount = 0; // Reset attempts + this.connect(); + } else { + console.debug('[pad.ws] Auth state changed, disconnecting.'); + this.disconnect(); // Disconnect if auth conditions no longer met + } + } + } + + public isOpen(): boolean { + return this.socket !== null && this.socket.readyState === WebSocket.OPEN; + } + + private sendJsonMessage(payload: WebSocketMessage): void { + if (!this.isOpen()) { + console.warn('[pad.ws] Cannot send message: WebSocket is not open.', payload.type); + return; + } + const validationResult = WebSocketMessageSchema.safeParse(payload); + if (!validationResult.success) { + console.error(`[pad.ws] Outgoing message validation failed for pad ${this.roomId}:`, validationResult.error.issues); + return; + } + this.socket?.send(JSON.stringify(payload)); + } + + public sendMessage(type: string, data?: any): void { + const messagePayload: WebSocketMessage = { + type, + pad_id: this.roomId, + timestamp: new Date().toISOString(), + user_id: this.user?.id, + data: data, + }; + console.debug(`[pad.ws] Sending message of type: ${messagePayload.type} for pad ${this.roomId}`); + this.sendJsonMessage(messagePayload); + } + + public broadcastMouseLocation = ( + pointerData: { x: number; y: number; tool: 'laser' | 'pointer' }, + button?: 'up' | 'down', + ) => { + const payload = { + pointer: pointerData, + button: button || 'up', + }; + this.sendMessage('pointer_update', payload); + }; + + public broadcastSceneUpdate = ( + updateType: 'SCENE_INIT' | 'SCENE_UPDATE', + elements: ReadonlyArray, + syncAll: boolean + ) => { + // Filtering logic based on broadcastedElementVersions would go here if not syncAll + // For now, simplified: + const payload = { + // type: updateType, // This was an Excalidraw subtype. Our backend expects a top-level type. + // The 'type' in sendMessage will be 'scene_update'. The payload contains details. + update_subtype: updateType, // To distinguish between INIT and UPDATE within the 'scene_update' message + elements: elements, + // appState: if sending app state changes + }; + + this.sendMessage('scene_update', payload); + + if (syncAll) { + this.broadcastedElementVersions.clear(); + } + elements.forEach(element => { + if (element && typeof element.id === 'string' && typeof element.version === 'number') { + this.broadcastedElementVersions.set(element.id, element.version); + } + }); + }; + + public broadcastUserViewportUpdate = (bounds: any): void => { + const payload = { + bounds: bounds, + }; + this.sendMessage('viewport_update', payload); + }; + + public requestFollowUser = (userToFollowId: string): void => { + this.sendMessage('user_follow_request', { userToFollowId }); + }; + + public requestUnfollowUser = (userToUnfollowId: string): void => { + this.sendMessage('user_unfollow_request', { userToUnfollowId }); + }; +} + +export default Portal; diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index e0dc59a..e81a3f4 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -37,9 +37,13 @@ interface TabContextMenuProps { padId: string; padName: string; onRename: (padId: string, newName: string) => void; - onDelete: (padId: string) => void; + onDelete: (padId: string) => void; // For deleting owned pads onUpdateSharingPolicy: (padId: string, policy: string) => void; onClose: () => void; + currentUserId?: string; + tabOwnerId?: string; + sharingPolicy?: string; + onLeaveSharedPad: (padId: string) => void; // For leaving shared pads } // Popover component @@ -203,22 +207,28 @@ class TabActionManager implements ActionManager { padId: string; padName: string; onRename: (padId: string, newName: string) => void; - onDelete: (padId: string) => void; + onDelete: (padId: string) => void; // For deleteOwnedPad onUpdateSharingPolicy: (padId: string, policy: string) => void; app: any; + sharingPolicy?: string; + onLeaveSharedPad: (padId: string) => void; // For leaveSharedPad constructor( padId: string, padName: string, onRename: (padId: string, newName: string) => void, - onDelete: (padId: string) => void, - onUpdateSharingPolicy: (padId: string, policy: string) => void + onDelete: (padId: string) => void, // This is for deleteOwnedPad + onUpdateSharingPolicy: (padId: string, policy: string) => void, + onLeaveSharedPad: (padId: string) => void, // Moved before optional param + sharingPolicy?: string ) { this.padId = padId; this.padName = padName; this.onRename = onRename; - this.onDelete = onDelete; + this.onDelete = onDelete; // Will be called by 'deleteOwnedPad' this.onUpdateSharingPolicy = onUpdateSharingPolicy; + this.onLeaveSharedPad = onLeaveSharedPad; // Will be called by 'leaveSharedPad' + this.sharingPolicy = sharingPolicy; this.app = { props: {} }; } @@ -228,16 +238,20 @@ class TabActionManager implements ActionManager { if (newName && newName.trim() !== '') { this.onRename(this.padId, newName); } - } else if (action.name === 'delete') { - console.debug('[pad.ws] Attempting to delete pad:', this.padId, this.padName); + } else if (action.name === 'deleteOwnedPad') { // Renamed from 'delete' + console.debug('[pad.ws] Attempting to delete owned pad:', this.padId, this.padName); if (window.confirm(`Are you sure you want to delete "${this.padName}"?`)) { console.debug('[pad.ws] User confirmed delete, calling onDelete'); - this.onDelete(this.padId); + this.onDelete(this.padId); // Calls original onDelete for owned pads } - } else if (action.name === 'setPublic') { - this.onUpdateSharingPolicy(this.padId, 'public'); - } else if (action.name === 'setPrivate') { - this.onUpdateSharingPolicy(this.padId, 'private'); + } else if (action.name === 'leaveSharedPad') { // New action for leaving + console.debug('[pad.ws] Attempting to leave shared pad:', this.padId, this.padName); + if (window.confirm(`Are you sure you want to leave "${this.padName}"? This will remove it from your list of open pads.`)) { + this.onLeaveSharedPad(this.padId); // Calls the new handler + } + } else if (action.name === 'toggleSharingPolicy') { + const newPolicy = this.sharingPolicy === 'public' ? 'private' : 'public'; + this.onUpdateSharingPolicy(this.padId, newPolicy); } else if (action.name === 'copyUrl') { const url = `${window.location.origin}/pad/${this.padId}`; navigator.clipboard.writeText(url).then(() => { @@ -258,42 +272,66 @@ const TabContextMenu: React.FC = ({ onRename, onDelete, onUpdateSharingPolicy, - onClose + onClose, + currentUserId, + tabOwnerId, + sharingPolicy, + onLeaveSharedPad // Destructure new prop }) => { + const isOwner = currentUserId && tabOwnerId && currentUserId === tabOwnerId; + const isPadPublic = sharingPolicy === 'public'; + // Create an action manager instance - const actionManager = new TabActionManager(padId, padName, onRename, onDelete, onUpdateSharingPolicy); + const actionManager = new TabActionManager(padId, padName, onRename, onDelete, onUpdateSharingPolicy, onLeaveSharedPad, sharingPolicy); // Define menu items - const menuItems = [ - { + const menuItemsResult: ContextMenuItems = []; + + if (isOwner) { + menuItemsResult.push({ name: 'rename', label: 'Rename', - predicate: () => true, - }, - CONTEXT_MENU_SEPARATOR, - { - name: 'setPublic', - label: 'Set Public', - predicate: () => true, - }, - { - name: 'setPrivate', - label: 'Set Private', - predicate: () => true, - }, - { - name: 'copyUrl', - label: 'Copy URL', - predicate: () => true, - }, - CONTEXT_MENU_SEPARATOR, - { - name: 'delete', - label: 'Delete', - predicate: () => true, - dangerous: true, + }); + // No separator needed here if toggleSharingPolicy directly follows + } + + // Always show Copy URL + menuItemsResult.push({ + name: 'copyUrl', + label: 'Copy URL', + }); + + if (isOwner) { + // Add separator if rename was added, before toggle policy + const renameItemIndex = menuItemsResult.findIndex(item => item && typeof item !== 'string' && item.name === 'rename'); + const copyUrlItemIndex = menuItemsResult.findIndex(item => item && typeof item !== 'string' && item.name === 'copyUrl'); + + if (renameItemIndex !== -1 && copyUrlItemIndex !== -1 && copyUrlItemIndex > renameItemIndex) { + menuItemsResult.splice(copyUrlItemIndex, 0, CONTEXT_MENU_SEPARATOR); + } else if (renameItemIndex !== -1 && copyUrlItemIndex === -1) { + // If copyUrl is not there for some reason, but rename is, add separator after rename + menuItemsResult.push(CONTEXT_MENU_SEPARATOR); } - ]; + + menuItemsResult.push({ + name: 'toggleSharingPolicy', + label: () => isPadPublic ? 'Set Private' : 'Set Public', + }); + } + + // Separator before delete/leave + if (menuItemsResult.length > 0 && menuItemsResult[menuItemsResult.length -1] !== CONTEXT_MENU_SEPARATOR) { + menuItemsResult.push(CONTEXT_MENU_SEPARATOR); + } + + menuItemsResult.push({ + name: !isOwner ? 'leaveSharedPad' : 'deleteOwnedPad', // Dynamically set action name + label: () => (!isOwner ? 'Leave shared pad' : 'Delete'), + dangerous: true, + }); + + const menuItems = menuItemsResult.filter(Boolean) as ContextMenuItems; + // Create a wrapper for onClose that handles the callback const handleClose = (callback?: () => void) => { @@ -314,4 +352,4 @@ const TabContextMenu: React.FC = ({ ); }; -export default TabContextMenu; \ No newline at end of file +export default TabContextMenu; diff --git a/src/frontend/src/ui/Tabs.scss b/src/frontend/src/ui/Tabs.scss index 0d944df..63c8307 100644 --- a/src/frontend/src/ui/Tabs.scss +++ b/src/frontend/src/ui/Tabs.scss @@ -47,6 +47,20 @@ font-weight: normal; } } + + /* Styles for tabs with 'public' sharing policy */ + &.tab-sharing-public { + } + + /* Styles for tabs with 'whitelist' sharing policy */ + /* &.tab-sharing-whitelist { + /* TODO: Add styles for whitelisted tabs */ + /* } */ + + /* Styles for tabs with 'private' sharing policy (default) */ + /* &.tab-sharing-private { */ + /* Default styles are applied, or add specific private styles here if needed */ + /* } */ } .tabs-wrapper { @@ -120,4 +134,4 @@ width: var(--lg-button-size) !important; } } -} \ No newline at end of file +} diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index 6735a8f..08bafb2 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -5,6 +5,7 @@ import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; import { FilePlus2, ChevronLeft, ChevronRight } from "lucide-react"; import { usePad } from "../hooks/usePadData"; +import { useAuthStatus } from "../hooks/useAuthStatus"; import type { Tab } from "../hooks/usePadTabs"; import { capture } from "../lib/posthog"; import TabContextMenu from "./TabContextMenu"; @@ -19,6 +20,7 @@ interface TabsProps { createNewPadAsync: () => Promise; renamePad: (args: { padId: string; newName: string }) => void; deletePad: (padId: string) => void; + leaveSharedPad: (padId: string) => void; // Added prop updateSharingPolicy: (args: { padId: string; policy: string }) => void; selectTab: (tabId: string) => void; } @@ -32,9 +34,11 @@ const Tabs: React.FC = ({ createNewPadAsync, renamePad, deletePad, + leaveSharedPad, // Destructure new prop updateSharingPolicy, selectTab, }) => { + const { user: currentUser } = useAuthStatus(); const { isLoading: isPadLoading, error: padError } = usePad(selectedTabId, excalidrawAPI); const [displayPadLoadingIndicator, setDisplayPadLoadingIndicator] = useState(false); @@ -223,7 +227,7 @@ const Tabs: React.FC = ({ children={ - -
-
- ) : ( -
- -
- )} -
); From 3c42cd1d794b9633b84f11a2d6293b125eab2862 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 25 May 2025 18:25:18 +0000 Subject: [PATCH 120/149] refactor: switch back to default.json for default canvas - Changed the file path from "templates/dev.json" to "templates/default.json" to ensure the correct template is loaded for default pad configuration. --- src/backend/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/config.py b/src/backend/config.py index 7043512..fe5ae23 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -35,7 +35,7 @@ OIDC_REDIRECT_URI = os.getenv('REDIRECT_URI') default_pad = {} -with open("templates/dev.json", 'r') as f: +with open("templates/default.json", 'r') as f: default_pad = json.load(f) # ===== Coder API Configuration ===== From 7f8f834fa48e9044373181d063b89a9e32dcd67b Mon Sep 17 00:00:00 2001 From: cyclotruc Date: Sun, 25 May 2025 18:28:19 +0000 Subject: [PATCH 121/149] removed unused script --- src/backend/scripts/ws_listener.py | 452 ----------------------------- 1 file changed, 452 deletions(-) delete mode 100644 src/backend/scripts/ws_listener.py diff --git a/src/backend/scripts/ws_listener.py b/src/backend/scripts/ws_listener.py deleted file mode 100644 index e5d8ade..0000000 --- a/src/backend/scripts/ws_listener.py +++ /dev/null @@ -1,452 +0,0 @@ -#!/usr/bin/env python3 -import json -import asyncio -import argparse -import signal -import os -from uuid import UUID -from datetime import datetime -import sys -from pathlib import Path -from dotenv import load_dotenv -import io - -# Add the parent directory to the path so we can import from modules -sys.path.append(str(Path(__file__).parent.parent)) - -from redis import asyncio as aioredis -from rich.console import Console -from rich.panel import Panel -from rich.text import Text -from rich.syntax import Syntax -from rich.markdown import Markdown -from rich import box -from rich.tree import Tree -from rich.json import JSON -from rich.console import RenderableType - -from textual.app import App, ComposeResult -from textual.containers import Container, ScrollableContainer -from textual.widgets import Header, Footer, Button, Static -from textual.reactive import reactive -from textual import work - -# Load environment variables from .env file -env_path = Path(__file__).parent.parent.parent.parent / '.env' -load_dotenv(dotenv_path=env_path) - -console = Console() - -# For non-Textual output during setup -setup_console = Console() - -class PadUpdateWidget(Static): - """A widget to display an individual pad update with expandable content.""" - - def __init__(self, message, **kwargs): - super().__init__("", **kwargs) - self.message = message - # Parse data field if it exists and appears to be JSON - if "data" in self.message and isinstance(self.message["data"], str): - try: - if self.message["data"].startswith("{") or self.message["data"].startswith("["): - self.message["data"] = json.loads(self.message["data"]) - except json.JSONDecodeError: - pass # Keep as string if it can't be parsed - self.expanded = False - self.update_display() - - def render_rich_tree(self, tree: Tree) -> str: - """Properly render a Rich Tree to a string.""" - # Create a string buffer and a console to render into it - string_io = io.StringIO() - temp_console = Console(file=string_io, width=100) - - # Render the tree to the string buffer - temp_console.print(tree) - - # Return the contents of the buffer - return string_io.getvalue() - - def update_display(self): - """Update the widget display based on expanded state.""" - msg_type = self.message.get("type", "unknown") - timestamp = self.message.get("timestamp", datetime.now().isoformat()) - connection_id = self.message.get("connection_id", "unknown")[:5] - user_id = self.message.get("user_id", "unknown")[:5] - - timestamp_display = timestamp.split('T')[1].split('.')[0] if 'T' in timestamp else timestamp - title = f"{msg_type} at {timestamp_display} [connection: {connection_id}, user: {user_id}]" - - content = "" - border_style = "dim" - title_style = "white" - box_type = box.SIMPLE - - if msg_type == "user_joined": - title_style = "bold green" - border_style = "green" - box_type = box.ROUNDED - content = f"User {user_id} joined the pad" - - elif msg_type == "user_left": - title_style = "bold red" - border_style = "red" - box_type = box.ROUNDED - content = f"User {user_id} left the pad" - - elif msg_type == "pad_update": - title_style = "bold blue" - border_style = "blue" - box_type = box.ROUNDED - - # Check for data field containing Excalidraw content - has_excalidraw_data = ( - "data" in self.message and - isinstance(self.message["data"], dict) and - ("elements" in self.message["data"] or "appState" in self.message["data"] or "files" in self.message["data"]) - ) - - if has_excalidraw_data: - button_text = "[▼] Show Excalidraw data" if not self.expanded else "[▲] Hide Excalidraw data" - content = f"User {user_id} updated the pad\n\n{button_text}" - - if self.expanded: - content = f"User {user_id} updated the pad\n\n{button_text}\n\n" - data = self.message["data"] - - # Create a tree to display the structure - excalidraw_tree = Tree("Excalidraw Data") - - # Elements (drawing objects) - if "elements" in data: - element_count = len(data["elements"]) - elements_branch = excalidraw_tree.add(f"[bold cyan]Elements[/] ({element_count})") - - # Show a preview of a few elements - max_elements = 3 - for i, element in enumerate(data["elements"][:max_elements]): - element_type = element.get("type", "unknown") - element_id = element.get("id", "unknown")[:8] - elements_branch.add(f"[cyan]{element_type}[/] (id: {element_id})") - - if element_count > max_elements: - elements_branch.add(f"... and {element_count - max_elements} more elements") - - # AppState (view state, settings) - if "appState" in data: - app_state = data["appState"] - app_state_branch = excalidraw_tree.add("[bold green]AppState[/]") - - # Show important appState properties - important_props = ["viewBackgroundColor", "gridSize", "zoom", "scrollX", "scrollY"] - for prop in important_props: - if prop in app_state: - app_state_branch.add(f"[green]{prop}[/]: {app_state[prop]}") - - # Show count of other properties - other_props_count = len(app_state) - len([p for p in important_props if p in app_state]) - if other_props_count > 0: - app_state_branch.add(f"... and {other_props_count} more properties") - - # Files (attached files/images) - if "files" in data: - files = data["files"] - files_count = len(files) - if files_count > 0: - files_branch = excalidraw_tree.add(f"[bold yellow]Files[/] ({files_count})") - for file_id, file_data in list(files.items())[:3]: - files_branch.add(f"[yellow]{file_id[:8]}...[/]") - - if files_count > 3: - files_branch.add(f"... and {files_count - 3} more files") - else: - excalidraw_tree.add("[bold yellow]Files[/] (none)") - - # Properly render the tree to a string - content += self.render_rich_tree(excalidraw_tree) - - self.update(Panel( - content, - title=Text(title, style=title_style), - border_style=border_style, - box=box_type - )) - return - else: - content = f"Content updated by user {user_id} (no Excalidraw data found in message)" - if "data" in self.message: - # Try to display raw data if available - if isinstance(self.message["data"], str) and len(self.message["data"]) > 0: - content += "\n\nData appears to be a string, not parsed JSON" - if self.expanded: - # Show preview of the raw data string - preview = self.message["data"][:200] + "..." if len(self.message["data"]) > 200 else self.message["data"] - content += f"\n\n{preview}" - - elif msg_type == "connected": - title_style = "bold cyan" - content = f"Successfully connected with connection ID: {connection_id}" - - elif msg_type == "welcome": - title_style = "bold magenta" - border_style = "magenta" - box_type = box.DOUBLE - content = self.message.get("message", "Welcome to pad listener!") - - else: - title_style = "bold yellow" - if self.expanded: - content = json.dumps(self.message, indent=2) - else: - content = f"Unknown event type: {msg_type} [▼] Show details" - - self.update(Panel( - content, - title=Text(title, style=title_style), - border_style=border_style, - box=box_type - )) - - def on_click(self): - """Toggle expanded state when clicked.""" - if self.message.get("type") in ["pad_update", "unknown"]: - self.expanded = not self.expanded - self.update_display() - -class PadEventApp(App): - """Main application for monitoring pad events.""" - CSS = """ - #events-container { - width: 100%; - height: 100%; - overflow-y: auto; - } - - PadUpdateWidget { - margin: 0 0 1 0; - } - - #status-bar { - dock: bottom; - height: 1; - background: $surface; - color: $text; - } - """ - - BINDINGS = [ - ("q", "quit", "Quit"), - ("c", "clear", "Clear Events"), - ] - - pad_id = reactive("") - connection_status = reactive("Disconnected") - event_count = reactive(0) - - def __init__(self, pad_id): - super().__init__() - self.pad_id = str(pad_id) - self.redis_client = None - - def compose(self) -> ComposeResult: - """Create UI components.""" - yield Header(show_clock=True) - yield ScrollableContainer(id="events-container") - yield Static(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}", id="status-bar") - yield Footer() - - def on_mount(self) -> None: - """Set up the application when it starts.""" - self.update_status("Connecting...") - # Starting the worker method directly - Textual handles the task creation - self.start_redis_listener() - - def update_status(self, status: str) -> None: - """Update the connection status and status bar.""" - self.connection_status = status - status_bar = self.query_one("#status-bar") - status_bar.update(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}") - - @work(thread=False) - async def start_redis_listener(self): - """Connect to Redis and start listening for events. - This uses Textual's work decorator to run as a background task. - """ - try: - # Connect to Redis - self.redis_client = await get_redis_client() - stream_key = f"pad:stream:{self.pad_id}" - - self.update_status("Connected") - - # Add a welcome message - welcome_message = { - "type": "welcome", - "timestamp": datetime.now().isoformat(), - "connection_id": "system", - "user_id": "system", - "message": f"Connected to pad stream: {self.pad_id}" - } - self.add_message(welcome_message) - - # Listen for events - last_id = "$" # Start from latest messages - - while True: - try: - # Read messages from the Redis stream - streams = await self.redis_client.xread({stream_key: last_id}, count=5, block=1000) - - if streams: - stream_name, stream_messages = streams[0] - for message_id, message_data_raw in stream_messages: - # Convert raw Redis data to a formatted dictionary - formatted_message = {} - for k, v in message_data_raw.items(): - key = k.decode() if isinstance(k, bytes) else k - - # Try to parse JSON values - if isinstance(v, bytes): - string_value = v.decode() - else: - string_value = str(v) - - formatted_message[key] = string_value - - # Add the message to the UI - self.add_message(formatted_message) - last_id = message_id - - # Prevent CPU hogging - await asyncio.sleep(0.1) - - except Exception as e: - self.update_status(f"Error: {str(e)}") - await asyncio.sleep(1) - - except Exception as e: - self.update_status(f"Connection failed: {str(e)}") - - def add_message(self, message): - """Add a new message to the UI.""" - container = self.query_one("#events-container") - update_widget = PadUpdateWidget(message) - container.mount(update_widget) - - # Scroll to the new message - container.scroll_end(animate=False) - - # Update event count - self.event_count += 1 - status_bar = self.query_one("#status-bar") - status_bar.update(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}") - - async def action_clear(self) -> None: - """Clear all events from the container.""" - container = self.query_one("#events-container") - container.remove_children() - self.event_count = 0 - status_bar = self.query_one("#status-bar") - status_bar.update(f"Monitoring pad: {self.pad_id} | Status: {self.connection_status} | Events: {self.event_count}") - - async def action_quit(self) -> None: - """Quit the application cleanly.""" - if self.redis_client: - try: - await self.redis_client.close() - except: - # Handle deprecated close() method - try: - await self.redis_client.aclose() - except: - pass - - # Wait a moment to ensure clean shutdown - await asyncio.sleep(0.1) - self.exit() - -# Reuse the helper functions from the previous script -async def get_redis_client(): - """Get Redis client using configuration from .env file.""" - redis_password = os.getenv("REDIS_PASSWORD", "pad") - redis_host = os.getenv("REDIS_HOST", "localhost") - redis_port = int(os.getenv("REDIS_PORT", 6379)) - - redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/0" - setup_console.print(f"[dim]Connecting to Redis at {redis_host}:{redis_port}[/dim]") - - try: - redis_client = await aioredis.from_url(redis_url) - # Test connection - await redis_client.ping() - setup_console.print("[green]Redis connection established[/green]") - return redis_client - except Exception as e: - setup_console.print(f"[red]Failed to connect to Redis:[/red] {str(e)}") - raise - -def detect_content_type(content): - """Try to detect content type for syntax highlighting.""" - if content.startswith("```") and "\n" in content: - # Possible markdown code block - lang_line = content.split("\n", 1)[0].strip("`").strip() - if lang_line in ["python", "javascript", "typescript", "html", "css", "json", "bash", "markdown"]: - return lang_line - - # Look for common patterns - if "" in content: - return "html" - if "function" in content and ("=>" in content or "{" in content): - return "javascript" - if "import " in content and "from " in content and "def " in content: - return "python" - if content.strip().startswith("{") and content.strip().endswith("}"): - try: - json.loads(content) - return "json" - except: - pass - - # Default to plain text - return "text" - -def format_content_for_display(content, content_type=None): - """Format content appropriately based on detected type.""" - if not content_type: - content_type = detect_content_type(content) - - # If content looks like markdown, render it as markdown - if content.startswith("#") or "**" in content or "*" in content or "##" in content: - try: - return Markdown(content) - except: - pass - - # Otherwise use syntax highlighting - return Syntax(content, content_type, theme="monokai", line_numbers=True, word_wrap=True) - -def main(): - """Main entry point for the pad events listener script.""" - parser = argparse.ArgumentParser(description="Interactive viewer for pad events from Redis") - parser.add_argument("pad_id", help="UUID of the pad to listen to") - parser.add_argument("--from-start", "-f", action="store_true", - help="Read from the beginning of the stream history") - - args = parser.parse_args() - - # Validate the pad_id is a valid UUID - try: - pad_uuid = UUID(args.pad_id) - except ValueError: - setup_console.print("[red]Invalid pad ID. Must be a valid UUID.[/red]") - return - - setup_console.print(f"[bold]Interactive Pad Event Listener[/bold] - Connecting to pad: {pad_uuid}") - - # Start the Textual app - app = PadEventApp(pad_uuid) - app.run() - -if __name__ == "__main__": - main() \ No newline at end of file From ddf54d610c3d6f9aec92f4032b1444353ab48947 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 25 May 2025 19:06:26 +0000 Subject: [PATCH 122/149] feat: re-implement workspace management hooks and state indicator - Added `useWorkspace` hook to manage workspace state, including fetching, starting, and stopping the workspace. - Updated `StateIndicator` component to reflect workspace state changes and loading/error conditions. - Enhanced `ControlButton` component to handle workspace start/stop actions based on current state. - Modified styles in `StateIndicator.scss` to improve visual feedback for workspace states. --- src/frontend/src/hooks/useWorkspace.ts | 134 ++++++++++++++++++ src/frontend/src/pad/StateIndicator.scss | 6 +- src/frontend/src/pad/StateIndicator.tsx | 41 ++++-- .../src/pad/buttons/ControlButton.tsx | 72 +++++++--- 4 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 src/frontend/src/hooks/useWorkspace.ts diff --git a/src/frontend/src/hooks/useWorkspace.ts b/src/frontend/src/hooks/useWorkspace.ts new file mode 100644 index 0000000..a853aac --- /dev/null +++ b/src/frontend/src/hooks/useWorkspace.ts @@ -0,0 +1,134 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// Matches the Pydantic model in workspace_router.py +export interface WorkspaceState { + id: string; + state: string; // e.g., "pending", "starting", "running", "stopping", "stopped", "failed", "canceling", "canceled", "deleting", "deleted" + name: string; + username: string; + base_url: string; + agent: string; +} + +const WORKSPACE_QUERY_KEY = ['workspaceState']; + +// API function to fetch workspace state (https://pkg.go.dev/github.com/coder/coder/codersdk#WorkspaceStatus) +const fetchWorkspaceState = async (): Promise => { + try { + const response = await fetch('/api/workspace/state'); + + if (!response.ok) { + let errorMessage = `Failed to fetch workspace state. Status: ${response.status}`; + try { + const errorData = await response.json(); + if (errorData && errorData.detail) { + errorMessage = errorData.detail; + } + } catch (e) { + // Ignore if error response cannot be parsed + } + throw new Error(errorMessage); + } + + const jsonData = await response.json(); + + if (!jsonData || typeof jsonData.state !== 'string') { + throw new Error('Invalid data structure received for workspace state.'); + } + + return jsonData as WorkspaceState; + + } catch (error) { + throw error; + } +}; + +// API function to start the workspace +const callStartWorkspace = async (): Promise => { + const response = await fetch('/api/workspace/start', { + method: 'POST', + }); + if (!response.ok) { + let errorMessage = 'Failed to start workspace.'; + try { + const errorData = await response.json(); + if (errorData && errorData.detail) { + errorMessage = errorData.detail; + } + } catch (e) { + // Error response parsing failed + } + throw new Error(errorMessage); + } + return response.json(); +}; + +// API function to stop the workspace +const callStopWorkspace = async (): Promise => { + const response = await fetch('/api/workspace/stop', { + method: 'POST', + }); + if (!response.ok) { + let errorMessage = 'Failed to stop workspace.'; + try { + const errorData = await response.json(); + if (errorData && errorData.detail) { + errorMessage = errorData.detail; + } + } catch (e) { + // Error response parsing failed + } + throw new Error(errorMessage); + } + return response.json(); +}; + +export const useWorkspace = () => { + const queryClient = useQueryClient(); + + const { + data: workspaceState, + isLoading: isLoadingState, + error: stateError, + isError: isStateError, + refetch: refetchWorkspaceState, + } = useQuery({ + queryKey: WORKSPACE_QUERY_KEY, + queryFn: fetchWorkspaceState, + refetchInterval: 5000, // Poll every 5 seconds + }); + + const startMutation = useMutation({ + mutationFn: callStartWorkspace, + onSuccess: () => { + // Invalidate and refetch workspace state after starting + queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + }, + }); + + const stopMutation = useMutation({ + mutationFn: callStopWorkspace, + onSuccess: () => { + // Invalidate and refetch workspace state after stopping + queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + }, + }); + + return { + workspaceState, + isLoadingState, + stateError, + isStateError, + refetchWorkspaceState, + + startWorkspace: startMutation.mutate, + isStarting: startMutation.isPending, + startError: startMutation.error, + isStartError: startMutation.isError, + + stopWorkspace: stopMutation.mutate, + isStopping: stopMutation.isPending, + stopError: stopMutation.error, + isStopError: stopMutation.isError, + }; +}; diff --git a/src/frontend/src/pad/StateIndicator.scss b/src/frontend/src/pad/StateIndicator.scss index 5ee2091..cbf7b3a 100644 --- a/src/frontend/src/pad/StateIndicator.scss +++ b/src/frontend/src/pad/StateIndicator.scss @@ -22,7 +22,7 @@ } &--starting { - background-color: #3498db; // Blue for starting + background-color: #e67e22; // Orange for starting, pending, loading } &--stopping { @@ -33,9 +33,7 @@ background-color: #e74c3c; // Red for stopped } - &--loading { - background-color: #9b59b6; // Purple for loading - } + // &--loading style removed as it will use 'starting' modifier &--unauthenticated { background-color: #34495e; // Dark blue for unauthenticated diff --git a/src/frontend/src/pad/StateIndicator.tsx b/src/frontend/src/pad/StateIndicator.tsx index 86e38d9..048d352 100644 --- a/src/frontend/src/pad/StateIndicator.tsx +++ b/src/frontend/src/pad/StateIndicator.tsx @@ -1,27 +1,44 @@ import React from 'react'; import './StateIndicator.scss'; +import { useWorkspace } from '../hooks/useWorkspace'; export const StateIndicator: React.FC = () => { - const workspaceState = null; //TODO + const { workspaceState, isLoadingState, stateError } = useWorkspace(); - const getState = () => { + const getState = () => { + if (isLoadingState) { + return { modifier: 'starting', text: 'Loading...' }; // Orange + } + if (stateError) { + return { modifier: 'error', text: 'Error Loading State' }; // Light gray + } if (!workspaceState) { - return { modifier: 'unknown', text: 'Unknown' }; + return { modifier: 'unknown', text: 'Unknown' }; // Dark gray } - switch (workspaceState.status) { - case 'running': - return { modifier: 'running', text: 'Running' }; + switch (workspaceState.state) { + case 'pending': + return { modifier: 'starting', text: 'Pending' }; // Orange case 'starting': - return { modifier: 'starting', text: 'Starting' }; + return { modifier: 'starting', text: 'Starting' }; // Orange + case 'running': + return { modifier: 'running', text: 'Running' }; // Green case 'stopping': - return { modifier: 'stopping', text: 'Stopping' }; + return { modifier: 'stopping', text: 'Stopping' }; // Orange case 'stopped': - return { modifier: 'stopped', text: 'Stopped' }; - case 'error': - return { modifier: 'error', text: 'Error' }; + return { modifier: 'stopped', text: 'Stopped' }; // Red + case 'failed': + return { modifier: 'error', text: 'Failed' }; // Light gray + case 'canceling': + return { modifier: 'stopping', text: 'Canceling' }; // Orange + case 'canceled': + return { modifier: 'stopped', text: 'Canceled' }; // Red + case 'deleting': + return { modifier: 'stopping', text: 'Deleting' }; // Orange + case 'deleted': + return { modifier: 'stopped', text: 'Deleted' }; // Red default: - return { modifier: 'unknown', text: 'Unknown' }; + return { modifier: 'unknown', text: `Unknown (${workspaceState.state})` }; // Dark gray } }; diff --git a/src/frontend/src/pad/buttons/ControlButton.tsx b/src/frontend/src/pad/buttons/ControlButton.tsx index b9fb304..c28562c 100644 --- a/src/frontend/src/pad/buttons/ControlButton.tsx +++ b/src/frontend/src/pad/buttons/ControlButton.tsx @@ -1,34 +1,62 @@ import React from 'react'; import './ControlButton.scss'; import { Play, Square, LoaderCircle } from 'lucide-react'; +import { useWorkspace } from '../../hooks/useWorkspace'; export const ControlButton: React.FC = () => { - - const workspaceState = { //TODO - status: 'running', - username: 'pad.ws', - name: 'pad.ws', - base_url: 'https://pad.ws', - agent: 'pad.ws', - error: null - } - - const isStarting = false; //TODO - const isStopping = false; //TODO + const { + workspaceState, + isLoadingState, + stateError, + startWorkspace, + isStarting, + stopWorkspace, + isStopping, + } = useWorkspace(); - // Determine current status - const currentStatus = workspaceState?.status || 'unknown'; + let currentUiStatus = 'unknown'; + if (isLoadingState) { + currentUiStatus = 'loading'; + } else if (stateError) { + currentUiStatus = 'error'; + } else if (workspaceState) { + switch (workspaceState.state) { + case 'pending': + case 'starting': + currentUiStatus = 'starting'; + break; + case 'running': + currentUiStatus = 'running'; + break; + case 'stopping': + case 'canceling': + case 'deleting': + currentUiStatus = 'stopping'; + break; + case 'stopped': + case 'canceled': + case 'deleted': + currentUiStatus = 'stopped'; + break; + case 'failed': + currentUiStatus = 'error'; + break; + default: + currentUiStatus = 'unknown'; + } + } const handleClick = () => { - if (isStarting || isStopping) return; - if (currentStatus === 'running') { - console.log('TODO: stopWorkspace'); //TODO - } else if (currentStatus === 'stopped' || currentStatus === 'error') { - console.log('TODO: startWorkspace'); //TODO + if (isStarting || isStopping || isLoadingState) return; + + if (currentUiStatus === 'running') { + stopWorkspace(); + } else if (currentUiStatus === 'stopped' || currentUiStatus === 'error' || currentUiStatus === 'unknown') { + startWorkspace(); } }; - if (currentStatus === 'starting' || currentStatus === 'stopping' || isStarting || isStopping) { + if (currentUiStatus === 'loading' || currentUiStatus === 'starting' || currentUiStatus === 'stopping' || isStarting || isStopping) { return ( ); - } else if (currentStatus === 'running' && (!workspaceState || !workspaceState.error)) { + } else if (currentUiStatus === 'running') { return ( ); - } else { + } else { // Covers 'stopped', 'error', 'unknown' return (