Add initial API documentation for Linsa API endpoints to docs/api.md

This commit is contained in:
Nikita
2025-12-28 11:45:07 -08:00
parent 3a2c78198a
commit c073fe6ee0
32 changed files with 4291 additions and 57 deletions

766
docs/api.md Normal file
View 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

View File

@@ -41,6 +41,7 @@ export default [
"**/node_modules/**", "**/node_modules/**",
"**/dist/**", "**/dist/**",
"**/.wrangler/**", "**/.wrangler/**",
"**/out/**",
"**/build/**", "**/build/**",
], ],
}, },

View File

@@ -362,6 +362,15 @@ description = "Start the web dev server on port 5613."
dependencies = ["node", "pnpm"] dependencies = ["node", "pnpm"]
shortcuts = ["d"] 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]] [[tasks]]
name = "deploy" name = "deploy"
command = """ command = """

View File

@@ -6,11 +6,13 @@
"packageManager": "pnpm@10.11.1", "packageManager": "pnpm@10.11.1",
"scripts": { "scripts": {
"dev:worker": "pnpm --filter @linsa/worker run dev", "dev:worker": "pnpm --filter @linsa/worker run dev",
"dev:desktop": "pnpm --filter @linsa/desktop run dev",
"deploy:worker": "pnpm --filter @linsa/worker run deploy", "deploy:worker": "pnpm --filter @linsa/worker run deploy",
"test:worker": "pnpm --filter @linsa/worker run test", "test:worker": "pnpm --filter @linsa/worker run test",
"dev:web": "pnpm --filter @linsa/web run dev", "dev:web": "pnpm --filter @linsa/web run dev",
"deploy:web": "pnpm --filter @linsa/web run deploy", "deploy:web": "pnpm --filter @linsa/web run deploy",
"test:web": "pnpm --filter @linsa/web run test", "test:web": "pnpm --filter @linsa/web run test",
"build:desktop": "pnpm --filter @linsa/desktop run build",
"dev": "pnpm dev:worker", "dev": "pnpm dev:worker",
"test": "pnpm -r test", "test": "pnpm -r test",
"lint": "pnpm -r lint && pnpm format:check", "lint": "pnpm -r lint && pnpm format:check",

View 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.

View 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()],
},
});

View 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>

View 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"
}
}

View 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"

View 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>

View 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")

View 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();
}
});

View 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),
});

View 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. Well 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>
)
}

View 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
View 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 {};

View 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>
)

View 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;
}

View 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>;

View 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")

View 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
})
}
}

