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:
kaitranntt
2025-12-07 14:23:56 -05:00
parent 56502ab6a8
commit 59758024c9
43 changed files with 3557 additions and 138 deletions
+5
View File
@@ -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/",
+39
View File
@@ -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 ==="
+48
View File
@@ -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`);
}
+1
View File
@@ -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'],
+230
View File
@@ -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' };
}
}
+8
View File
@@ -37,6 +37,14 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { apiRoutes } = await import('./routes');
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/)
const staticDir = options.staticDir || path.join(__dirname, '../../dist/ui');
app.use(express.static(staticDir));
+60
View File
@@ -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;
}
}
+146
View File
@@ -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 });
}
});
+149
View File
@@ -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
View File
@@ -52,6 +52,7 @@
"dist",
"tests",
"lib",
"scripts"
"scripts",
"ui"
]
}
+155
View File
@@ -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,
}
+92
View File
@@ -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
View File
@@ -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=="],
+1
View File
@@ -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",
+2
View File
@@ -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() {
</Route>
</Routes>
<Toaster position="top-right" />
<LocalhostDisclaimer />
</BrowserRouter>
</QueryClientProvider>
);
+50
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+155
View File
@@ -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,
}
+44 -31
View File
@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
function Button({
className,
variant,
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>(
({ 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 };
export { Button, buttonVariants }
+92
View File
@@ -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,
}
+141
View File
@@ -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,
}
+255
View File
@@ -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,
}
+21
View File
@@ -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 }
+24
View File
@@ -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 }
+35
View File
@@ -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>
)
}
+28
View File
@@ -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 }
+137
View File
@@ -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,
}
+710 -73
View File
@@ -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 (
<SidebarContext.Provider value={{ open, toggleOpen }}>
<div className="flex h-screen w-full">{children}</div>
</SidebarContext.Provider>
);
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<SidebarContextProps | null>(null)
return (
<aside className={cn('flex flex-col border-r bg-sidebar w-64 transition-all', !context.open && 'w-0 overflow-hidden')}>
{children}
</aside>
);
}
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>;
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return <button className={className}>{children}</button>;
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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<button onClick={context.toggleOpen} className="p-2 hover:bg-accent rounded">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
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" />
<line x1="3" x2="21" y1="12" y2="12" />
<line x1="3" x2="21" y1="18" y2="18" />
</svg>
</button>
);
{children}
</div>
)
}
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,
}
+13
View File
@@ -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 }
+114
View File
@@ -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,
}
+61
View File
@@ -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 }
+53
View File
@@ -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)
},
})
}
+19
View File
@@ -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
}
+22
View File
@@ -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()
},
})
}
+36
View File
@@ -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()
},
})
}
+64
View File
@@ -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>
)
}
+111
View File
@@ -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
View File
@@ -1,11 +1,4 @@
export function HomePage() {
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 { HomePage } from './home';
export { ApiPage } from './api';
@@ -27,29 +20,8 @@ export function AccountsPage() {
);
}
export function SettingsPage() {
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 { SettingsPage } from './settings';
export function HealthPage() {
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 { HealthPage } from './health';
export function SharedPage() {
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>
);
}
export { SharedPage } from './shared';
+215
View File
@@ -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>
)
}
+82
View File
@@ -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>
)
}
+1 -1
View File
@@ -4,7 +4,7 @@
* 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';
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';