diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..3291caca --- /dev/null +++ b/docs/api.md @@ -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= +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 diff --git a/eslint.config.js b/eslint.config.js index e6a69a48..5d69b87f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,7 @@ export default [ "**/node_modules/**", "**/dist/**", "**/.wrangler/**", + "**/out/**", "**/build/**", ], }, diff --git a/flow.toml b/flow.toml index d1f691c7..36efbd32 100644 --- a/flow.toml +++ b/flow.toml @@ -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 = """ diff --git a/package.json b/package.json index f69d2a0f..5e0ac8bc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/desktop/README.md b/packages/desktop/README.md new file mode 100644 index 00000000..fa1b4162 --- /dev/null +++ b/packages/desktop/README.md @@ -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. diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts new file mode 100644 index 00000000..55c72d40 --- /dev/null +++ b/packages/desktop/electron.vite.config.ts @@ -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()], + }, +}); diff --git a/packages/desktop/index.html b/packages/desktop/index.html new file mode 100644 index 00000000..b56c1f83 --- /dev/null +++ b/packages/desktop/index.html @@ -0,0 +1,12 @@ + + + + + + Linsa Desktop + + +
+ + + diff --git a/packages/desktop/package.json b/packages/desktop/package.json new file mode 100644 index 00000000..0ff68e86 --- /dev/null +++ b/packages/desktop/package.json @@ -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" + } +} diff --git a/packages/desktop/src/features/folders/model/atoms.ts b/packages/desktop/src/features/folders/model/atoms.ts new file mode 100644 index 00000000..04245816 --- /dev/null +++ b/packages/desktop/src/features/folders/model/atoms.ts @@ -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 + +// 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 + + 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 + + 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" diff --git a/packages/desktop/src/features/folders/model/schema.ts b/packages/desktop/src/features/folders/model/schema.ts new file mode 100644 index 00000000..3eab42b3 --- /dev/null +++ b/packages/desktop/src/features/folders/model/schema.ts @@ -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 + +// List of code folders +export const CodeFoldersList = co.list(CodeFolder) +export type CodeFoldersList = co.loaded + +// App root data +export const AppRoot = co.map({ + folders: CodeFoldersList, +}) +export type AppRoot = co.loaded + +// 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 diff --git a/packages/desktop/src/features/repos/model/atoms.ts b/packages/desktop/src/features/repos/model/atoms.ts new file mode 100644 index 00000000..d23bb72e --- /dev/null +++ b/packages/desktop/src/features/repos/model/atoms.ts @@ -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([], "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") diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts new file mode 100644 index 00000000..970b4831 --- /dev/null +++ b/packages/desktop/src/main/index.ts @@ -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 { + const repos: GitRepo[] = []; + + async function scan(currentPath: string, depth: number): Promise { + 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 => { + 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 => { + 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(); + 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(); + } +}); diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts new file mode 100644 index 00000000..6be12fa9 --- /dev/null +++ b/packages/desktop/src/preload/index.ts @@ -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, + scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise, + 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), +}); diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx new file mode 100644 index 00000000..2fcd00d1 --- /dev/null +++ b/packages/desktop/src/renderer/App.tsx @@ -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 ( +
+
+

Electron preload bridge is missing.

+
+ ) + } + + const me = useAccount(AppAccount, { + resolve: { root: { folders: { $each: { $onError: "catch" } } } }, + }) + + // Local state for repos (not synced via Jazz) + const [repos, setRepos] = useState([]) + const [isScanning, setIsScanning] = useState(false) + const [manualPath, setManualPath] = useState("") + const [pathError, setPathError] = useState(null) + + // UI state + const [showPalette, setShowPalette] = useState(false) + const [search, setSearch] = useState("") + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(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 ( +
+
+

Loading account...

+ +
+ ) + } + + return ( +
+ {/* Command Palette */} + {showPalette && ( +
setShowPalette(false)}> +
e.stopPropagation()}> + setSearch(e.target.value)} + onKeyDown={handlePaletteKeyDown} + /> +
+ {paletteItems.map((item, index) => ( + + ))} + {paletteItems.length === 0 &&
No results
} +
+
+
+ )} + + {/* Header */} +
+
+

Repos

+
+ Synced + +
+
+ +
+
+
+
+

Parent folders

+

Where to look for repos

+

Add one or more parent folders. We’ll scan them deeply for git repos.

+
+
{folderPaths.length} folders
+
+ +
+
+ +
+ setManualPath(e.target.value)} + placeholder="/Users/you/code" + className={`text-input ${pathError ? "has-error" : ""}`} + /> + + +
+ {pathError &&

{pathError}

} +
+
+ + {folderPaths.length === 0 ? ( +
+

No folders yet. Add at least one to start scanning.

+
+ ) : ( +
+ {folderPaths.map((path) => ( +
+ + {path} + + +
+ ))} +
+ )} + +
+ +

Deep git parsing will be added next.

+
+
+ + {repos.length === 0 ? ( +
+ {folderPaths.length === 0 ? ( + <> +

No folders configured

+

Add folders where you keep your code to get started.

+ + + ) : isScanning ? ( + <> +
+

Scanning for repos...

+ + ) : ( + <> +

No repos found

+

No git repositories found in the configured folders.

+ + )} +
+ ) : ( +
    + {repos.map((repo) => ( +
  • +
    openInEditor(repo)}> + {repo.name} + {repo.path} +
    +
    + {timeAgo(repo.lastModified)} +
    + + + +
    +
    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/renderer/AppSimple.tsx b/packages/desktop/src/renderer/AppSimple.tsx new file mode 100644 index 00000000..564c4a63 --- /dev/null +++ b/packages/desktop/src/renderer/AppSimple.tsx @@ -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(() => { + try { + const stored = localStorage.getItem("repo-folders") + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } +}, "folders") + +const reposAtom = atom([], "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(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 ( +
+ {showPalette && ( +
setShowPalette(false)}> +
e.stopPropagation()}> + setSearch(e.target.value)} + onKeyDown={handlePaletteKeyDown} + /> +
+ {paletteItems.map((item, index) => ( + + ))} + {paletteItems.length === 0 &&
No results
} +
+
+
+ )} + +
+
+

Repos

+
+ Local + +
+
+ + + +
+ {repos.length === 0 ? ( +
+ {folders.length === 0 ? ( + <> +

No folders configured

+

Add folders where you keep your code to get started.

+ + + ) : isScanning ? ( + <> +
+

Scanning for repos...

+ + ) : ( + <> +

No repos found

+

No git repositories found in the configured folders.

+ + )} +
+ ) : ( +
    + {repos.map((repo) => ( +
  • +
    openInEditor(repo)}> + {repo.name} + {repo.path} +
    +
    + {timeAgo(repo.lastModified)} +
    + + + +
    +
    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts new file mode 100644 index 00000000..883d33a3 --- /dev/null +++ b/packages/desktop/src/renderer/env.d.ts @@ -0,0 +1,21 @@ +interface GitRepo { + name: string; + path: string; + lastModified: number; +} + +interface ElectronAPI { + pickFolder: () => Promise; + scanRepos: (folders: string[]) => Promise; + showInFolder: (path: string) => Promise; + openInEditor: (path: string) => Promise; + openInTerminal: (path: string) => Promise; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} + +export {}; diff --git a/packages/desktop/src/renderer/main.tsx b/packages/desktop/src/renderer/main.tsx new file mode 100644 index 00000000..9a0f864c --- /dev/null +++ b/packages/desktop/src/renderer/main.tsx @@ -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 ( +
+
+

Connecting...

+
+ ) +} + +// 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 ( +
+

Connection Error

+

+ {this.state.error.message} +

+ +
+ ) + } + return this.props.children + } +} + +ReactDOM.createRoot(container).render( + + + + }> + + + + + + + +) diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css new file mode 100644 index 00000000..68f80dcc --- /dev/null +++ b/packages/desktop/src/renderer/styles.css @@ -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; +} diff --git a/packages/desktop/src/shared/jazzSchema.ts b/packages/desktop/src/shared/jazzSchema.ts new file mode 100644 index 00000000..9c9f5366 --- /dev/null +++ b/packages/desktop/src/shared/jazzSchema.ts @@ -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; + +export const PlayerRoot = co + .map({ + recentTracks: co.list(LocalTrack), + lastPlayedTrackId: z.optional(z.string()), + }) + .withPermissions({ onInlineCreate: "newGroup" }); +export type PlayerRoot = co.loaded; + +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; diff --git a/packages/desktop/src/shared/lib/jazz/context.ts b/packages/desktop/src/shared/lib/jazz/context.ts new file mode 100644 index 00000000..95917b9c --- /dev/null +++ b/packages/desktop/src/shared/lib/jazz/context.ts @@ -0,0 +1,49 @@ +import { atom, computed, withSuspenseInit, wrap } from "@reatom/core" +import { JazzContextManager, type AccountSchema, BrowserContext } from "jazz-tools" + +export async function createElectronJazzApp(opts: { + AccountSchema: S + sync?: { peer: string } +}) { + const contextManager = new JazzContextManager() + + 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 + } + + return { + current: requireCurrentContext, + manager: contextManager, + } +}, "jazzContext") diff --git a/packages/desktop/src/shared/lib/reatom/jazz.ts b/packages/desktop/src/shared/lib/reatom/jazz.ts new file mode 100644 index 00000000..553cd982 --- /dev/null +++ b/packages/desktop/src/shared/lib/reatom/jazz.ts @@ -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 = true, +>(options: { + schema: Schema + resolve?: ResolveQueryStrict + create: (api: { + loaded: co.loaded + name: string + target: Atom<{ co: co.loaded }> + }) => Return + onUnauthorized?: () => void + onUnavailable?: () => void + name?: string +}) => { + const { create, resolve, onUnauthorized, onUnavailable, name = "coValue" } = + options + + type AtomState = Return & { id: string; co: co.loaded } + + const cache = reatomMap & { 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 + 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) => { + const factoryReturn = create({ + loaded, + name: factoryName, + target, + }) + target.set(Object.assign(factoryReturn, { id, co: loaded })) + }) + ) + }), + () => ({ id }) + ) + return stateAtom + }) + } +} diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json new file mode 100644 index 00000000..500e8eba --- /dev/null +++ b/packages/desktop/tsconfig.json @@ -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/*"] + } + } +} diff --git a/packages/web/package.json b/packages/web/package.json index 1c545963..a5074ec0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/scripts/seed.ts b/packages/web/scripts/seed.ts index 3dd0e333..510e36bf 100644 --- a/packages/web/scripts/seed.ts +++ b/packages/web/scripts/seed.ts @@ -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" diff --git a/packages/web/src/components/Settings-panel.tsx b/packages/web/src/components/Settings-panel.tsx index 223c7de4..d6a95665 100644 --- a/packages/web/src/components/Settings-panel.tsx +++ b/packages/web/src/components/Settings-panel.tsx @@ -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 (