View 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/*"]
}
}
}

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --port 5613 --strictPort", "dev": "vite dev --port 5625 --strictPort",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "vitest run", "test": "vitest run",

View File

@@ -299,6 +299,31 @@ async function seed() {
ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text 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 ========== // ========== Seed nikiv user ==========
const nikivUserId = "nikiv" const nikivUserId = "nikiv"
const nikivEmail = "nikita.voloboev@gmail.com" const nikivEmail = "nikita.voloboev@gmail.com"

View File

@@ -20,6 +20,7 @@ interface SettingsPanelProps {
activeSection: SettingsSection activeSection: SettingsSection
onSelect: (section: SettingsSection) => void onSelect: (section: SettingsSection) => void
profile?: UserProfile | null | undefined profile?: UserProfile | null | undefined
showBilling?: boolean
} }
type NavItem = { type NavItem = {
@@ -66,7 +67,12 @@ export default function SettingsPanel({
activeSection, activeSection,
onSelect, onSelect,
profile, profile,
showBilling = false,
}: SettingsPanelProps) { }: SettingsPanelProps) {
const filteredNavItems = showBilling
? navItems
: navItems.filter((item) => item.id !== "billing")
return ( 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"> <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"> <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" /> <ArrowLeft className="w-4 h-4" />
<span>Back to app</span> <span>Back to app</span>
</a> </a>
{navItems.map(({ id, label, icon: Icon }) => { {filteredNavItems.map(({ id, label, icon: Icon }) => {
const isActive = activeSection === id const isActive = activeSection === id
return ( return (
<button <button

View File

@@ -572,3 +572,44 @@ export type CreatorTier = z.infer<typeof selectCreatorTierSchema>
export type CreatorSubscription = z.infer<typeof selectCreatorSubscriptionSchema> export type CreatorSubscription = z.infer<typeof selectCreatorSubscriptionSchema>
export type CreatorProduct = z.infer<typeof selectCreatorProductSchema> export type CreatorProduct = z.infer<typeof selectCreatorProductSchema>
export type CreatorPurchase = z.infer<typeof selectCreatorPurchaseSchema> 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>

View File

@@ -84,7 +84,7 @@ export const getAuth = () => {
usePlural: true, usePlural: true,
schema, schema,
}), }),
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"], trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:5625"],
plugins: [ plugins: [
tanstackStartCookies(), tanstackStartCookies(),
emailOTP({ emailOTP({

View File

@@ -14,7 +14,7 @@ export const usersCollection = createCollection(
"/api/users", "/api/users",
typeof window !== "undefined" typeof window !== "undefined"
? window.location.origin ? window.location.origin
: "http://localhost:3000", : "http://localhost:5625",
).toString(), ).toString(),
parser: { parser: {
timestamptz: (date: string) => new Date(date), timestamptz: (date: string) => new Date(date),
@@ -28,7 +28,7 @@ export const usersCollection = createCollection(
const baseUrl = const baseUrl =
typeof window !== "undefined" typeof window !== "undefined"
? window.location.origin ? window.location.origin
: "http://localhost:3000" : "http://localhost:5625"
// Create collections lazily to avoid fetching before authentication // Create collections lazily to avoid fetching before authentication
// Using a factory pattern so each call gets the same collection instance // Using a factory pattern so each call gets the same collection instance

View 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 })
}
},
})

View 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 })
}
},
})

View File

@@ -24,6 +24,9 @@ export const Route = createFileRoute("/settings")({
ssr: false, ssr: false,
}) })
// Feature flag: enable billing section
const BILLING_ENABLED = false
type Option = { value: string; label: string } type Option = { value: string; label: string }
function InlineSelect({ function InlineSelect({
@@ -641,6 +644,12 @@ function StreamingSection({ username }: { username: string | null | undefined })
title="Streaming" title="Streaming"
description="Configure your live stream settings." 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"> <div className="space-y-5">
{loading ? ( {loading ? (
<div className="h-32 bg-white/5 rounded-2xl animate-pulse" /> <div className="h-32 bg-white/5 rounded-2xl animate-pulse" />
@@ -953,7 +962,11 @@ function BillingSection() {
disabled={subscribing} 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" 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> </button>
)} )}
</div> </div>
@@ -992,7 +1005,7 @@ function SettingsPage() {
if (isPending) { if (isPending) {
return ( return (
<div className="min-h-screen text-white grid place-items-center"> <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> </div>
) )
} }
@@ -1005,6 +1018,7 @@ function SettingsPage() {
activeSection={activeSection} activeSection={activeSection}
onSelect={setActiveSection} onSelect={setActiveSection}
profile={session?.user} profile={session?.user}
showBilling={BILLING_ENABLED}
/> />
<div className="flex-1 space-y-12 overflow-auto pr-1 pb-12"> <div className="flex-1 space-y-12 overflow-auto pr-1 pb-12">
{activeSection === "preferences" ? ( {activeSection === "preferences" ? (
@@ -1017,7 +1031,7 @@ function SettingsPage() {
/> />
) : activeSection === "streaming" ? ( ) : activeSection === "streaming" ? (
<StreamingSection username={session?.user?.username} /> <StreamingSection username={session?.user?.username} />
) : activeSection === "billing" ? ( ) : activeSection === "billing" && BILLING_ENABLED ? (
<BillingSection /> <BillingSection />
) : null} ) : null}
</div> </div>

1060
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff