diff --git a/package.json b/package.json index e34ad416..dbebdfe6 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,18 @@ "scripts": { "build": "tsc && node scripts/add-shebang.js", "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:all": "rm -rf dist tsconfig.tsbuildinfo", + "postbuild:all": "node scripts/verify-bundle.js", "typecheck": "tsc --noEmit", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", "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:all": "bun test", "test:unit": "bun test tests/unit/", diff --git a/scripts/pre-release.sh b/scripts/pre-release.sh new file mode 100755 index 00000000..aeb3a260 --- /dev/null +++ b/scripts/pre-release.sh @@ -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 ===" diff --git a/scripts/verify-bundle.js b/scripts/verify-bundle.js new file mode 100644 index 00000000..6da1ca9f --- /dev/null +++ b/scripts/verify-bundle.js @@ -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`); +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 9719f726..2e4bf2b4 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -184,6 +184,7 @@ Claude Code Profile & Model Switcher`.trim(); printSubSection('Diagnostics', [ ['ccs doctor', 'Run health check and diagnostics'], ['ccs config', 'Open web configuration dashboard'], + ['ccs config --port 3000', 'Use specific port'], ['ccs sync', 'Sync delegation commands and skills'], ['ccs update', 'Update CCS to latest version'], ['ccs update --force', 'Force reinstall current version'], diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts new file mode 100644 index 00000000..374f430a --- /dev/null +++ b/src/web-server/health-service.ts @@ -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' }; + } +} diff --git a/src/web-server/index.ts b/src/web-server/index.ts index ff02b276..d1473963 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -37,6 +37,14 @@ export async function startServer(options: ServerOptions): Promise { + 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; + } +} diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 246b8b72..72d93001 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/config-manager'; import { Config, Settings } from '../types/config'; import { expandPath } from '../utils/helpers'; +import { runHealthChecks, fixHealthIssue } from './health-service'; export const apiRoutes = Router(); @@ -292,6 +293,127 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => { 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 ==================== /** @@ -340,3 +462,27 @@ apiRoutes.post('/accounts/default', (req: Request, res: Response): void => { 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 }); + } +}); diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts new file mode 100644 index 00000000..cd4adb5d --- /dev/null +++ b/src/web-server/shared-routes.ts @@ -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' }; +} diff --git a/tsconfig.json b/tsconfig.json index d40ad0d2..895dabca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,6 +52,7 @@ "dist", "tests", "lib", - "scripts" + "scripts", + "ui" ] } \ No newline at end of file diff --git a/ui/@/components/ui/alert-dialog.tsx b/ui/@/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..935eecf3 --- /dev/null +++ b/ui/@/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/ui/@/components/ui/card.tsx b/ui/@/components/ui/card.tsx new file mode 100644 index 00000000..681ad980 --- /dev/null +++ b/ui/@/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/ui/bun.lock b/ui/bun.lock index b5ebc161..832a6212 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -5,6 +5,7 @@ "name": "ui", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@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/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-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=="], + "@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-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=="], diff --git a/ui/package.json b/ui/package.json index 752c906a..0b496f70 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 09113f42..5cb5abc3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,7 @@ import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { ThemeToggle } from '@/components/theme-toggle'; import { ConnectionIndicator } from '@/components/connection-indicator'; +import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; import { @@ -50,6 +51,7 @@ export default function App() { + ); diff --git a/ui/src/components/confirm-dialog.tsx b/ui/src/components/confirm-dialog.tsx new file mode 100644 index 00000000..3af9f5e9 --- /dev/null +++ b/ui/src/components/confirm-dialog.tsx @@ -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 ( + !isOpen && onCancel()}> + + + {title} + {description} + + + Cancel + + {confirmText} + + + + + ) +} diff --git a/ui/src/components/health-card.tsx b/ui/src/components/health-card.tsx new file mode 100644 index 00000000..41a24ac1 --- /dev/null +++ b/ui/src/components/health-card.tsx @@ -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 ( + + +
+
+ + {check.name} +
+ {check.fixable && check.status !== 'ok' && ( + + )} +
+

{check.message}

+ {check.details && ( +

+ {check.details} +

+ )} +
+
+ ) +} diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx new file mode 100644 index 00000000..8dec9e34 --- /dev/null +++ b/ui/src/components/localhost-disclaimer.tsx @@ -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 ( +
+
+
+ + + This dashboard runs locally. All data stays on your machine. + Never expose this server to the internet. + +
+ +
+
+ ) +} diff --git a/ui/src/components/stat-card.tsx b/ui/src/components/stat-card.tsx new file mode 100644 index 00000000..75d1c81e --- /dev/null +++ b/ui/src/components/stat-card.tsx @@ -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 ( + + +
+
+

{title}

+

{value}

+
+ +
+
+
+ ) +} diff --git a/ui/src/components/ui/alert-dialog.tsx b/ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..935eecf3 --- /dev/null +++ b/ui/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 495fea69..21409a06 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -1,47 +1,60 @@ -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils" 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: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline', + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + 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: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10', + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, } -); +) -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) } -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : 'button'; - return ; - } -); -Button.displayName = 'Button'; - -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx new file mode 100644 index 00000000..681ad980 --- /dev/null +++ b/ui/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx new file mode 100644 index 00000000..6cb123b3 --- /dev/null +++ b/ui/src/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/ui/src/components/ui/dropdown-menu.tsx b/ui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..eaed9baf --- /dev/null +++ b/ui/src/components/ui/dropdown-menu.tsx @@ -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) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/ui/src/components/ui/input.tsx b/ui/src/components/ui/input.tsx new file mode 100644 index 00000000..89169058 --- /dev/null +++ b/ui/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/ui/src/components/ui/label.tsx b/ui/src/components/ui/label.tsx new file mode 100644 index 00000000..fb5fbc3e --- /dev/null +++ b/ui/src/components/ui/label.tsx @@ -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) { + return ( + + ) +} + +export { Label } diff --git a/ui/src/components/ui/masked-input.tsx b/ui/src/components/ui/masked-input.tsx new file mode 100644 index 00000000..5d7b248a --- /dev/null +++ b/ui/src/components/ui/masked-input.tsx @@ -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 { + label?: string +} + +export function MaskedInput({ label, ...props }: MaskedInputProps) { + const [visible, setVisible] = useState(false) + + return ( +
+ {label && } +
+ + +
+
+ ) +} diff --git a/ui/src/components/ui/separator.tsx b/ui/src/components/ui/separator.tsx new file mode 100644 index 00000000..275381ca --- /dev/null +++ b/ui/src/components/ui/separator.tsx @@ -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) { + return ( + + ) +} + +export { Separator } diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx new file mode 100644 index 00000000..6906f5b2 --- /dev/null +++ b/ui/src/components/ui/sheet.tsx @@ -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) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/ui/src/components/ui/sidebar.tsx b/ui/src/components/ui/sidebar.tsx index 5c21da8e..9c706c57 100644 --- a/ui/src/components/ui/sidebar.tsx +++ b/ui/src/components/ui/sidebar.tsx @@ -1,87 +1,724 @@ -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import * as React from "react" +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 [open, setOpen] = React.useState(true); - const toggleOpen = () => setOpen(!open); +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" - return ( - -
{children}
-
- ); +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void } -export function Sidebar({ children }: { children: React.ReactNode }) { - const context = React.useContext(SidebarContext); - if (!context) return null; +const SidebarContext = React.createContext(null) - return ( - - ); -} - -export function SidebarHeader({ children, className }: { children: React.ReactNode; className?: string }) { - return
{children}
; -} - -export function SidebarContent({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -export function SidebarMenu({ children }: { children: React.ReactNode }) { - return
    {children}
; -} - -export function SidebarMenuItem({ children }: { children: React.ReactNode }) { - return
  • {children}
  • ; -} - -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
    {children}
    ; +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") } - return ; + return context } -export function SidebarTrigger() { - const context = React.useContext(SidebarContext); - if (!context) return null; +function SidebarProvider({ + defaultOpen = true, + 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( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) return ( - - ); + {children} +
    + ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
    {children}
    +
    +
    + ) + } + + return ( +
    + {/* This is what handles the sidebar gap on desktop */} +
    + +
    + ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +