mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 03:40:23 +01:00
Add initial API documentation for Linsa API endpoints to docs/api.md
This commit is contained in:
766
docs/api.md
Normal file
766
docs/api.md
Normal file
@@ -0,0 +1,766 @@
|
||||
# Linsa API Documentation
|
||||
|
||||
This document describes the Linsa API endpoints and how to connect to them.
|
||||
|
||||
## Base URL
|
||||
|
||||
- **Local Development:** `http://localhost:5625`
|
||||
- **Production:** `https://linsa.io` (or your deployed domain)
|
||||
|
||||
## Authentication
|
||||
|
||||
Linsa uses [better-auth](https://www.better-auth.com/) with email OTP for authentication.
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Request OTP**: Send email to receive a one-time password
|
||||
2. **Verify OTP**: Submit the code to authenticate
|
||||
3. **Session Cookie**: A `better-auth.session_token` cookie is set on successful authentication
|
||||
|
||||
All authenticated endpoints require the session cookie to be included in requests.
|
||||
|
||||
### Auth Endpoints
|
||||
|
||||
All auth endpoints are handled by better-auth at `/api/auth/*`:
|
||||
|
||||
```
|
||||
POST /api/auth/email-otp/send-verification-otp
|
||||
POST /api/auth/email-otp/verify-otp
|
||||
GET /api/auth/session
|
||||
POST /api/auth/sign-out
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Key Authentication
|
||||
|
||||
For programmatic access (CLI tools, browser extensions), you can use API keys instead of session cookies.
|
||||
|
||||
### Create API Key
|
||||
|
||||
```http
|
||||
POST /api/api-keys
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My CLI Tool"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"key": "lk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"id": "uuid",
|
||||
"name": "My CLI Tool",
|
||||
"created_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
> **Important:** The plain API key is only returned once on creation. Store it securely.
|
||||
|
||||
### List API Keys
|
||||
|
||||
```http
|
||||
GET /api/api-keys
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My CLI Tool",
|
||||
"last_used_at": "2024-01-15T12:00:00Z",
|
||||
"created_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Delete API Key
|
||||
|
||||
```http
|
||||
DELETE /api/api-keys?id=<key_id>
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
### Using API Keys
|
||||
|
||||
Pass the API key in the `X-API-Key` header or in the request body as `api_key`:
|
||||
|
||||
```http
|
||||
GET /api/bookmarks
|
||||
X-API-Key: lk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Profile
|
||||
|
||||
### Get Current User Profile
|
||||
|
||||
```http
|
||||
GET /api/profile
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"username": "johndoe",
|
||||
"image": "https://...",
|
||||
"bio": "Developer and streamer",
|
||||
"website": "https://johndoe.com",
|
||||
"stream": {
|
||||
"id": "uuid",
|
||||
"title": "John's Stream",
|
||||
"is_live": false,
|
||||
"hls_url": "https://...",
|
||||
"webrtc_url": "https://...",
|
||||
"playback": { ... },
|
||||
"stream_key": "abc123..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Profile
|
||||
|
||||
```http
|
||||
PUT /api/profile
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "John Doe",
|
||||
"username": "johndoe",
|
||||
"image": "https://...",
|
||||
"bio": "Developer and streamer",
|
||||
"website": "https://johndoe.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Username Requirements:**
|
||||
- Minimum 3 characters
|
||||
- Only lowercase letters, numbers, hyphens, and underscores
|
||||
- Must be unique
|
||||
|
||||
---
|
||||
|
||||
## Streams
|
||||
|
||||
### Get Stream by Username (Public)
|
||||
|
||||
```http
|
||||
GET /api/streams/{username}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"name": "John Doe",
|
||||
"username": "johndoe",
|
||||
"image": "https://...",
|
||||
"bio": "Developer and streamer",
|
||||
"website": "https://johndoe.com",
|
||||
"location": "San Francisco",
|
||||
"joinedAt": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
"stream": {
|
||||
"id": "uuid",
|
||||
"title": "Coding Session",
|
||||
"description": "Building cool stuff",
|
||||
"is_live": true,
|
||||
"viewer_count": 42,
|
||||
"hls_url": "https://...",
|
||||
"webrtc_url": "https://...",
|
||||
"playback": {
|
||||
"type": "cloudflare",
|
||||
"hlsUrl": "https://...",
|
||||
"webrtcUrl": "https://..."
|
||||
},
|
||||
"thumbnail_url": "https://...",
|
||||
"started_at": "2024-01-15T14:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Current User's Stream
|
||||
|
||||
```http
|
||||
GET /api/stream
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
### Update Stream
|
||||
|
||||
```http
|
||||
PUT /api/stream
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "New Stream Title",
|
||||
"description": "Updated description",
|
||||
"hls_url": "https://...",
|
||||
"webrtc_url": "https://...",
|
||||
"is_live": true
|
||||
}
|
||||
```
|
||||
|
||||
### Stream Settings
|
||||
|
||||
```http
|
||||
GET /api/stream/settings
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "My Stream",
|
||||
"description": "Stream description",
|
||||
"cloudflare_live_input_uid": "...",
|
||||
"cloudflare_customer_code": "...",
|
||||
"hls_url": "https://...",
|
||||
"stream_key": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
```http
|
||||
PUT /api/stream/settings
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "My Stream",
|
||||
"description": "Stream description",
|
||||
"cloudflare_live_input_uid": "...",
|
||||
"cloudflare_customer_code": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### Check HLS Stream Status
|
||||
|
||||
```http
|
||||
GET /api/streams/{username}/check-hls
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/check-hls?url={hls_url}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stream Replays
|
||||
|
||||
### List Replays
|
||||
|
||||
```http
|
||||
GET /api/stream-replays
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"replays": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"stream_id": "uuid",
|
||||
"title": "Stream Replay",
|
||||
"description": "...",
|
||||
"status": "ready",
|
||||
"playback_url": "https://...",
|
||||
"thumbnail_url": "https://...",
|
||||
"started_at": "2024-01-15T14:00:00Z",
|
||||
"ended_at": "2024-01-15T16:00:00Z",
|
||||
"duration_seconds": 7200,
|
||||
"is_public": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Create Replay
|
||||
|
||||
```http
|
||||
POST /api/stream-replays
|
||||
Authorization: (session cookie or X-Stream-Key header)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "My Replay",
|
||||
"description": "Description",
|
||||
"status": "processing",
|
||||
"playback_url": "https://...",
|
||||
"thumbnail_url": "https://...",
|
||||
"started_at": "2024-01-15T14:00:00Z",
|
||||
"ended_at": "2024-01-15T16:00:00Z",
|
||||
"is_public": true
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:** `recording`, `processing`, `ready`, `failed`
|
||||
|
||||
### Get/Update/Delete Replay
|
||||
|
||||
```http
|
||||
GET /api/stream-replays/{replayId}
|
||||
PUT /api/stream-replays/{replayId}
|
||||
DELETE /api/stream-replays/{replayId}
|
||||
```
|
||||
|
||||
### Get Public Replays for User
|
||||
|
||||
```http
|
||||
GET /api/streams/{username}/replays
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bookmarks
|
||||
|
||||
API key authentication is required for bookmarks.
|
||||
|
||||
### Add Bookmark
|
||||
|
||||
```http
|
||||
POST /api/bookmarks
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"api_key": "lk_xxx...",
|
||||
"url": "https://example.com/article",
|
||||
"title": "Interesting Article",
|
||||
"description": "A great read",
|
||||
"tags": ["tech", "programming"]
|
||||
}
|
||||
```
|
||||
|
||||
### List Bookmarks
|
||||
|
||||
```http
|
||||
GET /api/bookmarks
|
||||
X-API-Key: lk_xxx...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"bookmarks": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"url": "https://example.com/article",
|
||||
"title": "Interesting Article",
|
||||
"description": "A great read",
|
||||
"tags": ["tech", "programming"],
|
||||
"created_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser Sessions
|
||||
|
||||
Save and sync browser tab sessions.
|
||||
|
||||
### List Sessions
|
||||
|
||||
```http
|
||||
GET /api/browser-sessions?page=1&limit=50
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Research Session",
|
||||
"browser": "safari",
|
||||
"tab_count": 15,
|
||||
"is_favorite": false,
|
||||
"captured_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 100,
|
||||
"totalPages": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Save Session
|
||||
|
||||
```http
|
||||
POST /api/browser-sessions
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "save",
|
||||
"name": "Research Session",
|
||||
"browser": "safari",
|
||||
"tabs": [
|
||||
{
|
||||
"title": "Page Title",
|
||||
"url": "https://example.com",
|
||||
"favicon_url": "https://example.com/favicon.ico"
|
||||
}
|
||||
],
|
||||
"captured_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Session with Tabs
|
||||
|
||||
```http
|
||||
POST /api/browser-sessions
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "get",
|
||||
"session_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Session
|
||||
|
||||
```http
|
||||
POST /api/browser-sessions
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "update",
|
||||
"session_id": "uuid",
|
||||
"name": "Updated Name",
|
||||
"is_favorite": true
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Session
|
||||
|
||||
```http
|
||||
POST /api/browser-sessions
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "delete",
|
||||
"session_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### Search Tabs Across Sessions
|
||||
|
||||
```http
|
||||
POST /api/browser-sessions
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "searchTabs",
|
||||
"query": "github",
|
||||
"limit": 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Chat
|
||||
|
||||
### Send Message (Streaming)
|
||||
|
||||
```http
|
||||
POST /api/chat/ai
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"threadId": 123,
|
||||
"messages": [
|
||||
{ "role": "user", "content": "Hello!" }
|
||||
],
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Server-sent events stream with AI response chunks.
|
||||
|
||||
### Chat Mutations
|
||||
|
||||
```http
|
||||
POST /api/chat/mutations
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "createThread" | "createMessage" | "deleteThread",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Guest Chat
|
||||
|
||||
```http
|
||||
POST /api/chat/guest
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"messages": [
|
||||
{ "role": "user", "content": "Hello!" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Canvas (Collaborative Drawing)
|
||||
|
||||
### List Canvases
|
||||
|
||||
```http
|
||||
GET /api/canvas
|
||||
Authorization: (session cookie or guest cookie)
|
||||
```
|
||||
|
||||
### Create Canvas
|
||||
|
||||
```http
|
||||
POST /api/canvas
|
||||
Authorization: (session cookie or guest cookie)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Canvas"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Canvas
|
||||
|
||||
```http
|
||||
PATCH /api/canvas
|
||||
Authorization: (session cookie or guest cookie)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"canvasId": "uuid",
|
||||
"name": "Updated Name",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"defaultModel": "flux",
|
||||
"defaultStyle": "anime"
|
||||
}
|
||||
```
|
||||
|
||||
### Canvas Images
|
||||
|
||||
```http
|
||||
GET /api/canvas/images?canvasId={id}
|
||||
POST /api/canvas/images
|
||||
GET /api/canvas/images/{imageId}
|
||||
POST /api/canvas/images/{imageId}/generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Items (Knowledge Base)
|
||||
|
||||
### List Context Items
|
||||
|
||||
```http
|
||||
GET /api/context-items
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
### Create/Update Context Item
|
||||
|
||||
```http
|
||||
POST /api/context-items
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://docs.example.com",
|
||||
"name": "API Docs"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archives
|
||||
|
||||
### List Archives
|
||||
|
||||
```http
|
||||
GET /api/archives
|
||||
Authorization: (session cookie required)
|
||||
```
|
||||
|
||||
### Create Archive
|
||||
|
||||
```http
|
||||
POST /api/archives
|
||||
Authorization: (session cookie required)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://example.com/page",
|
||||
"title": "Page Title"
|
||||
}
|
||||
```
|
||||
|
||||
### Get/Delete Archive
|
||||
|
||||
```http
|
||||
GET /api/archives/{archiveId}
|
||||
DELETE /api/archives/{archiveId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Electric SQL Sync
|
||||
|
||||
These endpoints proxy to Electric SQL for real-time data sync:
|
||||
|
||||
```http
|
||||
GET /api/users # Sync users table
|
||||
GET /api/chat-threads # Sync chat threads
|
||||
GET /api/chat-messages # Sync chat messages
|
||||
GET /api/usage-events # Sync usage events
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Billing (Flowglad)
|
||||
|
||||
When billing is enabled:
|
||||
|
||||
```http
|
||||
GET /api/flowglad/* # Flowglad webhook handling
|
||||
POST /api/stripe/checkout # Create checkout session
|
||||
POST /api/stripe/portal # Customer portal
|
||||
POST /api/stripe/webhooks # Stripe webhooks
|
||||
GET /api/stripe/billing # Billing status
|
||||
```
|
||||
|
||||
### Creator Subscriptions
|
||||
|
||||
```http
|
||||
GET /api/creator/{username}/access # Check access to creator content
|
||||
POST /api/creator/subscribe # Subscribe to creator
|
||||
GET /api/creator/tiers # List subscription tiers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return errors in a consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error message describing what went wrong"
|
||||
}
|
||||
```
|
||||
|
||||
**Common HTTP Status Codes:**
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 400 | Bad Request - Invalid input |
|
||||
| 401 | Unauthorized - Authentication required |
|
||||
| 403 | Forbidden - No permission |
|
||||
| 404 | Not Found |
|
||||
| 409 | Conflict - Resource already exists |
|
||||
| 429 | Too Many Requests - Rate limited |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
AI chat endpoints have usage limits based on subscription tier. When limits are exceeded:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Usage limit exceeded",
|
||||
"reason": "monthly_limit",
|
||||
"remaining": 0,
|
||||
"limit": 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CORS
|
||||
|
||||
The API supports CORS for browser-based requests. Credentials (cookies) are allowed from trusted origins configured in the application.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket / Real-time
|
||||
|
||||
For real-time features like chat and stream viewer counts, the application uses:
|
||||
|
||||
1. **Electric SQL** - For syncing database changes to clients
|
||||
2. **Server-Sent Events (SSE)** - For AI chat streaming responses
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Authentication Flow
|
||||
|
||||
```bash
|
||||
# 1. Request OTP
|
||||
curl -X POST http://localhost:5625/api/auth/email-otp/send-verification-otp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com"}'
|
||||
|
||||
# 2. Verify OTP (check terminal for code in dev mode)
|
||||
curl -X POST http://localhost:5625/api/auth/email-otp/verify-otp \
|
||||
-H "Content-Type: application/json" \
|
||||
-c cookies.txt \
|
||||
-d '{"email": "user@example.com", "otp": "123456"}'
|
||||
|
||||
# 3. Access authenticated endpoint
|
||||
curl http://localhost:5625/api/profile \
|
||||
-b cookies.txt
|
||||
|
||||
# 4. Create API key for programmatic access
|
||||
curl -X POST http://localhost:5625/api/api-keys \
|
||||
-H "Content-Type: application/json" \
|
||||
-b cookies.txt \
|
||||
-d '{"name": "My CLI Tool"}'
|
||||
|
||||
# 5. Use API key
|
||||
curl http://localhost:5625/api/bookmarks \
|
||||
-H "X-API-Key: lk_xxxxxxxx..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
- In development mode, OTP codes are logged to the terminal instead of being emailed
|
||||
- The dev server runs on port 5625
|
||||
- Database: PostgreSQL (Neon in production)
|
||||
- Authentication: better-auth with email OTP plugin
|
||||
@@ -41,6 +41,7 @@ export default [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.wrangler/**",
|
||||
"**/out/**",
|
||||
"**/build/**",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -362,6 +362,15 @@ description = "Start the web dev server on port 5613."
|
||||
dependencies = ["node", "pnpm"]
|
||||
shortcuts = ["d"]
|
||||
|
||||
[[tasks]]
|
||||
name = "desktop"
|
||||
command = """
|
||||
pnpm --filter @linsa/desktop dev
|
||||
"""
|
||||
description = "Start the Electron desktop app (electron-vite dev)."
|
||||
dependencies = ["node", "pnpm"]
|
||||
shortcuts = ["desk"]
|
||||
|
||||
[[tasks]]
|
||||
name = "deploy"
|
||||
command = """
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"scripts": {
|
||||
"dev:worker": "pnpm --filter @linsa/worker run dev",
|
||||
"dev:desktop": "pnpm --filter @linsa/desktop run dev",
|
||||
"deploy:worker": "pnpm --filter @linsa/worker run deploy",
|
||||
"test:worker": "pnpm --filter @linsa/worker run test",
|
||||
"dev:web": "pnpm --filter @linsa/web run dev",
|
||||
"deploy:web": "pnpm --filter @linsa/web run deploy",
|
||||
"test:web": "pnpm --filter @linsa/web run test",
|
||||
"build:desktop": "pnpm --filter @linsa/desktop run build",
|
||||
"dev": "pnpm dev:worker",
|
||||
"test": "pnpm -r test",
|
||||
"lint": "pnpm -r lint && pnpm format:check",
|
||||
|
||||
27
packages/desktop/README.md
Normal file
27
packages/desktop/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Linsa desktop
|
||||
|
||||
Electron shell that mirrors the same structure we use in the `as` project: `electron-vite` bundling the main, preload, and React renderer, plus a small Jazz schema for storing folders locally.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @linsa/desktop dev
|
||||
```
|
||||
|
||||
Set a Jazz Cloud key to sync state instead of keeping it only on the device:
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
echo "VITE_JAZZ_API_KEY=your_jazz_key" >> .env
|
||||
# optional: point to a custom peer
|
||||
# echo "VITE_JAZZ_PEER=ws://localhost:4200" >> .env
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- Uses `electron-vite` to bundle `main`, `preload`, and the React renderer.
|
||||
- Wraps the renderer with `JazzReactProvider` (storage in IndexedDB) and a simple Jazz schema to keep track of folders you want scanned.
|
||||
- Opens an OS folder picker (via the preload bridge) to add/remove code folders.
|
||||
- Scans those folders for git repos and lets you open them in VS Code, Terminal, or Finder.
|
||||
- Shows the current Jazz sync status so we can expand to syncing folder lists later.
|
||||
56
packages/desktop/electron.vite.config.ts
Normal file
56
packages/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, "src/main/index.ts"),
|
||||
output: {
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/preload/index.ts"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
format: "cjs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: __dirname,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
"@shared": resolve(__dirname, "src/shared"),
|
||||
"@renderer": resolve(__dirname, "src/renderer"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
strictPort: false,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, "index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
},
|
||||
});
|
||||
12
packages/desktop/index.html
Normal file
12
packages/desktop/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linsa Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/renderer/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
packages/desktop/package.json
Normal file
35
packages/desktop/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@linsa/desktop",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reatom/core": "^1000.1.0",
|
||||
"@reatom/react": "^1000.0.0",
|
||||
"electron": "^31.7.0",
|
||||
"jazz-tools": "^0.19.13",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
109
packages/desktop/src/features/folders/model/atoms.ts
Normal file
109
packages/desktop/src/features/folders/model/atoms.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { atom, computed } from "@reatom/core"
|
||||
import { reatomJazz } from "@/shared/lib/reatom/jazz"
|
||||
import { jazzContext } from "@/shared/lib/jazz/context"
|
||||
import {
|
||||
AppAccount,
|
||||
AppRoot,
|
||||
CodeFolder,
|
||||
CodeFoldersList,
|
||||
} from "./schema"
|
||||
|
||||
// Reatom factory for CodeFolder
|
||||
export const reatomCodeFolder = reatomJazz({
|
||||
schema: CodeFolder,
|
||||
name: "codeFolder",
|
||||
create: ({ loaded }) => ({
|
||||
path: loaded.path,
|
||||
addedAt: loaded.addedAt,
|
||||
}),
|
||||
})
|
||||
export type CodeFolderModel = ReturnType<typeof reatomCodeFolder>
|
||||
|
||||
// Reatom factory for CodeFoldersList
|
||||
export const reatomCodeFoldersList = reatomJazz({
|
||||
schema: CodeFoldersList,
|
||||
name: "codeFoldersList",
|
||||
create: ({ loaded }) => {
|
||||
const items = [...loaded.$jazz.refs].map((ref) => reatomCodeFolder(ref.id))
|
||||
return { items }
|
||||
},
|
||||
})
|
||||
|
||||
// Reatom factory for AppRoot
|
||||
export const reatomAppRoot = reatomJazz({
|
||||
schema: AppRoot,
|
||||
name: "appRoot",
|
||||
resolve: { folders: { $each: true } },
|
||||
create: ({ loaded }) => {
|
||||
const foldersId = loaded.$jazz.refs.folders?.id
|
||||
return {
|
||||
folders: foldersId ? reatomCodeFoldersList(foldersId) : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Reatom factory for AppAccount
|
||||
export const reatomAppAccount = reatomJazz({
|
||||
schema: AppAccount,
|
||||
name: "appAccount",
|
||||
resolve: { root: { folders: { $each: true } } },
|
||||
create: ({ loaded }) => {
|
||||
const rootId = loaded.$jazz.refs.root?.id
|
||||
return {
|
||||
root: rootId ? reatomAppRoot(rootId) : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Current account atom - derived from Jazz context
|
||||
export const currentAccount = computed(() => {
|
||||
const ctx = jazzContext()
|
||||
const accountId = ctx.current().me.$jazz.id
|
||||
return reatomAppAccount(accountId)()
|
||||
}, "currentAccount")
|
||||
|
||||
// Convenience atom for folders list
|
||||
export const codeFolders = computed(() => {
|
||||
const account = currentAccount()
|
||||
const root = account.root?.()
|
||||
const folders = root?.folders?.()
|
||||
return folders?.items ?? []
|
||||
}, "codeFolders")
|
||||
|
||||
// Actions
|
||||
export const addFolderAction = atom(null, (get, set, path: string) => {
|
||||
const ctx = jazzContext()
|
||||
const me = ctx.current().me as co.loaded<typeof AppAccount>
|
||||
|
||||
if (!me.root?.folders) return
|
||||
|
||||
// Check if already exists
|
||||
const existing = [...me.root.folders].find((f) => f?.path === path)
|
||||
if (existing) return
|
||||
|
||||
// Create new folder entry
|
||||
const folder = CodeFolder.create({
|
||||
path,
|
||||
addedAt: Date.now(),
|
||||
})
|
||||
|
||||
// Add to list
|
||||
me.root.folders.$jazz.push(folder)
|
||||
}, "addFolderAction")
|
||||
|
||||
export const removeFolderAction = atom(null, (get, set, path: string) => {
|
||||
const ctx = jazzContext()
|
||||
const me = ctx.current().me as co.loaded<typeof AppAccount>
|
||||
|
||||
if (!me.root?.folders) return
|
||||
|
||||
// Find index
|
||||
const index = [...me.root.folders].findIndex((f) => f?.path === path)
|
||||
if (index === -1) return
|
||||
|
||||
// Remove from list
|
||||
me.root.folders.$jazz.splice(index, 1)
|
||||
}, "removeFolderAction")
|
||||
|
||||
// Import for co type
|
||||
import { co } from "jazz-tools"
|
||||
65
packages/desktop/src/features/folders/model/schema.ts
Normal file
65
packages/desktop/src/features/folders/model/schema.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { co, z, setDefaultSchemaPermissions } from "jazz-tools"
|
||||
|
||||
setDefaultSchemaPermissions({
|
||||
onInlineCreate: "sameAsContainer",
|
||||
})
|
||||
|
||||
// A single code folder entry
|
||||
export const CodeFolder = co.map({
|
||||
path: z.string(),
|
||||
addedAt: z.number(),
|
||||
})
|
||||
export type CodeFolder = co.loaded<typeof CodeFolder>
|
||||
|
||||
// List of code folders
|
||||
export const CodeFoldersList = co.list(CodeFolder)
|
||||
export type CodeFoldersList = co.loaded<typeof CodeFoldersList>
|
||||
|
||||
// App root data
|
||||
export const AppRoot = co.map({
|
||||
folders: CodeFoldersList,
|
||||
})
|
||||
export type AppRoot = co.loaded<typeof AppRoot>
|
||||
|
||||
// Account profile (minimal)
|
||||
export const AppProfile = co.profile({
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
// Main account schema with migration
|
||||
export const AppAccount = co
|
||||
.account({
|
||||
root: AppRoot,
|
||||
profile: AppProfile,
|
||||
})
|
||||
.withMigration(async (account) => {
|
||||
// Initialize root if missing
|
||||
if (!account.$jazz.has("root")) {
|
||||
account.$jazz.set(
|
||||
"root",
|
||||
AppRoot.create({
|
||||
folders: CodeFoldersList.create([]),
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// Ensure folders list exists
|
||||
const { root } = await account.$jazz.ensureLoaded({
|
||||
resolve: { root: true },
|
||||
})
|
||||
if (!root.$jazz.has("folders")) {
|
||||
root.$jazz.set("folders", CodeFoldersList.create([]))
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize profile if missing
|
||||
if (!account.$jazz.has("profile")) {
|
||||
account.$jazz.set(
|
||||
"profile",
|
||||
AppProfile.create({
|
||||
name: "Desktop User",
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export type AppAccount = co.loaded<typeof AppAccount>
|
||||
30
packages/desktop/src/features/repos/model/atoms.ts
Normal file
30
packages/desktop/src/features/repos/model/atoms.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { atom, action } from "@reatom/core"
|
||||
|
||||
export interface GitRepo {
|
||||
name: string
|
||||
path: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
// Local state for scanned repos (not synced via Jazz)
|
||||
export const reposAtom = atom<GitRepo[]>([], "repos")
|
||||
|
||||
// Scanning state
|
||||
export const isScanningAtom = atom(false, "isScanning")
|
||||
|
||||
// Scan folders for git repos
|
||||
export const scanReposAction = action(async (ctx, folders: string[]) => {
|
||||
if (folders.length === 0) {
|
||||
reposAtom(ctx, [])
|
||||
return
|
||||
}
|
||||
|
||||
isScanningAtom(ctx, true)
|
||||
|
||||
try {
|
||||
const repos = await window.electronAPI.scanRepos(folders)
|
||||
reposAtom(ctx, repos)
|
||||
} finally {
|
||||
isScanningAtom(ctx, false)
|
||||
}
|
||||
}, "scanRepos")
|
||||
157
packages/desktop/src/main/index.ts
Normal file
157
packages/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
interface GitRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
async function findGitRepos(dir: string, maxDepth = 4): Promise<GitRepo[]> {
|
||||
const repos: GitRepo[] = [];
|
||||
|
||||
async function scan(currentPath: string, depth: number): Promise<void> {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
try {
|
||||
const entries = await readdir(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".") && entry.name !== ".git") continue;
|
||||
if (entry.name === "node_modules") continue;
|
||||
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
|
||||
if (entry.name === ".git") {
|
||||
const repoPath = currentPath;
|
||||
const stats = await stat(fullPath);
|
||||
repos.push({
|
||||
name: basename(repoPath),
|
||||
path: repoPath,
|
||||
lastModified: stats.mtimeMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await scan(fullPath, depth + 1);
|
||||
}
|
||||
} catch {
|
||||
// Permission denied or other error - skip
|
||||
}
|
||||
}
|
||||
|
||||
await scan(dir, 0);
|
||||
return repos;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const preloadJs = join(__dirname, "../preload/index.js");
|
||||
const preloadCjs = join(__dirname, "../preload/index.cjs");
|
||||
const preloadMjs = join(__dirname, "../preload/index.mjs");
|
||||
const preloadPath = [preloadJs, preloadCjs, preloadMjs].find((p) =>
|
||||
existsSync(p),
|
||||
);
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
titleBarStyle: "hiddenInset",
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("http")) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
ipcMain.handle("dialog:pick-folder", async (): Promise<string | null> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select code folder",
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise<GitRepo[]> => {
|
||||
const allRepos: GitRepo[] = [];
|
||||
|
||||
for (const folder of folders) {
|
||||
const repos = await findGitRepos(folder);
|
||||
allRepos.push(...repos);
|
||||
}
|
||||
|
||||
allRepos.sort((a, b) => b.lastModified - a.lastModified);
|
||||
|
||||
const seen = new Set<string>();
|
||||
return allRepos.filter((repo) => {
|
||||
if (seen.has(repo.path)) return false;
|
||||
seen.add(repo.path);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:show-in-folder", async (_event, path: string) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:open-in-editor", async (_event, path: string) => {
|
||||
const { exec } = await import("node:child_process");
|
||||
exec(`code "${path}"`);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:open-in-terminal", async (_event, path: string) => {
|
||||
const { exec } = await import("node:child_process");
|
||||
if (process.platform === "darwin") {
|
||||
exec(`open -a Terminal "${path}"`);
|
||||
} else if (process.platform === "win32") {
|
||||
exec(`start cmd /K "cd /d ${path}"`);
|
||||
} else {
|
||||
exec(`x-terminal-emulator --working-directory="${path}"`);
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
15
packages/desktop/src/preload/index.ts
Normal file
15
packages/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
export interface GitRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise<string | null>,
|
||||
scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise<GitRepo[]>,
|
||||
showInFolder: (path: string) => ipcRenderer.invoke("shell:show-in-folder", path),
|
||||
openInEditor: (path: string) => ipcRenderer.invoke("shell:open-in-editor", path),
|
||||
openInTerminal: (path: string) => ipcRenderer.invoke("shell:open-in-terminal", path),
|
||||
});
|
||||
392
packages/desktop/src/renderer/App.tsx
Normal file
392
packages/desktop/src/renderer/App.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useAccount } from "jazz-tools/react"
|
||||
import { AppAccount, CodeFolder } from "../features/folders/model/schema"
|
||||
|
||||
interface Command {
|
||||
id: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface GitRepo {
|
||||
name: string
|
||||
path: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
export function App() {
|
||||
if (!window.electronAPI) {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<div className="spinner" />
|
||||
<p>Electron preload bridge is missing.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const me = useAccount(AppAccount, {
|
||||
resolve: { root: { folders: { $each: { $onError: "catch" } } } },
|
||||
})
|
||||
|
||||
// Local state for repos (not synced via Jazz)
|
||||
const [repos, setRepos] = useState<GitRepo[]>([])
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [manualPath, setManualPath] = useState("")
|
||||
const [pathError, setPathError] = useState<string | null>(null)
|
||||
|
||||
// UI state
|
||||
const [showPalette, setShowPalette] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Get folders from Jazz (safely handle loading state)
|
||||
const folders = me?.root?.folders ?? []
|
||||
const folderPaths = [...folders].filter(Boolean).map((f) => f!.path)
|
||||
|
||||
// Filter repos by search
|
||||
const filteredRepos = repos.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
repo.path.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
// Scan folders for repos
|
||||
const scanRepos = useCallback(async () => {
|
||||
if (folderPaths.length === 0) {
|
||||
setRepos([])
|
||||
return
|
||||
}
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const found = await window.electronAPI.scanRepos(folderPaths)
|
||||
setRepos(found)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, [folderPaths.join(","), setRepos, setIsScanning])
|
||||
|
||||
// Scan when folders change
|
||||
useEffect(() => {
|
||||
if (folderPaths.length > 0) {
|
||||
scanRepos()
|
||||
} else {
|
||||
setRepos([])
|
||||
}
|
||||
}, [folderPaths.join(",")])
|
||||
|
||||
// Add a folder (saves to Jazz)
|
||||
const addFolder = useCallback(async () => {
|
||||
if (!me?.root?.folders) return
|
||||
|
||||
const path = await window.electronAPI.pickFolder()
|
||||
if (!path) return
|
||||
if (folderPaths.includes(path)) return
|
||||
|
||||
// Create folder entry and add to Jazz
|
||||
const folder = CodeFolder.create({ path, addedAt: Date.now() })
|
||||
me.root.folders.$jazz.push(folder)
|
||||
|
||||
setShowPalette(false)
|
||||
}, [me?.root?.folders, folderPaths])
|
||||
|
||||
const addManualFolder = useCallback(() => {
|
||||
setPathError(null)
|
||||
const trimmed = manualPath.trim()
|
||||
if (!trimmed) {
|
||||
setPathError("Enter a folder path")
|
||||
return
|
||||
}
|
||||
if (folderPaths.includes(trimmed)) {
|
||||
setPathError("Already added")
|
||||
return
|
||||
}
|
||||
if (!me?.root?.folders) return
|
||||
|
||||
const folder = CodeFolder.create({ path: trimmed, addedAt: Date.now() })
|
||||
me.root.folders.$jazz.push(folder)
|
||||
setManualPath("")
|
||||
}, [manualPath, folderPaths, me?.root?.folders])
|
||||
|
||||
// Remove a folder (removes from Jazz)
|
||||
const removeFolder = useCallback(
|
||||
(path: string) => {
|
||||
if (!me?.root?.folders) return
|
||||
|
||||
const idx = [...folders].findIndex((f) => f?.path === path)
|
||||
if (idx !== -1) {
|
||||
me.root.folders.$jazz.splice(idx, 1)
|
||||
}
|
||||
},
|
||||
[folders, me?.root?.folders]
|
||||
)
|
||||
|
||||
// Open repo in editor
|
||||
const openInEditor = useCallback((repo: GitRepo) => {
|
||||
window.electronAPI.openInEditor(repo.path)
|
||||
setShowPalette(false)
|
||||
}, [])
|
||||
|
||||
// Commands for palette
|
||||
const commands: Command[] = [
|
||||
{ id: "add-folder", label: "Add code folder", shortcut: "A", action: addFolder },
|
||||
{ id: "refresh", label: "Refresh repos", shortcut: "R", action: scanRepos },
|
||||
]
|
||||
|
||||
// When palette is open with search, show repos as commands
|
||||
const paletteItems =
|
||||
showPalette && search
|
||||
? filteredRepos.map((repo) => ({
|
||||
id: repo.path,
|
||||
label: repo.name,
|
||||
shortcut: undefined,
|
||||
action: () => openInEditor(repo),
|
||||
}))
|
||||
: commands
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setShowPalette((prev) => !prev)
|
||||
setSearch("")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
if (e.key === "Escape" && showPalette) {
|
||||
setShowPalette(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [showPalette])
|
||||
|
||||
// Focus input when palette opens
|
||||
useEffect(() => {
|
||||
if (showPalette && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [showPalette])
|
||||
|
||||
// Reset selected index when search changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search])
|
||||
|
||||
const handlePaletteKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, paletteItems.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter" && paletteItems.length > 0) {
|
||||
paletteItems[selectedIndex]?.action()
|
||||
}
|
||||
}
|
||||
|
||||
const timeAgo = (ms: number) => {
|
||||
const seconds = Math.floor((Date.now() - ms) / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<div className="spinner" />
|
||||
<p>Loading account...</p>
|
||||
<button
|
||||
className="secondary"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => {
|
||||
indexedDB.deleteDatabase("cojson")
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
Reset local session
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Command Palette */}
|
||||
{showPalette && (
|
||||
<div className="palette-overlay" onClick={() => setShowPalette(false)}>
|
||||
<div className="palette" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="palette-input"
|
||||
placeholder="Search repos or type a command..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handlePaletteKeyDown}
|
||||
/>
|
||||
<div className="palette-commands">
|
||||
{paletteItems.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`palette-command ${index === selectedIndex ? "selected" : ""}`}
|
||||
onClick={item.action}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && <kbd className="palette-shortcut">{item.shortcut}</kbd>}
|
||||
</button>
|
||||
))}
|
||||
{paletteItems.length === 0 && <div className="palette-empty">No results</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<div className="header-drag" />
|
||||
<h1>Repos</h1>
|
||||
<div className="header-right">
|
||||
<span className="sync-status sync-connected">Synced</span>
|
||||
<button className="header-btn" onClick={() => setShowPalette(true)}>
|
||||
<kbd>Cmd+K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
<section className="card">
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<p className="eyebrow">Parent folders</p>
|
||||
<h2>Where to look for repos</h2>
|
||||
<p className="muted">Add one or more parent folders. We’ll scan them deeply for git repos.</p>
|
||||
</div>
|
||||
<div className="pill">{folderPaths.length} folders</div>
|
||||
</div>
|
||||
|
||||
<div className="folder-inputs">
|
||||
<div className="input-stack">
|
||||
<label className="label">Add by path</label>
|
||||
<div className="input-row">
|
||||
<input
|
||||
value={manualPath}
|
||||
onChange={(e) => setManualPath(e.target.value)}
|
||||
placeholder="/Users/you/code"
|
||||
className={`text-input ${pathError ? "has-error" : ""}`}
|
||||
/>
|
||||
<button className="primary-btn" onClick={addManualFolder}>
|
||||
Add
|
||||
</button>
|
||||
<button className="ghost-btn" onClick={addFolder}>
|
||||
Pick from Finder
|
||||
</button>
|
||||
</div>
|
||||
{pathError && <p className="error-text">{pathError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{folderPaths.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
<p>No folders yet. Add at least one to start scanning.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chip-grid">
|
||||
{folderPaths.map((path) => (
|
||||
<div key={path} className="chip">
|
||||
<span className="chip-label" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<button className="chip-remove" onClick={() => removeFolder(path)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="secondary-btn"
|
||||
onClick={scanRepos}
|
||||
disabled={isScanning || folderPaths.length === 0}
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Refresh now"}
|
||||
</button>
|
||||
<p className="muted">Deep git parsing will be added next.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{repos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{folderPaths.length === 0 ? (
|
||||
<>
|
||||
<h2>No folders configured</h2>
|
||||
<p>Add folders where you keep your code to get started.</p>
|
||||
<button className="primary-btn" onClick={addFolder}>
|
||||
Add folder
|
||||
</button>
|
||||
</>
|
||||
) : isScanning ? (
|
||||
<>
|
||||
<div className="spinner" />
|
||||
<p>Scanning for repos...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>No repos found</h2>
|
||||
<p>No git repositories found in the configured folders.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="repo-list">
|
||||
{repos.map((repo) => (
|
||||
<li key={repo.path} className="repo-item">
|
||||
<div className="repo-info" onClick={() => openInEditor(repo)}>
|
||||
<span className="repo-name">{repo.name}</span>
|
||||
<span className="repo-path">{repo.path}</span>
|
||||
</div>
|
||||
<div className="repo-meta">
|
||||
<span className="repo-time">{timeAgo(repo.lastModified)}</span>
|
||||
<div className="repo-actions">
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => window.electronAPI.openInEditor(repo.path)}
|
||||
title="Open in VS Code"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => window.electronAPI.openInTerminal(repo.path)}
|
||||
title="Open in Terminal"
|
||||
>
|
||||
Term
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => window.electronAPI.showInFolder(repo.path)}
|
||||
title="Show in Finder"
|
||||
>
|
||||
Finder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
294
packages/desktop/src/renderer/AppSimple.tsx
Normal file
294
packages/desktop/src/renderer/AppSimple.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useAtom } from "@reatom/react"
|
||||
import { atom } from "@reatom/core"
|
||||
|
||||
interface Command {
|
||||
id: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface GitRepo {
|
||||
name: string
|
||||
path: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
// Reatom atoms for state
|
||||
const foldersAtom = atom<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("repo-folders")
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, "folders")
|
||||
|
||||
const reposAtom = atom<GitRepo[]>([], "repos")
|
||||
const isScanningAtom = atom(false, "isScanning")
|
||||
|
||||
export function App() {
|
||||
// Reatom state
|
||||
const [folders, setFolders] = useAtom(foldersAtom)
|
||||
const [repos, setRepos] = useAtom(reposAtom)
|
||||
const [isScanning, setIsScanning] = useAtom(isScanningAtom)
|
||||
|
||||
// UI state
|
||||
const [showPalette, setShowPalette] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Save folders to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("repo-folders", JSON.stringify(folders))
|
||||
}, [folders])
|
||||
|
||||
// Filter repos by search
|
||||
const filteredRepos = repos.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
repo.path.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
// Scan folders for repos
|
||||
const scanRepos = useCallback(async () => {
|
||||
if (folders.length === 0) {
|
||||
setRepos([])
|
||||
return
|
||||
}
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const found = await window.electronAPI.scanRepos(folders)
|
||||
setRepos(found)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, [folders, setRepos, setIsScanning])
|
||||
|
||||
// Initial scan
|
||||
useEffect(() => {
|
||||
if (folders.length > 0) {
|
||||
scanRepos()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add a folder
|
||||
const addFolder = useCallback(async () => {
|
||||
const path = await window.electronAPI.pickFolder()
|
||||
if (!path) return
|
||||
if (folders.includes(path)) return
|
||||
setFolders([...folders, path])
|
||||
setShowPalette(false)
|
||||
// Scan after adding
|
||||
setTimeout(async () => {
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const found = await window.electronAPI.scanRepos([...folders, path])
|
||||
setRepos(found)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, 100)
|
||||
}, [folders, setFolders, setRepos, setIsScanning])
|
||||
|
||||
// Remove a folder
|
||||
const removeFolder = useCallback(
|
||||
(path: string) => {
|
||||
const newFolders = folders.filter((f) => f !== path)
|
||||
setFolders(newFolders)
|
||||
if (newFolders.length > 0) {
|
||||
window.electronAPI.scanRepos(newFolders).then(setRepos)
|
||||
} else {
|
||||
setRepos([])
|
||||
}
|
||||
},
|
||||
[folders, setFolders, setRepos]
|
||||
)
|
||||
|
||||
// Open repo in editor
|
||||
const openInEditor = useCallback((repo: GitRepo) => {
|
||||
window.electronAPI.openInEditor(repo.path)
|
||||
setShowPalette(false)
|
||||
}, [])
|
||||
|
||||
// Commands
|
||||
const commands: Command[] = [
|
||||
{ id: "add-folder", label: "Add code folder", shortcut: "A", action: addFolder },
|
||||
{ id: "refresh", label: "Refresh repos", shortcut: "R", action: scanRepos },
|
||||
]
|
||||
|
||||
const paletteItems =
|
||||
showPalette && search
|
||||
? filteredRepos.map((repo) => ({
|
||||
id: repo.path,
|
||||
label: repo.name,
|
||||
shortcut: undefined,
|
||||
action: () => openInEditor(repo),
|
||||
}))
|
||||
: commands
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setShowPalette((prev) => !prev)
|
||||
setSearch("")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
if (e.key === "Escape" && showPalette) {
|
||||
setShowPalette(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [showPalette])
|
||||
|
||||
useEffect(() => {
|
||||
if (showPalette && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [showPalette])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search])
|
||||
|
||||
const handlePaletteKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, paletteItems.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter" && paletteItems.length > 0) {
|
||||
paletteItems[selectedIndex]?.action()
|
||||
}
|
||||
}
|
||||
|
||||
const timeAgo = (ms: number) => {
|
||||
const seconds = Math.floor((Date.now() - ms) / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{showPalette && (
|
||||
<div className="palette-overlay" onClick={() => setShowPalette(false)}>
|
||||
<div className="palette" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="palette-input"
|
||||
placeholder="Search repos or type a command..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handlePaletteKeyDown}
|
||||
/>
|
||||
<div className="palette-commands">
|
||||
{paletteItems.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`palette-command ${index === selectedIndex ? "selected" : ""}`}
|
||||
onClick={item.action}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && <kbd className="palette-shortcut">{item.shortcut}</kbd>}
|
||||
</button>
|
||||
))}
|
||||
{paletteItems.length === 0 && <div className="palette-empty">No results</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="header">
|
||||
<div className="header-drag" />
|
||||
<h1>Repos</h1>
|
||||
<div className="header-right">
|
||||
<span className="sync-status">Local</span>
|
||||
<button className="header-btn" onClick={() => setShowPalette(true)}>
|
||||
<kbd>Cmd+K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Folders</span>
|
||||
<button className="icon-btn" onClick={addFolder} title="Add folder">+</button>
|
||||
</div>
|
||||
{folders.length === 0 ? (
|
||||
<div className="sidebar-empty">
|
||||
No folders added.<br />
|
||||
<button className="link-btn" onClick={addFolder}>Add a folder</button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="folder-list">
|
||||
{folders.map((path) => (
|
||||
<li key={path} className="folder-item">
|
||||
<span className="folder-path" title={path}>{path.split("/").pop()}</span>
|
||||
<button className="icon-btn remove" onClick={() => removeFolder(path)} title="Remove">×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button className="sidebar-refresh" onClick={scanRepos} disabled={isScanning || folders.length === 0}>
|
||||
{isScanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
{repos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{folders.length === 0 ? (
|
||||
<>
|
||||
<h2>No folders configured</h2>
|
||||
<p>Add folders where you keep your code to get started.</p>
|
||||
<button className="primary-btn" onClick={addFolder}>Add folder</button>
|
||||
</>
|
||||
) : isScanning ? (
|
||||
<>
|
||||
<div className="spinner" />
|
||||
<p>Scanning for repos...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>No repos found</h2>
|
||||
<p>No git repositories found in the configured folders.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="repo-list">
|
||||
{repos.map((repo) => (
|
||||
<li key={repo.path} className="repo-item">
|
||||
<div className="repo-info" onClick={() => openInEditor(repo)}>
|
||||
<span className="repo-name">{repo.name}</span>
|
||||
<span className="repo-path">{repo.path}</span>
|
||||
</div>
|
||||
<div className="repo-meta">
|
||||
<span className="repo-time">{timeAgo(repo.lastModified)}</span>
|
||||
<div className="repo-actions">
|
||||
<button className="action-btn" onClick={() => window.electronAPI.openInEditor(repo.path)} title="Open in VS Code">Code</button>
|
||||
<button className="action-btn" onClick={() => window.electronAPI.openInTerminal(repo.path)} title="Open in Terminal">Term</button>
|
||||
<button className="action-btn" onClick={() => window.electronAPI.showInFolder(repo.path)} title="Show in Finder">Finder</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
packages/desktop/src/renderer/env.d.ts
vendored
Normal file
21
packages/desktop/src/renderer/env.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
interface GitRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
pickFolder: () => Promise<string | null>;
|
||||
scanRepos: (folders: string[]) => Promise<GitRepo[]>;
|
||||
showInFolder: (path: string) => Promise<void>;
|
||||
openInEditor: (path: string) => Promise<void>;
|
||||
openInTerminal: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
86
packages/desktop/src/renderer/main.tsx
Normal file
86
packages/desktop/src/renderer/main.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { Suspense, Component, ReactNode } from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { JazzReactProvider } from "jazz-tools/react"
|
||||
import { AppAccount } from "../features/folders/model/schema"
|
||||
import { App } from "./App"
|
||||
import "./styles.css"
|
||||
|
||||
const container = document.getElementById("root")
|
||||
|
||||
if (!container) {
|
||||
throw new Error("Root container not found")
|
||||
}
|
||||
|
||||
const apiKey = import.meta.env.VITE_JAZZ_API_KEY as string | undefined
|
||||
const customPeer = import.meta.env.VITE_JAZZ_PEER as string | undefined
|
||||
const peer = customPeer ?? (apiKey ? (`wss://cloud.jazz.tools/?key=${apiKey}` as const) : undefined)
|
||||
const syncConfig = peer ? { peer } : { when: "never" as const }
|
||||
|
||||
// Loading state
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<div className="spinner" />
|
||||
<p>Connecting...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error boundary for Jazz errors
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback?: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("Jazz error:", error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<h2 style={{ color: "#ef4444", marginBottom: 8 }}>Connection Error</h2>
|
||||
<p style={{ color: "#888", maxWidth: 400, textAlign: "center", marginBottom: 16 }}>
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
<button
|
||||
className="primary-btn"
|
||||
onClick={() => {
|
||||
this.setState({ error: null })
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<JazzReactProvider
|
||||
AccountSchema={AppAccount}
|
||||
storage="indexedDB"
|
||||
defaultProfileName="Linsa Desktop"
|
||||
authSecretStorageKey="linsa-desktop-jazz"
|
||||
sync={syncConfig}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</JazzReactProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
619
packages/desktop/src/renderer/styles.css
Normal file
619
packages/desktop/src/renderer/styles.css
Normal file
@@ -0,0 +1,619 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--bg-secondary: #161616;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--border: #2a2a2a;
|
||||
--text: #e5e5e5;
|
||||
--text-secondary: #888;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-rows: 48px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.header-drag {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.header-btn kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sync-status.sync-connected {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.folder-path {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-refresh {
|
||||
margin: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-refresh:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.sidebar-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
padding: 18px 18px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Repo list */
|
||||
.repo-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.repo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.repo-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.repo-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.repo-path {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.repo-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.repo-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.repo-item:hover .repo-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 18px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.folder-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-input.has-error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chip-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.empty-inline {
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Command Palette */
|
||||
.palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 20vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.palette-commands {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.palette-command {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.palette-command:hover,
|
||||
.palette-command.selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.palette-shortcut {
|
||||
padding: 3px 6px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.palette-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
51
packages/desktop/src/shared/jazzSchema.ts
Normal file
51
packages/desktop/src/shared/jazzSchema.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { co, setDefaultSchemaPermissions, z } from "jazz-tools";
|
||||
|
||||
setDefaultSchemaPermissions({
|
||||
onInlineCreate: "sameAsContainer",
|
||||
});
|
||||
|
||||
export const LocalTrack = co.map({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
url: z.string(),
|
||||
addedAt: z.number(),
|
||||
lastPlayedAt: z.optional(z.number()),
|
||||
});
|
||||
export type LocalTrack = co.loaded<typeof LocalTrack>;
|
||||
|
||||
export const PlayerRoot = co
|
||||
.map({
|
||||
recentTracks: co.list(LocalTrack),
|
||||
lastPlayedTrackId: z.optional(z.string()),
|
||||
})
|
||||
.withPermissions({ onInlineCreate: "newGroup" });
|
||||
export type PlayerRoot = co.loaded<typeof PlayerRoot>;
|
||||
|
||||
export const PlayerAccount = co
|
||||
.account({
|
||||
profile: co.profile({
|
||||
avatar: co.optional(co.image()),
|
||||
}),
|
||||
root: PlayerRoot,
|
||||
})
|
||||
.withMigration(async (account) => {
|
||||
if (!account.$jazz.has("root")) {
|
||||
account.$jazz.set("root", {
|
||||
recentTracks: [],
|
||||
lastPlayedTrackId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.$jazz.has("profile")) {
|
||||
account.$jazz.set("profile", {
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
})
|
||||
.resolved({
|
||||
profile: true,
|
||||
root: {
|
||||
recentTracks: { $each: true },
|
||||
},
|
||||
});
|
||||
export type PlayerAccount = co.loaded<typeof PlayerAccount>;
|
||||
49
packages/desktop/src/shared/lib/jazz/context.ts
Normal file
49
packages/desktop/src/shared/lib/jazz/context.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { atom, computed, withSuspenseInit, wrap } from "@reatom/core"
|
||||
import { JazzContextManager, type AccountSchema, BrowserContext } from "jazz-tools"
|
||||
|
||||
export async function createElectronJazzApp<S extends AccountSchema>(opts: {
|
||||
AccountSchema: S
|
||||
sync?: { peer: string }
|
||||
}) {
|
||||
const contextManager = new JazzContextManager<S>()
|
||||
|
||||
await contextManager.createContext({
|
||||
...opts,
|
||||
guestMode: false,
|
||||
storage: "indexedDB",
|
||||
defaultProfileName: "Linsa Desktop",
|
||||
authSecretStorageKey: "linsa-desktop-auth",
|
||||
})
|
||||
|
||||
return contextManager
|
||||
}
|
||||
|
||||
// Jazz context as a Reatom atom
|
||||
const jazzApp = atom(async () => {
|
||||
const { AppAccount } = await wrap(import("@/features/folders/model/schema"))
|
||||
|
||||
return wrap(
|
||||
createElectronJazzApp({
|
||||
AccountSchema: AppAccount,
|
||||
// No sync for now - local only
|
||||
// sync: { peer: "wss://cloud.jazz.tools/?key=..." },
|
||||
})
|
||||
)
|
||||
}, "jazzApp").extend(withSuspenseInit())
|
||||
|
||||
export type JazzContextAtom = typeof jazzContext
|
||||
export const jazzContext = computed(() => {
|
||||
const contextManager = jazzApp()
|
||||
|
||||
const requireCurrentContext = () => {
|
||||
const currentValue = contextManager.getCurrentValue()
|
||||
if (!currentValue) throw new Error("Jazz context not ready")
|
||||
if (!("me" in currentValue)) throw new Error("Guest mode not supported")
|
||||
return currentValue as BrowserContext<typeof import("@/features/folders/model/schema").AppAccount>
|
||||
}
|
||||
|
||||
return {
|
||||
current: requireCurrentContext,
|
||||
manager: contextManager,
|
||||
}
|
||||
}, "jazzContext")
|
||||
104
packages/desktop/src/shared/lib/reatom/jazz.ts
Normal file
104
packages/desktop/src/shared/lib/reatom/jazz.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
type Atom,
|
||||
atom,
|
||||
reatomMap,
|
||||
type Rec,
|
||||
withConnectHook,
|
||||
withSuspenseInit,
|
||||
wrap,
|
||||
} from "@reatom/core"
|
||||
|
||||
import {
|
||||
co,
|
||||
coValueClassFromCoValueClassOrSchema,
|
||||
type CoValueClassOrSchema,
|
||||
loadCoValue,
|
||||
type ResolveQuery,
|
||||
type ResolveQueryStrict,
|
||||
subscribeToCoValue,
|
||||
} from "jazz-tools"
|
||||
|
||||
export const reatomJazz = <
|
||||
Schema extends CoValueClassOrSchema,
|
||||
Return extends Rec,
|
||||
const Resolve extends ResolveQuery<Schema> = true,
|
||||
>(options: {
|
||||
schema: Schema
|
||||
resolve?: ResolveQueryStrict<Schema, Resolve>
|
||||
create: (api: {
|
||||
loaded: co.loaded<Schema, Resolve>
|
||||
name: string
|
||||
target: Atom<{ co: co.loaded<Schema, Resolve> }>
|
||||
}) => Return
|
||||
onUnauthorized?: () => void
|
||||
onUnavailable?: () => void
|
||||
name?: string
|
||||
}) => {
|
||||
const { create, resolve, onUnauthorized, onUnavailable, name = "coValue" } =
|
||||
options
|
||||
|
||||
type AtomState = Return & { id: string; co: co.loaded<Schema, Resolve> }
|
||||
|
||||
const cache = reatomMap<string, Atom<AtomState> & { id: string }>(
|
||||
undefined,
|
||||
`${name}._cache`
|
||||
)
|
||||
|
||||
return (id: string) => {
|
||||
const factoryName = `${name}.${id}`
|
||||
return cache.getOrCreate(factoryName, () => {
|
||||
const stateAtom = atom(async () => {
|
||||
return loadCoValue(
|
||||
coValueClassFromCoValueClassOrSchema(options.schema),
|
||||
id,
|
||||
// @ts-expect-error resolve types
|
||||
{ resolve }
|
||||
)
|
||||
.then(
|
||||
wrap((result) => {
|
||||
if (result.$isLoaded) {
|
||||
const loaded = result as co.loaded<Schema, Resolve>
|
||||
const factoryReturn = create({
|
||||
loaded,
|
||||
name: factoryName,
|
||||
target: stateAtom,
|
||||
})
|
||||
return Object.assign(factoryReturn, { id, co: loaded })
|
||||
} else {
|
||||
throw new Error(`Failed to load ${factoryName}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(
|
||||
wrap((error) => {
|
||||
throw new Error(`Failed to build ${factoryName}: ${error}`)
|
||||
})
|
||||
)
|
||||
}, `${name}.loaded`).extend(
|
||||
withSuspenseInit(),
|
||||
withConnectHook((target) => {
|
||||
return subscribeToCoValue(
|
||||
coValueClassFromCoValueClassOrSchema(options.schema),
|
||||
id,
|
||||
{
|
||||
// @ts-expect-error resolve types
|
||||
resolve,
|
||||
onUnauthorized: wrap(() => onUnauthorized?.()),
|
||||
onUnavailable: wrap(() => onUnavailable?.()),
|
||||
},
|
||||
wrap((loaded: co.loaded<Schema, Resolve>) => {
|
||||
const factoryReturn = create({
|
||||
loaded,
|
||||
name: factoryName,
|
||||
target,
|
||||
})
|
||||
target.set(Object.assign(factoryReturn, { id, co: loaded }))
|
||||
})
|
||||
)
|
||||
}),
|
||||
() => ({ id })
|
||||
)
|
||||
return stateAtom
|
||||
})
|
||||
}
|
||||
}
|
||||
29
packages/desktop/tsconfig.json
Normal file
29
packages/desktop/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"electron.vite.config.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node", "vite/client"],
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["./src/shared/*"],
|
||||
"@renderer/*": ["./src/renderer/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5613 --strictPort",
|
||||
"dev": "vite dev --port 5625 --strictPort",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -299,6 +299,31 @@ async function seed() {
|
||||
ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text
|
||||
`)
|
||||
|
||||
// Create API keys table
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"key_hash" text NOT NULL UNIQUE,
|
||||
"name" text NOT NULL DEFAULT 'Default',
|
||||
"last_used_at" timestamptz,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
// Create bookmarks table
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "bookmarks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"url" text NOT NULL,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"tags" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
// ========== Seed nikiv user ==========
|
||||
const nikivUserId = "nikiv"
|
||||
const nikivEmail = "nikita.voloboev@gmail.com"
|
||||
|
||||
@@ -20,6 +20,7 @@ interface SettingsPanelProps {
|
||||
activeSection: SettingsSection
|
||||
onSelect: (section: SettingsSection) => void
|
||||
profile?: UserProfile | null | undefined
|
||||
showBilling?: boolean
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
@@ -66,7 +67,12 @@ export default function SettingsPanel({
|
||||
activeSection,
|
||||
onSelect,
|
||||
profile,
|
||||
showBilling = false,
|
||||
}: SettingsPanelProps) {
|
||||
const filteredNavItems = showBilling
|
||||
? navItems
|
||||
: navItems.filter((item) => item.id !== "billing")
|
||||
|
||||
return (
|
||||
<aside className="shrink-0 bg-transparent border border-white/5 rounded-2xl h-[calc(100vh-6em)] sticky top-6 px-2 py-4 items-start flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
@@ -78,7 +84,7 @@ export default function SettingsPanel({
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to app</span>
|
||||
</a>
|
||||
{navItems.map(({ id, label, icon: Icon }) => {
|
||||
{filteredNavItems.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = activeSection === id
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -572,3 +572,44 @@ export type CreatorTier = z.infer<typeof selectCreatorTierSchema>
|
||||
export type CreatorSubscription = z.infer<typeof selectCreatorSubscriptionSchema>
|
||||
export type CreatorProduct = z.infer<typeof selectCreatorProductSchema>
|
||||
export type CreatorPurchase = z.infer<typeof selectCreatorPurchaseSchema>
|
||||
|
||||
// =============================================================================
|
||||
// API Keys
|
||||
// =============================================================================
|
||||
|
||||
export const api_keys = pgTable("api_keys", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
key_hash: text("key_hash").notNull().unique(), // SHA-256 hash of the key
|
||||
name: text("name").notNull().default("Default"), // User-friendly name
|
||||
last_used_at: timestamp("last_used_at", { withTimezone: true }),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const selectApiKeySchema = createSelectSchema(api_keys)
|
||||
export type ApiKey = z.infer<typeof selectApiKeySchema>
|
||||
|
||||
// =============================================================================
|
||||
// Bookmarks
|
||||
// =============================================================================
|
||||
|
||||
export const bookmarks = pgTable("bookmarks", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
url: text("url").notNull(),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
tags: text("tags"), // Comma-separated tags
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const selectBookmarkSchema = createSelectSchema(bookmarks)
|
||||
export type Bookmark = z.infer<typeof selectBookmarkSchema>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const getAuth = () => {
|
||||
usePlural: true,
|
||||
schema,
|
||||
}),
|
||||
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
|
||||
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:5625"],
|
||||
plugins: [
|
||||
tanstackStartCookies(),
|
||||
emailOTP({
|
||||
|
||||
@@ -14,7 +14,7 @@ export const usersCollection = createCollection(
|
||||
"/api/users",
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000",
|
||||
: "http://localhost:5625",
|
||||
).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
@@ -28,7 +28,7 @@ export const usersCollection = createCollection(
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000"
|
||||
: "http://localhost:5625"
|
||||
|
||||
// Create collections lazily to avoid fetching before authentication
|
||||
// Using a factory pattern so each call gets the same collection instance
|
||||
|
||||
133
packages/web/src/routes/api/api-keys.ts
Normal file
133
packages/web/src/routes/api/api-keys.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createAPIFileRoute } from "@tanstack/react-start/api"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { api_keys } from "@/db/schema"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { headers } from "@tanstack/react-start/server"
|
||||
|
||||
// Generate a random API key
|
||||
function generateApiKey(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let key = "lk_" // linsa key prefix
|
||||
for (let i = 0; i < 32; i++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Hash function for API key storage
|
||||
async function hashApiKey(key: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(key)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
export const APIRoute = createAPIFileRoute("/api/api-keys")({
|
||||
// GET - List user's API keys (without the actual key, just metadata)
|
||||
GET: async () => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
const keys = await db
|
||||
.select({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
last_used_at: api_keys.last_used_at,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
.from(api_keys)
|
||||
.where(eq(api_keys.user_id, session.user.id))
|
||||
.orderBy(api_keys.created_at)
|
||||
|
||||
return Response.json({ keys })
|
||||
} catch (error) {
|
||||
console.error("Error fetching API keys:", error)
|
||||
return Response.json({ error: "Failed to fetch API keys" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
// POST - Create a new API key
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const name = body.name || "Default"
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Generate new key
|
||||
const plainKey = generateApiKey()
|
||||
const keyHash = await hashApiKey(plainKey)
|
||||
|
||||
// Insert key record
|
||||
const [keyRecord] = await db
|
||||
.insert(api_keys)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
key_hash: keyHash,
|
||||
name,
|
||||
})
|
||||
.returning({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
|
||||
// Return the plain key ONLY on creation (it won't be retrievable later)
|
||||
return Response.json({
|
||||
key: plainKey,
|
||||
id: keyRecord.id,
|
||||
name: keyRecord.name,
|
||||
created_at: keyRecord.created_at,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error)
|
||||
return Response.json({ error: "Failed to create API key" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
// DELETE - Revoke an API key
|
||||
DELETE: async ({ request }) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const keyId = url.searchParams.get("id")
|
||||
|
||||
if (!keyId) {
|
||||
return Response.json({ error: "Key ID is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Delete key (only if it belongs to the user)
|
||||
const [deleted] = await db
|
||||
.delete(api_keys)
|
||||
.where(eq(api_keys.id, keyId))
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Key not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error deleting API key:", error)
|
||||
return Response.json({ error: "Failed to delete API key" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
})
|
||||
124
packages/web/src/routes/api/bookmarks.ts
Normal file
124
packages/web/src/routes/api/bookmarks.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createAPIFileRoute } from "@tanstack/react-start/api"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { api_keys, bookmarks, users } from "@/db/schema"
|
||||
|
||||
// Hash function for API key verification
|
||||
async function hashApiKey(key: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(key)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
// Get user from API key
|
||||
async function getUserFromApiKey(apiKey: string) {
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
const keyHash = await hashApiKey(apiKey)
|
||||
|
||||
const [keyRecord] = await db
|
||||
.select({
|
||||
userId: api_keys.user_id,
|
||||
keyId: api_keys.id,
|
||||
})
|
||||
.from(api_keys)
|
||||
.where(eq(api_keys.key_hash, keyHash))
|
||||
.limit(1)
|
||||
|
||||
if (!keyRecord) return null
|
||||
|
||||
// Update last_used_at
|
||||
await db
|
||||
.update(api_keys)
|
||||
.set({ last_used_at: new Date() })
|
||||
.where(eq(api_keys.id, keyRecord.keyId))
|
||||
|
||||
// Get user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, keyRecord.userId))
|
||||
.limit(1)
|
||||
|
||||
return user || null
|
||||
}
|
||||
|
||||
export const APIRoute = createAPIFileRoute("/api/bookmarks")({
|
||||
// POST - Add a bookmark
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { url, title, description, tags, api_key } = body
|
||||
|
||||
if (!url) {
|
||||
return Response.json({ error: "URL is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!api_key) {
|
||||
return Response.json({ error: "API key is required" }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await getUserFromApiKey(api_key)
|
||||
if (!user) {
|
||||
return Response.json({ error: "Invalid API key" }, { status: 401 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Insert bookmark
|
||||
const [bookmark] = await db
|
||||
.insert(bookmarks)
|
||||
.values({
|
||||
user_id: user.id,
|
||||
url,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
tags: tags || null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
bookmark: {
|
||||
id: bookmark.id,
|
||||
url: bookmark.url,
|
||||
title: bookmark.title,
|
||||
created_at: bookmark.created_at,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error adding bookmark:", error)
|
||||
return Response.json({ error: "Failed to add bookmark" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
// GET - List bookmarks (requires API key in header)
|
||||
GET: async ({ request }) => {
|
||||
try {
|
||||
const apiKey = request.headers.get("x-api-key")
|
||||
|
||||
if (!apiKey) {
|
||||
return Response.json({ error: "API key is required" }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await getUserFromApiKey(apiKey)
|
||||
if (!user) {
|
||||
return Response.json({ error: "Invalid API key" }, { status: 401 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
const userBookmarks = await db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.user_id, user.id))
|
||||
.orderBy(bookmarks.created_at)
|
||||
|
||||
return Response.json({ bookmarks: userBookmarks })
|
||||
} catch (error) {
|
||||
console.error("Error fetching bookmarks:", error)
|
||||
return Response.json({ error: "Failed to fetch bookmarks" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -24,6 +24,9 @@ export const Route = createFileRoute("/settings")({
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
// Feature flag: enable billing section
|
||||
const BILLING_ENABLED = false
|
||||
|
||||
type Option = { value: string; label: string }
|
||||
|
||||
function InlineSelect({
|
||||
@@ -641,6 +644,12 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
||||
title="Streaming"
|
||||
description="Configure your live stream settings."
|
||||
/>
|
||||
<div className="mb-5 p-4 bg-purple-500/10 border border-purple-500/20 rounded-xl flex items-center gap-3">
|
||||
<span className="px-2 py-0.5 text-xs font-bold uppercase bg-purple-500 text-white rounded">Beta</span>
|
||||
<p className="text-sm text-purple-200">
|
||||
Streaming is currently in beta. Features may change and some functionality is still being developed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{loading ? (
|
||||
<div className="h-32 bg-white/5 rounded-2xl animate-pulse" />
|
||||
@@ -953,7 +962,11 @@ function BillingSection() {
|
||||
disabled={subscribing}
|
||||
className="w-full py-3 rounded-xl text-sm font-semibold bg-teal-500 hover:bg-teal-400 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{subscribing ? "Loading..." : "Subscribe Now"}
|
||||
{subscribing ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto" />
|
||||
) : (
|
||||
"Subscribe Now"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -992,7 +1005,7 @@ function SettingsPage() {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="min-h-screen text-white grid place-items-center">
|
||||
<p className="text-slate-400">Loading settings…</p>
|
||||
<div className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1005,6 +1018,7 @@ function SettingsPage() {
|
||||
activeSection={activeSection}
|
||||
onSelect={setActiveSection}
|
||||
profile={session?.user}
|
||||
showBilling={BILLING_ENABLED}
|
||||
/>
|
||||
<div className="flex-1 space-y-12 overflow-auto pr-1 pb-12">
|
||||
{activeSection === "preferences" ? (
|
||||
@@ -1017,7 +1031,7 @@ function SettingsPage() {
|
||||
/>
|
||||
) : activeSection === "streaming" ? (
|
||||
<StreamingSection username={session?.user?.username} />
|
||||
) : activeSection === "billing" ? (
|
||||
) : activeSection === "billing" && BILLING_ENABLED ? (
|
||||
<BillingSection />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
1060
pnpm-lock.yaml
generated
1060
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user