Skip to content

Commit f0e077a

Browse files
authored
feat: add tabs (#80)
* feat: integrate Tabs and Footer components into ExcalidrawWrapper - Added a new Tabs component for enhanced functionality within the Excalidraw interface. - Integrated the Footer component to house the Tabs, improving layout and user interaction. - Updated ExcalidrawWrapper to conditionally render the Footer and Tabs based on the excalidrawAPI availability. * chore: update @atyrode/excalidraw dependency to version 0.18.0-5 * chore: update yarn lockfile * chore: update @atyrode/excalidraw dependency to version 0.18.0-6 * refactor: restructure pad folder * chore: update @atyrode/excalidraw dependency to version 0.18.0-7 * feat: add Tabs component styling and functionality - Introduced a new Tabs.scss file for styling the Tabs component. - Updated Tabs.tsx to implement a tabs bar with a new button for creating a new pad, enhancing user interaction within the Excalidraw interface. * chore: update @atyrode/excalidraw dependency to version 0.18.0-8 * chore: update @atyrode/excalidraw dependency to version 0.18.0-8 * fix: (temp) work around cytoscape issue by forcing version * refactor: replace console.log with console.debug for build info and logout messages - Updated logging statements in vite.config.mts, BuildVersionCheck.tsx, and MainMenu.tsx to use console.debug instead of console.log for improved log level management. - Added a prefix '[pad.ws]' to debug messages for better context in logs. * refactor: remove main execution block from CoderAPI - Deleted the main execution block in coder.py that instantiated CoderAPI and performed workspace status checks, streamlining the code for better modularity. * 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. * feat: add pad backups retrieval functionality - Implemented a new API endpoint to retrieve backups for a specific pad, including ownership verification and backup data formatting. - Updated frontend API hooks to support fetching pad backups and integrated this functionality into the BackupsDialog component. - Enhanced the context menu and tab components to improve user interaction with pad actions and backup management. - Refactored styles for the tab context menu to improve UI consistency and responsiveness. * chore: update @atyrode/excalidraw dependency to version 0.18.0-9 * chore: add clsx dependency and update excalidraw version in package.json and yarn.lock * feat: enhance context menu and tab functionality - Refactored TabContextMenu and Tabs components to improve user interaction with context menus. - Introduced a new Popover component for better positioning and handling of context menus. - Updated styles for context menus to ensure consistency and responsiveness across different screen sizes. - Added functionality for dynamic context menu items based on user actions, including rename and delete options. - Improved tooltip display logic in Tabs component for better user experience with long pad names. * feat: enhance tab navigation and styling - Added scroll buttons for navigating through tabs, improving user experience when there are more pads than can fit in the view. - Implemented functionality to store and retrieve the current scroll index in local storage, ensuring consistent tab visibility across sessions. - Updated tab styles for better layout and responsiveness, including new styles for tab content and positioning. - Refactored Tabs component to handle horizontal scrolling via mouse wheel events, enhancing navigation efficiency. * feat: improve tab visibility and tooltip display - Enhanced the Tabs component to ensure newly created pads are visible by adjusting the start pad index based on the current pads in the query cache. - Updated tooltip logic to display truncated pad names more effectively, improving user experience with long pad names. * style: update tab button borders for improved aesthetics - Removed border from tab buttons for a cleaner look. - Added a solid border to active tab buttons to enhance visibility. - Adjusted styles for new tab button container to maintain consistency in design. * feat: add PadsDialog for managing pads - Introduced a new PadsDialog component to facilitate pad management, including renaming and deleting pads. - Updated ExcalidrawWrapper and MainMenuConfig to integrate the new PadsDialog, allowing users to access it from the main menu. - Enhanced the Tabs component to handle active pad changes via custom events, improving synchronization across components. - Added styles for the PadsDialog to ensure a consistent and user-friendly interface. - Implemented analytics tracking for pad creation, renaming, and deletion events to enhance user insights.
1 parent e8550e5 commit f0e077a

29 files changed

+2331
-249
lines changed

src/backend/coder.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,3 @@ def set_workspace_dormancy(self, workspace_id, dormant: bool):
311311
response = requests.put(endpoint, headers=headers, json=data)
312312
response.raise_for_status()
313313
return response.json()
314-
315-
316-
if __name__ == "__main__":
317-
coder = CoderAPI()
318-
workspace_id = coder.get_workspace_status_for_user("alex")["id"]
319-
coder.set_workspace_dormancy(workspace_id, True)
320-
state = coder.get_workspace_status_for_user("alex")
321-
print(state)

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: 191 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]
171+
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"])
74178

