mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
feat(web-dashboard): complete settings, health, shared data and build integration
- Settings editor with API key masking and conflict detection - Health dashboard with status cards and one-click fixes - Home dashboard with stats and quick actions - Shared data viewer for commands/skills/agents - Build scripts for UI + server bundle - Bundle size verification (<500KB gzipped) - Pre-release checklist script
This commit is contained in:
@@ -52,13 +52,18 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && node scripts/add-shebang.js",
|
"build": "tsc && node scripts/add-shebang.js",
|
||||||
"build:watch": "tsc --watch",
|
"build:watch": "tsc --watch",
|
||||||
|
"build:server": "tsc && node scripts/add-shebang.js",
|
||||||
|
"build:all": "bun run ui:build && bun run build:server",
|
||||||
"prebuild": "rm -rf dist tsconfig.tsbuildinfo",
|
"prebuild": "rm -rf dist tsconfig.tsbuildinfo",
|
||||||
|
"prebuild:all": "rm -rf dist tsconfig.tsbuildinfo",
|
||||||
|
"postbuild:all": "node scripts/verify-bundle.js",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint src/ --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"format:check": "prettier --check src/",
|
"format:check": "prettier --check src/",
|
||||||
"validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test",
|
"validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test",
|
||||||
|
"verify:bundle": "node scripts/verify-bundle.js",
|
||||||
"test": "bun run build && bun run test:all",
|
"test": "bun run build && bun run test:all",
|
||||||
"test:all": "bun test",
|
"test:all": "bun test",
|
||||||
"test:unit": "bun test tests/unit/",
|
"test:unit": "bun test tests/unit/",
|
||||||
|
|||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pre-Release Checklist for CCS
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== Pre-Release Checklist ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Version check
|
||||||
|
echo "[i] Current version: $(node -p "require('./package.json').version")"
|
||||||
|
|
||||||
|
# 2. Clean build
|
||||||
|
echo "[i] Clean build..."
|
||||||
|
rm -rf dist
|
||||||
|
bun run build:all
|
||||||
|
|
||||||
|
# 3. Bundle size
|
||||||
|
echo "[i] Bundle size check..."
|
||||||
|
node scripts/verify-bundle.js
|
||||||
|
|
||||||
|
# 4. Lint & typecheck
|
||||||
|
echo "[i] Lint & typecheck..."
|
||||||
|
bun run validate
|
||||||
|
|
||||||
|
# 5. Tests
|
||||||
|
echo "[i] Running tests..."
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# 6. Help consistency check
|
||||||
|
echo "[i] Checking help text includes config command..."
|
||||||
|
if ! grep -q "ccs config" src/commands/help-command.ts; then
|
||||||
|
echo "[!] Missing config in help-command.ts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Package contents
|
||||||
|
echo "[i] Package contents..."
|
||||||
|
npm pack --dry-run 2>&1 | head -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Ready for release ==="
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify UI bundle size is under 500KB gzipped
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
const UI_DIR = path.join(__dirname, '../dist/ui');
|
||||||
|
const MAX_SIZE = 500 * 1024; // 500KB
|
||||||
|
|
||||||
|
function getGzipSize(filePath) {
|
||||||
|
const content = fs.readFileSync(filePath);
|
||||||
|
return zlib.gzipSync(content).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkDir(dir) {
|
||||||
|
let totalSize = 0;
|
||||||
|
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dir, file.name);
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
totalSize += walkDir(filePath);
|
||||||
|
} else {
|
||||||
|
totalSize += getGzipSize(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(UI_DIR)) {
|
||||||
|
console.log('[!] dist/ui not found. Run bun run ui:build first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = walkDir(UI_DIR);
|
||||||
|
const sizeKB = (totalSize / 1024).toFixed(1);
|
||||||
|
|
||||||
|
if (totalSize > MAX_SIZE) {
|
||||||
|
console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: 500KB)`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`[OK] Bundle size: ${sizeKB}KB gzipped`);
|
||||||
|
}
|
||||||
@@ -184,6 +184,7 @@ Claude Code Profile & Model Switcher`.trim();
|
|||||||
printSubSection('Diagnostics', [
|
printSubSection('Diagnostics', [
|
||||||
['ccs doctor', 'Run health check and diagnostics'],
|
['ccs doctor', 'Run health check and diagnostics'],
|
||||||
['ccs config', 'Open web configuration dashboard'],
|
['ccs config', 'Open web configuration dashboard'],
|
||||||
|
['ccs config --port 3000', 'Use specific port'],
|
||||||
['ccs sync', 'Sync delegation commands and skills'],
|
['ccs sync', 'Sync delegation commands and skills'],
|
||||||
['ccs update', 'Update CCS to latest version'],
|
['ccs update', 'Update CCS to latest version'],
|
||||||
['ccs update --force', 'Force reinstall current version'],
|
['ccs update --force', 'Force reinstall current version'],
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Health Check Service (Phase 06)
|
||||||
|
*
|
||||||
|
* Runs health checks for CCS dashboard: Claude CLI, config files, CLIProxy binary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { getCcsDir, getConfigPath } from '../utils/config-manager';
|
||||||
|
|
||||||
|
export interface HealthCheck {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: 'ok' | 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
details?: string;
|
||||||
|
fixable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthReport {
|
||||||
|
timestamp: number;
|
||||||
|
checks: HealthCheck[];
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
passed: number;
|
||||||
|
warnings: number;
|
||||||
|
errors: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all health checks and return report
|
||||||
|
*/
|
||||||
|
export function runHealthChecks(): HealthReport {
|
||||||
|
const checks: HealthCheck[] = [];
|
||||||
|
|
||||||
|
// Check 1: Claude CLI
|
||||||
|
checks.push(checkClaudeCli());
|
||||||
|
|
||||||
|
// Check 2: Config file
|
||||||
|
checks.push(checkConfigFile());
|
||||||
|
|
||||||
|
// Check 3: Profiles file
|
||||||
|
checks.push(checkProfilesFile());
|
||||||
|
|
||||||
|
// Check 4: CLIProxy binary
|
||||||
|
checks.push(checkCliproxy());
|
||||||
|
|
||||||
|
// Check 5: CCS directory
|
||||||
|
checks.push(checkCcsDirectory());
|
||||||
|
|
||||||
|
// Calculate summary
|
||||||
|
const summary = {
|
||||||
|
total: checks.length,
|
||||||
|
passed: checks.filter((c) => c.status === 'ok').length,
|
||||||
|
warnings: checks.filter((c) => c.status === 'warning').length,
|
||||||
|
errors: checks.filter((c) => c.status === 'error').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
checks,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkClaudeCli(): HealthCheck {
|
||||||
|
try {
|
||||||
|
const version = execSync('claude --version', { encoding: 'utf8', timeout: 5000 }).trim();
|
||||||
|
return {
|
||||||
|
id: 'claude-cli',
|
||||||
|
name: 'Claude CLI',
|
||||||
|
status: 'ok',
|
||||||
|
message: `Installed: ${version}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: 'claude-cli',
|
||||||
|
name: 'Claude CLI',
|
||||||
|
status: 'error',
|
||||||
|
message: 'Not found in PATH',
|
||||||
|
details: 'Install: npm install -g @anthropic-ai/claude-code',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkConfigFile(): HealthCheck {
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return {
|
||||||
|
id: 'config-file',
|
||||||
|
name: 'Config File',
|
||||||
|
status: 'warning',
|
||||||
|
message: 'Not found',
|
||||||
|
details: configPath,
|
||||||
|
fixable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(configPath, 'utf8');
|
||||||
|
JSON.parse(content);
|
||||||
|
return {
|
||||||
|
id: 'config-file',
|
||||||
|
name: 'Config File',
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Valid JSON',
|
||||||
|
details: configPath,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: 'config-file',
|
||||||
|
name: 'Config File',
|
||||||
|
status: 'error',
|
||||||
|
message: 'Invalid JSON',
|
||||||
|
details: configPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkProfilesFile(): HealthCheck {
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const profilesPath = path.join(ccsDir, 'profiles.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(profilesPath)) {
|
||||||
|
return {
|
||||||
|
id: 'profiles-file',
|
||||||
|
name: 'Profiles Registry',
|
||||||
|
status: 'warning',
|
||||||
|
message: 'Not found (will be created on first account)',
|
||||||
|
details: profilesPath,
|
||||||
|
fixable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(profilesPath, 'utf8');
|
||||||
|
JSON.parse(content);
|
||||||
|
return {
|
||||||
|
id: 'profiles-file',
|
||||||
|
name: 'Profiles Registry',
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Valid',
|
||||||
|
details: profilesPath,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: 'profiles-file',
|
||||||
|
name: 'Profiles Registry',
|
||||||
|
status: 'error',
|
||||||
|
message: 'Invalid JSON',
|
||||||
|
details: profilesPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCliproxy(): HealthCheck {
|
||||||
|
try {
|
||||||
|
execSync('cliproxy --version', { encoding: 'utf8', timeout: 5000 });
|
||||||
|
return {
|
||||||
|
id: 'cliproxy',
|
||||||
|
name: 'CLIProxy',
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Binary available',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: 'cliproxy',
|
||||||
|
name: 'CLIProxy',
|
||||||
|
status: 'warning',
|
||||||
|
message: 'Not found (optional)',
|
||||||
|
details: 'Required for gemini/codex/agy providers',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCcsDirectory(): HealthCheck {
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(ccsDir)) {
|
||||||
|
return {
|
||||||
|
id: 'ccs-dir',
|
||||||
|
name: 'CCS Directory',
|
||||||
|
status: 'warning',
|
||||||
|
message: 'Not found',
|
||||||
|
details: ccsDir,
|
||||||
|
fixable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'ccs-dir',
|
||||||
|
name: 'CCS Directory',
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Exists',
|
||||||
|
details: ccsDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix a health issue by its check ID
|
||||||
|
*/
|
||||||
|
export function fixHealthIssue(checkId: string): { success: boolean; message: string } {
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
|
||||||
|
switch (checkId) {
|
||||||
|
case 'ccs-dir':
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
return { success: true, message: 'Created ~/.ccs directory' };
|
||||||
|
|
||||||
|
case 'config-file': {
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||||
|
return { success: true, message: 'Created config.json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'profiles-file': {
|
||||||
|
const profilesPath = path.join(ccsDir, 'profiles.json');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(profilesPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||||
|
return { success: true, message: 'Created profiles.json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { success: false, message: 'Cannot auto-fix this issue' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
const { apiRoutes } = await import('./routes');
|
const { apiRoutes } = await import('./routes');
|
||||||
app.use('/api', apiRoutes);
|
app.use('/api', apiRoutes);
|
||||||
|
|
||||||
|
// Shared data routes (Phase 07)
|
||||||
|
const { sharedRoutes } = await import('./shared-routes');
|
||||||
|
app.use('/api/shared', sharedRoutes);
|
||||||
|
|
||||||
|
// Overview routes (Phase 07)
|
||||||
|
const { overviewRoutes } = await import('./overview-routes');
|
||||||
|
app.use('/api/overview', overviewRoutes);
|
||||||
|
|
||||||
// Static files (dist/ui/)
|
// Static files (dist/ui/)
|
||||||
const staticDir = options.staticDir || path.join(__dirname, '../../dist/ui');
|
const staticDir = options.staticDir || path.join(__dirname, '../../dist/ui');
|
||||||
app.use(express.static(staticDir));
|
app.use(express.static(staticDir));
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Overview Routes (Phase 07)
|
||||||
|
*
|
||||||
|
* Dashboard overview API for counts and health summary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir, loadConfig } from '../utils/config-manager';
|
||||||
|
import { runHealthChecks } from './health-service';
|
||||||
|
|
||||||
|
export const overviewRoutes = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/overview
|
||||||
|
*/
|
||||||
|
overviewRoutes.get('/', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const profileCount = Object.keys(config.profiles).length;
|
||||||
|
const cliproxyCount = Object.keys(config.cliproxy || {}).length;
|
||||||
|
|
||||||
|
// Get quick health summary
|
||||||
|
const health = runHealthChecks();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profiles: profileCount,
|
||||||
|
cliproxy: cliproxyCount,
|
||||||
|
accounts: getAccountCount(),
|
||||||
|
health: {
|
||||||
|
status:
|
||||||
|
health.summary.errors > 0 ? 'error' : health.summary.warnings > 0 ? 'warning' : 'ok',
|
||||||
|
passed: health.summary.passed,
|
||||||
|
total: health.summary.total,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.json({
|
||||||
|
profiles: 0,
|
||||||
|
cliproxy: 0,
|
||||||
|
accounts: 0,
|
||||||
|
health: { status: 'error', passed: 0, total: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAccountCount(): number {
|
||||||
|
try {
|
||||||
|
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(profilesPath)) return 0;
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
|
||||||
|
return Object.keys(data.profiles || {}).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import * as path from 'path';
|
|||||||
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/config-manager';
|
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/config-manager';
|
||||||
import { Config, Settings } from '../types/config';
|
import { Config, Settings } from '../types/config';
|
||||||
import { expandPath } from '../utils/helpers';
|
import { expandPath } from '../utils/helpers';
|
||||||
|
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||||
|
|
||||||
export const apiRoutes = Router();
|
export const apiRoutes = Router();
|
||||||
|
|
||||||
@@ -292,6 +293,127 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => {
|
|||||||
res.json({ name, deleted: true });
|
res.json({ name, deleted: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== Settings (Phase 05) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Mask API keys in settings
|
||||||
|
*/
|
||||||
|
function maskApiKeys(settings: Settings): Settings {
|
||||||
|
if (!settings.env) return settings;
|
||||||
|
|
||||||
|
const masked = { ...settings, env: { ...settings.env } };
|
||||||
|
const sensitiveKeys = ['ANTHROPIC_AUTH_TOKEN', 'API_KEY', 'AUTH_TOKEN'];
|
||||||
|
|
||||||
|
for (const key of Object.keys(masked.env)) {
|
||||||
|
if (sensitiveKeys.some((sensitive) => key.includes(sensitive))) {
|
||||||
|
const value = masked.env[key];
|
||||||
|
if (value && value.length > 8) {
|
||||||
|
masked.env[key] =
|
||||||
|
value.slice(0, 4) + '*'.repeat(Math.max(0, value.length - 8)) + value.slice(-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/:profile - Get settings with masked API keys
|
||||||
|
*/
|
||||||
|
apiRoutes.get('/settings/:profile', (req: Request, res: Response): void => {
|
||||||
|
const { profile } = req.params;
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(settingsPath)) {
|
||||||
|
res.status(404).json({ error: 'Settings not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(settingsPath);
|
||||||
|
const settings = loadSettings(settingsPath);
|
||||||
|
|
||||||
|
// Mask API keys in response
|
||||||
|
const masked = maskApiKeys(settings);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profile,
|
||||||
|
settings: masked,
|
||||||
|
mtime: stat.mtime.getTime(),
|
||||||
|
path: settingsPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/:profile/raw - Get full settings (for editing)
|
||||||
|
*/
|
||||||
|
apiRoutes.get('/settings/:profile/raw', (req: Request, res: Response): void => {
|
||||||
|
const { profile } = req.params;
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(settingsPath)) {
|
||||||
|
res.status(404).json({ error: 'Settings not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(settingsPath);
|
||||||
|
const settings = loadSettings(settingsPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profile,
|
||||||
|
settings,
|
||||||
|
mtime: stat.mtime.getTime(),
|
||||||
|
path: settingsPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/settings/:profile - Update settings with conflict detection and backup
|
||||||
|
*/
|
||||||
|
apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => {
|
||||||
|
const { profile } = req.params;
|
||||||
|
const { settings, expectedMtime } = req.body;
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(settingsPath)) {
|
||||||
|
res.status(404).json({ error: 'Settings not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict detection
|
||||||
|
const stat = fs.statSync(settingsPath);
|
||||||
|
if (expectedMtime && stat.mtime.getTime() !== expectedMtime) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'File modified externally',
|
||||||
|
currentMtime: stat.mtime.getTime(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
const backupDir = path.join(ccsDir, 'backups');
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
||||||
|
fs.copyFileSync(settingsPath, backupPath);
|
||||||
|
|
||||||
|
// Write new settings atomically
|
||||||
|
const tempPath = settingsPath + '.tmp';
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
fs.renameSync(tempPath, settingsPath);
|
||||||
|
|
||||||
|
const newStat = fs.statSync(settingsPath);
|
||||||
|
res.json({
|
||||||
|
profile,
|
||||||
|
mtime: newStat.mtime.getTime(),
|
||||||
|
backupPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== Accounts ====================
|
// ==================== Accounts ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,3 +462,27 @@ apiRoutes.post('/accounts/default', (req: Request, res: Response): void => {
|
|||||||
|
|
||||||
res.json({ default: name });
|
res.json({ default: name });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== Health (Phase 06) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/health - Run health checks
|
||||||
|
*/
|
||||||
|
apiRoutes.get('/health', (_req: Request, res: Response) => {
|
||||||
|
const report = runHealthChecks();
|
||||||
|
res.json(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/health/fix/:checkId - Fix a health issue
|
||||||
|
*/
|
||||||
|
apiRoutes.post('/health/fix/:checkId', (req: Request, res: Response): void => {
|
||||||
|
const { checkId } = req.params;
|
||||||
|
const result = fixHealthIssue(checkId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({ success: true, message: result.message });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false, message: result.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Shared Data Routes (Phase 07)
|
||||||
|
*
|
||||||
|
* API routes for commands, skills, agents from ~/.ccs/shared/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
|
export const sharedRoutes = Router();
|
||||||
|
|
||||||
|
interface SharedItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
type: 'command' | 'skill' | 'agent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/shared/commands
|
||||||
|
*/
|
||||||
|
sharedRoutes.get('/commands', (_req: Request, res: Response) => {
|
||||||
|
const items = getSharedItems('commands');
|
||||||
|
res.json({ items });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/shared/skills
|
||||||
|
*/
|
||||||
|
sharedRoutes.get('/skills', (_req: Request, res: Response) => {
|
||||||
|
const items = getSharedItems('skills');
|
||||||
|
res.json({ items });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/shared/agents
|
||||||
|
*/
|
||||||
|
sharedRoutes.get('/agents', (_req: Request, res: Response) => {
|
||||||
|
const items = getSharedItems('agents');
|
||||||
|
res.json({ items });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/shared/summary
|
||||||
|
*/
|
||||||
|
sharedRoutes.get('/summary', (_req: Request, res: Response) => {
|
||||||
|
const commands = getSharedItems('commands').length;
|
||||||
|
const skills = getSharedItems('skills').length;
|
||||||
|
const agents = getSharedItems('agents').length;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
commands,
|
||||||
|
skills,
|
||||||
|
agents,
|
||||||
|
total: commands + skills + agents,
|
||||||
|
symlinkStatus: checkSymlinkStatus(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const sharedDir = path.join(ccsDir, 'shared', type);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sharedDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: SharedItem[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// Skill/Agent: look for prompt.md
|
||||||
|
const promptPath = path.join(sharedDir, entry.name, 'prompt.md');
|
||||||
|
if (fs.existsSync(promptPath)) {
|
||||||
|
const content = fs.readFileSync(promptPath, 'utf8');
|
||||||
|
const description = extractDescription(content);
|
||||||
|
items.push({
|
||||||
|
name: entry.name,
|
||||||
|
description,
|
||||||
|
path: path.join(sharedDir, entry.name),
|
||||||
|
type: type === 'commands' ? 'command' : (type.slice(0, -1) as 'skill' | 'agent'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (entry.name.endsWith('.md')) {
|
||||||
|
// Command: .md file
|
||||||
|
const filePath = path.join(sharedDir, entry.name);
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const description = extractDescription(content);
|
||||||
|
items.push({
|
||||||
|
name: entry.name.replace('.md', ''),
|
||||||
|
description,
|
||||||
|
path: filePath,
|
||||||
|
type: 'command',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory read failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDescription(content: string): string {
|
||||||
|
// Extract first non-empty, non-heading line
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) {
|
||||||
|
return trimmed.slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'No description';
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSymlinkStatus(): { valid: boolean; message: string } {
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const sharedDir = path.join(ccsDir, 'shared');
|
||||||
|
|
||||||
|
if (!fs.existsSync(sharedDir)) {
|
||||||
|
return { valid: false, message: 'Shared directory not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ~/.claude/commands links to shared
|
||||||
|
const claudeDir = path.join(os.homedir(), '.claude');
|
||||||
|
const commandsLink = path.join(claudeDir, 'commands');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(commandsLink)) {
|
||||||
|
const stats = fs.lstatSync(commandsLink);
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
const target = fs.readlinkSync(commandsLink);
|
||||||
|
if (target.includes('.ccs/shared/commands')) {
|
||||||
|
return { valid: true, message: 'Symlinks active' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a symlink or read error
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, message: 'Symlinks not configured' };
|
||||||
|
}
|
||||||
+2
-1
@@ -52,6 +52,7 @@
|
|||||||
"dist",
|
"dist",
|
||||||
"tests",
|
"tests",
|
||||||
"lib",
|
"lib",
|
||||||
"scripts"
|
"scripts",
|
||||||
|
"ui"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -182,6 +183,8 @@
|
|||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||||
|
|
||||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
@@ -674,6 +677,8 @@
|
|||||||
|
|
||||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
|||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/app-sidebar';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
import { ConnectionIndicator } from '@/components/connection-indicator';
|
import { ConnectionIndicator } from '@/components/connection-indicator';
|
||||||
|
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { queryClient } from '@/lib/query-client';
|
import { queryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
<LocalhostDisclaimer />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmText?: string
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
variant = 'default',
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700' : ''}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react'
|
||||||
|
import { useFixHealth } from '@/hooks/use-health'
|
||||||
|
|
||||||
|
interface HealthCheck {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'ok' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
details?: string
|
||||||
|
fixable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
ok: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: 'text-green-500',
|
||||||
|
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||||
|
border: 'border-green-200 dark:border-green-800',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
border: 'border-yellow-200 dark:border-yellow-800',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-red-500',
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthCard({ check }: { check: HealthCheck }) {
|
||||||
|
const fixMutation = useFixHealth()
|
||||||
|
const config = statusConfig[check.status]
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`${config.bg} ${config.border} border`}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||||
|
<span className="font-medium">{check.name}</span>
|
||||||
|
</div>
|
||||||
|
{check.fixable && check.status !== 'ok' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fixMutation.mutate(check.id)}
|
||||||
|
disabled={fixMutation.isPending}
|
||||||
|
>
|
||||||
|
<Wrench className="w-3 h-3 mr-1" />
|
||||||
|
Fix
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">{check.message}</p>
|
||||||
|
{check.details && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||||
|
{check.details}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Shield, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function LocalhostDisclaimer() {
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
|
||||||
|
if (dismissed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-yellow-50 dark:bg-yellow-900/20 border-t border-yellow-200 dark:border-yellow-800 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
This dashboard runs locally. All data stays on your machine.
|
||||||
|
Never expose this server to the internet.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: number | string
|
||||||
|
icon: LucideIcon
|
||||||
|
color?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ title, value, icon: Icon, color = 'text-primary', onClick }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`cursor-pointer hover:shadow-md transition-shadow ${onClick ? 'hover:border-primary' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
|
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
<Icon className={`w-8 h-8 ${color} opacity-20`} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -1,47 +1,60 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
destructive:
|
||||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
outline:
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: 'h-10 w-10',
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
function Button({
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
className,
|
||||||
VariantProps<typeof buttonVariants> {
|
variant,
|
||||||
asChild?: boolean;
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
export { Button, buttonVariants }
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : 'button';
|
|
||||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaskedInput({ label, ...props }: MaskedInputProps) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{label && <label className="text-sm font-medium">{label}</label>}
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
className="pr-10 font-mono"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-3"
|
||||||
|
onClick={() => setVisible(!visible)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -1,87 +1,724 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react"
|
||||||
import { cn } from '@/lib/utils';
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
const SidebarContext = React.createContext<{ open: boolean; toggleOpen: () => void } | null>(null);
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
const [open, setOpen] = React.useState(true);
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
const toggleOpen = () => setOpen(!open);
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
return (
|
type SidebarContextProps = {
|
||||||
<SidebarContext.Provider value={{ open, toggleOpen }}>
|
state: "expanded" | "collapsed"
|
||||||
<div className="flex h-screen w-full">{children}</div>
|
open: boolean
|
||||||
</SidebarContext.Provider>
|
setOpen: (open: boolean) => void
|
||||||
);
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ children }: { children: React.ReactNode }) {
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
const context = React.useContext(SidebarContext);
|
|
||||||
if (!context) return null;
|
|
||||||
|
|
||||||
return (
|
function useSidebar() {
|
||||||
<aside className={cn('flex flex-col border-r bg-sidebar w-64 transition-all', !context.open && 'w-0 overflow-hidden')}>
|
const context = React.useContext(SidebarContext)
|
||||||
{children}
|
if (!context) {
|
||||||
</aside>
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarHeader({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
||||||
return <div className={cn('px-4 py-3', className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarContent({ children }: { children: React.ReactNode }) {
|
|
||||||
return <div className="flex-1 overflow-y-auto py-2">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarMenu({ children }: { children: React.ReactNode }) {
|
|
||||||
return <ul className="space-y-1 px-2">{children}</ul>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarMenuItem({ children }: { children: React.ReactNode }) {
|
|
||||||
return <li>{children}</li>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarMenuButtonProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
isActive?: boolean;
|
|
||||||
asChild?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarMenuButton({ children, isActive, asChild }: SidebarMenuButtonProps) {
|
|
||||||
const className = cn(
|
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
||||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
||||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (asChild) {
|
|
||||||
return <div className={className}>{children}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button className={className}>{children}</button>;
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarTrigger() {
|
function SidebarProvider({
|
||||||
const context = React.useContext(SidebarContext);
|
defaultOpen = true,
|
||||||
if (!context) return null;
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button onClick={context.toggleOpen} className="p-2 hover:bg-accent rounded">
|
<SidebarContext.Provider value={contextValue}>
|
||||||
<svg
|
<TooltipProvider delayDuration={0}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div
|
||||||
width="24"
|
data-slot="sidebar-wrapper"
|
||||||
height="24"
|
style={
|
||||||
viewBox="0 0 24 24"
|
{
|
||||||
fill="none"
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
stroke="currentColor"
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
strokeWidth="2"
|
...style,
|
||||||
strokeLinecap="round"
|
} as React.CSSProperties
|
||||||
strokeLinejoin="round"
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<line x1="3" x2="21" y1="6" y2="6" />
|
{children}
|
||||||
<line x1="3" x2="21" y1="12" y2="12" />
|
</div>
|
||||||
<line x1="3" x2="21" y1="18" y2="18" />
|
)
|
||||||
</svg>
|
}
|
||||||
</button>
|
|
||||||
);
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("size-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface HealthCheck {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'ok' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
details?: string
|
||||||
|
fixable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthReport {
|
||||||
|
timestamp: number
|
||||||
|
checks: HealthCheck[]
|
||||||
|
summary: {
|
||||||
|
total: number
|
||||||
|
passed: number
|
||||||
|
warnings: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHealth() {
|
||||||
|
return useQuery<HealthReport>({
|
||||||
|
queryKey: ['health'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/health')
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
refetchInterval: 30000, // Auto-refresh every 30s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFixHealth() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (checkId: string) => {
|
||||||
|
const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.message)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: (data: { message: string }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health'] })
|
||||||
|
toast.success(data.message)
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
interface Overview {
|
||||||
|
profiles: number
|
||||||
|
cliproxy: number
|
||||||
|
accounts: number
|
||||||
|
health: {
|
||||||
|
status: 'ok' | 'warning' | 'error'
|
||||||
|
passed: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverview() {
|
||||||
|
return useQuery<Overview>({
|
||||||
|
queryKey: ['overview'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/overview')
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
interface SharedItem {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
path: string
|
||||||
|
type: 'command' | 'skill' | 'agent'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedSummary {
|
||||||
|
commands: number
|
||||||
|
skills: number
|
||||||
|
agents: number
|
||||||
|
total: number
|
||||||
|
symlinkStatus: { valid: boolean; message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSharedSummary() {
|
||||||
|
return useQuery<SharedSummary>({
|
||||||
|
queryKey: ['shared', 'summary'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch('/api/shared/summary')
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSharedItems(type: 'commands' | 'skills' | 'agents') {
|
||||||
|
return useQuery<{ items: SharedItem[] }>({
|
||||||
|
queryKey: ['shared', type],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/shared/${type}`)
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { HealthCard } from '@/components/health-card'
|
||||||
|
import { useHealth } from '@/hooks/use-health'
|
||||||
|
|
||||||
|
export function HealthPage() {
|
||||||
|
const { data, isLoading, refetch, dataUpdatedAt } = useHealth()
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
return new Date(timestamp).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Health Dashboard</h1>
|
||||||
|
{dataUpdatedAt && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Last check: {formatTime(dataUpdatedAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium text-green-600">{data.summary.passed}</span>
|
||||||
|
<span className="text-muted-foreground">passed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium text-yellow-600">{data.summary.warnings}</span>
|
||||||
|
<span className="text-muted-foreground">warnings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium text-red-600">{data.summary.errors}</span>
|
||||||
|
<span className="text-muted-foreground">errors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && !data && (
|
||||||
|
<div className="text-muted-foreground">Running health checks...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{data.checks.map((check) => (
|
||||||
|
<HealthCard key={check.id} check={check} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { StatCard } from '@/components/stat-card'
|
||||||
|
import { Key, Zap, Users, Activity, Plus, Stethoscope, BookOpen, FolderOpen } from 'lucide-react'
|
||||||
|
import { useOverview } from '@/hooks/use-overview'
|
||||||
|
import { useSharedSummary } from '@/hooks/use-shared'
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: overview } = useOverview()
|
||||||
|
const { data: shared } = useSharedSummary()
|
||||||
|
|
||||||
|
const healthColor = {
|
||||||
|
ok: 'text-green-500',
|
||||||
|
warning: 'text-yellow-500',
|
||||||
|
error: 'text-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Welcome to CCS Config</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your Claude Code Switch configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="API Profiles"
|
||||||
|
value={overview?.profiles ?? 0}
|
||||||
|
icon={Key}
|
||||||
|
onClick={() => navigate('/api')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="CLIProxy Variants"
|
||||||
|
value={overview?.cliproxy ?? 0}
|
||||||
|
icon={Zap}
|
||||||
|
onClick={() => navigate('/cliproxy')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Accounts"
|
||||||
|
value={overview?.accounts ?? 0}
|
||||||
|
icon={Users}
|
||||||
|
onClick={() => navigate('/accounts')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Health"
|
||||||
|
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
|
||||||
|
icon={Activity}
|
||||||
|
color={overview?.health ? healthColor[overview.health.status] : undefined}
|
||||||
|
onClick={() => navigate('/health')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-3">
|
||||||
|
<Button onClick={() => navigate('/api')}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> New Profile
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/health')}>
|
||||||
|
<Stethoscope className="w-4 h-4 mr-2" /> Run Doctor
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="https://github.com/anthropics/claude-cli" target="_blank" rel="noopener noreferrer">
|
||||||
|
<BookOpen className="w-4 h-4 mr-2" /> Documentation
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Shared Data Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Shared Data</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/shared')}>
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{shared?.commands ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Commands</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{shared?.skills ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Skills</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{shared?.agents ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Agents</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shared?.symlinkStatus && (
|
||||||
|
<p className={`text-xs mt-2 ${shared.symlinkStatus.valid ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
|
{shared.symlinkStatus.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+4
-32
@@ -1,11 +1,4 @@
|
|||||||
export function HomePage() {
|
export { HomePage } from './home';
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold">Home</h1>
|
|
||||||
<p className="mt-4 text-muted-foreground">Welcome to CCS Config Dashboard</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ApiPage } from './api';
|
export { ApiPage } from './api';
|
||||||
|
|
||||||
@@ -27,29 +20,8 @@ export function AccountsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export { SettingsPage } from './settings';
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
|
||||||
<p className="mt-4 text-muted-foreground">Configure profile settings (Phase 05)</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HealthPage() {
|
export { HealthPage } from './health';
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold">Health</h1>
|
|
||||||
<p className="mt-4 text-muted-foreground">System health dashboard (Phase 06)</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SharedPage() {
|
export { SharedPage } from './shared';
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold">Shared Data</h1>
|
|
||||||
<p className="mt-4 text-muted-foreground">Commands, skills, agents viewer (Phase 07)</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { MaskedInput } from '@/components/ui/masked-input'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { Edit, Save, X } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
env?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsResponse {
|
||||||
|
profile: string
|
||||||
|
settings: Settings
|
||||||
|
mtime: number
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const profile = searchParams.get('profile')
|
||||||
|
const [editMode, setEditMode] = useState(false)
|
||||||
|
const [editedSettings, setEditedSettings] = useState<Settings | null>(null)
|
||||||
|
const [conflictDialog, setConflictDialog] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Fetch profiles for selector
|
||||||
|
const { data: profilesData } = useQuery({
|
||||||
|
queryKey: ['profiles'],
|
||||||
|
queryFn: () => api.profiles.list(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch settings for selected profile
|
||||||
|
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
|
||||||
|
queryKey: ['settings', profile],
|
||||||
|
queryFn: () => fetch(`/api/settings/${profile}/raw`).then(r => r.json()),
|
||||||
|
enabled: !!profile,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize edited settings when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.settings) {
|
||||||
|
setEditedSettings(data.settings)
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// Save mutation
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch(`/api/settings/${profile}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: editedSettings,
|
||||||
|
expectedMtime: data?.mtime,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
throw new Error('CONFLICT')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to save')
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings', profile] })
|
||||||
|
setEditMode(false)
|
||||||
|
toast.success('Settings saved')
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
if (error.message === 'CONFLICT') {
|
||||||
|
setConflictDialog(true)
|
||||||
|
} else {
|
||||||
|
toast.error(error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConflictResolve = async (overwrite: boolean) => {
|
||||||
|
setConflictDialog(false)
|
||||||
|
if (overwrite) {
|
||||||
|
// Refetch to get new mtime, then save
|
||||||
|
await refetch()
|
||||||
|
saveMutation.mutate()
|
||||||
|
} else {
|
||||||
|
// Discard local changes
|
||||||
|
if (data?.settings) {
|
||||||
|
setEditedSettings(data.settings)
|
||||||
|
}
|
||||||
|
setEditMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEnvValue = (key: string, value: string) => {
|
||||||
|
setEditedSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
env: {
|
||||||
|
...prev?.env,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Settings Editor</h1>
|
||||||
|
<select
|
||||||
|
className="border rounded px-3 py-2"
|
||||||
|
value={profile || ''}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSearchParams({ profile: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select profile...</option>
|
||||||
|
{profilesData?.profiles.map((p) => (
|
||||||
|
<option key={p.name} value={p.name}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!profile && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-muted-foreground">Select a profile to view/edit settings.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile && isLoading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile && data && editedSettings && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Environment Variables</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!editMode ? (
|
||||||
|
<Button variant="outline" onClick={() => setEditMode(true)}>
|
||||||
|
<Edit className="w-4 h-4 mr-2" /> Edit
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setEditedSettings(data.settings)
|
||||||
|
setEditMode(false)
|
||||||
|
}}>
|
||||||
|
<X className="w-4 h-4 mr-2" /> Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{Object.entries(editedSettings.env || {}).map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<Label>{key}</Label>
|
||||||
|
{key.includes('TOKEN') || key.includes('KEY') ? (
|
||||||
|
<MaskedInput
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||||
|
disabled={!editMode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||||
|
disabled={!editMode}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="pt-4 text-xs text-muted-foreground">
|
||||||
|
<p>Path: {data.path}</p>
|
||||||
|
<p>Last modified: {new Date(data.mtime).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={conflictDialog}
|
||||||
|
title="File Modified Externally"
|
||||||
|
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||||
|
confirmText="Overwrite"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={() => handleConflictResolve(true)}
|
||||||
|
onCancel={() => handleConflictResolve(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useSharedItems, useSharedSummary } from '@/hooks/use-shared'
|
||||||
|
import { FileText, Sparkles, Bot, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
type TabType = 'commands' | 'skills' | 'agents'
|
||||||
|
|
||||||
|
export function SharedPage() {
|
||||||
|
const [tab, setTab] = useState<TabType>('commands')
|
||||||
|
const { data: summary } = useSharedSummary()
|
||||||
|
const { data: items, isLoading } = useSharedItems(tab)
|
||||||
|
|
||||||
|
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
|
||||||
|
{ id: 'commands', label: 'Commands', icon: FileText, count: summary?.commands ?? 0 },
|
||||||
|
{ id: 'skills', label: 'Skills', icon: Sparkles, count: summary?.skills ?? 0 },
|
||||||
|
{ id: 'agents', label: 'Agents', icon: Bot, count: summary?.agents ?? 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Shared Data</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Commands, skills, and agents shared across Claude instances
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && !summary.symlinkStatus.valid && (
|
||||||
|
<Card className="bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
||||||
|
<CardContent className="pt-4 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab buttons */}
|
||||||
|
<div className="flex gap-2 border-b pb-2">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t.id}
|
||||||
|
variant={tab === t.id ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<t.icon className="w-4 h-4" />
|
||||||
|
{t.label} ({t.count})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
) : items?.items.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground">No {tab} found</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{items?.items.map((item) => (
|
||||||
|
<Card key={item.name}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 font-mono truncate">
|
||||||
|
{item.path}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* React context provider for WebSocket connection.
|
* React context provider for WebSocket connection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext, useContext, ReactNode } from 'react';
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
import { useWebSocket } from '@/hooks/use-websocket';
|
import { useWebSocket } from '@/hooks/use-websocket';
|
||||||
|
|
||||||
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
|
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
|
||||||
|
|||||||
Reference in New Issue
Block a user