Skip to content

Commit 672a66b

Browse files
committed
feat: add tabs
- Updated PadRepository to return pads sorted by creation timestamp for better organization. - Refactored PadService to include type annotations for pads and removed redundant checks for existing pads during creation and updates. - Implemented new API endpoints in PadRouter for updating, renaming, and deleting pads, enhancing user interaction with pad data. - Introduced a context menu in the frontend for pad actions (rename, delete) and improved pad selection handling in Tabs component. - Added utility functions for managing pad data in local storage, ensuring a seamless user experience across sessions.
1 parent 50d0623 commit 672a66b

File tree

10 files changed

+933
-95
lines changed

10 files changed

+933
-95
lines changed

src/backend/database/repository/pad_repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ async def get_by_id(self, pad_id: UUID) -> Optional[PadModel]:
3333
return result.scalars().first()
3434

3535
async def get_by_owner(self, owner_id: UUID) -> List[PadModel]:
36-
"""Get all pads for a specific owner"""
37-
stmt = select(PadModel).where(PadModel.owner_id == owner_id)
36+
"""Get all pads for a specific owner, sorted by created_at timestamp"""
37+
stmt = select(PadModel).where(PadModel.owner_id == owner_id).order_by(PadModel.created_at)
3838
result = await self.session.execute(stmt)
3939
return result.scalars().all()
4040