75-
# Return the first pad's data (assuming one pad per user for now)
76-
return user_pads[0]["data"]
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
)
@@ -121,6 +237,52 @@ async def create_pad_from_template(
121237
raise HTTPException(status_code=500, detail=f"Failed to create pad from template: {str(e)}")
122238

123239

240+
@pad_router.get("/{pad_id}/backups")
241+
async def get_pad_backups(
242+
pad_id: UUID,
243+
limit: int = MAX_BACKUPS_PER_USER,
244+
user: UserSession = Depends(require_auth),
245+
pad_service: PadService = Depends(get_pad_service),
246+
backup_service: BackupService = Depends(get_backup_service)
247+
):
248+
"""Get backups for a specific pad"""
249+
# Limit the number of backups to the maximum configured value
250+
if limit > MAX_BACKUPS_PER_USER:
251+
limit = MAX_BACKUPS_PER_USER
252+
253+
try:
254+
# Get the pad to verify ownership
255+
pad = await pad_service.get_pad(pad_id)
256+
257+
if not pad:
258+
raise HTTPException(status_code=404, detail="Pad not found")
259+
260+
# Verify the user owns this pad
261+
if str(pad["owner_id"]) != str(user.id):
262+
raise HTTPException(status_code=403, detail="You don't have permission to access this pad's backups")
263+
264+
# Get backups for this specific pad
265+
backups_data = await backup_service.get_backups_by_source(pad_id)
266+
267+
# Limit the number of backups if needed
268+
if len(backups_data) > limit:
269+
backups_data = backups_data[:limit]
270+
271+
# Format backups to match the expected response format
272+
backups = []
273+
for backup in backups_data:
274+
backups.append({
275+
"id": backup["id"],
276+
"timestamp": backup["created_at"],
277+
"data": backup["data"]
278+
})
279+
280+
return {"backups": backups, "pad_name": pad["display_name"]}
281+
except Exception as e:
282+
print(f"Error getting pad backups: {str(e)}")
283+
raise HTTPException(status_code=500, detail=f"Failed to get pad backups: {str(e)}")
284+
285+
124286
@pad_router.get("/recent")
125287
async def get_recent_canvas_backups(
126288
limit: int = MAX_BACKUPS_PER_USER,

src/frontend/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,23 @@
33
"version": "1.0.0",
44
"private": true,
55
"dependencies": {
6-
"@atyrode/excalidraw": "^0.18.0-4",
6+
"@atyrode/excalidraw": "^0.18.0-9",
77
"@monaco-editor/react": "^4.7.0",
88
"@tanstack/react-query": "^5.74.3",
99
"@tanstack/react-query-devtools": "^5.74.3",
1010
"@types/crypto-js": "^4.2.2",
1111
"browser-fs-access": "0.29.1",
12+
"clsx": "^2.1.1",
1213
"crypto-js": "^4.2.0",
1314
"lucide-react": "^0.488.0",
1415
"posthog-js": "^1.236.0",
1516
"react": "19.0.0",
1617
"react-dom": "19.0.0"
1718
},
19+
"resolutions": {
20+
"cytoscape": "3.31.2",
21+
"**/cytoscape": "3.31.2"
22+
},
1823
"devDependencies": {
1924
"@types/node": "^22.14.0",
2025
"typescript": "^5",

0 commit comments

Comments
 (0)