mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 14:49:39 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634e00264e | ||
|
|
fc2a94a2e5 | ||
|
|
d8bc6348d5 | ||
|
|
ac32026cfc | ||
|
|
ec9ff3336a | ||
|
|
b27702797f | ||
|
|
c8aa3d5d4e | ||
|
|
bca97a5284 | ||
|
|
1bdc75e37b | ||
|
|
c5e3bd0633 | ||
|
|
27f34db777 | ||
|
|
fca741ab3f | ||
|
|
2de9766597 | ||
|
|
fbe2f356cf | ||
|
|
5bbc0446a7 | ||
|
|
1906f3b53f | ||
|
|
4e0e0e6e92 | ||
|
|
781394bbcd |
1084
package-lock.json
generated
1084
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||
@@ -33,14 +34,17 @@
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -54,12 +58,12 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"postcss": "^8.4.32",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const { spawn } = require('child_process');
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings } = options;
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
|
||||
@@ -36,15 +36,38 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
args.push('--model', 'sonnet');
|
||||
}
|
||||
|
||||
// Add permission mode if specified (works for both new and resumed sessions)
|
||||
if (permissionMode && permissionMode !== 'default') {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
console.log('🔒 Using permission mode:', permissionMode);
|
||||
}
|
||||
|
||||
// Add tools settings flags
|
||||
if (settings.skipPermissions) {
|
||||
// Don't use --dangerously-skip-permissions when in plan mode
|
||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
|
||||
} else {
|
||||
// Only add allowed/disallowed tools if not skipping permissions
|
||||
|
||||
// Collect all allowed tools, including plan mode defaults
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// Add plan mode specific tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
|
||||
// Add plan mode tools that aren't already in the allowed list
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
}
|
||||
}
|
||||
console.log('📝 Plan mode: Added default allowed tools:', planModeTools);
|
||||
}
|
||||
|
||||
// Add allowed tools
|
||||
if (settings.allowedTools && settings.allowedTools.length > 0) {
|
||||
for (const tool of settings.allowedTools) {
|
||||
if (allowedTools.length > 0) {
|
||||
for (const tool of allowedTools) {
|
||||
args.push('--allowedTools', tool);
|
||||
console.log('✅ Allowing tool:', tool);
|
||||
}
|
||||
@@ -57,6 +80,11 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
console.log('❌ Disallowing tool:', tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Log when skip permissions is disabled due to plan mode
|
||||
if (settings.skipPermissions && permissionMode === 'plan') {
|
||||
console.log('📝 Skip permissions disabled due to plan mode');
|
||||
}
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
@@ -201,7 +229,7 @@ function abortClaudeSession(sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
spawnClaude,
|
||||
abortClaudeSession
|
||||
};
|
||||
86
server/database/db.js
Normal file
86
server/database/db.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'auth.db');
|
||||
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
||||
|
||||
// Create database connection
|
||||
const db = new Database(DB_PATH);
|
||||
console.log('Connected to SQLite database');
|
||||
|
||||
// Initialize database with schema
|
||||
const initializeDatabase = async () => {
|
||||
try {
|
||||
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
||||
db.exec(initSQL);
|
||||
console.log('Database initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing database:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// User database operations
|
||||
const userDb = {
|
||||
// Check if any users exist
|
||||
hasUsers: () => {
|
||||
try {
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
return row.count > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new user
|
||||
createUser: (username, passwordHash) => {
|
||||
try {
|
||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
const result = stmt.run(username, passwordHash);
|
||||
return { id: result.lastInsertRowid, username };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get user by username
|
||||
getUserByUsername: (username) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Update last login time
|
||||
updateLastLogin: (userId) => {
|
||||
try {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get user by ID
|
||||
getUserById: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
db,
|
||||
initializeDatabase,
|
||||
userDb
|
||||
};
|
||||
16
server/database/init.sql
Normal file
16
server/database/init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Initialize authentication database
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Users table (single user system)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
222
server/index.js
222
server/index.js
@@ -1,7 +1,13 @@
|
||||
// Load environment variables from .env file
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
@@ -19,28 +25,31 @@ try {
|
||||
|
||||
console.log('PORT from env:', process.env.PORT);
|
||||
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs').promises;
|
||||
const { spawn } = require('child_process');
|
||||
const os = require('os');
|
||||
const pty = require('node-pty');
|
||||
const fetch = require('node-fetch');
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import cors from 'cors';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
import pty from 'node-pty';
|
||||
import fetch from 'node-fetch';
|
||||
import mime from 'mime-types';
|
||||
|
||||
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
|
||||
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
|
||||
const gitRoutes = require('./routes/git');
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
|
||||
import gitRoutes from './routes/git.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
|
||||
// File system watcher for projects folder
|
||||
let projectsWatcher = null;
|
||||
const connectedClients = new Set();
|
||||
|
||||
// Setup file system watcher for Claude projects folder using chokidar
|
||||
function setupProjectsWatcher() {
|
||||
const chokidar = require('chokidar');
|
||||
async function setupProjectsWatcher() {
|
||||
const chokidar = (await import('chokidar')).default;
|
||||
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
||||
|
||||
if (projectsWatcher) {
|
||||
@@ -76,6 +85,9 @@ function setupProjectsWatcher() {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
|
||||
// Clear project directory cache when files change
|
||||
clearProjectDirectoryCache();
|
||||
|
||||
// Get updated projects list
|
||||
const updatedProjects = await getProjects();
|
||||
|
||||
@@ -139,19 +151,43 @@ const wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: (info) => {
|
||||
console.log('WebSocket connection attempt to:', info.req.url);
|
||||
return true; // Accept all connections for now
|
||||
|
||||
// Extract token from query parameters or headers
|
||||
const url = new URL(info.req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token') ||
|
||||
info.req.headers.authorization?.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const user = authenticateWebSocket(token);
|
||||
if (!user) {
|
||||
console.log('❌ WebSocket authentication failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store user info in the request for later use
|
||||
info.req.user = user;
|
||||
console.log('✅ WebSocket authenticated for user:', user.username);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Optional API key validation (if configured)
|
||||
app.use('/api', validateApiKey);
|
||||
|
||||
// Authentication routes (public)
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Git API Routes (protected)
|
||||
app.use('/api/git', authenticateToken, gitRoutes);
|
||||
|
||||
// Static files served after API routes
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
// Git API Routes
|
||||
app.use('/api/git', gitRoutes);
|
||||
|
||||
// API Routes
|
||||
app.get('/api/config', (req, res) => {
|
||||
// API Routes (protected)
|
||||
app.get('/api/config', authenticateToken, (req, res) => {
|
||||
// Always use the server's actual IP and port for WebSocket connections
|
||||
const serverIP = getServerIP();
|
||||
const host = `${serverIP}:${PORT}`;
|
||||
@@ -165,7 +201,7 @@ app.get('/api/config', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/projects', async (req, res) => {
|
||||
app.get('/api/projects', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const projects = await getProjects();
|
||||
res.json(projects);
|
||||
@@ -174,7 +210,7 @@ app.get('/api/projects', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectName/sessions', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { limit = 5, offset = 0 } = req.query;
|
||||
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
||||
@@ -185,7 +221,7 @@ app.get('/api/projects/:projectName/sessions', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get messages for a specific session
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const messages = await getSessionMessages(projectName, sessionId);
|
||||
@@ -196,7 +232,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, r
|
||||
});
|
||||
|
||||
// Rename project endpoint
|
||||
app.put('/api/projects/:projectName/rename', async (req, res) => {
|
||||
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { displayName } = req.body;
|
||||
await renameProject(req.params.projectName, displayName);
|
||||
@@ -207,7 +243,7 @@ app.put('/api/projects/:projectName/rename', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete session endpoint
|
||||
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
|
||||
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
await deleteSession(projectName, sessionId);
|
||||
@@ -218,7 +254,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) =>
|
||||
});
|
||||
|
||||
// Delete project endpoint (only if empty)
|
||||
app.delete('/api/projects/:projectName', async (req, res) => {
|
||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
await deleteProject(projectName);
|
||||
@@ -229,7 +265,7 @@ app.delete('/api/projects/:projectName', async (req, res) => {
|
||||
});
|
||||
|
||||
// Create project endpoint
|
||||
app.post('/api/projects/create', async (req, res) => {
|
||||
app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: projectPath } = req.body;
|
||||
|
||||
@@ -246,21 +282,21 @@ app.post('/api/projects/create', async (req, res) => {
|
||||
});
|
||||
|
||||
// Read file content endpoint
|
||||
app.get('/api/projects/:projectName/file', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath } = req.query;
|
||||
|
||||
console.log('📄 File read request:', projectName, filePath);
|
||||
|
||||
const fs = require('fs').promises;
|
||||
// Using fsPromises from import
|
||||
|
||||
// Security check - ensure the path is safe and absolute
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const content = await fsPromises.readFile(filePath, 'utf8');
|
||||
res.json({ content, path: filePath });
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
@@ -275,15 +311,15 @@ app.get('/api/projects/:projectName/file', async (req, res) => {
|
||||
});
|
||||
|
||||
// Serve binary file content endpoint (for images, etc.)
|
||||
app.get('/api/projects/:projectName/files/content', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { path: filePath } = req.query;
|
||||
|
||||
console.log('🖼️ Binary file serve request:', projectName, filePath);
|
||||
|
||||
const fs = require('fs');
|
||||
const mime = require('mime-types');
|
||||
// Using fs from import
|
||||
// Using mime from import
|
||||
|
||||
// Security check - ensure the path is safe and absolute
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
@@ -292,7 +328,7 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
await fsPromises.access(filePath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
@@ -321,14 +357,14 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
|
||||
});
|
||||
|
||||
// Save file content endpoint
|
||||
app.put('/api/projects/:projectName/file', async (req, res) => {
|
||||
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath, content } = req.body;
|
||||
|
||||
console.log('💾 File save request:', projectName, filePath);
|
||||
|
||||
const fs = require('fs').promises;
|
||||
// Using fsPromises from import
|
||||
|
||||
// Security check - ensure the path is safe and absolute
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
@@ -342,14 +378,14 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
|
||||
// Create backup of original file
|
||||
try {
|
||||
const backupPath = filePath + '.backup.' + Date.now();
|
||||
await fs.copyFile(filePath, backupPath);
|
||||
await fsPromises.copyFile(filePath, backupPath);
|
||||
console.log('📋 Created backup:', backupPath);
|
||||
} catch (backupError) {
|
||||
console.warn('Could not create backup:', backupError.message);
|
||||
}
|
||||
|
||||
// Write the new content
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
await fsPromises.writeFile(filePath, content, 'utf8');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -368,56 +404,24 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectName/files', async (req, res) => {
|
||||
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
|
||||
|
||||
// Try different methods to get the actual project path
|
||||
let actualPath = projectPath;
|
||||
// Using fsPromises from import
|
||||
|
||||
// Use extractProjectDirectory to get the actual project path
|
||||
let actualPath;
|
||||
try {
|
||||
// First try to read metadata.json
|
||||
const metadataPath = path.join(projectPath, 'metadata.json');
|
||||
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
||||
actualPath = metadata.path || metadata.cwd;
|
||||
} catch (e) {
|
||||
// Fallback: try to find the actual path by testing different dash interpretations
|
||||
let testPath = req.params.projectName;
|
||||
if (testPath.startsWith('-')) {
|
||||
testPath = testPath.substring(1);
|
||||
}
|
||||
|
||||
// Try to intelligently decode the path by testing which directories exist
|
||||
const pathParts = testPath.split('-');
|
||||
actualPath = '/' + pathParts.join('/');
|
||||
|
||||
// If the simple replacement doesn't work, try to find the correct path
|
||||
// by testing combinations where some dashes might be part of directory names
|
||||
if (!require('fs').existsSync(actualPath)) {
|
||||
// Try different combinations of dash vs slash
|
||||
for (let i = pathParts.length - 1; i >= 0; i--) {
|
||||
let testParts = [...pathParts];
|
||||
// Try joining some parts with dashes instead of slashes
|
||||
for (let j = i; j < testParts.length - 1; j++) {
|
||||
testParts[j] = testParts[j] + '-' + testParts[j + 1];
|
||||
testParts.splice(j + 1, 1);
|
||||
let testActualPath = '/' + testParts.join('/');
|
||||
if (require('fs').existsSync(testActualPath)) {
|
||||
actualPath = testActualPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (require('fs').existsSync(actualPath)) break;
|
||||
}
|
||||
}
|
||||
|
||||
actualPath = await extractProjectDirectory(req.params.projectName);
|
||||
} catch (error) {
|
||||
console.error('Error extracting project directory:', error);
|
||||
// Fallback to simple dash replacement
|
||||
actualPath = req.params.projectName.replace(/-/g, '/');
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
await fs.access(actualPath);
|
||||
await fsPromises.access(actualPath);
|
||||
} catch (e) {
|
||||
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
||||
}
|
||||
@@ -438,12 +442,16 @@ wss.on('connection', (ws, request) => {
|
||||
const url = request.url;
|
||||
console.log('🔗 Client connected to:', url);
|
||||
|
||||
if (url === '/shell') {
|
||||
// Parse URL to get pathname without query parameters
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
if (pathname === '/shell') {
|
||||
handleShellConnection(ws);
|
||||
} else if (url === '/ws') {
|
||||
} else if (pathname === '/ws') {
|
||||
handleChatConnection(ws);
|
||||
} else {
|
||||
console.log('❌ Unknown WebSocket path:', url);
|
||||
console.log('❌ Unknown WebSocket path:', pathname);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
@@ -658,9 +666,9 @@ function handleShellConnection(ws) {
|
||||
});
|
||||
}
|
||||
// Audio transcription endpoint
|
||||
app.post('/api/transcribe', async (req, res) => {
|
||||
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const multer = require('multer');
|
||||
const multer = (await import('multer')).default;
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// Handle multipart form data
|
||||
@@ -680,7 +688,7 @@ app.post('/api/transcribe', async (req, res) => {
|
||||
|
||||
try {
|
||||
// Create form data for OpenAI
|
||||
const FormData = require('form-data');
|
||||
const FormData = (await import('form-data')).default;
|
||||
const formData = new FormData();
|
||||
formData.append('file', req.file.buffer, {
|
||||
filename: req.file.originalname,
|
||||
@@ -691,7 +699,6 @@ app.post('/api/transcribe', async (req, res) => {
|
||||
formData.append('language', 'en');
|
||||
|
||||
// Make request to OpenAI
|
||||
const fetch = require('node-fetch');
|
||||
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -724,7 +731,7 @@ app.post('/api/transcribe', async (req, res) => {
|
||||
|
||||
// Handle different enhancement modes
|
||||
try {
|
||||
const OpenAI = require('openai');
|
||||
const OpenAI = (await import('openai')).default;
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
|
||||
@@ -814,11 +821,11 @@ app.get('*', (req, res) => {
|
||||
});
|
||||
|
||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||
const fs = require('fs').promises;
|
||||
// Using fsPromises from import
|
||||
const items = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Debug: log all entries including hidden files
|
||||
@@ -839,7 +846,7 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
// Recursively get subdirectories but limit depth
|
||||
try {
|
||||
// Check if we can access the directory before trying to read it
|
||||
await fs.access(item.path, fs.constants.R_OK);
|
||||
await fsPromises.access(item.path, fs.constants.R_OK);
|
||||
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||
} catch (e) {
|
||||
// Silently skip directories we can't access (permission denied, etc.)
|
||||
@@ -865,9 +872,24 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
||||
|
||||
// Start watching the projects folder for changes
|
||||
setupProjectsWatcher();
|
||||
});
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize authentication database
|
||||
await initializeDatabase();
|
||||
console.log('✅ Database initialization skipped (testing)');
|
||||
|
||||
server.listen(PORT, '0.0.0.0', async () => {
|
||||
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
||||
|
||||
// Start watching the projects folder for changes
|
||||
await setupProjectsWatcher(); // Re-enabled with better-sqlite3
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
80
server/middleware/auth.js
Normal file
80
server/middleware/auth.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb } from '../database/db.js';
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
|
||||
// Optional API key middleware
|
||||
const validateApiKey = (req, res, next) => {
|
||||
// Skip API key validation if not configured
|
||||
if (!process.env.API_KEY) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey !== process.env.API_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Verify user still exists and is active
|
||||
const user = userDb.getUserById(decoded.userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
// Generate JWT token (never expires)
|
||||
const generateToken = (user) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username
|
||||
},
|
||||
JWT_SECRET
|
||||
// No expiration - token lasts forever
|
||||
);
|
||||
};
|
||||
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
console.error('WebSocket token verification error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
validateApiKey,
|
||||
authenticateToken,
|
||||
generateToken,
|
||||
authenticateWebSocket,
|
||||
JWT_SECRET
|
||||
};
|
||||
@@ -1,6 +1,18 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
import { promises as fs } from 'fs';
|
||||
import fsSync from 'fs';
|
||||
import path from 'path';
|
||||
import readline from 'readline';
|
||||
|
||||
// Cache for extracted project directories
|
||||
const projectDirectoryCache = new Map();
|
||||
let cacheTimestamp = Date.now();
|
||||
|
||||
// Clear cache when needed (called when project files change)
|
||||
function clearProjectDirectoryCache() {
|
||||
projectDirectoryCache.clear();
|
||||
cacheTimestamp = Date.now();
|
||||
console.log('🗑️ Project directory cache cleared');
|
||||
}
|
||||
|
||||
// Load project configuration file
|
||||
async function loadProjectConfig() {
|
||||
@@ -21,9 +33,9 @@ async function saveProjectConfig(config) {
|
||||
}
|
||||
|
||||
// Generate better display name from path
|
||||
async function generateDisplayName(projectName) {
|
||||
// Convert "-home-user-projects-myapp" to a readable format
|
||||
let projectPath = projectName.replace(/-/g, '/');
|
||||
async function generateDisplayName(projectName, actualProjectDir = null) {
|
||||
// Use actual project directory if provided, otherwise decode from project name
|
||||
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
||||
|
||||
// Try to read package.json from the project path
|
||||
try {
|
||||
@@ -54,6 +66,111 @@ async function generateDisplayName(projectName) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Extract the actual project directory from JSONL sessions (with caching)
|
||||
async function extractProjectDirectory(projectName) {
|
||||
// Check cache first
|
||||
if (projectDirectoryCache.has(projectName)) {
|
||||
return projectDirectoryCache.get(projectName);
|
||||
}
|
||||
|
||||
console.log(`🔍 Extracting project directory for: ${projectName}`);
|
||||
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const cwdCounts = new Map();
|
||||
let latestTimestamp = 0;
|
||||
let latestCwd = null;
|
||||
let extractedPath;
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
||||
|
||||
if (jsonlFiles.length === 0) {
|
||||
// Fall back to decoded project name if no sessions
|
||||
extractedPath = projectName.replace(/-/g, '/');
|
||||
} else {
|
||||
// Process all JSONL files to collect cwd values
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const fileStream = fsSync.createReadStream(jsonlFile);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
if (entry.cwd) {
|
||||
// Count occurrences of each cwd
|
||||
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
||||
|
||||
// Track the most recent cwd
|
||||
const timestamp = new Date(entry.timestamp || 0).getTime();
|
||||
if (timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
latestCwd = entry.cwd;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the best cwd to use
|
||||
if (cwdCounts.size === 0) {
|
||||
// No cwd found, fall back to decoded project name
|
||||
extractedPath = projectName.replace(/-/g, '/');
|
||||
} else if (cwdCounts.size === 1) {
|
||||
// Only one cwd, use it
|
||||
extractedPath = Array.from(cwdCounts.keys())[0];
|
||||
} else {
|
||||
// Multiple cwd values - prefer the most recent one if it has reasonable usage
|
||||
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
||||
const maxCount = Math.max(...cwdCounts.values());
|
||||
|
||||
// Use most recent if it has at least 25% of the max count
|
||||
if (mostRecentCount >= maxCount * 0.25) {
|
||||
extractedPath = latestCwd;
|
||||
} else {
|
||||
// Otherwise use the most frequently used cwd
|
||||
for (const [cwd, count] of cwdCounts.entries()) {
|
||||
if (count === maxCount) {
|
||||
extractedPath = cwd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (shouldn't reach here)
|
||||
if (!extractedPath) {
|
||||
extractedPath = latestCwd || projectName.replace(/-/g, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
projectDirectoryCache.set(projectName, extractedPath);
|
||||
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
|
||||
|
||||
return extractedPath;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
// Fall back to decoded project name
|
||||
extractedPath = projectName.replace(/-/g, '/');
|
||||
|
||||
// Cache the fallback result too
|
||||
projectDirectoryCache.set(projectName, extractedPath);
|
||||
|
||||
return extractedPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjects() {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
@@ -69,14 +186,17 @@ async function getProjects() {
|
||||
existingProjects.add(entry.name);
|
||||
const projectPath = path.join(claudeDir, entry.name);
|
||||
|
||||
// Extract actual project directory from JSONL sessions
|
||||
const actualProjectDir = await extractProjectDirectory(entry.name);
|
||||
|
||||
// Get display name from config or generate one
|
||||
const customName = config[entry.name]?.displayName;
|
||||
const autoDisplayName = await generateDisplayName(entry.name);
|
||||
const fullPath = entry.name.replace(/-/g, '/');
|
||||
const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
|
||||
const fullPath = actualProjectDir;
|
||||
|
||||
const project = {
|
||||
name: entry.name,
|
||||
path: projectPath,
|
||||
path: actualProjectDir,
|
||||
displayName: customName || autoDisplayName,
|
||||
fullPath: fullPath,
|
||||
isCustomName: !!customName,
|
||||
@@ -105,17 +225,27 @@ async function getProjects() {
|
||||
// Add manually configured projects that don't exist as folders yet
|
||||
for (const [projectName, projectConfig] of Object.entries(config)) {
|
||||
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
|
||||
const fullPath = projectName.replace(/-/g, '/');
|
||||
// Use the original path if available, otherwise extract from potential sessions
|
||||
let actualProjectDir = projectConfig.originalPath;
|
||||
|
||||
const project = {
|
||||
name: projectName,
|
||||
path: null, // No physical path yet
|
||||
displayName: projectConfig.displayName || await generateDisplayName(projectName),
|
||||
fullPath: fullPath,
|
||||
isCustomName: !!projectConfig.displayName,
|
||||
isManuallyAdded: true,
|
||||
sessions: []
|
||||
};
|
||||
if (!actualProjectDir) {
|
||||
try {
|
||||
actualProjectDir = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
// Fall back to decoded project name
|
||||
actualProjectDir = projectName.replace(/-/g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
const project = {
|
||||
name: projectName,
|
||||
path: actualProjectDir,
|
||||
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
|
||||
fullPath: actualProjectDir,
|
||||
isCustomName: !!projectConfig.displayName,
|
||||
isManuallyAdded: true,
|
||||
sessions: []
|
||||
};
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
@@ -196,7 +326,7 @@ async function parseJsonlSessions(filePath) {
|
||||
const sessions = new Map();
|
||||
|
||||
try {
|
||||
const fileStream = require('fs').createReadStream(filePath);
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
@@ -280,7 +410,7 @@ async function getSessionMessages(projectName, sessionId) {
|
||||
// Process all JSONL files to find messages for this session
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const fileStream = require('fs').createReadStream(jsonlFile);
|
||||
const fileStream = fsSync.createReadStream(jsonlFile);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
@@ -463,16 +593,16 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
|
||||
return {
|
||||
name: projectName,
|
||||
path: null,
|
||||
path: absolutePath,
|
||||
fullPath: absolutePath,
|
||||
displayName: displayName || await generateDisplayName(projectName),
|
||||
displayName: displayName || await generateDisplayName(projectName, absolutePath),
|
||||
isManuallyAdded: true,
|
||||
sessions: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
getProjects,
|
||||
getSessions,
|
||||
getSessionMessages,
|
||||
@@ -483,5 +613,7 @@ module.exports = {
|
||||
deleteProject,
|
||||
addProjectManually,
|
||||
loadProjectConfig,
|
||||
saveProjectConfig
|
||||
saveProjectConfig,
|
||||
extractProjectDirectory,
|
||||
clearProjectDirectoryCache
|
||||
};
|
||||
125
server/routes/auth.js
Normal file
125
server/routes/auth.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { generateToken, authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Check auth status and setup requirements
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const hasUsers = await userDb.hasUsers();
|
||||
res.json({
|
||||
needsSetup: !hasUsers,
|
||||
isAuthenticated: false // Will be overridden by frontend if token exists
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth status error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// User registration (setup) - only allowed if no users exist
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
if (username.length < 3 || password.length < 6) {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
|
||||
}
|
||||
|
||||
// Check if users already exist (only allow one user)
|
||||
const hasUsers = userDb.hasUsers();
|
||||
if (hasUsers) {
|
||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = userDb.createUser(username, passwordHash);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
res.status(409).json({ error: 'Username already exists' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// User login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = userDb.getUserByUsername(username);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user (protected route)
|
||||
router.get('/user', authenticateToken, (req, res) => {
|
||||
res.json({
|
||||
user: req.user
|
||||
});
|
||||
});
|
||||
|
||||
// Logout (client-side token removal, but this endpoint can be used for logging)
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
// In a simple JWT system, logout is mainly client-side
|
||||
// This endpoint exists for consistency and potential future logging
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,17 +1,49 @@
|
||||
const express = require('express');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
function getActualProjectPath(projectName) {
|
||||
// Claude stores projects with dashes instead of slashes
|
||||
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui"
|
||||
return projectName.replace(/-/g, '/');
|
||||
async function getActualProjectPath(projectName) {
|
||||
try {
|
||||
return await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
// Fallback to the old method
|
||||
return projectName.replace(/-/g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to validate git repository
|
||||
async function validateGitRepository(projectPath) {
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fs.access(projectPath);
|
||||
} catch {
|
||||
throw new Error(`Project path not found: ${projectPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use --show-toplevel to get the root of the git repository
|
||||
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
const normalizedGitRoot = path.resolve(gitRoot.trim());
|
||||
const normalizedProjectPath = path.resolve(projectPath);
|
||||
|
||||
// Ensure the git root matches our project path (prevent using parent git repos)
|
||||
if (normalizedGitRoot !== normalizedProjectPath) {
|
||||
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('Project directory is not a git repository')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status for a project
|
||||
@@ -23,24 +55,11 @@ router.get('/status', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
console.log('Git status for project:', project, '-> path:', projectPath);
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
await fs.access(projectPath);
|
||||
} catch {
|
||||
console.error('Project path not found:', projectPath);
|
||||
return res.json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Check if it's a git repository
|
||||
try {
|
||||
await execAsync('git rev-parse --git-dir', { cwd: projectPath });
|
||||
} catch {
|
||||
console.error('Not a git repository:', projectPath);
|
||||
return res.json({ error: 'Not a git repository' });
|
||||
}
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
@@ -79,7 +98,14 @@ router.get('/status', async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git status error:', error);
|
||||
res.json({ error: error.message });
|
||||
res.json({
|
||||
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
: 'Git operation failed',
|
||||
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
: `Failed to get git status: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,7 +118,10 @@ router.get('/diff', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if file is untracked
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
@@ -133,7 +162,10 @@ router.post('/commit', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Stage selected files
|
||||
for (const file of files) {
|
||||
@@ -159,9 +191,12 @@ router.get('/branches', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
console.log('Git branches for project:', project, '-> path:', projectPath);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get all branches
|
||||
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
|
||||
|
||||
@@ -199,7 +234,7 @@ router.post('/checkout', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Checkout the branch
|
||||
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
|
||||
@@ -220,7 +255,7 @@ router.post('/create-branch', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Create and checkout new branch
|
||||
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
|
||||
@@ -241,7 +276,7 @@ router.get('/commits', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Get commit log with stats
|
||||
const { stdout } = await execAsync(
|
||||
@@ -292,7 +327,7 @@ router.get('/commit-diff', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Get diff for the commit
|
||||
const { stdout } = await execAsync(
|
||||
@@ -316,7 +351,7 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Get diff for selected files
|
||||
let combinedDiff = '';
|
||||
@@ -385,4 +420,4 @@ function generateSimpleCommitMessage(files, diff) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
125
src/App.jsx
125
src/App.jsx
@@ -28,6 +28,10 @@ import QuickSettingsPanel from './components/QuickSettingsPanel';
|
||||
|
||||
import { useWebSocket } from './utils/websocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import { api } from './utils/api';
|
||||
|
||||
|
||||
// Main App component with routing
|
||||
@@ -35,6 +39,9 @@ function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams();
|
||||
|
||||
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [selectedSession, setSelectedSession] = useState(null);
|
||||
@@ -178,7 +185,7 @@ function AppContent() {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
setIsLoadingProjects(true);
|
||||
const response = await fetch('/api/projects');
|
||||
const response = await api.projects();
|
||||
const data = await response.json();
|
||||
|
||||
// Optimize to preserve object references when data hasn't changed
|
||||
@@ -300,7 +307,7 @@ function AppContent() {
|
||||
const handleSidebarRefresh = async () => {
|
||||
// Refresh only the sessions for all projects, don't change selected state
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const response = await api.projects();
|
||||
const freshProjects = await response.json();
|
||||
|
||||
// Optimize to preserve object references and minimize re-renders
|
||||
@@ -399,6 +406,92 @@ function AppContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// Version Upgrade Modal Component
|
||||
const VersionUpgradeModal = () => {
|
||||
if (!showVersionModal) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">A new version is ready</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
|
||||
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
|
||||
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Instructions */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">How to upgrade:</h3>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
||||
git checkout main && git pull && npm install
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Run this command in your Claude Code UI directory to update to the latest version.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Copy command to clipboard
|
||||
navigator.clipboard.writeText('git checkout main && git pull && npm install');
|
||||
setShowVersionModal(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
Copy Command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
{/* Fixed Desktop Sidebar */}
|
||||
@@ -417,6 +510,10 @@ function AppContent() {
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,6 +555,10 @@ function AppContent() {
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,6 +585,7 @@ function AppContent() {
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -524,6 +626,9 @@ function AppContent() {
|
||||
isOpen={showToolsSettings}
|
||||
onClose={() => setShowToolsSettings(false)}
|
||||
/>
|
||||
|
||||
{/* Version Upgrade Modal */}
|
||||
<VersionUpgradeModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -532,12 +637,16 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import ClaudeLogo from './ClaudeLogo.jsx';
|
||||
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
@@ -902,6 +903,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const [sessionMessages, setSessionMessages] = useState([]);
|
||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||
const [permissionMode, setPermissionMode] = useState('default');
|
||||
const messagesEndRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
@@ -949,7 +951,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
setIsLoadingSessionMessages(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`);
|
||||
const response = await api.sessionMessages(projectName, sessionId);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session messages');
|
||||
}
|
||||
@@ -1112,15 +1114,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const isNearBottom = useCallback(() => {
|
||||
if (!scrollContainerRef.current) return false;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
// Consider "near bottom" if within 100px of the bottom
|
||||
return scrollHeight - scrollTop - clientHeight < 100;
|
||||
// Consider "near bottom" if within 50px of the bottom
|
||||
return scrollHeight - scrollTop - clientHeight < 50;
|
||||
}, []);
|
||||
|
||||
// Handle scroll events to detect when user manually scrolls up
|
||||
const handleScroll = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const wasNearBottom = isNearBottom();
|
||||
setIsUserScrolledUp(!wasNearBottom);
|
||||
const nearBottom = isNearBottom();
|
||||
setIsUserScrolledUp(!nearBottom);
|
||||
}
|
||||
}, [isNearBottom]);
|
||||
|
||||
@@ -1451,7 +1453,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
const fetchProjectFiles = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
if (response.ok) {
|
||||
const files = await response.json();
|
||||
// Flatten the file tree to get all file paths
|
||||
@@ -1540,13 +1542,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-scroll to bottom when new messages arrive if:
|
||||
// 1. Auto-scroll is enabled in settings
|
||||
// 2. User hasn't manually scrolled up
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
if (scrollContainerRef.current && chatMessages.length > 0) {
|
||||
if (autoScrollToBottom) {
|
||||
// If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 0);
|
||||
setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated
|
||||
}
|
||||
} else {
|
||||
// When auto-scroll is disabled, preserve the visual position
|
||||
@@ -1564,12 +1565,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]);
|
||||
|
||||
// Scroll to bottom when component mounts with existing messages
|
||||
// Scroll to bottom when component mounts with existing messages or when messages first load
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && chatMessages.length > 0 && autoScrollToBottom) {
|
||||
setTimeout(() => scrollToBottom(), 100); // Small delay to ensure rendering
|
||||
if (scrollContainerRef.current && chatMessages.length > 0) {
|
||||
// Always scroll to bottom when messages first load (user expects to see latest)
|
||||
// Also reset scroll state
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 200); // Longer delay to ensure full rendering
|
||||
}
|
||||
}, [scrollToBottom, autoScrollToBottom]);
|
||||
}, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear
|
||||
|
||||
// Add scroll event listener to detect user scrolling
|
||||
useEffect(() => {
|
||||
@@ -1636,8 +1640,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
can_interrupt: true
|
||||
});
|
||||
|
||||
// Always scroll to bottom when user sends a message (they're actively participating)
|
||||
setTimeout(() => scrollToBottom(), 0);
|
||||
// Always scroll to bottom when user sends a message and reset scroll state
|
||||
setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
|
||||
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
|
||||
|
||||
// Session Protection: Mark session as active to prevent automatic project updates during conversation
|
||||
// This is crucial for maintaining chat state integrity. We handle two cases:
|
||||
@@ -1677,7 +1682,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
cwd: selectedProject.fullPath,
|
||||
sessionId: currentSessionId,
|
||||
resume: !!currentSessionId,
|
||||
toolsSettings: toolsSettings
|
||||
toolsSettings: toolsSettings,
|
||||
permissionMode: permissionMode
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1722,6 +1728,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Tab key for mode switching (only when file dropdown is not showing)
|
||||
if (e.key === 'Tab' && !showFileDropdown) {
|
||||
e.preventDefault();
|
||||
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
const currentIndex = modes.indexOf(permissionMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
setPermissionMode(modes[nextIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
||||
if (e.key === 'Enter') {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
@@ -1786,6 +1802,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeSwitch = () => {
|
||||
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
const currentIndex = modes.indexOf(permissionMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
setPermissionMode(modes[nextIndex]);
|
||||
};
|
||||
|
||||
// Don't render if no project is selected
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
@@ -1882,21 +1905,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
{/* Floating scroll to bottom button */}
|
||||
{isUserScrolledUp && chatMessages.length > 0 && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-4 right-4 w-10 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-10"
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Input Area - Fixed Bottom */}
|
||||
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
|
||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
|
||||
@@ -1908,6 +1919,57 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
onAbort={handleAbortSession}
|
||||
/>
|
||||
|
||||
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
||||
<div className="max-w-4xl mx-auto mb-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleModeSwitch}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
: permissionMode === 'acceptEdits'
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-100 dark:hover:bg-green-900/30'
|
||||
: permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30'
|
||||
}`}
|
||||
title="Click to change permission mode (or press Tab in input)"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-gray-500'
|
||||
: permissionMode === 'acceptEdits'
|
||||
? 'bg-green-500'
|
||||
: permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-500'
|
||||
: 'bg-blue-500'
|
||||
}`} />
|
||||
<span>
|
||||
{permissionMode === 'default' && 'Default Mode'}
|
||||
{permissionMode === 'acceptEdits' && 'Accept Edits'}
|
||||
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'}
|
||||
{permissionMode === 'plan' && 'Plan Mode'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Scroll to bottom button - positioned next to mode indicator */}
|
||||
{isUserScrolledUp && chatMessages.length > 0 && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
||||
<div className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||
<textarea
|
||||
@@ -1977,8 +2039,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Mic button */}
|
||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2">
|
||||
{/* Mic button - HIDDEN */}
|
||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||
<MicButton
|
||||
onTranscript={handleTranscript}
|
||||
className="w-10 h-10 sm:w-10 sm:h-10"
|
||||
@@ -2037,12 +2099,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
{/* Hint text */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||||
Press Enter to send • Shift+Enter for new line • @ to reference files
|
||||
Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files
|
||||
</div>
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
Enter to send • @ for files
|
||||
Enter to send • Tab for modes • @ for files
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, Decoration } from '@codemirror/view';
|
||||
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath }) {
|
||||
const [content, setContent] = useState('');
|
||||
@@ -19,6 +20,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
|
||||
// Create diff highlighting
|
||||
const diffEffect = StateEffect.define();
|
||||
@@ -138,7 +140,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`/api/projects/${file.projectName}/file?filePath=${encodeURIComponent(file.path)}`);
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
@@ -175,16 +177,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${file.projectName}/file`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filePath: file.path,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
const response = await api.saveFile(file.projectName, file.path, content);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
@@ -265,28 +258,17 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.code-editor-modal {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
.code-editor-modal:hover {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={`fixed inset-0 z-50 ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={`code-editor-modal shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
<div className={`fixed inset-0 z-50 ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={`bg-white shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-sm font-mono">
|
||||
@@ -295,14 +277,14 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded whitespace-nowrap">
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
📝 Has changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
<p className="text-sm text-gray-500 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,6 +299,18 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
|
||||
wordWrap
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<span className="text-sm md:text-xs font-mono font-bold">↵</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
@@ -384,7 +378,8 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
diffField,
|
||||
diffTheme
|
||||
diffTheme,
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
@@ -421,7 +416,6 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function FileTree({ selectedProject }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
@@ -22,7 +23,7 @@ function FileTree({ selectedProject }) {
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
@@ -55,14 +56,14 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Git status response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('Git status error:', data.error);
|
||||
setGitStatus(null);
|
||||
setGitStatus({ error: data.error, details: data.details });
|
||||
} else {
|
||||
setGitStatus(data);
|
||||
setCurrentBranch(data.branch || 'main');
|
||||
@@ -93,7 +94,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchBranches = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const response = await authenticatedFetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.branches) {
|
||||
@@ -106,7 +107,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const switchBranch = async (branchName) => {
|
||||
try {
|
||||
const response = await fetch('/api/git/checkout', {
|
||||
const response = await authenticatedFetch('/api/git/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -133,7 +134,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
setIsCreatingBranch(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/create-branch', {
|
||||
const response = await authenticatedFetch('/api/git/create-branch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -162,7 +163,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchFileDiff = async (filePath) => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
@@ -178,7 +179,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchRecentCommits = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.commits) {
|
||||
@@ -191,7 +192,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
const fetchCommitDiff = async (commitHash) => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
|
||||
const response = await authenticatedFetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
@@ -208,7 +209,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const generateCommitMessage = async () => {
|
||||
setIsGeneratingMessage(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/generate-commit-message', {
|
||||
const response = await authenticatedFetch('/api/git/generate-commit-message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -275,7 +276,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
setIsCommitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/commit', {
|
||||
const response = await authenticatedFetch('/api/git/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -506,169 +507,191 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Changes</span>
|
||||
{/* Git Repository Not Found Message */}
|
||||
{gitStatus?.error ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
|
||||
<GitBranch className="w-20 h-20 mb-6 opacity-30" />
|
||||
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
|
||||
{gitStatus.details && (
|
||||
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
|
||||
)}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
|
||||
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('history')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'history'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
<span>History</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleCommit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-2 flex gap-1">
|
||||
{/* Tab Navigation - Only show when git is available */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Changes</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('history')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'history'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
<span>History</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleCommit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-2 flex gap-1">
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Generate commit message"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<div style={{ display: 'none' }}>
|
||||
<MicButton
|
||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||
mode="default"
|
||||
className="p-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File Selection Controls - Only show in changes view and when git is working */}
|
||||
{activeView === 'changes' && gitStatus && !gitStatus.error && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Generate commit message"
|
||||
onClick={() => {
|
||||
const allFiles = new Set([
|
||||
...(gitStatus?.modified || []),
|
||||
...(gitStatus?.added || []),
|
||||
...(gitStatus?.deleted || []),
|
||||
...(gitStatus?.untracked || [])
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
}}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
<MicButton
|
||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||
mode="default"
|
||||
className="p-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status Legend Toggle */}
|
||||
{!gitStatus?.error && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
<Info className="w-3 h-3" />
|
||||
<span>File Status Guide</span>
|
||||
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{showLegend && (
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
|
||||
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
|
||||
M
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
|
||||
A
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
|
||||
D
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
|
||||
U
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File Selection Controls - Only show in changes view */}
|
||||
{activeView === 'changes' && gitStatus && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const allFiles = new Set([
|
||||
...(gitStatus?.modified || []),
|
||||
...(gitStatus?.added || []),
|
||||
...(gitStatus?.deleted || []),
|
||||
...(gitStatus?.untracked || [])
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
}}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Legend Toggle */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
<span>File Status Guide</span>
|
||||
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{showLegend && (
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
|
||||
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
|
||||
M
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
|
||||
A
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
|
||||
D
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
|
||||
U
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File List - Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
{/* File List - Changes View - Only show when git is available */}
|
||||
{activeView === 'changes' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
@@ -690,8 +713,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History View */}
|
||||
{activeView === 'history' && (
|
||||
{/* History View - Only show when git is available */}
|
||||
{activeView === 'history' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
|
||||
108
src/components/LoginForm.jsx
Normal file
108
src/components/LoginForm.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Sign in to your Claude Code UI account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your credentials to access Claude Code UI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
@@ -5,13 +5,35 @@ import { transcribeWithWhisper } from '../utils/whisper';
|
||||
export function MicButton({ onTranscript, className = '' }) {
|
||||
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
|
||||
const [error, setError] = useState(null);
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const lastTapRef = useRef(0);
|
||||
|
||||
// Version indicator to verify updates
|
||||
// Check microphone support on mount
|
||||
useEffect(() => {
|
||||
const checkSupport = () => {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
setIsSupported(false);
|
||||
setError('Microphone not supported. Please use HTTPS or a modern browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional check for secure context
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
setIsSupported(false);
|
||||
setError('Microphone requires HTTPS. Please use a secure connection.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
checkSupport();
|
||||
}, []);
|
||||
|
||||
// Start recording
|
||||
const startRecording = async () => {
|
||||
@@ -20,6 +42,11 @@ export function MicButton({ onTranscript, className = '' }) {
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
|
||||
// Check if getUserMedia is available
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
@@ -79,7 +106,23 @@ export function MicButton({ onTranscript, className = '' }) {
|
||||
console.log('Recording started successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
setError('Microphone access denied');
|
||||
|
||||
// Provide specific error messages based on error type
|
||||
let errorMessage = 'Microphone access failed';
|
||||
|
||||
if (err.name === 'NotAllowedError') {
|
||||
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
errorMessage = 'No microphone found. Please check your audio devices.';
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
errorMessage = 'Microphone not supported by this browser.';
|
||||
} else if (err.name === 'NotReadableError') {
|
||||
errorMessage = 'Microphone is being used by another application.';
|
||||
} else if (err.message.includes('HTTPS')) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
@@ -109,6 +152,11 @@ export function MicButton({ onTranscript, className = '' }) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Don't proceed if microphone is not supported
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce for mobile double-tap issue
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < 300) {
|
||||
@@ -138,6 +186,14 @@ export function MicButton({ onTranscript, className = '' }) {
|
||||
|
||||
// Button appearance based on state
|
||||
const getButtonAppearance = () => {
|
||||
if (!isSupported) {
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5" />,
|
||||
className: 'bg-gray-400 cursor-not-allowed',
|
||||
disabled: true
|
||||
};
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
return {
|
||||
|
||||
42
src/components/ProtectedRoute.jsx
Normal file
42
src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <SetupForm />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -142,8 +142,8 @@ const QuickSettingsPanel = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Whisper Dictation Settings */}
|
||||
<div className="space-y-2">
|
||||
{/* Whisper Dictation Settings - HIDDEN */}
|
||||
<div className="space-y-2" style={{ display: 'none' }}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
135
src/components/SetupForm.jsx
Normal file
135
src/components/SetupForm.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const SetupForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { register } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
setError('Username must be at least 3 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await register(username, password);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Set up your account to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
|
||||
>
|
||||
{isLoading ? 'Setting up...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is a single-user system. Only one account can be created.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupForm;
|
||||
@@ -322,10 +322,21 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch server configuration to get the correct WebSocket URL
|
||||
let wsBaseUrl;
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
const configResponse = await fetch('/api/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const config = await configResponse.json();
|
||||
wsBaseUrl = config.wsUrl;
|
||||
|
||||
@@ -343,7 +354,8 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
|
||||
const wsUrl = `${wsBaseUrl}/shell`;
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from './ui/input';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
||||
const formatTimeAgo = (dateString, currentTime) => {
|
||||
@@ -44,7 +45,11 @@ function Sidebar({
|
||||
onProjectDelete,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
onShowSettings
|
||||
onShowSettings,
|
||||
updateAvailable,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
onShowVersionModal
|
||||
}) {
|
||||
const [expandedProjects, setExpandedProjects] = useState(new Set());
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
@@ -61,9 +66,13 @@ function Sidebar({
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||
|
||||
// Touch handler to prevent double-tap issues on iPad
|
||||
// Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas)
|
||||
const handleTouchClick = (callback) => {
|
||||
return (e) => {
|
||||
// Only prevent default for buttons/clickable elements, not scrollable areas
|
||||
if (e.target.closest('.overflow-y-auto') || e.target.closest('[data-scroll-container]')) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
callback();
|
||||
@@ -128,13 +137,7 @@ function Sidebar({
|
||||
|
||||
const saveProjectName = async (projectName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/rename`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ displayName: editingName }),
|
||||
});
|
||||
const response = await api.renameProject(projectName, editingName);
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh projects to get updated data
|
||||
@@ -160,9 +163,7 @@ function Sidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await api.deleteSession(projectName, sessionId);
|
||||
|
||||
if (response.ok) {
|
||||
// Call parent callback if provided
|
||||
@@ -185,9 +186,7 @@ function Sidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await api.deleteProject(projectName);
|
||||
|
||||
if (response.ok) {
|
||||
// Call parent callback if provided
|
||||
@@ -214,15 +213,7 @@ function Sidebar({
|
||||
setCreatingProject(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: newProjectPath.trim()
|
||||
}),
|
||||
});
|
||||
const response = await api.createProject(newProjectPath.trim());
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
@@ -1036,6 +1027,50 @@ function Sidebar({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Version Update Notification */}
|
||||
{updateAvailable && (
|
||||
<div className="md:p-2 border-t border-border/50 flex-shrink-0">
|
||||
{/* Desktop Version Notification */}
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 p-3 h-auto font-normal text-left hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors duration-200 border border-blue-200 dark:border-blue-700 rounded-lg mb-2"
|
||||
onClick={onShowVersionModal}
|
||||
>
|
||||
<div className="relative">
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Version Notification */}
|
||||
<div className="md:hidden p-3 pb-2">
|
||||
<button
|
||||
className="w-full h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl flex items-center justify-start gap-3 px-4 active:scale-[0.98] transition-all duration-150"
|
||||
onClick={onShowVersionModal}
|
||||
>
|
||||
<div className="relative">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Section */}
|
||||
<div className="md:p-2 md:border-t md:border-border flex-shrink-0">
|
||||
{/* Mobile Settings */}
|
||||
|
||||
@@ -7,7 +7,13 @@ const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) =>
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="h-full w-full rounded-[inherit] overflow-auto">
|
||||
<div
|
||||
className="h-full w-full rounded-[inherit] overflow-auto"
|
||||
style={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
158
src/contexts/AuthContext.jsx
Normal file
158
src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
const AuthContext = createContext({
|
||||
user: null,
|
||||
token: null,
|
||||
login: () => {},
|
||||
register: () => {},
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
needsSetup: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(localStorage.getItem('auth-token'));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check if system needs setup
|
||||
const statusResponse = await api.auth.status();
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
if (statusData.needsSetup) {
|
||||
setNeedsSetup(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a token, verify it
|
||||
if (token) {
|
||||
try {
|
||||
const userResponse = await api.auth.user();
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
setUser(userData.user);
|
||||
setNeedsSetup(false);
|
||||
} else {
|
||||
// Token is invalid
|
||||
localStorage.removeItem('auth-token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
localStorage.removeItem('auth-token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth status check failed:', error);
|
||||
setError('Failed to check authentication status');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await api.auth.login(username, password);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
localStorage.setItem('auth-token', data.token);
|
||||
return { success: true };
|
||||
} else {
|
||||
setError(data.error || 'Login failed');
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
const errorMessage = 'Network error. Please try again.';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username, password) => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await api.auth.register(username, password);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
setNeedsSetup(false);
|
||||
localStorage.setItem('auth-token', data.token);
|
||||
return { success: true };
|
||||
} else {
|
||||
setError(data.error || 'Registration failed');
|
||||
return { success: false, error: data.error || 'Registration failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
const errorMessage = 'Network error. Please try again.';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('auth-token');
|
||||
|
||||
// Optional: Call logout endpoint for logging
|
||||
if (token) {
|
||||
api.auth.logout().catch(error => {
|
||||
console.error('Logout endpoint error:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isLoading,
|
||||
needsSetup,
|
||||
error
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
39
src/hooks/useVersionCheck.js
Normal file
39
src/hooks/useVersionCheck.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// hooks/useVersionCheck.js
|
||||
import { useState, useEffect } from 'react';
|
||||
import { version } from '../../package.json';
|
||||
|
||||
export const useVersionCheck = (owner, repo) => {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkVersion = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
|
||||
const data = await response.json();
|
||||
|
||||
// Handle the case where there might not be any releases
|
||||
if (data.tag_name) {
|
||||
const latest = data.tag_name.replace(/^v/, '');
|
||||
setLatestVersion(latest);
|
||||
setUpdateAvailable(version !== latest);
|
||||
} else {
|
||||
// No releases found, don't show update notification
|
||||
setUpdateAvailable(false);
|
||||
setLatestVersion(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Version check failed:', error);
|
||||
// On error, don't show update notification
|
||||
setUpdateAvailable(false);
|
||||
setLatestVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
checkVersion();
|
||||
const interval = setInterval(checkVersion, 5 * 60 * 1000); // Check every 5 minutes
|
||||
return () => clearInterval(interval);
|
||||
}, [owner, repo]);
|
||||
|
||||
return { updateAvailable, latestVersion, currentVersion: version };
|
||||
};
|
||||
@@ -436,6 +436,12 @@
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Allow vertical scrolling in scroll containers */
|
||||
.overflow-y-auto, [data-scroll-container] {
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Preserve checkbox visibility */
|
||||
input[type="checkbox"] {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
|
||||
81
src/utils/api.js
Normal file
81
src/utils/api.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Utility function for authenticated API calls
|
||||
export const authenticatedFetch = (url, options = {}) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// API endpoints
|
||||
export const api = {
|
||||
// Auth endpoints (no token required)
|
||||
auth: {
|
||||
status: () => fetch('/api/auth/status'),
|
||||
login: (username, password) => fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
register: (username, password) => fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
user: () => authenticatedFetch('/api/auth/user'),
|
||||
logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Protected endpoints
|
||||
config: () => authenticatedFetch('/api/config'),
|
||||
projects: () => authenticatedFetch('/api/projects'),
|
||||
sessions: (projectName, limit = 5, offset = 0) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||
sessionMessages: (projectName, sessionId) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
|
||||
renameProject: (projectName, displayName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/rename`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ displayName }),
|
||||
}),
|
||||
deleteSession: (projectName, sessionId) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
createProject: (path) =>
|
||||
authenticatedFetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
readFile: (projectName, filePath) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
||||
saveFile: (projectName, filePath, content) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/file`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ filePath, content }),
|
||||
}),
|
||||
getFiles: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/files`),
|
||||
transcribe: (formData) =>
|
||||
authenticatedFetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
};
|
||||
@@ -21,10 +21,21 @@ export function useWebSocket() {
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.warn('No authentication token found for WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch server configuration to get the correct WebSocket URL
|
||||
let wsBaseUrl;
|
||||
try {
|
||||
const configResponse = await fetch('/api/config');
|
||||
const configResponse = await fetch('/api/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const config = await configResponse.json();
|
||||
wsBaseUrl = config.wsUrl;
|
||||
|
||||
@@ -44,7 +55,8 @@ export function useWebSocket() {
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
|
||||
const wsUrl = `${wsBaseUrl}/ws`;
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`;
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { api } from './api';
|
||||
|
||||
export async function transcribeWithWhisper(audioBlob, onStatusChange) {
|
||||
const formData = new FormData();
|
||||
const fileName = `recording_${Date.now()}.webm`;
|
||||
@@ -14,10 +16,7 @@ export async function transcribeWithWhisper(audioBlob, onStatusChange) {
|
||||
onStatusChange('transcribing');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const response = await api.transcribe(formData);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user