src/backend/database/service/pad_service.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ..repository import PadRepository, UserRepository
1111
from .user_service import UserService
12-
12+
from ..models import PadModel
1313
# Use TYPE_CHECKING to avoid circular imports
1414
if TYPE_CHECKING:
1515
from dependencies import UserSession
@@ -63,11 +63,6 @@ async def create_pad(self, owner_id: UUID, display_name: str, data: Dict[str, An
6363
print(f"Error creating user as failsafe: {str(e)}")
6464
raise ValueError(f"Failed to create user with ID '{owner_id}': {str(e)}")
6565

66-
# Check if pad with same name already exists for this owner
67-
existing_pad = await self.repository.get_by_name(owner_id, display_name)
68-
if existing_pad:
69-
raise ValueError(f"Pad with name '{display_name}' already exists for this user")
70-
7166
# Create pad
7267
pad = await self.repository.create(owner_id, display_name, data)
7368
return pad.to_dict()
@@ -86,7 +81,7 @@ async def get_pads_by_owner(self, owner_id: UUID) -> List[Dict[str, Any]]:
8681
# This allows the pad_router to handle the case where a user doesn't exist
8782
return []
8883

89-
pads = await self.repository.get_by_owner(owner_id)
84+
pads: list[PadModel] = await self.repository.get_by_owner(owner_id)
9085
return [pad.to_dict() for pad in pads]
9186

9287
async def get_pad_by_name(self, owner_id: UUID, display_name: str) -> Optional[Dict[str, Any]]:
@@ -105,12 +100,6 @@ async def update_pad(self, pad_id: UUID, data: Dict[str, Any]) -> Optional[Dict[
105100
if 'display_name' in data and not data['display_name']:
106101
raise ValueError("Display name cannot be empty")
107102

108-
# Check if new display_name already exists for this owner (if being updated)
109-
if 'display_name' in data and data['display_name'] != pad.display_name:
110-
existing_pad = await self.repository.get_by_name(pad.owner_id, data['display_name'])
111-
if existing_pad:
112-
raise ValueError(f"Pad with name '{data['display_name']}' already exists for this user")
113-
114103
# Update pad
115104
updated_pad = await self.repository.update(pad_id, data)
116105
return updated_pad.to_dict() if updated_pad else None

src/backend/routers/pad_router.py

Lines changed: 145 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,71 +9,175 @@
99
from config import MAX_BACKUPS_PER_USER, MIN_INTERVAL_MINUTES, DEFAULT_PAD_NAME, DEFAULT_TEMPLATE_NAME
1010
pad_router = APIRouter()
1111

12+
def ensure_pad_metadata(data: Dict[str, Any], pad_id: str, display_name: str) -> Dict[str, Any]:
13+
"""
14+
Ensure the pad metadata (uniqueId and displayName) is set in the data.
15+
16+
Args:
17+
data: The pad data to modify
18+
pad_id: The pad ID to set as uniqueId
19+
display_name: The display name to set
20+
21+
Returns:
22+
The modified data
23+
"""
24+
# Ensure the appState and pad objects exist
25+
if "appState" not in data:
26+
data["appState"] = {}
27+
if "pad" not in data["appState"]:
28+
data["appState"]["pad"] = {}
29+
30+
# Set the uniqueId to match the database ID
31+
data["appState"]["pad"]["uniqueId"] = str(pad_id)
32+
data["appState"]["pad"]["displayName"] = display_name
33+
34+
return data
35+
1236

13-
@pad_router.post("")
14-
async def save_pad(
37+
@pad_router.post("/{pad_id}")
38+
async def update_specific_pad(
39+
pad_id: UUID,
1540
data: Dict[str, Any],
1641
user: UserSession = Depends(require_auth),
1742
pad_service: PadService = Depends(get_pad_service),
1843
backup_service: BackupService = Depends(get_backup_service),
1944
):
20-
"""Save pad data for the authenticated user"""
45+
"""Update a specific pad's data for the authenticated user"""
2146
try:
22-
# Check if user already has a pad
23-
user_pads = await pad_service.get_pads_by_owner(user.id)
47+
# Get the pad to verify ownership
48+
pad = await pad_service.get_pad(pad_id)
2449

25-
if not user_pads:
26-
# Create a new pad if user doesn't have one
27-
pad = await pad_service.create_pad(
28-
owner_id=user.id,
29-
display_name=DEFAULT_PAD_NAME,
30-
data=data,
31-
user_session=user
32-
)
33-
else:
34-
# Update existing pad
35-
pad = user_pads[0] # Use the first pad (assuming one pad per user for now)
36-
await pad_service.update_pad_data(pad["id"], data)
50+
if not pad:
51+
raise HTTPException(status_code=404, detail="Pad not found")
3752

38-
# Create a backup only if needed (if none exist or latest is > 5 min old)
53+
# Verify the user owns this pad
54+
if str(pad["owner_id"]) != str(user.id):
55+
raise HTTPException(status_code=403, detail="You don't have permission to update this pad")
56+
57+
# Ensure the uniqueId and displayName are set in the data
58+
data = ensure_pad_metadata(data, str(pad_id), pad["display_name"])
59+
60+
# Update the pad
61+
await pad_service.update_pad_data(pad_id, data)
62+
63+
# Create a backup if needed
3964
await backup_service.create_backup_if_needed(
40-
source_id=pad["id"],
65+
source_id=pad_id,
4166
data=data,
4267
min_interval_minutes=MIN_INTERVAL_MINUTES,
4368
max_backups=MAX_BACKUPS_PER_USER
4469
)
4570

4671
return {"status": "success"}
4772
except Exception as e:
48-
print(f"Error saving pad data: {str(e)}")
49-
raise HTTPException(status_code=500, detail=f"Failed to save canvas data: {str(e)}")
73+
print(f"Error updating pad: {str(e)}")
74+
raise HTTPException(status_code=500, detail=f"Failed to update pad: {str(e)}")
75+
76+
77+
@pad_router.patch("/{pad_id}")
78+
async def rename_pad(
79+
pad_id: UUID,
80+
data: Dict[str, str],
81+
user: UserSession = Depends(require_auth),
82+
pad_service: PadService = Depends(get_pad_service),
83+
):
84+
"""Rename a pad for the authenticated user"""
85+
try:
86+
# Get the pad to verify ownership
87+
pad = await pad_service.get_pad(pad_id)
88+
89+
if not pad:
90+
raise HTTPException(status_code=404, detail="Pad not found")
91+
92+
# Verify the user owns this pad
93+
if str(pad["owner_id"]) != str(user.id):
94+
raise HTTPException(status_code=403, detail="You don't have permission to rename this pad")
95+
96+
# Check if display_name is provided
97+
if "display_name" not in data:
98+
raise HTTPException(status_code=400, detail="display_name is required")
99+
100+
# Update the pad's display name
101+
update_data = {"display_name": data["display_name"]}
102+
updated_pad = await pad_service.update_pad(pad_id, update_data)
103+
104+
return {"status": "success", "pad": updated_pad}
105+
except ValueError as e:
106+
print(f"Error renaming pad: {str(e)}")
107+
raise HTTPException(status_code=400, detail=str(e))
108+
except Exception as e:
109+
print(f"Error renaming pad: {str(e)}")
110+
raise HTTPException(status_code=500, detail=f"Failed to rename pad: {str(e)}")
111+
112+
113+
@pad_router.delete("/{pad_id}")
114+
async def delete_pad(
115+
pad_id: UUID,
116+
user: UserSession = Depends(require_auth),
117+
pad_service: PadService = Depends(get_pad_service),
118+
):
119+
"""Delete a pad for the authenticated user"""
120+
try:
121+
# Get the pad to verify ownership
122+
pad = await pad_service.get_pad(pad_id)
123+
124+
if not pad:
125+
raise HTTPException(status_code=404, detail="Pad not found")
126+
127+
# Verify the user owns this pad
128+
if str(pad["owner_id"]) != str(user.id):
129+
raise HTTPException(status_code=403, detail="You don't have permission to delete this pad")
130+
131+
# Delete the pad
132+
success = await pad_service.delete_pad(pad_id)
133+
134+
if not success:
135+
raise HTTPException(status_code=500, detail="Failed to delete pad")
136+
137+
return {"status": "success"}
138+
except ValueError as e:
139+
print(f"Error deleting pad: {str(e)}")
140+
raise HTTPException(status_code=400, detail=str(e))
141+
except Exception as e:
142+
print(f"Error deleting pad: {str(e)}")
143+
raise HTTPException(status_code=500, detail=f"Failed to delete pad: {str(e)}")
50144

51145

52146
@pad_router.get("")
53-
async def get_pad(
147+
async def get_all_pads(
54148
user: UserSession = Depends(require_auth),
55149
pad_service: PadService = Depends(get_pad_service),
56150
template_pad_service: TemplatePadService = Depends(get_template_pad_service),
57151
backup_service: BackupService = Depends(get_backup_service)
58152
):
59-
"""Get pad data for the authenticated user"""
153+
"""Get all pads for the authenticated user"""
60154
try:
61155
# Get user's pads
62156
user_pads = await pad_service.get_pads_by_owner(user.id)
63157

64158
if not user_pads:
65-
# Return default canvas if user doesn't have a pad
66-
return await create_pad_from_template(
159+
# Create a default pad if user doesn't have any
160+
new_pad = await create_pad_from_template(
67161
name=DEFAULT_TEMPLATE_NAME,
68162
display_name=DEFAULT_PAD_NAME,
69163
user=user,
70164
pad_service=pad_service,
71165
template_pad_service=template_pad_service,
72166
backup_service=backup_service
73167
)
168+
169+
# Return the new pad in a list
170+
return [new_pad]
74171

75-
# Return the first pad's data (assuming one pad per user for now)
76-
return user_pads[0]["data"]
172+
# Ensure each pad's data has the uniqueId and displayName set
173+
for pad in user_pads:
174+
pad_data = pad["data"]
175+
176+
# Ensure the uniqueId and displayName are set in the data
177+
pad_data = ensure_pad_metadata(pad_data, str(pad["id"]), pad["display_name"])
178+
179+
# Return all pads
180+
return user_pads
77181
except Exception as e:
78182
print(f"Error getting pad data: {str(e)}")
79183
raise HTTPException(status_code=500, detail=f"Failed to get pad data: {str(e)}")
@@ -96,18 +200,30 @@ async def create_pad_from_template(
96200
if not template:
97201
raise HTTPException(status_code=404, detail="Template not found")
98202

203+
# Get the template data
204+
template_data = template["data"]
205+
206+
# Before creating, ensure the pad object exists in the data
207+
template_data = ensure_pad_metadata(template_data, "", "")
208+
99209
# Create a new pad using the template data
100210
pad = await pad_service.create_pad(
101211
owner_id=user.id,
102212
display_name=display_name,
103-
data=template["data"],
213+
data=template_data,
104214
user_session=user
105215
)
106216

217+
# Set the uniqueId and displayName to match the database ID and display name
218+
template_data = ensure_pad_metadata(template_data, str(pad["id"]), display_name)
219+
220+
# Update the pad with the modified data
221+
await pad_service.update_pad_data(pad["id"], template_data)
222+
107223
# Create an initial backup for the new pad
108224
await backup_service.create_backup_if_needed(
109225
source_id=pad["id"],
110-
data=template["data"],
226+
data=template_data,
111227
min_interval_minutes=0, # Always create initial backup
112228
max_backups=MAX_BACKUPS_PER_USER
113229
)

src/frontend/src/App.tsx

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import React, { useState, useCallback, useEffect, useRef } from "react";
2-
import { useCanvas, useDefaultCanvas, useUserProfile } from "./api/hooks";
2+
import { useAllPads, useUserProfile } from "./api/hooks";
33
import { ExcalidrawWrapper } from "./ExcalidrawWrapper";
44
import { debounce } from "./utils/debounce";
55
import posthog from "./utils/posthog";
6-
import { normalizeCanvasData } from "./utils/canvasUtils";
6+
import {
7+
normalizeCanvasData,
8+
getPadData,
9+
storePadData,
10+
setActivePad,
11+
getActivePad,
12+
getStoredActivePad,
13+
loadPadData
14+
} from "./utils/canvasUtils";
715
import { useSaveCanvas } from "./api/hooks";
816
import type * as TExcalidraw from "@atyrode/excalidraw";
917
import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types";
@@ -28,23 +36,49 @@ export default function App({
2836
const { data: isAuthenticated, isLoading: isAuthLoading } = useAuthCheck();
2937
const { data: userProfile } = useUserProfile();
3038

31-
// Only enable canvas queries if authenticated and not loading
32-
const { data: canvasData } = useCanvas({
33-
queryKey: ['canvas'],
39+
// Only enable pad queries if authenticated and not loading
40+
const { data: pads } = useAllPads({
41+
queryKey: ['allPads'],
3442
enabled: isAuthenticated === true && !isAuthLoading,
3543
retry: 1,
3644
});
45+
46+
// Get the first pad's data to use as the canvas data
47+
const canvasData = pads && pads.length > 0 ? pads[0].data : null;
3748

3849
// Excalidraw API ref
3950
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
4051
useCustom(excalidrawAPI, customArgs);
4152
useHandleLibrary({ excalidrawAPI });
4253

54+
// Using imported functions from canvasUtils.ts
55+
4356
useEffect(() => {
44-
if (excalidrawAPI && canvasData) {
45-
excalidrawAPI.updateScene(normalizeCanvasData(canvasData));
57+
if (excalidrawAPI && pads && pads.length > 0) {
58+
// Check if there's a stored active pad ID
59+
const storedActivePadId = getStoredActivePad();
60+
61+
// Find the pad that matches the stored ID, or use the first pad if no match
62+
let padToActivate = pads[0];
63+
64+
if (storedActivePadId) {
65+
// Try to find the pad with the stored ID
66+
const matchingPad = pads.find(pad => pad.id === storedActivePadId);
67+
if (matchingPad) {
68+
console.debug(`[pad.ws] Found stored active pad in App.tsx: ${storedActivePadId}`);
69+
padToActivate = matchingPad;
70+
} else {
71+
console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`);
72+
}
73+
}
74+
75+
// Set the active pad ID globally
76+
setActivePad(padToActivate.id);
77+
78+
// Load the pad data for the selected pad
79+
loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data);
4680
}
47-
}, [excalidrawAPI, canvasData]);
81+
}, [excalidrawAPI, pads]);
4882

4983
const { mutate: saveCanvas } = useSaveCanvas({
5084
onSuccess: () => {
@@ -72,6 +106,10 @@ export default function App({
72106
(elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => {
73107
if (!isAuthenticated) return;
74108

109+
// Get the active pad ID using the imported function
110+
const activePadId = getActivePad();
111+
if (!activePadId) return;
112+
75113
const canvasData = {
76114
elements,
77115
appState: state,
@@ -81,12 +119,17 @@ export default function App({
81119
const serialized = JSON.stringify(canvasData);
82120
if (serialized !== lastSentCanvasDataRef.current) {
83121
lastSentCanvasDataRef.current = serialized;
122+
123+
// Store the canvas data in local storage
124+
storePadData(activePadId, canvasData);
125+
126+
// Save the canvas data to the server
84127
saveCanvas(canvasData);
85128
}
86129
},
87130
1200
88131
),
89-
[saveCanvas, isAuthenticated]
132+
[saveCanvas, isAuthenticated, storePadData]
90133
);
91134

92135
useEffect(() => {

0 commit comments

Comments
 (0)