Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fc7320158 | |||
| d64e999db5 | |||
| 85d6b18529 | |||
| 737060902f | |||
| 062758bc1d | |||
| 435cc0733a | |||
| 5aac714b59 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
*.md
|
||||
tests
|
||||
scripts
|
||||
guides
|
||||
apps/frontend
|
||||
apps/backend
|
||||
@@ -0,0 +1,165 @@
|
||||
# Publish OSS Packages to npm
|
||||
#
|
||||
# Builds and publishes @auto-claude/types and @auto-claude/ui to npm
|
||||
# when a version tag is pushed. Includes a dry-run job on PRs to catch
|
||||
# issues before release.
|
||||
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
|
||||
concurrency:
|
||||
group: publish-packages-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# Validate - Typecheck and test before publishing
|
||||
# --------------------------------------------------------------------------
|
||||
validate:
|
||||
name: Validate packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Typecheck @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.json --noEmit
|
||||
|
||||
- name: Typecheck @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.json --noEmit
|
||||
|
||||
- name: Build @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.build.json
|
||||
|
||||
- name: Build @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.build.json
|
||||
|
||||
- name: Typecheck frontend
|
||||
working-directory: apps/frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Dry Run - Verify publish would succeed (PRs only)
|
||||
# --------------------------------------------------------------------------
|
||||
dry-run:
|
||||
name: Publish dry run
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Build @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.build.json
|
||||
|
||||
- name: Build @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.build.json
|
||||
|
||||
- name: Dry run publish @auto-claude/types
|
||||
run: npm publish --workspace packages/types --dry-run
|
||||
|
||||
- name: Dry run publish @auto-claude/ui
|
||||
run: npm publish --workspace packages/ui --dry-run
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Publish - Push packages to npm (tagged releases only)
|
||||
# --------------------------------------------------------------------------
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Configure npm authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Validate tag matches package versions
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TYPES_VERSION=$(node -p "require('./packages/types/package.json').version")
|
||||
UI_VERSION=$(node -p "require('./packages/ui/package.json').version")
|
||||
echo "Tag version: ${TAG_VERSION}"
|
||||
echo "types version: ${TYPES_VERSION}"
|
||||
echo "ui version: ${UI_VERSION}"
|
||||
if [ "$TAG_VERSION" != "$TYPES_VERSION" ]; then
|
||||
echo "::error::Tag v${TAG_VERSION} does not match @auto-claude/types version ${TYPES_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG_VERSION" != "$UI_VERSION" ]; then
|
||||
echo "::error::Tag v${TAG_VERSION} does not match @auto-claude/ui version ${UI_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.build.json
|
||||
|
||||
- name: Build @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.build.json
|
||||
|
||||
- name: Publish @auto-claude/types
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./packages/types/package.json').version")
|
||||
HTTP_CODE=$(npm view "@auto-claude/types@${PACKAGE_VERSION}" version --json 2>&1) && RC=$? || RC=$?
|
||||
if [ $RC -eq 0 ] && echo "$HTTP_CODE" | grep -q "$PACKAGE_VERSION"; then
|
||||
echo "::notice::@auto-claude/types@${PACKAGE_VERSION} already published, skipping"
|
||||
elif echo "$HTTP_CODE" | grep -qi "E404\|not found\|is not in this registry"; then
|
||||
npm publish --workspace packages/types --access public --provenance
|
||||
else
|
||||
echo "::warning::Unexpected error checking @auto-claude/types@${PACKAGE_VERSION}: ${HTTP_CODE}"
|
||||
npm publish --workspace packages/types --access public --provenance
|
||||
fi
|
||||
|
||||
- name: Publish @auto-claude/ui
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./packages/ui/package.json').version")
|
||||
HTTP_CODE=$(npm view "@auto-claude/ui@${PACKAGE_VERSION}" version --json 2>&1) && RC=$? || RC=$?
|
||||
if [ $RC -eq 0 ] && echo "$HTTP_CODE" | grep -q "$PACKAGE_VERSION"; then
|
||||
echo "::notice::@auto-claude/ui@${PACKAGE_VERSION} already published, skipping"
|
||||
elif echo "$HTTP_CODE" | grep -qi "E404\|not found\|is not in this registry"; then
|
||||
npm publish --workspace packages/ui --access public --provenance
|
||||
else
|
||||
echo "::warning::Unexpected error checking @auto-claude/ui@${PACKAGE_VERSION}: ${HTTP_CODE}"
|
||||
npm publish --workspace packages/ui --access public --provenance
|
||||
fi
|
||||
@@ -119,6 +119,7 @@ apps/frontend/node_modules
|
||||
# Build output
|
||||
dist/
|
||||
out/
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
apps/frontend/python-runtime/
|
||||
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.7)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
|
||||
| **Windows** | [Auto-Claude-2.7.7-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.7-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.7-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.7-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.7-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.flatpak) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.4"
|
||||
__version__ = "2.7.7"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -27,6 +27,8 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin({
|
||||
// Bundle these packages into the main process (they won't be in node_modules in packaged app)
|
||||
exclude: [
|
||||
// Workspace packages — must be bundled, not externalized
|
||||
'@auto-claude/types',
|
||||
'uuid',
|
||||
'chokidar',
|
||||
'dotenv',
|
||||
@@ -90,7 +92,10 @@ export default defineConfig({
|
||||
'@features': resolve(__dirname, 'src/renderer/features'),
|
||||
'@components': resolve(__dirname, 'src/renderer/shared/components'),
|
||||
'@hooks': resolve(__dirname, 'src/renderer/shared/hooks'),
|
||||
'@lib': resolve(__dirname, 'src/renderer/shared/lib')
|
||||
'@lib': resolve(__dirname, 'src/renderer/shared/lib'),
|
||||
// Workspace packages — resolve to source for HMR support
|
||||
'@auto-claude/types': resolve(__dirname, '../../packages/types/src'),
|
||||
'@auto-claude/ui': resolve(__dirname, '../../packages/ui/src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.6-beta.4",
|
||||
"version": "2.7.7",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
@@ -51,6 +51,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auto-claude/types": "*",
|
||||
"@auto-claude/ui": "*",
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants/ipc';
|
||||
import type { IPCResult } from '../../../shared/types/common';
|
||||
import type { IPCResult } from '@auto-claude/types';
|
||||
import type { CustomMcpServer, McpHealthCheckResult, McpTestConnectionResult } from '../../../shared/types/project';
|
||||
|
||||
export interface McpAPI {
|
||||
|
||||
@@ -1,149 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-[90vh]',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
'bg-card border border-border rounded-2xl p-6',
|
||||
'shadow-xl',
|
||||
'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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 mt-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
export * from '@auto-claude/ui/primitives/alert-dialog';
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-success/10 text-success',
|
||||
warning: 'border-transparent bg-warning/10 text-warning',
|
||||
info: 'border-transparent bg-info/10 text-info',
|
||||
purple: 'border-transparent bg-purple-500/10 text-purple-400',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export * from '@auto-claude/ui/primitives/badge';
|
||||
|
||||
@@ -1,64 +1 @@
|
||||
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';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
success:
|
||||
'bg-[var(--success)] text-[var(--success-foreground)] hover:bg-[var(--success)]/90 active:scale-[0.98]',
|
||||
warning:
|
||||
'bg-warning text-warning-foreground hover:bg-warning/90 active:scale-[0.98]',
|
||||
info:
|
||||
'bg-info text-info-foreground hover:bg-info/90 active:scale-[0.98]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2 text-sm rounded-lg',
|
||||
sm: 'h-8 px-3 text-xs rounded-md',
|
||||
lg: 'h-12 px-6 text-base rounded-lg',
|
||||
icon: 'h-10 w-10 rounded-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
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 * from '@auto-claude/ui/primitives/button';
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card text-card-foreground transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export * from '@auto-claude/ui/primitives/card';
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, checked, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
'data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
{checked === 'indeterminate' ? (
|
||||
<Minus className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
export * from '@auto-claude/ui/primitives/checkbox';
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
export * from '@auto-claude/ui/primitives/collapsible';
|
||||
|
||||
@@ -1,301 +1 @@
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Optional group name for grouping options (e.g., "Local Branches", "Remote Branches") */
|
||||
group?: string;
|
||||
/** Optional icon to display before the label */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional badge to display after the label */
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
/** Currently selected value */
|
||||
value: string;
|
||||
/** Callback when value changes */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Available options */
|
||||
options: ComboboxOption[];
|
||||
/** Placeholder text for the trigger button */
|
||||
placeholder?: string;
|
||||
/** Placeholder text for the search input */
|
||||
searchPlaceholder?: string;
|
||||
/** Message shown when no results match the search */
|
||||
emptyMessage?: string;
|
||||
/** Whether the combobox is disabled */
|
||||
disabled?: boolean;
|
||||
/** Additional class names for the trigger */
|
||||
className?: string;
|
||||
/** ID for the trigger element */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = 'Select...',
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No results found',
|
||||
disabled = false,
|
||||
className,
|
||||
id,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const optionRefs = React.useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
const listboxId = React.useId();
|
||||
|
||||
// Find the selected option's label
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
const displayValue = selectedOption?.label || placeholder;
|
||||
|
||||
// Filter options based on search
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!search.trim()) return options;
|
||||
const searchLower = search.toLowerCase();
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(searchLower) ||
|
||||
opt.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
// Get option ID for aria-activedescendant
|
||||
const getOptionId = (index: number) => `${listboxId}-option-${index}`;
|
||||
|
||||
// Get the currently focused option ID
|
||||
const activeDescendant =
|
||||
focusedIndex >= 0 && focusedIndex < filteredOptions.length
|
||||
? getOptionId(focusedIndex)
|
||||
: undefined;
|
||||
|
||||
// Focus input when popover opens, reset focused index
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to ensure the popover is rendered
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
// Reset focused index when opening
|
||||
setFocusedIndex(-1);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
// Clear search when closing
|
||||
setSearch('');
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset focused index when filtered options change
|
||||
React.useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, []);
|
||||
|
||||
// Scroll focused option into view
|
||||
React.useEffect(() => {
|
||||
if (focusedIndex >= 0) {
|
||||
const optionEl = optionRefs.current.get(focusedIndex);
|
||||
optionEl?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setFocusedIndex(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
||||
handleSelect(filteredOptions[focusedIndex].value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
if (filteredOptions.length > 0) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
if (filteredOptions.length > 0) {
|
||||
setFocusedIndex(filteredOptions.length - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
id={id}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-lg',
|
||||
'border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex items-center gap-2 truncate', !selectedOption && 'text-muted-foreground')}>
|
||||
{selectedOption?.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{selectedOption.icon}</span>
|
||||
)}
|
||||
<span className="truncate">{displayValue}</span>
|
||||
{selectedOption?.badge && (
|
||||
<span className="shrink-0">{selectedOption.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b border-border px-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeDescendant}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className={cn(
|
||||
'flex h-10 w-full bg-transparent py-3 px-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div id={listboxId} role="listbox" aria-label={searchPlaceholder || placeholder} className="p-1">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => {
|
||||
// Check if we need to render a group header
|
||||
const prevOption = index > 0 ? filteredOptions[index - 1] : null;
|
||||
const showGroupHeader = option.group && option.group !== prevOption?.group;
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.value}>
|
||||
{/* Group header */}
|
||||
{showGroupHeader && (
|
||||
<div
|
||||
role="presentation"
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
|
||||
index > 0 && 'mt-1 border-t border-border pt-2'
|
||||
)}
|
||||
>
|
||||
{option.group}
|
||||
</div>
|
||||
)}
|
||||
{/* Option item */}
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
optionRefs.current.set(index, el);
|
||||
} else {
|
||||
optionRefs.current.delete(index);
|
||||
}
|
||||
}}
|
||||
id={getOptionId(index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors duration-150',
|
||||
focusedIndex === index && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
{value === option.value && <Check className="h-4 w-4 text-primary" />}
|
||||
</span>
|
||||
<span className="flex flex-1 items-center gap-2 truncate">
|
||||
{option.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
|
||||
)}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.badge && (
|
||||
<span className="shrink-0">{option.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Combobox.displayName = 'Combobox';
|
||||
|
||||
export { Combobox };
|
||||
export * from '@auto-claude/ui/primitives/combobox';
|
||||
|
||||
@@ -1,126 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, hideCloseButton, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-[90vh]',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
'bg-card border border-border rounded-2xl p-6',
|
||||
'shadow-xl',
|
||||
'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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'duration-200 overflow-hidden flex flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-lg p-1 z-10',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
||||
'disabled:pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 mt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
export * from '@auto-claude/ui/primitives/dialog';
|
||||
|
||||
@@ -1,201 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'max-h-[var(--radix-dropdown-menu-content-available-height,400px)]',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
export * from '@auto-claude/ui/primitives/dropdown-menu';
|
||||
|
||||
@@ -1,80 +1,22 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { Card, CardContent } from './card';
|
||||
import type React from 'react';
|
||||
import { ErrorBoundary as BaseErrorBoundary } from '@auto-claude/ui/primitives/error-boundary';
|
||||
import { captureException } from '../../lib/sentry';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
type BaseErrorBoundaryProps = React.ComponentProps<typeof BaseErrorBoundary>;
|
||||
|
||||
/**
|
||||
* Error boundary component to gracefully handle render errors.
|
||||
* Prevents the entire page from crashing when a component fails.
|
||||
* App-level ErrorBoundary that automatically reports errors to Sentry.
|
||||
* Wraps the shared @auto-claude/ui ErrorBoundary with Sentry integration.
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Report to Sentry with React component stack
|
||||
captureException(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.props.onReset?.();
|
||||
export function ErrorBoundary(props: BaseErrorBoundaryProps) {
|
||||
const handleError = (error: Error, errorInfo: React.ErrorInfo): void => {
|
||||
captureException(error, { componentStack: errorInfo.componentStack });
|
||||
props.onError?.(error, errorInfo);
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-destructive m-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">Something went wrong</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
An error occurred while rendering this content.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<p className="text-xs text-muted-foreground font-mono bg-muted p-2 rounded max-w-md overflow-auto">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={this.handleReset} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
return (
|
||||
<BaseErrorBoundary {...props} onError={handleError}>
|
||||
{props.children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const FullScreenDialog = DialogPrimitive.Root;
|
||||
|
||||
const FullScreenDialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const FullScreenDialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const FullScreenDialogClose = DialogPrimitive.Close;
|
||||
|
||||
const FullScreenDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogOverlay.displayName = 'FullScreenDialogOverlay';
|
||||
|
||||
const FullScreenDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<FullScreenDialogPortal>
|
||||
<FullScreenDialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-4 z-50 flex flex-col',
|
||||
'bg-card border border-border rounded-2xl',
|
||||
'shadow-2xl overflow-hidden',
|
||||
'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-98 data-[state=open]:zoom-in-98',
|
||||
'duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-lg p-2',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
||||
'disabled:pointer-events-none z-10'
|
||||
)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</FullScreenDialogPortal>
|
||||
));
|
||||
FullScreenDialogContent.displayName = 'FullScreenDialogContent';
|
||||
|
||||
const FullScreenDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 p-6 pb-4 border-b border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
FullScreenDialogHeader.displayName = 'FullScreenDialogHeader';
|
||||
|
||||
const FullScreenDialogBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex-1 overflow-hidden', className)} {...props} />
|
||||
);
|
||||
FullScreenDialogBody.displayName = 'FullScreenDialogBody';
|
||||
|
||||
const FullScreenDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 p-6 pt-4 border-t border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
FullScreenDialogFooter.displayName = 'FullScreenDialogFooter';
|
||||
|
||||
const FullScreenDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-xl font-semibold leading-none tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogTitle.displayName = 'FullScreenDialogTitle';
|
||||
|
||||
const FullScreenDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogDescription.displayName = 'FullScreenDialogDescription';
|
||||
|
||||
export {
|
||||
FullScreenDialog,
|
||||
FullScreenDialogPortal,
|
||||
FullScreenDialogOverlay,
|
||||
FullScreenDialogClose,
|
||||
FullScreenDialogTrigger,
|
||||
FullScreenDialogContent,
|
||||
FullScreenDialogHeader,
|
||||
FullScreenDialogBody,
|
||||
FullScreenDialogFooter,
|
||||
FullScreenDialogTitle,
|
||||
FullScreenDialogDescription,
|
||||
};
|
||||
export * from '@auto-claude/ui/primitives/full-screen-dialog';
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
// Re-export all UI components
|
||||
export * from './badge';
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './combobox';
|
||||
export * from './dialog';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './progress';
|
||||
export * from './scroll-area';
|
||||
export * from './select';
|
||||
export * from './separator';
|
||||
export * from './switch';
|
||||
export * from './tabs';
|
||||
export * from './textarea';
|
||||
export * from './tooltip';
|
||||
// Re-export all UI primitives from shared @auto-claude/ui package
|
||||
export * from '@auto-claude/ui/primitives';
|
||||
|
||||
// Override base ErrorBoundary with Sentry-integrated version
|
||||
export { ErrorBoundary } from './error-boundary';
|
||||
|
||||
@@ -1,33 +1 @@
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, spellCheck, lang, ...props }, ref) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
spellCheck={spellCheck ?? true}
|
||||
lang={lang ?? i18n.language}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
export * from '@auto-claude/ui/primitives/input';
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||
VariantProps<typeof labelVariants> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
)
|
||||
);
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
export * from '@auto-claude/ui/primitives/label';
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
export * from '@auto-claude/ui/primitives/popover';
|
||||
|
||||
@@ -1,32 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, animated, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
'h-full w-full flex-1 bg-primary transition-all duration-300 ease-out',
|
||||
animated && 'progress-working'
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
export * from '@auto-claude/ui/primitives/progress';
|
||||
|
||||
@@ -1,43 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { Circle } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
export * from '@auto-claude/ui/primitives/radio-group';
|
||||
|
||||
@@ -1,165 +1 @@
|
||||
/**
|
||||
* ResizablePanels - A split panel layout with a draggable divider
|
||||
*
|
||||
* Features:
|
||||
* - Smooth drag-to-resize functionality
|
||||
* - Min/max width constraints
|
||||
* - Persists width to localStorage
|
||||
* - Visual feedback on hover and drag
|
||||
* - Touch support for mobile devices
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ResizablePanelsProps {
|
||||
leftPanel: ReactNode;
|
||||
rightPanel: ReactNode;
|
||||
defaultLeftWidth?: number; // percentage, default 50
|
||||
minLeftWidth?: number; // percentage, default 30
|
||||
maxLeftWidth?: number; // percentage, default 70
|
||||
storageKey?: string; // localStorage key for persistence
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ResizablePanels({
|
||||
leftPanel,
|
||||
rightPanel,
|
||||
defaultLeftWidth = 50,
|
||||
minLeftWidth = 30,
|
||||
maxLeftWidth = 70,
|
||||
storageKey,
|
||||
className,
|
||||
}: ResizablePanelsProps) {
|
||||
// Load initial width from storage or use default
|
||||
const [leftWidth, setLeftWidth] = useState(() => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = parseFloat(stored);
|
||||
if (!Number.isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g., private browsing)
|
||||
}
|
||||
}
|
||||
return defaultLeftWidth;
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save to storage when width changes (debounced by only saving when not dragging)
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, leftWidth.toString());
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g., private browsing, quota exceeded)
|
||||
}
|
||||
}
|
||||
}, [leftWidth, storageKey, isDragging]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
// Guard against division by zero when container has no width
|
||||
if (rect.width <= 0) return;
|
||||
const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
|
||||
setLeftWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!containerRef.current || e.touches.length === 0) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const touch = e.touches[0];
|
||||
const newWidth = ((touch.clientX - rect.left) / rect.width) * 100;
|
||||
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
|
||||
setLeftWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Add user-select: none to body during drag to prevent text selection
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [isDragging, minLeftWidth, maxLeftWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("flex-1 flex min-h-0", className)}
|
||||
>
|
||||
{/* Left panel */}
|
||||
<div
|
||||
className="flex flex-col min-w-0 overflow-hidden"
|
||||
style={{ width: `${leftWidth}%` }}
|
||||
>
|
||||
{leftPanel}
|
||||
</div>
|
||||
|
||||
{/* Resizable divider */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1 flex-shrink-0 relative cursor-col-resize touch-none",
|
||||
"bg-border transition-colors duration-150",
|
||||
"hover:bg-primary/40",
|
||||
isDragging && "bg-primary/60"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Wider invisible hit area for easier grabbing */}
|
||||
<div className="absolute inset-y-0 -left-1 -right-1 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div
|
||||
className="flex flex-col min-w-0 overflow-hidden"
|
||||
style={{ width: `${100 - leftWidth}%` }}
|
||||
>
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export * from '@auto-claude/ui/primitives/resizable-panels';
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportClassName?: string;
|
||||
onViewportRef?: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
>(({ className, children, viewportClassName, onViewportRef, ...props }, ref) => {
|
||||
const viewportRef = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
onViewportRef?.(element);
|
||||
},
|
||||
[onViewportRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
className={cn('h-full w-full rounded-[inherit]', viewportClassName)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
});
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
export * from '@auto-claude/ui/primitives/scroll-area';
|
||||
|
||||
@@ -1,166 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-lg',
|
||||
'border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden',
|
||||
'rounded-lg border border-border bg-card text-foreground shadow-lg',
|
||||
'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',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1 max-h-[300px] overflow-y-auto',
|
||||
position === 'popper' &&
|
||||
'w-full min-w-(--radix-select-trigger-width)'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'transition-colors duration-150',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
export * from '@auto-claude/ui/primitives/select';
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
export * from '@auto-claude/ui/primitives/separator';
|
||||
|
||||
@@ -1,33 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
|
||||
'border-2 border-transparent transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full shadow-sm ring-0 transition-transform duration-200',
|
||||
'bg-white dark:bg-foreground',
|
||||
'data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
'data-[state=checked]:bg-primary-foreground'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
export * from '@auto-claude/ui/primitives/switch';
|
||||
|
||||
@@ -1,57 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-lg bg-secondary p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-card data-[state=active]:text-foreground',
|
||||
'data-[state=inactive]:hover:text-foreground/80',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export * from '@auto-claude/ui/primitives/tabs';
|
||||
|
||||
@@ -1,32 +1 @@
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, spellCheck, lang, ...props }, ref) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<textarea
|
||||
spellCheck={spellCheck ?? true}
|
||||
lang={lang ?? i18n.language}
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
'resize-none',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
export * from '@auto-claude/ui/primitives/textarea';
|
||||
|
||||
@@ -1,130 +1 @@
|
||||
/**
|
||||
* Toast UI Components
|
||||
*
|
||||
* Based on Radix UI Toast for non-intrusive notifications.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-card text-foreground',
|
||||
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
export * from '@auto-claude/ui/primitives/toast';
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
/**
|
||||
* Toaster Component
|
||||
*
|
||||
* Renders the toast viewport where toasts are displayed.
|
||||
* Should be included once in the app root.
|
||||
*/
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from './toast';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
export * from '@auto-claude/ui/primitives/toaster';
|
||||
|
||||
@@ -1,31 +1 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground shadow-lg',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export * from '@auto-claude/ui/primitives/tooltip';
|
||||
|
||||
@@ -1,192 +1 @@
|
||||
/**
|
||||
* Toast Hook
|
||||
*
|
||||
* Manages toast state for displaying notifications.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '../components/ui/toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
export { useToast, toast, reducer } from '@auto-claude/ui/primitives/use-toast';
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
// Re-export cn() from the shared UI package
|
||||
export { cn } from '@auto-claude/ui';
|
||||
|
||||
/**
|
||||
* Calculate progress percentage from subtasks
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,5 @@
|
||||
/**
|
||||
* Common utility types shared across the application
|
||||
* Re-exports common types from @auto-claude/types.
|
||||
* Kept for backwards compatibility — prefer importing directly from '@auto-claude/types'.
|
||||
*/
|
||||
|
||||
// IPC Types
|
||||
export interface IPCResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
export type { IPCResult } from '@auto-claude/types';
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
/**
|
||||
* Central export point for all shared types
|
||||
*
|
||||
* Re-exports shared platform types from @auto-claude/types,
|
||||
* plus Electron-specific type definitions.
|
||||
*/
|
||||
|
||||
// Common types
|
||||
export * from './common';
|
||||
// Shared platform types
|
||||
export * from '@auto-claude/types';
|
||||
|
||||
// Domain-specific types
|
||||
export * from './project';
|
||||
export * from './task';
|
||||
export * from './kanban';
|
||||
// Electron-specific types
|
||||
export * from './terminal';
|
||||
export * from './agent';
|
||||
export * from './profile';
|
||||
export * from './unified-account';
|
||||
export * from './settings';
|
||||
export * from './changelog';
|
||||
export * from './insights';
|
||||
export * from './roadmap';
|
||||
export * from './integrations';
|
||||
export * from './terminal-session';
|
||||
export * from './screenshot';
|
||||
export * from './app-update';
|
||||
export * from './cli';
|
||||
export * from './pr-status';
|
||||
export * from './profile';
|
||||
export * from './unified-account';
|
||||
|
||||
// IPC types (must be last to use types from other modules)
|
||||
export * from './ipc';
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
* IPC (Inter-Process Communication) types for Electron API
|
||||
*/
|
||||
|
||||
import type { IPCResult } from './common';
|
||||
import type { KanbanPreferences } from './kanban';
|
||||
import type { SupportedIDE, SupportedTerminal } from './settings';
|
||||
import type { IPCResult, KanbanPreferences, SupportedIDE, SupportedTerminal } from '@auto-claude/types';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSettings,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ClaudeUsageData, ClaudeRateLimitEvent } from './agent';
|
||||
* Users can configure custom Anthropic-compatible API endpoints with profiles.
|
||||
* Each profile contains name, base URL, API key, and optional model mappings.
|
||||
*
|
||||
* NOTE: These types are intentionally duplicated from libs/profile-service/src/types/profile.ts
|
||||
* NOTE: These types are intentionally duplicated from packages/profile-service/src/types/profile.ts
|
||||
* because the frontend build (Electron + Vite) doesn't consume the workspace library types directly.
|
||||
* Keep these definitions in sync with the library types when making changes.
|
||||
*/
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
"@features/*": ["src/renderer/features/*"],
|
||||
"@components/*": ["src/renderer/shared/components/*"],
|
||||
"@hooks/*": ["src/renderer/shared/hooks/*"],
|
||||
"@lib/*": ["src/renderer/shared/lib/*"]
|
||||
"@lib/*": ["src/renderer/shared/lib/*"],
|
||||
"@auto-claude/types": ["../../packages/types/src/index.ts"],
|
||||
"@auto-claude/types/*": ["../../packages/types/src/*"],
|
||||
"@auto-claude/ui": ["../../packages/ui/src/index.ts"],
|
||||
"@auto-claude/ui/*": ["../../packages/ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "../../packages/types/src/**/*", "../../packages/ui/src/**/*"],
|
||||
"exclude": ["node_modules", "out", "dist"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env*
|
||||
@@ -0,0 +1,68 @@
|
||||
# Build context should be the monorepo root:
|
||||
# docker build -t ac-web -f apps/web/Dockerfile .
|
||||
|
||||
FROM node:24-alpine AS base
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace config and lockfile
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY apps/web/package.json apps/web/
|
||||
|
||||
RUN npm ci --workspaces --include-workspace-root
|
||||
|
||||
# --- Builder ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# npm workspaces hoists everything to root node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy workspace package sources
|
||||
COPY package.json ./
|
||||
COPY packages/types/ packages/types/
|
||||
COPY packages/ui/ packages/ui/
|
||||
COPY apps/web/ apps/web/
|
||||
|
||||
# Build shared packages first
|
||||
RUN cd packages/types && npm run build
|
||||
RUN cd packages/ui && npm run build
|
||||
|
||||
ARG NEXT_PUBLIC_CONVEX_URL
|
||||
ENV NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL
|
||||
|
||||
ARG NEXT_PUBLIC_CONVEX_SITE_URL
|
||||
ENV NEXT_PUBLIC_CONVEX_SITE_URL=$NEXT_PUBLIC_CONVEX_SITE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN cd apps/web && npx next build
|
||||
|
||||
# --- Runner ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,20 @@
|
||||
// Node.js 22+ includes a built-in localStorage that breaks SSR in libraries
|
||||
// expecting browser-only localStorage. Remove it before any modules load.
|
||||
if (typeof window === "undefined" && typeof globalThis.localStorage !== "undefined") {
|
||||
delete globalThis.localStorage;
|
||||
}
|
||||
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
// Transpile shared workspace packages
|
||||
transpilePackages: ["@auto-claude/ui", "@auto-claude/types"],
|
||||
// Set turbopack root to monorepo root so it can resolve workspaces
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname, "../.."),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@auto-claude/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auto-claude/types": "*",
|
||||
"@auto-claude/ui": "*",
|
||||
"@convex-dev/better-auth": "0.10.10",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"better-auth": "1.4.9",
|
||||
"convex": "^1.31.0",
|
||||
"i18next": "^25.8.7",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "16.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"jsdom": "^27.3.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Auto Claude - Dashboard",
|
||||
description: "AI-powered software development platform",
|
||||
};
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/layout";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <AppShell />;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import HomePage from '../page';
|
||||
|
||||
// Mock the hooks and dependencies
|
||||
vi.mock('@/hooks/useCloudMode', () => ({
|
||||
useCloudMode: vi.fn(() => ({ isCloud: false, apiUrl: 'http://localhost:8000' })),
|
||||
}));
|
||||
|
||||
vi.mock('@/providers/AuthGate', () => ({
|
||||
CloudAuthenticated: vi.fn(({ children }: { children: React.ReactNode }) => <>{children}</>),
|
||||
CloudUnauthenticated: vi.fn(() => null),
|
||||
CloudAuthLoading: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/convex-imports', () => ({
|
||||
getConvexReact: vi.fn(() => ({
|
||||
useQuery: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
})),
|
||||
getConvexApi: vi.fn(() => ({
|
||||
api: {
|
||||
users: {
|
||||
me: 'users:me',
|
||||
ensureUser: 'users:ensureUser',
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock i18next - load actual English locale files for realistic rendering
|
||||
import enCommon from '@/locales/en/common.json';
|
||||
import enPages from '@/locales/en/pages.json';
|
||||
import enLayout from '@/locales/en/layout.json';
|
||||
import enKanban from '@/locales/en/kanban.json';
|
||||
import enViews from '@/locales/en/views.json';
|
||||
import enIntegrations from '@/locales/en/integrations.json';
|
||||
import enSettings from '@/locales/en/settings.json';
|
||||
|
||||
const locales: Record<string, any> = {
|
||||
common: enCommon,
|
||||
pages: enPages,
|
||||
layout: enLayout,
|
||||
kanban: enKanban,
|
||||
views: enViews,
|
||||
integrations: enIntegrations,
|
||||
settings: enSettings,
|
||||
};
|
||||
|
||||
function resolveKey(key: string, ns: string, params?: any): string {
|
||||
// Handle "ns:key" format
|
||||
let namespace = ns;
|
||||
let path = key;
|
||||
if (key.includes(':')) {
|
||||
const [nsOverride, ...rest] = key.split(':');
|
||||
namespace = nsOverride;
|
||||
path = rest.join(':');
|
||||
}
|
||||
const data = locales[namespace];
|
||||
if (!data) return key;
|
||||
const value = path.split('.').reduce((obj: any, k: string) => obj?.[k], data);
|
||||
if (typeof value !== 'string') return key;
|
||||
if (params) {
|
||||
return value.replace(/\{\{(\w+)\}\}/g, (_: string, p: string) => params[p] ?? '');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: (ns: string = 'common') => ({
|
||||
t: (key: string, params?: any) => resolveKey(key, ns, params),
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the data layer to prevent API calls from stores
|
||||
vi.mock('@/lib/data', () => ({
|
||||
apiClient: {
|
||||
getProjects: vi.fn(() => Promise.resolve({ projects: [] })),
|
||||
getTasks: vi.fn(() => Promise.resolve({ tasks: [] })),
|
||||
getSettings: vi.fn(() => Promise.resolve({ settings: {} })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({})),
|
||||
updateTaskStatus: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('self-hosted mode', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the AppShell with sidebar and welcome screen', () => {
|
||||
render(<HomePage />);
|
||||
// Sidebar renders "Auto Claude" branding
|
||||
expect(screen.getByText('Auto Claude')).toBeInTheDocument();
|
||||
// WelcomeScreen renders when no project is selected
|
||||
expect(screen.getByText('Welcome to Auto Claude')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the welcome screen when no project is selected', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Welcome to Auto Claude')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Get started by connecting a project/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the Connect a Project button', () => {
|
||||
render(<HomePage />);
|
||||
const connectButton = screen.getByRole('button', { name: /Connect a Project/i });
|
||||
expect(connectButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show sidebar navigation items', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('Insights')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roadmap')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ideation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Changelog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Context')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Settings button in the sidebar', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not disable Settings button when no project is active', () => {
|
||||
render(<HomePage />);
|
||||
const settingsButton = screen.getByText('Settings').closest('button');
|
||||
expect(settingsButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable nav items when no project is active', () => {
|
||||
render(<HomePage />);
|
||||
const tasksButton = screen.getByText('Tasks').closest('button');
|
||||
expect(tasksButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render main element', () => {
|
||||
const { container } = render(<HomePage />);
|
||||
expect(container.querySelector('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode - unauthenticated', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: true,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
// Mock the AuthGate components to show unauthenticated state
|
||||
const { CloudAuthenticated, CloudUnauthenticated, CloudAuthLoading } = await import('@/providers/AuthGate');
|
||||
vi.mocked(CloudAuthenticated).mockImplementation(() => null as unknown as React.ReactElement);
|
||||
vi.mocked(CloudUnauthenticated).mockImplementation(({ children }) => <>{children}</>);
|
||||
vi.mocked(CloudAuthLoading).mockImplementation(() => null as unknown as React.ReactElement);
|
||||
});
|
||||
|
||||
it('should render without crashing in cloud mode', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Auto Claude Cloud')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show landing page for unauthenticated users', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Auto Claude Cloud')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cloud-synced specs, personas, and team collaboration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show get started button linking to login', () => {
|
||||
render(<HomePage />);
|
||||
const getStartedLink = screen.getByText('Get Started');
|
||||
expect(getStartedLink).toBeInTheDocument();
|
||||
expect(getStartedLink.closest('a')).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('should wrap content with auth gates', () => {
|
||||
const { container } = render(<HomePage />);
|
||||
expect(container.querySelector('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode - authenticated', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: true,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
const { getConvexReact } = await import('@/lib/convex-imports');
|
||||
vi.mocked(getConvexReact).mockReturnValue({
|
||||
useQuery: vi.fn(() => ({
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
tier: 'pro',
|
||||
})),
|
||||
useMutation: vi.fn(() => vi.fn()),
|
||||
} as any);
|
||||
|
||||
// Mock the AuthGate components to show authenticated state
|
||||
const { CloudAuthenticated, CloudUnauthenticated, CloudAuthLoading } = await import('@/providers/AuthGate');
|
||||
vi.mocked(CloudAuthenticated).mockImplementation(({ children }) => <>{children}</>);
|
||||
vi.mocked(CloudUnauthenticated).mockImplementation(() => null);
|
||||
vi.mocked(CloudAuthLoading).mockImplementation(() => null);
|
||||
});
|
||||
|
||||
it('should render the AppShell for authenticated cloud users', () => {
|
||||
render(<HomePage />);
|
||||
// AppShell renders Sidebar with branding
|
||||
expect(screen.getByText('Auto Claude')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show welcome screen when no project is selected', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Welcome to Auto Claude')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Connect a Project/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show sidebar navigation items for authenticated users', () => {
|
||||
render(<HomePage />);
|
||||
const navLabels = ['Tasks', 'Insights', 'Roadmap', 'Ideation', 'Changelog', 'Context'];
|
||||
navLabels.forEach(label => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode - loading state', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: true,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
const { getConvexReact } = await import('@/lib/convex-imports');
|
||||
vi.mocked(getConvexReact).mockReturnValue({
|
||||
useQuery: vi.fn(() => null),
|
||||
useMutation: vi.fn(() => vi.fn()),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should show loading state when user is null', () => {
|
||||
render(<HomePage />);
|
||||
// Should show loading in both CloudAuthLoading and CloudDashboard
|
||||
const loadingElements = screen.getAllByText('Loading...');
|
||||
expect(loadingElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have proper heading hierarchy in self-hosted mode', async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
render(<HomePage />);
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible buttons in self-hosted mode', async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
render(<HomePage />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handler } from "@/lib/auth-server";
|
||||
|
||||
export const { GET, POST } = handler;
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-background: oklch(1 0 0);
|
||||
--color-foreground: oklch(0.145 0 0);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.145 0 0);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.145 0 0);
|
||||
--color-primary: oklch(0.205 0.072 265.755);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.97 0 0);
|
||||
--color-secondary-foreground: oklch(0.205 0 0);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-accent: oklch(0.97 0 0);
|
||||
--color-accent-foreground: oklch(0.205 0 0);
|
||||
--color-destructive: oklch(0.577 0.245 27.325);
|
||||
--color-destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--color-border: oklch(0.922 0 0);
|
||||
--color-input: oklch(0.922 0 0);
|
||||
--color-ring: oklch(0.708 0.165 254.624);
|
||||
--color-sidebar: oklch(0.985 0 0);
|
||||
--color-sidebar-foreground: oklch(0.145 0 0);
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-card: oklch(0.185 0 0);
|
||||
--color-card-foreground: oklch(0.985 0 0);
|
||||
--color-popover: oklch(0.185 0 0);
|
||||
--color-popover-foreground: oklch(0.985 0 0);
|
||||
--color-primary: oklch(0.708 0.165 254.624);
|
||||
--color-primary-foreground: oklch(0.145 0 0);
|
||||
--color-secondary: oklch(0.248 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.248 0 0);
|
||||
--color-muted-foreground: oklch(0.646 0 0);
|
||||
--color-accent: oklch(0.248 0 0);
|
||||
--color-accent-foreground: oklch(0.985 0 0);
|
||||
--color-destructive: oklch(0.396 0.141 25.768);
|
||||
--color-destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--color-border: oklch(0.295 0 0);
|
||||
--color-input: oklch(0.295 0 0);
|
||||
--color-ring: oklch(0.551 0.177 259.082);
|
||||
--color-sidebar: oklch(0.165 0 0);
|
||||
--color-sidebar-foreground: oklch(0.985 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ConvexClientProvider } from "@/providers/ConvexClientProvider";
|
||||
import { I18nProvider } from "@/providers/I18nProvider";
|
||||
import { CLOUD_MODE } from "@/lib/cloud-mode";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Auto Claude",
|
||||
description: "AI-powered software development platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
let token: string | null = null;
|
||||
|
||||
if (CLOUD_MODE) {
|
||||
try {
|
||||
const { getToken } = await import("@/lib/auth-server");
|
||||
token = (await getToken()) ?? null;
|
||||
} catch {
|
||||
// auth-server not available in self-hosted mode
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<I18nProvider>
|
||||
<ConvexClientProvider initialToken={token}>
|
||||
{children}
|
||||
</ConvexClientProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { CloudAuthenticated } from "@/providers/AuthGate";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(["pages", "common", "auth"]);
|
||||
|
||||
// Self-hosted mode: no login needed, redirect home
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{t("pages:login.selfHosted.title")}</h1>
|
||||
<p className="text-gray-600">{t("pages:login.selfHosted.noLoginRequired")}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.goToDashboard")}
|
||||
</a>
|
||||
<div className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-4 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("pages:login.selfHosted.cloudPromo")}
|
||||
</p>
|
||||
<a href="https://autoclaude.com" className="text-sm text-blue-600 hover:underline">
|
||||
{t("pages:login.selfHosted.tryCloud")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = async () => {
|
||||
const { authClient } = await import("@/lib/auth-client");
|
||||
authClient.signIn.social({ provider: "github", callbackURL: "/" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CloudAuthenticated>
|
||||
<RedirectHome />
|
||||
</CloudAuthenticated>
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{t("auth:signInTitle")}</h1>
|
||||
<button
|
||||
onClick={signIn}
|
||||
className="rounded-lg bg-gray-900 px-6 py-3 text-white hover:bg-gray-800"
|
||||
>
|
||||
{t("auth:signIn")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectHome() {
|
||||
const router = useRouter();
|
||||
useEffect(() => { router.push("/"); }, [router]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { CloudAuthenticated, CloudUnauthenticated, CloudAuthLoading } from "@/providers/AuthGate";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { getConvexReact, getConvexApi } from "@/lib/convex-imports";
|
||||
import { AppShell } from "@/components/layout";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudDashboard() {
|
||||
const { useQuery, useMutation } = getConvexReact();
|
||||
const { api } = getConvexApi();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
const user = useQuery(api.users.me);
|
||||
const ensureUser = useMutation(api.users.ensureUser);
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) {
|
||||
ensureUser();
|
||||
}
|
||||
}, [user, ensureUser]);
|
||||
|
||||
if (!user) return <div className="flex h-screen items-center justify-center">{t("common:loading")}</div>;
|
||||
|
||||
return <AppShell />;
|
||||
}
|
||||
|
||||
function SelfHostedDashboard() {
|
||||
return <AppShell />;
|
||||
}
|
||||
|
||||
function LandingPage() {
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-4xl font-bold">{t("pages:home.landing.title")}</h1>
|
||||
<p className="text-gray-600">{t("pages:home.landing.subtitle")}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.getStarted")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<main>
|
||||
<SelfHostedDashboard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<CloudAuthLoading>
|
||||
<div className="flex h-screen items-center justify-center">{t("loading")}</div>
|
||||
</CloudAuthLoading>
|
||||
<CloudUnauthenticated>
|
||||
<LandingPage />
|
||||
</CloudUnauthenticated>
|
||||
<CloudAuthenticated>
|
||||
<CloudDashboard />
|
||||
</CloudAuthenticated>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { usePersonas } from "@/hooks";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudPersonas() {
|
||||
const user = useCurrentUser();
|
||||
const { personas, isLoading, createPersona } = usePersonas();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
// Personas require Pro tier or higher
|
||||
const hasPersonaAccess = hasTierAccess(user?.tier, "pro");
|
||||
|
||||
if (!hasPersonaAccess) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:personas.title")}</h1>
|
||||
<p className="text-gray-600">
|
||||
{t("pages:personas.description")}
|
||||
</p>
|
||||
<a
|
||||
href="/settings"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.upgrade", { tier: t("common:tiers.pro") })} - {formatPrice("pro")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.personas") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("pages:personas.title")}</h1>
|
||||
<button
|
||||
onClick={() => createPersona("New Persona", "A helpful coding assistant", [])}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("pages:personas.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{personas.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:personas.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{personas.map((persona: any) => (
|
||||
<li key={persona._id} className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold">{persona.name}</h3>
|
||||
<p className="text-gray-600">{persona.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PersonasPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
if (!isCloud) {
|
||||
return <CloudUpgradeCTA title={t("personas.title")} description={t("personas.cloudFeature")} />;
|
||||
}
|
||||
|
||||
return <CloudPersonas />;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { usePRQueue } from "@/hooks";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudPRQueue() {
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
// PR Queue requires Team tier or higher
|
||||
const hasTeamAccess = hasTierAccess(user?.tier, "team");
|
||||
|
||||
if (!hasTeamAccess) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:prQueue.title")}</h1>
|
||||
<p className="text-gray-600">
|
||||
{t("pages:prQueue.description")}
|
||||
</p>
|
||||
<a
|
||||
href="/settings"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - {formatPrice("team")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Get teamId from user's active team
|
||||
const teamId = "";
|
||||
const { prs, isLoading, updateStatus, assignPR } = usePRQueue(teamId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.prQueue") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:prQueue.title")}</h1>
|
||||
<div className="mt-8">
|
||||
{prs.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:prQueue.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{prs.map((pr: any) => (
|
||||
<li key={pr._id} className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">{t("pages:prQueue.prNumber", { number: pr.prNumber })} {pr.title}</span>
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-sm">{pr.status}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PRQueuePage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
if (!isCloud) {
|
||||
return <CloudUpgradeCTA title={t("prQueue.title")} description={t("prQueue.cloudFeature")} />;
|
||||
}
|
||||
|
||||
return <CloudPRQueue />;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { getConvexReact, getConvexApi } from "@/lib/convex-imports";
|
||||
import { useState } from "react";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice, PRICING } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Tier = "pro" | "team" | "enterprise";
|
||||
|
||||
function SelfHostedSettings() {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold">{t("settings:title")}</h1>
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">{t("settings:selfHosted.mode")}</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{t("settings:selfHosted.description")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-6">
|
||||
<h2 className="text-lg font-semibold">{t("settings:selfHosted.unlockFeatures")}</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{t("settings:selfHosted.featuresDescription")}
|
||||
</p>
|
||||
<a
|
||||
href="https://autoclaude.com"
|
||||
className="mt-4 inline-block rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.learnMore")}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudSettings() {
|
||||
const { useAction, useQuery } = getConvexReact();
|
||||
const { api } = getConvexApi();
|
||||
const { t } = useTranslation(["settings", "common", "auth"]);
|
||||
|
||||
const user = useCurrentUser();
|
||||
const tierInfo = useQuery(api.billing.getTierInfo);
|
||||
const createCheckout = useAction(api.stripe.createCheckoutSession);
|
||||
const createPortal = useAction(api.stripe.createPortalSession);
|
||||
const [loading, setLoading] = useState<Tier | "portal" | null>(null);
|
||||
|
||||
const logout = async () => {
|
||||
const { authClient } = await import("@/lib/auth-client");
|
||||
authClient.signOut();
|
||||
};
|
||||
|
||||
const handleUpgrade = async (tier: Tier) => {
|
||||
setLoading(tier);
|
||||
try {
|
||||
const url = await createCheckout({ tier });
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
alert(t("common:errors.checkoutFailed"));
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading("portal");
|
||||
try {
|
||||
const url = await createPortal();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Portal error:", error);
|
||||
alert(t("common:errors.portalFailed"));
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <div className="p-8">{t("common:errors.loginRequired")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold">{t("settings:title")}</h1>
|
||||
|
||||
{/* Profile Section */}
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">{t("settings:profile.title")}</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p><strong>{t("settings:profile.name")}</strong> {user.name}</p>
|
||||
<p><strong>{t("settings:profile.email")}</strong> {user.email}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Billing Section */}
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">{t("settings:subscription.title")}</h2>
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
<strong>{t("settings:subscription.currentPlan")}</strong>{" "}
|
||||
{tierInfo?.tierName ?? (user.tier ? user.tier.charAt(0).toUpperCase() + user.tier.slice(1) : t("common:tiers.free"))}
|
||||
{tierInfo?.price ? ` — ${t("settings:subscription.price", { price: tierInfo.price })}` : ""}
|
||||
</p>
|
||||
|
||||
{/* Usage */}
|
||||
{tierInfo && (
|
||||
<div className="mt-4 space-y-1 text-sm text-gray-600">
|
||||
<p>
|
||||
{t("settings:subscription.usage.specs", {
|
||||
current: tierInfo.usage.specs,
|
||||
limit: tierInfo.limits.specs === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.specs
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("settings:subscription.usage.personas", {
|
||||
current: tierInfo.usage.personas,
|
||||
limit: tierInfo.limits.personas === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.personas
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("settings:subscription.usage.teamMembers", {
|
||||
current: tierInfo.usage.teamMembers,
|
||||
limit: tierInfo.limits.teamMembers === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.teamMembers
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade buttons */}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{!hasTierAccess(user.tier, "pro") && (
|
||||
<button
|
||||
onClick={() => handleUpgrade("pro")}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === "pro" ? t("common:buttons.redirecting") : `${t("common:buttons.upgrade", { tier: t("common:tiers.pro") })} - ${formatPrice("pro")}`}
|
||||
</button>
|
||||
)}
|
||||
{!hasTierAccess(user.tier, "team") && (
|
||||
<button
|
||||
onClick={() => handleUpgrade("team")}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === "team" ? t("common:buttons.redirecting") : `${t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - ${formatPrice("team")}`}
|
||||
</button>
|
||||
)}
|
||||
{!hasTierAccess(user.tier, "enterprise") && (
|
||||
<button
|
||||
onClick={() => handleUpgrade("enterprise")}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === "enterprise" ? t("common:buttons.redirecting") : `${t("common:tiers.enterprise")} - ${formatPrice("enterprise")}`}
|
||||
</button>
|
||||
)}
|
||||
{hasTierAccess(user.tier, "pro") && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg border px-4 py-2 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{loading === "portal" ? t("common:buttons.redirecting") : t("common:buttons.manageSubscription")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sign Out */}
|
||||
<section className="mt-8">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-lg border border-red-600 px-4 py-2 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
{t("auth:signOut")}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
|
||||
if (!isCloud) {
|
||||
return <SelfHostedSettings />;
|
||||
}
|
||||
|
||||
return <CloudSettings />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSpec } from "@/hooks";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function SpecPage() {
|
||||
const params = useParams();
|
||||
const specId = params.id as string;
|
||||
const { spec, isLoading } = useSpec(specId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">Loading spec...</div>;
|
||||
}
|
||||
|
||||
if (!spec) {
|
||||
return <div className="p-8">Spec not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{(spec as any).name}</h1>
|
||||
<pre className="mt-4 rounded-lg bg-gray-100 p-4">
|
||||
{(spec as any).content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useSpecs } from "@/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SpecsPage() {
|
||||
const { specs, isLoading, createSpec } = useSpecs();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.specs") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("pages:specs.title")}</h1>
|
||||
<button
|
||||
onClick={() => createSpec("New Spec", "# New Spec\n\nDescription here...")}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("pages:specs.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{specs.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:specs.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{specs.map((spec: any) => (
|
||||
<li key={spec._id}>
|
||||
<a
|
||||
href={`/specs/${spec._id}`}
|
||||
className="block rounded-lg border p-4 hover:bg-gray-50"
|
||||
>
|
||||
{spec.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useTeam, useTeamMembers } from "@/hooks";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function TeamPage() {
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const { team, isLoading } = useTeam(teamId);
|
||||
const { members, inviteMember, removeMember } = useTeamMembers(teamId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">Loading team...</div>;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return <div className="p-8">Team not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{(team as any).name}</h1>
|
||||
<h2 className="mt-8 text-xl font-semibold">Members</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{members.map((member: any) => (
|
||||
<li key={member._id} className="flex items-center justify-between rounded-lg border p-4">
|
||||
<span>{member.userId}</span>
|
||||
<span className="text-gray-500">{member.role}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useTeams } from "@/hooks";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudTeams() {
|
||||
const user = useCurrentUser();
|
||||
const { teams, isLoading, createTeam } = useTeams();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
// Team feature requires Team tier or higher
|
||||
const hasTeamAccess = hasTierAccess(user?.tier, "team");
|
||||
|
||||
if (!hasTeamAccess) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:teams.singleTitle")}</h1>
|
||||
<p className="text-gray-600">{t("pages:teams.description")}</p>
|
||||
<a
|
||||
href="/settings"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - {formatPrice("team")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.teams") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("pages:teams.title")}</h1>
|
||||
<button
|
||||
onClick={() => createTeam("New Team")}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("pages:teams.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:teams.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{teams.map((team: any) => (
|
||||
<li key={team._id}>
|
||||
<a
|
||||
href={`/teams/${team._id}`}
|
||||
className="block rounded-lg border p-4 hover:bg-gray-50"
|
||||
>
|
||||
{team.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamsPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
if (!isCloud) {
|
||||
return <CloudUpgradeCTA title={t("teams.singleTitle")} description={t("teams.cloudFeature")} />;
|
||||
}
|
||||
|
||||
return <CloudTeams />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function CloudUpgradeCTA({ title, description }: { title: string; description: string }) {
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<p className="text-gray-600">{description}</p>
|
||||
<a href="https://autoclaude.com" className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700">
|
||||
{t("cloudUpgrade.learnMore")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
GitPullRequest,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
|
||||
interface ChangelogViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface ChangelogEntry {
|
||||
id: string;
|
||||
version: string;
|
||||
date: string;
|
||||
title: string;
|
||||
changes: {
|
||||
type: "added" | "changed" | "fixed" | "removed";
|
||||
description: string;
|
||||
}[];
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const CHANGE_TYPE_COLORS = {
|
||||
added: "bg-green-500/10 text-green-600",
|
||||
changed: "bg-blue-500/10 text-blue-600",
|
||||
fixed: "bg-orange-500/10 text-orange-600",
|
||||
removed: "bg-red-500/10 text-red-600",
|
||||
};
|
||||
|
||||
const CHANGE_TYPE_KEYS: Record<string, string> = {
|
||||
added: "changelog.changeTypes.added",
|
||||
changed: "changelog.changeTypes.changed",
|
||||
fixed: "changelog.changeTypes.fixed",
|
||||
removed: "changelog.changeTypes.removed",
|
||||
};
|
||||
|
||||
const PLACEHOLDER_ENTRIES: ChangelogEntry[] = [
|
||||
{
|
||||
id: "1",
|
||||
version: "2.7.7",
|
||||
date: "2025-02-14",
|
||||
title: "Cloud Platform Preparation",
|
||||
changes: [
|
||||
{ type: "added", description: "Shared UI component library extracted to @auto-claude/ui" },
|
||||
{ type: "added", description: "Shared TypeScript types package @auto-claude/types" },
|
||||
{ type: "added", description: "Web application foundation with Next.js 16" },
|
||||
{ type: "changed", description: "Migrated from libs/ to packages/ directory structure" },
|
||||
],
|
||||
isExpanded: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
version: "2.7.6",
|
||||
date: "2025-02-10",
|
||||
title: "Stability Improvements",
|
||||
changes: [
|
||||
{ type: "fixed", description: "PR review recovery for structured output validation" },
|
||||
{ type: "fixed", description: "Sentry integration for Python subprocesses" },
|
||||
{ type: "changed", description: "Improved task execution progress tracking" },
|
||||
],
|
||||
isExpanded: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
version: "2.7.5",
|
||||
date: "2025-02-05",
|
||||
title: "OAuth and Security Updates",
|
||||
changes: [
|
||||
{ type: "added", description: "Claude OAuth authentication support" },
|
||||
{ type: "fixed", description: "Session management for multi-profile setups" },
|
||||
{ type: "changed", description: "Re-authentication flow for improved security" },
|
||||
],
|
||||
isExpanded: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function ChangelogView({ projectId }: ChangelogViewProps) {
|
||||
const { t } = useTranslation("views");
|
||||
const [entries, setEntries] = useState(PLACEHOLDER_ENTRIES);
|
||||
const [isEmpty] = useState(false);
|
||||
|
||||
const toggleEntry = (id: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, isExpanded: !e.isExpanded } : e))
|
||||
);
|
||||
};
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<FileText className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">{t("changelog.empty.title")}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{t("changelog.empty.description")}
|
||||
</p>
|
||||
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("changelog.empty.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold">{t("changelog.title")}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("changelog.refresh")}
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("changelog.newRelease")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-lg border border-border bg-card overflow-hidden"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-accent/50 transition-colors text-left"
|
||||
onClick={() => toggleEntry(entry.id)}
|
||||
>
|
||||
{entry.isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex items-center gap-2.5 flex-1">
|
||||
<Tag className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
v{entry.version}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{entry.date}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{entry.isExpanded && (
|
||||
<div className="border-t border-border px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
{entry.changes.map((change, idx) => {
|
||||
const color = CHANGE_TYPE_COLORS[change.type];
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 mt-0.5 rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||||
color
|
||||
)}
|
||||
>
|
||||
{t(CHANGE_TYPE_KEYS[change.type])}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{change.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
FolderTree,
|
||||
Database,
|
||||
Brain,
|
||||
Server,
|
||||
Code,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
Globe,
|
||||
Box,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ContextViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string;
|
||||
language: string;
|
||||
framework: string;
|
||||
type: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_SERVICES: ServiceInfo[] = [
|
||||
{ name: "frontend", language: "TypeScript", framework: "React", type: "frontend", path: "apps/frontend/" },
|
||||
{ name: "web", language: "TypeScript", framework: "Next.js", type: "frontend", path: "apps/web/" },
|
||||
{ name: "backend", language: "Python", framework: "FastAPI", type: "backend", path: "apps/backend/" },
|
||||
{ name: "types", language: "TypeScript", framework: "N/A", type: "library", path: "packages/types/" },
|
||||
{ name: "ui", language: "TypeScript", framework: "React", type: "library", path: "packages/ui/" },
|
||||
];
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
frontend: Globe,
|
||||
backend: Server,
|
||||
library: Box,
|
||||
worker: Code,
|
||||
};
|
||||
|
||||
export function ContextView({ projectId }: ContextViewProps) {
|
||||
const { t } = useTranslation("integrations");
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "services" | "memories">("overview");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">{t("context.title")}</h1>
|
||||
<div className="flex items-center rounded-lg border border-border bg-card/50">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors rounded-l-lg",
|
||||
activeTab === "overview" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("overview")}
|
||||
>
|
||||
<FolderTree className="h-3 w-3" />
|
||||
{t("context.tabs.overview")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
activeTab === "services" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("services")}
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
{t("context.tabs.services")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors rounded-r-lg",
|
||||
activeTab === "memories" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("memories")}
|
||||
>
|
||||
<Brain className="h-3 w-3" />
|
||||
{t("context.tabs.memories")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("context.reindex")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{activeTab === "overview" && (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Project Type */}
|
||||
<div className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FolderTree className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">{t("context.fields.projectStructure")}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("context.fields.type")}</p>
|
||||
<p className="text-sm font-medium">{t("context.fields.monorepo")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("context.fields.services")}</p>
|
||||
<p className="text-sm font-medium">{PLACEHOLDER_SERVICES.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Summary */}
|
||||
<div className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">{t("context.tabs.services")}</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{PLACEHOLDER_SERVICES.map((service) => {
|
||||
const Icon = TYPE_ICONS[service.type] || Code;
|
||||
return (
|
||||
<div
|
||||
key={service.name}
|
||||
className="flex items-center gap-3 rounded-md border border-border p-3 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{service.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{service.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{service.language}
|
||||
</span>
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{service.framework}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Status */}
|
||||
<div className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">{t("context.fields.memorySystem")}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("context.fields.status")}</p>
|
||||
<p className="text-sm font-medium text-green-600">{t("context.fields.active")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("context.fields.episodes")}</p>
|
||||
<p className="text-sm font-medium">0</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("context.fields.database")}</p>
|
||||
<p className="text-sm font-medium">{t("context.fields.ladybugDB")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "services" && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="space-y-4">
|
||||
{PLACEHOLDER_SERVICES.map((service) => {
|
||||
const Icon = TYPE_ICONS[service.type] || Code;
|
||||
return (
|
||||
<div key={service.name} className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{service.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{service.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<p className="text-[10px] text-muted-foreground">{t("context.fields.language")}</p>
|
||||
<p className="text-xs font-medium">{service.language}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<p className="text-[10px] text-muted-foreground">{t("context.fields.framework")}</p>
|
||||
<p className="text-xs font-medium">{service.framework}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<p className="text-[10px] text-muted-foreground">{t("context.fields.type")}</p>
|
||||
<p className="text-xs font-medium capitalize">{service.type}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-2.5">
|
||||
<p className="text-[10px] text-muted-foreground">{t("context.fields.path")}</p>
|
||||
<p className="text-xs font-medium truncate">{service.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "memories" && (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t("context.search.memories")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Brain className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-sm font-semibold mb-1">{t("context.empty.noMemories")}</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
{t("context.empty.noMemoriesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Github,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
Tag,
|
||||
Settings,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitHubIssuesViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface GitHubIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "open" | "closed";
|
||||
labels: { name: string; color: string }[];
|
||||
author: string;
|
||||
comments: number;
|
||||
createdAt: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ISSUES: GitHubIssue[] = [
|
||||
{
|
||||
number: 42,
|
||||
title: "Add dark mode support for mobile views",
|
||||
state: "open",
|
||||
labels: [{ name: "enhancement", color: "a2eeef" }, { name: "ui", color: "7057ff" }],
|
||||
author: "user1",
|
||||
comments: 3,
|
||||
createdAt: "2025-02-10",
|
||||
body: "The mobile views don't properly support dark mode...",
|
||||
},
|
||||
{
|
||||
number: 41,
|
||||
title: "Fix authentication token refresh race condition",
|
||||
state: "open",
|
||||
labels: [{ name: "bug", color: "d73a4a" }, { name: "priority:high", color: "e11d48" }],
|
||||
author: "user2",
|
||||
comments: 7,
|
||||
createdAt: "2025-02-08",
|
||||
body: "When multiple API calls happen simultaneously...",
|
||||
},
|
||||
{
|
||||
number: 39,
|
||||
title: "Implement webhook support for external integrations",
|
||||
state: "open",
|
||||
labels: [{ name: "feature", color: "0075ca" }],
|
||||
author: "user3",
|
||||
comments: 2,
|
||||
createdAt: "2025-02-05",
|
||||
body: "We need webhook endpoints for...",
|
||||
},
|
||||
];
|
||||
|
||||
export function GitHubIssuesView({ projectId }: GitHubIssuesViewProps) {
|
||||
const { t } = useTranslation("integrations");
|
||||
const [issues] = useState(PLACEHOLDER_ISSUES);
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isConnected] = useState(true);
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary">
|
||||
<Github className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">{t("github.issues.notConnected")}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{t("github.issues.notConnectedDescription")}
|
||||
</p>
|
||||
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("github.issues.configure")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Issue List */}
|
||||
<div className={cn("flex flex-col border-r border-border", selectedIssue ? "w-96" : "flex-1")}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h1 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Github className="h-4 w-4" />
|
||||
{t("github.issues.title")}
|
||||
</h1>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder={t("github.issues.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.number}
|
||||
className={cn(
|
||||
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
|
||||
selectedIssue?.number === issue.number ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
#{issue.number} {issue.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="rounded-full border border-border px-2 py-0.5 text-[10px]"
|
||||
style={{ borderColor: `#${label.color}40`, color: `#${label.color}` }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span>{issue.author}</span>
|
||||
<span>{issue.createdAt}</span>
|
||||
{issue.comments > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageSquare className="h-2.5 w-2.5" />
|
||||
{issue.comments}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail */}
|
||||
{selectedIssue && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
#{selectedIssue.number} {selectedIssue.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`https://github.com/issues/${selectedIssue.number}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedIssue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="rounded-full border px-2.5 py-0.5 text-xs"
|
||||
style={{ borderColor: `#${label.color}40`, color: `#${label.color}` }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{selectedIssue.body}
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<button className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
{t("github.issues.createTask")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
GitPullRequest,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
GitMerge,
|
||||
Settings,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitHubPRsViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface PullRequest {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "open" | "closed" | "merged";
|
||||
author: string;
|
||||
reviewStatus: "pending" | "approved" | "changes_requested" | "reviewing";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
files: number;
|
||||
createdAt: string;
|
||||
labels: { name: string; color: string }[];
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_PRS: PullRequest[] = [
|
||||
{
|
||||
number: 1783,
|
||||
title: "Cloud Phase 1: Extract shared types and UI packages",
|
||||
state: "merged",
|
||||
author: "dev1",
|
||||
reviewStatus: "approved",
|
||||
additions: 2500,
|
||||
deletions: 150,
|
||||
files: 45,
|
||||
createdAt: "2025-02-12",
|
||||
labels: [{ name: "cloud", color: "0075ca" }],
|
||||
draft: false,
|
||||
},
|
||||
{
|
||||
number: 1804,
|
||||
title: "Fix Sentry integration for Python subprocesses",
|
||||
state: "merged",
|
||||
author: "dev2",
|
||||
reviewStatus: "approved",
|
||||
additions: 120,
|
||||
deletions: 30,
|
||||
files: 5,
|
||||
createdAt: "2025-02-11",
|
||||
labels: [{ name: "bug", color: "d73a4a" }],
|
||||
draft: false,
|
||||
},
|
||||
{
|
||||
number: 1810,
|
||||
title: "Add real-time task progress WebSocket support",
|
||||
state: "open",
|
||||
author: "dev1",
|
||||
reviewStatus: "pending",
|
||||
additions: 890,
|
||||
deletions: 45,
|
||||
files: 12,
|
||||
createdAt: "2025-02-13",
|
||||
labels: [{ name: "feature", color: "a2eeef" }],
|
||||
draft: false,
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_ICONS: Record<string, React.ElementType> = {
|
||||
open: GitPullRequest,
|
||||
merged: GitMerge,
|
||||
closed: XCircle,
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<string, string> = {
|
||||
open: "text-green-500",
|
||||
merged: "text-purple-500",
|
||||
closed: "text-red-500",
|
||||
};
|
||||
|
||||
const REVIEW_STATUS_COLORS: Record<string, string> = {
|
||||
pending: "bg-yellow-500/10 text-yellow-600",
|
||||
approved: "bg-green-500/10 text-green-600",
|
||||
changes_requested: "bg-red-500/10 text-red-600",
|
||||
reviewing: "bg-blue-500/10 text-blue-600",
|
||||
};
|
||||
|
||||
const REVIEW_STATUS_KEYS: Record<string, string> = {
|
||||
pending: "github.prs.reviews.pending",
|
||||
approved: "github.prs.reviews.approved",
|
||||
changes_requested: "github.prs.reviews.changesRequested",
|
||||
reviewing: "github.prs.reviews.inReview",
|
||||
};
|
||||
|
||||
export function GitHubPRsView({ projectId }: GitHubPRsViewProps) {
|
||||
const { t } = useTranslation("integrations");
|
||||
const [prs] = useState(PLACEHOLDER_PRS);
|
||||
const [selectedPR, setSelectedPR] = useState<PullRequest | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "open" | "merged" | "closed">("all");
|
||||
|
||||
const filteredPRs = prs.filter((pr) => {
|
||||
if (filter !== "all" && pr.state !== filter) return false;
|
||||
if (searchQuery && !pr.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* PR List */}
|
||||
<div className={cn("flex flex-col border-r border-border", selectedPR ? "w-96" : "flex-1")}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h1 className="text-sm font-semibold flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
{t("github.prs.title")}
|
||||
</h1>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="border-b border-border px-4 py-2 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder={t("github.prs.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["all", "open", "merged", "closed"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors",
|
||||
filter === f ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{t(`github.prs.filters.${f}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PR list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredPRs.map((pr) => {
|
||||
const StateIcon = STATE_ICONS[pr.state] || GitPullRequest;
|
||||
const stateColor = STATE_COLORS[pr.state] || "text-muted-foreground";
|
||||
const reviewColor = REVIEW_STATUS_COLORS[pr.reviewStatus];
|
||||
const reviewKey = REVIEW_STATUS_KEYS[pr.reviewStatus];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pr.number}
|
||||
className={cn(
|
||||
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
|
||||
selectedPR?.number === pr.number ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setSelectedPR(pr)}
|
||||
>
|
||||
<StateIcon className={cn("h-4 w-4 mt-0.5 shrink-0", stateColor)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
#{pr.number} {pr.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
{pr.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="rounded-full border px-2 py-0.5 text-[10px]"
|
||||
style={{ borderColor: `#${label.color}40`, color: `#${label.color}` }}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reviewColor)}>
|
||||
{t(reviewKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span>{pr.author}</span>
|
||||
<span>{pr.createdAt}</span>
|
||||
<span className="text-green-600">+{pr.additions}</span>
|
||||
<span className="text-red-600">-{pr.deletions}</span>
|
||||
<span>{t("github.prs.stats.filesCount", { count: pr.files })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PR Detail */}
|
||||
{selectedPR && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
#{selectedPR.number} {selectedPR.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("github.prs.aiReview")}
|
||||
</button>
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold text-green-600">+{selectedPR.additions}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("github.prs.stats.additions")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold text-red-600">-{selectedPR.deletions}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("github.prs.stats.deletions")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold">{selectedPR.files}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("github.prs.stats.files")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold capitalize">{selectedPR.state}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("github.prs.status")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review status */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-medium mb-2">{t("github.prs.reviewStatus")}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPR.reviewStatus === "approved" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : selectedPR.reviewStatus === "changes_requested" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{t(REVIEW_STATUS_KEYS[selectedPR.reviewStatus])}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Review button */}
|
||||
<button className="w-full flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Eye className="h-4 w-4" />
|
||||
{t("github.prs.startAiReview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// GitLab icon as inline SVG since lucide-react's GitlabIcon may not be available in all versions
|
||||
function GitLabIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m22 13.29-3.33-10a.42.42 0 0 0-.14-.18.38.38 0 0 0-.22-.11.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18l-2.26 6.67H8.32L6.1 3.26a.42.42 0 0 0-.1-.18.38.38 0 0 0-.26-.08.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18L2 13.29a.74.74 0 0 0 .27.83L12 21l9.69-6.88a.71.71 0 0 0 .31-.83Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface GitLabIssuesViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface GitLabIssue {
|
||||
iid: number;
|
||||
title: string;
|
||||
state: "opened" | "closed";
|
||||
labels: string[];
|
||||
author: string;
|
||||
comments: number;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ISSUES: GitLabIssue[] = [
|
||||
{
|
||||
iid: 15,
|
||||
title: "Implement CI/CD pipeline for staging environment",
|
||||
state: "opened",
|
||||
labels: ["devops", "priority::high"],
|
||||
author: "dev1",
|
||||
comments: 4,
|
||||
createdAt: "2025-02-12",
|
||||
description: "We need to set up a proper CI/CD pipeline for the staging environment...",
|
||||
},
|
||||
{
|
||||
iid: 14,
|
||||
title: "Add internationalization support",
|
||||
state: "opened",
|
||||
labels: ["feature", "i18n"],
|
||||
author: "dev2",
|
||||
comments: 2,
|
||||
createdAt: "2025-02-10",
|
||||
description: "Support multiple languages in the application...",
|
||||
},
|
||||
{
|
||||
iid: 12,
|
||||
title: "Fix database connection pool exhaustion",
|
||||
state: "opened",
|
||||
labels: ["bug", "priority::critical"],
|
||||
author: "dev3",
|
||||
comments: 8,
|
||||
createdAt: "2025-02-08",
|
||||
description: "Under heavy load, the database connection pool gets exhausted...",
|
||||
},
|
||||
];
|
||||
|
||||
export function GitLabIssuesView({ projectId }: GitLabIssuesViewProps) {
|
||||
const { t } = useTranslation("integrations");
|
||||
const [issues] = useState(PLACEHOLDER_ISSUES);
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitLabIssue | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isConnected] = useState(true);
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary">
|
||||
<GitLabIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">{t("gitlab.issues.notConnected")}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{t("gitlab.issues.notConnectedDescription")}
|
||||
</p>
|
||||
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("gitlab.issues.configure")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Issue List */}
|
||||
<div className={cn("flex flex-col border-r border-border", selectedIssue ? "w-96" : "flex-1")}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h1 className="text-sm font-semibold flex items-center gap-2">
|
||||
<GitLabIcon className="h-4 w-4" />
|
||||
{t("gitlab.issues.title")}
|
||||
</h1>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder={t("gitlab.issues.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.iid}
|
||||
className={cn(
|
||||
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
|
||||
selectedIssue?.iid === issue.iid ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
#{issue.iid} {issue.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span>{issue.author}</span>
|
||||
<span>{issue.createdAt}</span>
|
||||
{issue.comments > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageSquare className="h-2.5 w-2.5" />
|
||||
{issue.comments}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail */}
|
||||
{selectedIssue && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
#{selectedIssue.iid} {selectedIssue.title}
|
||||
</h2>
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedIssue.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{selectedIssue.description}
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<button className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
{t("gitlab.issues.createTask")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
GitMerge,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitLabMRsViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface MergeRequest {
|
||||
iid: number;
|
||||
title: string;
|
||||
state: "opened" | "merged" | "closed";
|
||||
author: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
createdAt: string;
|
||||
labels: string[];
|
||||
draft: boolean;
|
||||
approvals: number;
|
||||
approvalsRequired: number;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_MRS: MergeRequest[] = [
|
||||
{
|
||||
iid: 95,
|
||||
title: "Add database migration framework",
|
||||
state: "opened",
|
||||
author: "dev1",
|
||||
additions: 450,
|
||||
deletions: 20,
|
||||
changedFiles: 8,
|
||||
createdAt: "2025-02-13",
|
||||
labels: ["database", "feature"],
|
||||
draft: false,
|
||||
approvals: 1,
|
||||
approvalsRequired: 2,
|
||||
},
|
||||
{
|
||||
iid: 94,
|
||||
title: "Fix memory leak in worker process",
|
||||
state: "merged",
|
||||
author: "dev2",
|
||||
additions: 35,
|
||||
deletions: 12,
|
||||
changedFiles: 3,
|
||||
createdAt: "2025-02-12",
|
||||
labels: ["bug", "hotfix"],
|
||||
draft: false,
|
||||
approvals: 2,
|
||||
approvalsRequired: 2,
|
||||
},
|
||||
{
|
||||
iid: 93,
|
||||
title: "WIP: Refactor authentication module",
|
||||
state: "opened",
|
||||
author: "dev1",
|
||||
additions: 890,
|
||||
deletions: 340,
|
||||
changedFiles: 15,
|
||||
createdAt: "2025-02-11",
|
||||
labels: ["refactoring"],
|
||||
draft: true,
|
||||
approvals: 0,
|
||||
approvalsRequired: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_COLORS: Record<string, string> = {
|
||||
opened: "text-green-500",
|
||||
merged: "text-purple-500",
|
||||
closed: "text-red-500",
|
||||
};
|
||||
|
||||
export function GitLabMRsView({ projectId }: GitLabMRsViewProps) {
|
||||
const { t } = useTranslation("integrations");
|
||||
const [mrs] = useState(PLACEHOLDER_MRS);
|
||||
const [selectedMR, setSelectedMR] = useState<MergeRequest | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "opened" | "merged" | "closed">("all");
|
||||
|
||||
const filteredMRs = mrs.filter((mr) => {
|
||||
if (filter !== "all" && mr.state !== filter) return false;
|
||||
if (searchQuery && !mr.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* MR List */}
|
||||
<div className={cn("flex flex-col border-r border-border", selectedMR ? "w-96" : "flex-1")}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h1 className="text-sm font-semibold flex items-center gap-2">
|
||||
<GitMerge className="h-4 w-4" />
|
||||
{t("gitlab.mrs.title")}
|
||||
</h1>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border px-4 py-2 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder={t("gitlab.mrs.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["all", "opened", "merged", "closed"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors",
|
||||
filter === f ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{t(`gitlab.mrs.filters.${f}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredMRs.map((mr) => (
|
||||
<div
|
||||
key={mr.iid}
|
||||
className={cn(
|
||||
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
|
||||
selectedMR?.iid === mr.iid ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setSelectedMR(mr)}
|
||||
>
|
||||
<GitMerge className={cn("h-4 w-4 mt-0.5 shrink-0", STATE_COLORS[mr.state])} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
!{mr.iid} {mr.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||
{mr.draft && (
|
||||
<span className="rounded-full bg-yellow-500/10 text-yellow-600 px-2 py-0.5 text-[10px] font-medium">
|
||||
{t("gitlab.mrs.draft")}
|
||||
</span>
|
||||
)}
|
||||
{mr.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span>{mr.author}</span>
|
||||
<span>{mr.createdAt}</span>
|
||||
<span className="text-green-600">+{mr.additions}</span>
|
||||
<span className="text-red-600">-{mr.deletions}</span>
|
||||
<span>{t("gitlab.mrs.stats.filesCount", { count: mr.changedFiles })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MR Detail */}
|
||||
{selectedMR && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
!{selectedMR.iid} {selectedMR.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("gitlab.mrs.aiReview")}
|
||||
</button>
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold text-green-600">+{selectedMR.additions}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.stats.additions")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold text-red-600">-{selectedMR.deletions}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.stats.deletions")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold">{selectedMR.changedFiles}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.stats.files")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 text-center">
|
||||
<p className="text-lg font-semibold capitalize">{selectedMR.state}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.status")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-medium mb-2">{t("gitlab.mrs.approvals")}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedMR.approvals >= selectedMR.approvalsRequired ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{t("gitlab.mrs.approvalsCount", { current: selectedMR.approvals, required: selectedMR.approvalsRequired })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Eye className="h-4 w-4" />
|
||||
{t("gitlab.mrs.startAiReview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Zap,
|
||||
Code,
|
||||
Paintbrush,
|
||||
Bug,
|
||||
ArrowRight,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
|
||||
interface IdeationViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type IdeaCategory =
|
||||
| "code_improvements"
|
||||
| "security_hardening"
|
||||
| "performance_optimization"
|
||||
| "ui_ux_improvements"
|
||||
| "bug_predictions"
|
||||
| "new_features";
|
||||
|
||||
const CATEGORY_ICONS: Record<IdeaCategory, React.ElementType> = {
|
||||
code_improvements: Code,
|
||||
security_hardening: Shield,
|
||||
performance_optimization: Zap,
|
||||
ui_ux_improvements: Paintbrush,
|
||||
bug_predictions: Bug,
|
||||
new_features: Sparkles,
|
||||
};
|
||||
|
||||
const CATEGORY_KEYS: Record<IdeaCategory, string> = {
|
||||
code_improvements: "ideation.categories.codeImprovements",
|
||||
security_hardening: "ideation.categories.securityHardening",
|
||||
performance_optimization: "ideation.categories.performanceOptimization",
|
||||
ui_ux_improvements: "ideation.categories.uiUxImprovements",
|
||||
bug_predictions: "ideation.categories.bugPredictions",
|
||||
new_features: "ideation.categories.newFeatures",
|
||||
};
|
||||
|
||||
const CATEGORY_IDS: IdeaCategory[] = [
|
||||
"code_improvements",
|
||||
"security_hardening",
|
||||
"performance_optimization",
|
||||
"ui_ux_improvements",
|
||||
"bug_predictions",
|
||||
"new_features",
|
||||
];
|
||||
|
||||
interface Idea {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: IdeaCategory;
|
||||
impact: "low" | "medium" | "high";
|
||||
effort: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
const PLACEHOLDER_IDEAS: Idea[] = [
|
||||
{ id: "1", title: "Add input validation to user forms", description: "Several user-facing forms lack proper input validation which could lead to data integrity issues.", category: "code_improvements", impact: "high", effort: "small" },
|
||||
{ id: "2", title: "Rate limit API endpoints", description: "Public API endpoints should have rate limiting to prevent abuse and ensure fair usage.", category: "security_hardening", impact: "high", effort: "medium" },
|
||||
{ id: "3", title: "Optimize database queries on dashboard", description: "The dashboard page makes N+1 queries that could be batched for better performance.", category: "performance_optimization", impact: "medium", effort: "small" },
|
||||
{ id: "4", title: "Add loading skeletons", description: "Replace loading spinners with skeleton loaders for a smoother perceived loading experience.", category: "ui_ux_improvements", impact: "medium", effort: "small" },
|
||||
];
|
||||
|
||||
export function IdeationView({ projectId }: IdeationViewProps) {
|
||||
const { t } = useTranslation("views");
|
||||
const [selectedCategory, setSelectedCategory] = useState<IdeaCategory | null>(null);
|
||||
const [ideas] = useState<Idea[]>(PLACEHOLDER_IDEAS);
|
||||
const [isEmpty] = useState(false);
|
||||
|
||||
const filteredIdeas = selectedCategory
|
||||
? ideas.filter((i) => i.category === selectedCategory)
|
||||
: ideas;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Lightbulb className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">{t("ideation.empty.title")}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{t("ideation.empty.description")}
|
||||
</p>
|
||||
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("ideation.empty.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold">{t("ideation.title")}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("ideation.regenerate")}
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t("ideation.analyzeCodebase")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className="border-b border-border px-6 py-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
!selectedCategory ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
>
|
||||
{t("ideation.allFilter", { count: ideas.length })}
|
||||
</button>
|
||||
{CATEGORY_IDS.map((catId) => {
|
||||
const Icon = CATEGORY_ICONS[catId];
|
||||
const count = ideas.filter((i) => i.category === catId).length;
|
||||
return (
|
||||
<button
|
||||
key={catId}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
selectedCategory === catId ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedCategory(catId)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{t("ideation.categoryCount", { label: t(`${CATEGORY_KEYS[catId]}.label`), count })}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ideas list */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3 max-w-3xl">
|
||||
{filteredIdeas.map((idea) => {
|
||||
const Icon = CATEGORY_ICONS[idea.category] || Lightbulb;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idea.id}
|
||||
className="rounded-lg border border-border bg-card p-4 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary shrink-0">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium">{idea.title}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{idea.description}</p>
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
<span className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
idea.impact === "high" && "bg-red-500/10 text-red-600",
|
||||
idea.impact === "medium" && "bg-yellow-500/10 text-yellow-600",
|
||||
idea.impact === "low" && "bg-blue-500/10 text-blue-600"
|
||||
)}>
|
||||
{t("ideation.impact", { level: idea.impact })}
|
||||
</span>
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t("ideation.effort", { level: idea.effort })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-green-500">
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500">
|
||||
<ThumbsDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-primary">
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
User,
|
||||
Bot,
|
||||
Plus,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
|
||||
interface InsightsViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const SUGGESTION_KEYS = [
|
||||
"insights.suggestions.complexity",
|
||||
"insights.suggestions.security",
|
||||
"insights.suggestions.performance",
|
||||
"insights.suggestions.tests",
|
||||
"insights.suggestions.architecture",
|
||||
] as const;
|
||||
|
||||
export function InsightsView({ projectId }: InsightsViewProps) {
|
||||
const { t } = useTranslation("views");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [sessions] = useState<ChatSession[]>([
|
||||
{ id: "1", title: "Architecture Review", createdAt: new Date() },
|
||||
{ id: "2", title: "Security Audit", createdAt: new Date() },
|
||||
]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
content: input.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: `I've analyzed your query about "${userMessage.content}". This is a placeholder response -- in the full implementation, this will be powered by Claude analyzing your project's codebase and providing detailed insights.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, aiMessage]);
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
setInput(suggestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Chat History Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-64 border-r border-border bg-card/50 flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">{t("insights.chatHistory")}</h2>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{session.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
>
|
||||
{showSidebar ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<h1 className="text-sm font-semibold">{t("insights.title")}</h1>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t("insights.welcomeTitle")}</h2>
|
||||
<p className="text-sm text-muted-foreground text-center mb-8">
|
||||
{t("insights.welcomeDescription")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 w-full max-w-lg">
|
||||
{SUGGESTION_KEYS.map((key) => {
|
||||
const suggestion = t(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className="text-left rounded-lg border border-border bg-card/50 px-4 py-3 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="flex gap-3">
|
||||
<div className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full shrink-0",
|
||||
message.role === "user" ? "bg-secondary" : "bg-primary/10"
|
||||
)}>
|
||||
{message.role === "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{message.role === "user" ? t("insights.you") : t("insights.aiAssistant")}
|
||||
</p>
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 shrink-0">
|
||||
<Bot className="h-4 w-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="max-w-3xl mx-auto flex gap-2">
|
||||
<textarea
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t("insights.placeholder")}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
|
||||
input.trim() && !isLoading
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-secondary text-muted-foreground"
|
||||
)}
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Plus,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
GitPullRequest,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import type { Task, TaskStatus } from "@auto-claude/types";
|
||||
import { useTaskStore, loadTasks } from "@/stores/task-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { TaskDetailModal } from "./TaskDetailModal";
|
||||
|
||||
const TASK_STATUS_COLUMNS: TaskStatus[] = [
|
||||
"backlog",
|
||||
"queue",
|
||||
"in_progress",
|
||||
"ai_review",
|
||||
"human_review",
|
||||
"done",
|
||||
];
|
||||
|
||||
const COLUMN_CONFIG: Record<
|
||||
string,
|
||||
{ labelKey: string; icon: React.ElementType; color: string }
|
||||
> = {
|
||||
backlog: { labelKey: "columns.backlog", icon: Inbox, color: "text-muted-foreground" },
|
||||
queue: { labelKey: "columns.queue", icon: Loader2, color: "text-blue-500" },
|
||||
in_progress: { labelKey: "columns.in_progress", icon: Loader2, color: "text-yellow-500" },
|
||||
ai_review: { labelKey: "columns.ai_review", icon: Eye, color: "text-purple-500" },
|
||||
human_review: { labelKey: "columns.human_review", icon: Eye, color: "text-orange-500" },
|
||||
done: { labelKey: "columns.done", icon: CheckCircle2, color: "text-green-500" },
|
||||
pr_created: { labelKey: "columns.pr_created", icon: GitPullRequest, color: "text-green-600" },
|
||||
error: { labelKey: "columns.error", icon: AlertCircle, color: "text-red-500" },
|
||||
};
|
||||
|
||||
export function KanbanBoard() {
|
||||
const { t } = useTranslation("kanban");
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const isLoading = useTaskStore((s) => s.isLoading);
|
||||
const activeProjectId = useProjectStore((s) => s.activeProjectId);
|
||||
const selectedProjectId = useProjectStore((s) => s.selectedProjectId);
|
||||
const setNewTaskDialogOpen = useUIStore((s) => s.setNewTaskDialogOpen);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const currentProjectId = activeProjectId || selectedProjectId;
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = useMemo(() => {
|
||||
const grouped: Record<string, Task[]> = {};
|
||||
for (const status of TASK_STATUS_COLUMNS) {
|
||||
grouped[status] = [];
|
||||
}
|
||||
for (const task of tasks) {
|
||||
// Map pr_created to done, error to human_review for display
|
||||
const displayStatus =
|
||||
task.status === "pr_created"
|
||||
? "done"
|
||||
: task.status === "error"
|
||||
? "human_review"
|
||||
: task.status;
|
||||
if (grouped[displayStatus]) {
|
||||
grouped[displayStatus].push(task);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}, [tasks]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!currentProjectId) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await loadTasks(currentProjectId);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [currentProjectId]);
|
||||
|
||||
if (isLoading && tasks.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold">{t("board.title")}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")}
|
||||
/>
|
||||
{t("board.refresh")}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={() => setNewTaskDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("board.newTask")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div className="flex flex-1 overflow-x-auto p-4 gap-4">
|
||||
{TASK_STATUS_COLUMNS.map((status) => {
|
||||
const config = COLUMN_CONFIG[status];
|
||||
const columnTasks = tasksByStatus[status] || [];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="flex min-w-[280px] max-w-[320px] flex-1 flex-col rounded-lg bg-card/50 border border-border"
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<span className="text-sm font-medium">{t(config.labelKey)}</span>
|
||||
<span className="ml-auto rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{columnTasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<p className="text-xs">{t("board.noTasks")}</p>
|
||||
</div>
|
||||
) : (
|
||||
columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{selectedTask && (
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitPullRequest,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import type { Task } from "@auto-claude/types";
|
||||
|
||||
const STATUS_ICONS: Record<string, React.ElementType> = {
|
||||
backlog: Clock,
|
||||
queue: Clock,
|
||||
in_progress: Clock,
|
||||
ai_review: Eye,
|
||||
human_review: Eye,
|
||||
done: CheckCircle2,
|
||||
pr_created: GitPullRequest,
|
||||
error: AlertCircle,
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: "border-l-red-500",
|
||||
high: "border-l-orange-500",
|
||||
medium: "border-l-yellow-500",
|
||||
low: "border-l-blue-500",
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
const { t } = useTranslation("kanban");
|
||||
const priority = task.metadata?.priority;
|
||||
const category = task.metadata?.category;
|
||||
const StatusIcon = STATUS_ICONS[task.status] || Clock;
|
||||
|
||||
// Calculate subtask progress
|
||||
const totalSubtasks = task.subtasks?.length || 0;
|
||||
const completedSubtasks =
|
||||
task.subtasks?.filter((s) => s.status === "completed").length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card p-3 cursor-pointer transition-all hover:shadow-md hover:border-border/80",
|
||||
"border-l-2",
|
||||
priority ? PRIORITY_COLORS[priority] || "border-l-transparent" : "border-l-transparent"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-medium leading-tight line-clamp-2">
|
||||
{task.title}
|
||||
</h3>
|
||||
|
||||
{/* Description preview */}
|
||||
{task.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
{category && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(`card.category.${category}`, category)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.metadata?.complexity && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{task.metadata.complexity}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Subtask progress */}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{completedSubtasks}/{totalSubtasks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar for execution */}
|
||||
{task.executionProgress && task.executionProgress.overallProgress > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary transition-all"
|
||||
style={{
|
||||
width: `${task.executionProgress.overallProgress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{task.executionProgress.message && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground truncate">
|
||||
{task.executionProgress.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remapped status badges */}
|
||||
{task.status === "pr_created" && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600">
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("card.badges.prCreated")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "error" && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-600">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{t("card.badges.error")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review reason badge */}
|
||||
{task.status === "human_review" && task.reviewReason && (
|
||||
<div className="mt-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
task.reviewReason === "completed" &&
|
||||
"bg-green-500/10 text-green-600",
|
||||
task.reviewReason === "errors" &&
|
||||
"bg-red-500/10 text-red-600",
|
||||
task.reviewReason === "qa_rejected" &&
|
||||
"bg-orange-500/10 text-orange-600",
|
||||
task.reviewReason === "plan_review" &&
|
||||
"bg-blue-500/10 text-blue-600"
|
||||
)}
|
||||
>
|
||||
{t(`card.review.${task.reviewReason}`, task.reviewReason)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Image,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import type {
|
||||
TaskCategory,
|
||||
TaskPriority,
|
||||
TaskComplexity,
|
||||
} from "@auto-claude/types";
|
||||
|
||||
interface TaskCreationWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type Step = "details" | "metadata" | "review";
|
||||
|
||||
const CATEGORIES: { id: TaskCategory; labelKey: string }[] = [
|
||||
{ id: "feature", labelKey: "wizard.category.feature" },
|
||||
{ id: "bug_fix", labelKey: "wizard.category.bug_fix" },
|
||||
{ id: "refactoring", labelKey: "wizard.category.refactoring" },
|
||||
{ id: "documentation", labelKey: "wizard.category.documentation" },
|
||||
{ id: "security", labelKey: "wizard.category.security" },
|
||||
{ id: "performance", labelKey: "wizard.category.performance" },
|
||||
{ id: "ui_ux", labelKey: "wizard.category.ui_ux" },
|
||||
{ id: "infrastructure", labelKey: "wizard.category.infrastructure" },
|
||||
{ id: "testing", labelKey: "wizard.category.testing" },
|
||||
];
|
||||
|
||||
const PRIORITIES: { id: TaskPriority; labelKey: string; color: string }[] = [
|
||||
{ id: "urgent", labelKey: "wizard.priority.urgent", color: "border-red-500 bg-red-500/10 text-red-600" },
|
||||
{ id: "high", labelKey: "wizard.priority.high", color: "border-orange-500 bg-orange-500/10 text-orange-600" },
|
||||
{ id: "medium", labelKey: "wizard.priority.medium", color: "border-yellow-500 bg-yellow-500/10 text-yellow-600" },
|
||||
{ id: "low", labelKey: "wizard.priority.low", color: "border-blue-500 bg-blue-500/10 text-blue-600" },
|
||||
];
|
||||
|
||||
const COMPLEXITIES: { id: TaskComplexity; labelKey: string; descKey: string }[] = [
|
||||
{ id: "trivial", labelKey: "wizard.complexityOption.trivial", descKey: "wizard.complexityOption.trivialDesc" },
|
||||
{ id: "small", labelKey: "wizard.complexityOption.small", descKey: "wizard.complexityOption.smallDesc" },
|
||||
{ id: "medium", labelKey: "wizard.complexityOption.medium", descKey: "wizard.complexityOption.mediumDesc" },
|
||||
{ id: "large", labelKey: "wizard.complexityOption.large", descKey: "wizard.complexityOption.largeDesc" },
|
||||
{ id: "complex", labelKey: "wizard.complexityOption.complex", descKey: "wizard.complexityOption.complexDesc" },
|
||||
];
|
||||
|
||||
export function TaskCreationWizard({
|
||||
open,
|
||||
onClose,
|
||||
projectId,
|
||||
}: TaskCreationWizardProps) {
|
||||
const { t } = useTranslation("kanban");
|
||||
const [step, setStep] = useState<Step>("details");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState<TaskCategory | "">("");
|
||||
const [priority, setPriority] = useState<TaskPriority | "">("");
|
||||
const [complexity, setComplexity] = useState<TaskComplexity | "">("");
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setCategory("");
|
||||
setPriority("");
|
||||
setComplexity("");
|
||||
setStep("details");
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetForm();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, handleClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
// TODO: API call to create task
|
||||
console.log("Creating task:", { title, description, category, priority, complexity, projectId });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={handleClose} />
|
||||
<div className="relative z-10 w-full max-w-2xl max-h-[85vh] overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("wizard.newTask")}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn("rounded-full px-2 py-0.5", step === "details" ? "bg-primary text-primary-foreground" : "bg-secondary")}>{t("wizard.steps.details")}</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5", step === "metadata" ? "bg-primary text-primary-foreground" : "bg-secondary")}>{t("wizard.steps.config")}</span>
|
||||
<span className={cn("rounded-full px-2 py-0.5", step === "review" ? "bg-primary text-primary-foreground" : "bg-secondary")}>{t("wizard.steps.review")}</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto max-h-[calc(85vh-130px)] p-6">
|
||||
{step === "details" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("wizard.taskTitle")}</label>
|
||||
<input
|
||||
className="mt-1.5 w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t("wizard.titlePlaceholder")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("wizard.descriptionLabel")}</label>
|
||||
<textarea
|
||||
className="mt-1.5 w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t("wizard.descriptionPlaceholder")}
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{t("wizard.categoryLabel")}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
category === cat.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{t(cat.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "metadata" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{t("wizard.priorityLabel")}</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{PRIORITIES.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"rounded-lg border-2 p-3 text-center text-sm font-medium transition-colors",
|
||||
priority === p.id ? p.color : "border-border hover:border-border/80"
|
||||
)}
|
||||
onClick={() => setPriority(p.id)}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{t("wizard.complexityLabel")}</label>
|
||||
<div className="space-y-2">
|
||||
{COMPLEXITIES.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
complexity === c.id ? "border-primary bg-primary/5" : "border-border hover:border-border/80"
|
||||
)}
|
||||
onClick={() => setComplexity(c.id)}
|
||||
>
|
||||
<p className="text-sm font-medium">{t(c.labelKey)}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(c.descKey)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("wizard.reviewTask")}</h3>
|
||||
<div className="rounded-lg border border-border bg-card/50 p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.title")}</p>
|
||||
<p className="text-sm font-medium">{title || t("wizard.untitled")}</p>
|
||||
</div>
|
||||
{description && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.description")}</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{category && (
|
||||
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs">
|
||||
{t(`wizard.category.${category}`)}
|
||||
</span>
|
||||
)}
|
||||
{priority && (
|
||||
<span className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-xs",
|
||||
PRIORITIES.find((p) => p.id === priority)?.color
|
||||
)}>
|
||||
{t(`wizard.priority.${priority}`)}
|
||||
</span>
|
||||
)}
|
||||
{complexity && (
|
||||
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs">
|
||||
{t(`wizard.complexityOption.${complexity}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("wizard.aiHandlesRest")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("wizard.aiHandlesRestDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border px-6 py-4">
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
if (step === "details") handleClose();
|
||||
else if (step === "metadata") setStep("details");
|
||||
else setStep("metadata");
|
||||
}}
|
||||
>
|
||||
{step === "details" ? (
|
||||
t("wizard.cancel")
|
||||
) : (
|
||||
<>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t("wizard.back")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
disabled={step === "details" && !title.trim()}
|
||||
onClick={() => {
|
||||
if (step === "details") setStep("metadata");
|
||||
else if (step === "metadata") setStep("review");
|
||||
else handleSubmit();
|
||||
}}
|
||||
>
|
||||
{step === "review" ? (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t("wizard.createTask")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("wizard.continue")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import type { Task } from "@auto-claude/types";
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskDetailModal({ task, onClose }: TaskDetailModalProps) {
|
||||
const { t } = useTranslation("kanban");
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative z-10 w-full max-w-3xl max-h-[85vh] overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-border p-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
task.status === "done" && "bg-green-500/10 text-green-600",
|
||||
task.status === "in_progress" && "bg-yellow-500/10 text-yellow-600",
|
||||
task.status === "error" && "bg-red-500/10 text-red-600",
|
||||
task.status === "human_review" && "bg-orange-500/10 text-orange-600",
|
||||
!["done", "in_progress", "error", "human_review"].includes(task.status) &&
|
||||
"bg-secondary text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(`columns.${task.status}`, task.status)}
|
||||
</span>
|
||||
{task.metadata?.category && (
|
||||
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{task.metadata.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{task.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto max-h-[calc(85vh-80px)] p-6 space-y-6">
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t("detail.description")}</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Progress */}
|
||||
{task.executionProgress && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t("detail.executionProgress")}</h3>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{t("detail.phase", { phase: task.executionProgress.phase })}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{task.executionProgress.overallProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-2 rounded-full bg-primary transition-all"
|
||||
style={{
|
||||
width: `${task.executionProgress.overallProgress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{task.executionProgress.message && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{task.executionProgress.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
{t("detail.subtasks", {
|
||||
completed: task.subtasks.filter((s) => s.status === "completed").length,
|
||||
total: task.subtasks.length,
|
||||
})}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{task.subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-start gap-2 rounded-md border border-border p-3"
|
||||
>
|
||||
{subtask.status === "completed" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
|
||||
) : subtask.status === "failed" ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
|
||||
) : subtask.status === "in_progress" ? (
|
||||
<Clock className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0 animate-pulse" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{subtask.title}</p>
|
||||
{subtask.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{subtask.description}
|
||||
</p>
|
||||
)}
|
||||
{subtask.files && subtask.files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{subtask.files.map((file) => (
|
||||
<span
|
||||
key={file}
|
||||
className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
{file.split("/").pop()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QA Report */}
|
||||
{task.qaReport && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t("detail.qaReport")}</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4",
|
||||
task.qaReport.status === "passed" &&
|
||||
"border-green-500/30 bg-green-500/5",
|
||||
task.qaReport.status === "failed" &&
|
||||
"border-red-500/30 bg-red-500/5",
|
||||
task.qaReport.status === "pending" &&
|
||||
"border-border bg-card"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{t("detail.qaStatus", { status: task.qaReport.status })}
|
||||
</p>
|
||||
{task.qaReport.issues && task.qaReport.issues.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{task.qaReport.issues.map((issue) => (
|
||||
<li
|
||||
key={issue.id}
|
||||
className="text-xs text-muted-foreground flex items-start gap-1"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 mt-0.5 h-1.5 w-1.5 rounded-full",
|
||||
issue.severity === "critical" && "bg-red-500",
|
||||
issue.severity === "major" && "bg-orange-500",
|
||||
issue.severity === "minor" && "bg-yellow-500"
|
||||
)}
|
||||
/>
|
||||
{issue.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{task.metadata && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t("detail.details")}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{task.metadata.priority && (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("detail.priority")}</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{task.metadata.priority}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{task.metadata.complexity && (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("detail.complexity")}</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{task.metadata.complexity}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{task.metadata.impact && (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("detail.impact")}</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{task.metadata.impact}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{task.metadata.model && (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("detail.model")}</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{task.metadata.model}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR Link */}
|
||||
{task.metadata?.prUrl && (
|
||||
<div>
|
||||
<a
|
||||
href={task.metadata.prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border p-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<GitPullRequest className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">{t("detail.viewPullRequest")}</span>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground ml-auto" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { KanbanBoard } from "./KanbanBoard";
|
||||
export { TaskCard } from "./TaskCard";
|
||||
export { TaskDetailModal } from "./TaskDetailModal";
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ProjectTabBar } from "./ProjectTabBar";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { loadTasks } from "@/stores/task-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { KanbanBoard } from "@/components/kanban/KanbanBoard";
|
||||
import { TaskCreationWizard } from "@/components/kanban/TaskCreationWizard";
|
||||
import { RoadmapView } from "@/components/roadmap/RoadmapView";
|
||||
import { IdeationView } from "@/components/ideation/IdeationView";
|
||||
import { InsightsView } from "@/components/insights/InsightsView";
|
||||
import { ChangelogView } from "@/components/changelog/ChangelogView";
|
||||
import { ContextView } from "@/components/context/ContextView";
|
||||
import { GitHubIssuesView } from "@/components/github/GitHubIssuesView";
|
||||
import { GitHubPRsView } from "@/components/github/GitHubPRsView";
|
||||
import { GitLabIssuesView } from "@/components/gitlab/GitLabIssuesView";
|
||||
import { GitLabMRsView } from "@/components/gitlab/GitLabMRsView";
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
|
||||
import { WelcomeScreen } from "./WelcomeScreen";
|
||||
|
||||
export function AppShell() {
|
||||
const activeView = useUIStore((s) => s.activeView);
|
||||
const isNewTaskDialogOpen = useUIStore((s) => s.isNewTaskDialogOpen);
|
||||
const setNewTaskDialogOpen = useUIStore((s) => s.setNewTaskDialogOpen);
|
||||
const isOnboardingOpen = useUIStore((s) => s.isOnboardingOpen);
|
||||
const setOnboardingOpen = useUIStore((s) => s.setOnboardingOpen);
|
||||
const activeProjectId = useProjectStore((s) => s.activeProjectId);
|
||||
const selectedProjectId = useProjectStore((s) => s.selectedProjectId);
|
||||
const projects = useProjectStore((s) => s.projects);
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
const currentProjectId = activeProjectId || selectedProjectId;
|
||||
const selectedProject = projects.find((p) => p.id === currentProjectId);
|
||||
|
||||
// Load tasks when project changes
|
||||
useEffect(() => {
|
||||
if (currentProjectId) {
|
||||
loadTasks(currentProjectId);
|
||||
} else {
|
||||
useTaskStore.getState().clearTasks();
|
||||
}
|
||||
}, [currentProjectId]);
|
||||
|
||||
// Apply theme from settings
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const applyTheme = () => {
|
||||
if (settings.theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else if (settings.theme === "light") {
|
||||
root.classList.remove("dark");
|
||||
} else {
|
||||
// System preference
|
||||
if (prefersDark.matches) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
prefersDark.addEventListener("change", applyTheme);
|
||||
return () => prefersDark.removeEventListener("change", applyTheme);
|
||||
}, [settings.theme]);
|
||||
|
||||
// Keyboard shortcuts for navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement ||
|
||||
(e.target as HTMLElement)?.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProjectId) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const key = e.key.toUpperCase();
|
||||
const viewMap: Record<string, typeof activeView> = {
|
||||
K: "kanban",
|
||||
N: "insights",
|
||||
D: "roadmap",
|
||||
I: "ideation",
|
||||
L: "changelog",
|
||||
C: "context",
|
||||
G: "github-issues",
|
||||
P: "github-prs",
|
||||
B: "gitlab-issues",
|
||||
R: "gitlab-merge-requests",
|
||||
};
|
||||
|
||||
if (viewMap[key]) {
|
||||
e.preventDefault();
|
||||
useUIStore.getState().setActiveView(viewMap[key]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [currentProjectId]);
|
||||
|
||||
const renderContent = () => {
|
||||
// Settings is always accessible, even without a project
|
||||
if (activeView === "settings") {
|
||||
return <SettingsView />;
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
switch (activeView) {
|
||||
case "kanban":
|
||||
return <KanbanBoard />;
|
||||
case "roadmap":
|
||||
return <RoadmapView projectId={currentProjectId!} />;
|
||||
case "ideation":
|
||||
return <IdeationView projectId={currentProjectId!} />;
|
||||
case "insights":
|
||||
return <InsightsView projectId={currentProjectId!} />;
|
||||
case "changelog":
|
||||
return <ChangelogView projectId={currentProjectId!} />;
|
||||
case "context":
|
||||
return <ContextView projectId={currentProjectId!} />;
|
||||
case "github-issues":
|
||||
return <GitHubIssuesView projectId={currentProjectId!} />;
|
||||
case "github-prs":
|
||||
return <GitHubPRsView projectId={currentProjectId!} />;
|
||||
case "gitlab-issues":
|
||||
return <GitLabIssuesView projectId={currentProjectId!} />;
|
||||
case "gitlab-merge-requests":
|
||||
return <GitLabMRsView projectId={currentProjectId!} />;
|
||||
default:
|
||||
return <KanbanBoard />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Project Tabs */}
|
||||
<ProjectTabBar />
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 overflow-hidden">{renderContent()}</main>
|
||||
</div>
|
||||
|
||||
{/* Task Creation Wizard */}
|
||||
{currentProjectId && (
|
||||
<TaskCreationWizard
|
||||
open={isNewTaskDialogOpen}
|
||||
onClose={() => setNewTaskDialogOpen(false)}
|
||||
projectId={currentProjectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Onboarding Wizard */}
|
||||
<OnboardingWizard
|
||||
open={isOnboardingOpen}
|
||||
onClose={() => setOnboardingOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { X, Plus } from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ProjectTabBar() {
|
||||
const projects = useProjectStore((s) => s.projects);
|
||||
const openProjectIds = useProjectStore((s) => s.openProjectIds);
|
||||
const activeProjectId = useProjectStore((s) => s.activeProjectId);
|
||||
const setActiveProject = useProjectStore((s) => s.setActiveProject);
|
||||
const closeProjectTab = useProjectStore((s) => s.closeProjectTab);
|
||||
const { t } = useTranslation("layout");
|
||||
|
||||
const projectTabs = openProjectIds
|
||||
.map((id) => projects.find((p) => p.id === id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (projectTabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-border bg-card/50">
|
||||
<div className="flex flex-1 items-center overflow-x-auto">
|
||||
{projectTabs.map((project) => {
|
||||
if (!project) return null;
|
||||
const isActive = project.id === activeProjectId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 border-r border-border px-4 py-2 text-sm cursor-pointer transition-colors min-w-0",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "bg-card/50 text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setActiveProject(project.id)}
|
||||
>
|
||||
<div className="w-1.5 h-4 rounded-full bg-muted-foreground/30 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">{project.name}</span>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity ml-1 hover:bg-accent rounded-sm p-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeProjectTab(project.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="flex h-full items-center px-3 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
aria-label={t("projectTabBar.addProject")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Settings,
|
||||
LayoutGrid,
|
||||
Map,
|
||||
BookOpen,
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Github,
|
||||
GitPullRequest,
|
||||
GitMerge,
|
||||
HelpCircle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useSettingsStore, saveSettings } from "@/stores/settings-store";
|
||||
import { useUIStore, type SidebarView } from "@/stores/ui-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface NavItem {
|
||||
id: SidebarView;
|
||||
labelKey: string;
|
||||
icon: React.ElementType;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const baseNavItems: NavItem[] = [
|
||||
{ id: "kanban", labelKey: "sidebar.nav.tasks", icon: LayoutGrid, shortcut: "K" },
|
||||
{ id: "insights", labelKey: "sidebar.nav.insights", icon: Sparkles, shortcut: "N" },
|
||||
{ id: "roadmap", labelKey: "sidebar.nav.roadmap", icon: Map, shortcut: "D" },
|
||||
{ id: "ideation", labelKey: "sidebar.nav.ideation", icon: Lightbulb, shortcut: "I" },
|
||||
{ id: "changelog", labelKey: "sidebar.nav.changelog", icon: FileText, shortcut: "L" },
|
||||
{ id: "context", labelKey: "sidebar.nav.context", icon: BookOpen, shortcut: "C" },
|
||||
];
|
||||
|
||||
const githubNavItems: NavItem[] = [
|
||||
{ id: "github-issues", labelKey: "sidebar.nav.githubIssues", icon: Github, shortcut: "G" },
|
||||
{ id: "github-prs", labelKey: "sidebar.nav.githubPrs", icon: GitPullRequest, shortcut: "P" },
|
||||
];
|
||||
|
||||
const gitlabNavItems: NavItem[] = [
|
||||
{ id: "gitlab-issues", labelKey: "sidebar.nav.gitlabIssues", icon: Github, shortcut: "B" },
|
||||
{ id: "gitlab-merge-requests", labelKey: "sidebar.nav.gitlabMrs", icon: GitMerge, shortcut: "R" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const activeView = useUIStore((s) => s.activeView);
|
||||
const setActiveView = useUIStore((s) => s.setActiveView);
|
||||
const setNewTaskDialogOpen = useUIStore((s) => s.setNewTaskDialogOpen);
|
||||
const activeProjectId = useProjectStore((s) => s.activeProjectId);
|
||||
const { t } = useTranslation("layout");
|
||||
|
||||
const isCollapsed = settings.sidebarCollapsed ?? false;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
saveSettings({ sidebarCollapsed: !isCollapsed });
|
||||
};
|
||||
|
||||
// Show GitHub and GitLab items by default (env config will drive this later)
|
||||
const visibleNavItems = useMemo(() => {
|
||||
return [...baseNavItems, ...githubNavItems, ...gitlabNavItems];
|
||||
}, []);
|
||||
|
||||
const renderNavItem = (item: NavItem) => {
|
||||
const isActive = activeView === item.id;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveView(item.id)}
|
||||
disabled={!activeProjectId}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-lg text-sm transition-all duration-200",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{t(item.labelKey)}</span>
|
||||
{item.shortcut && (
|
||||
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded-md border border-border bg-secondary px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:flex">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col bg-sidebar border-r border-border transition-all duration-300",
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 items-center transition-all duration-300",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
)}
|
||||
>
|
||||
{!isCollapsed && (
|
||||
<span className="text-lg font-bold text-primary">{t("sidebar.brand")}</span>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<span className="text-lg font-bold text-primary">{t("sidebar.brandShort")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Toggle button */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex py-2 transition-all duration-300",
|
||||
isCollapsed ? "justify-center px-2" : "justify-end px-3"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
|
||||
onClick={toggleSidebar}
|
||||
aria-label={isCollapsed ? t("sidebar.aria.expandSidebar") : t("sidebar.aria.collapseSidebar")}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"py-4 transition-all duration-300",
|
||||
isCollapsed ? "px-2" : "px-3"
|
||||
)}
|
||||
>
|
||||
{!isCollapsed && (
|
||||
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("sidebar.sectionProject")}
|
||||
</h3>
|
||||
)}
|
||||
<nav className="space-y-1">{visibleNavItems.map(renderNavItem)}</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Bottom section */}
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 transition-all duration-300",
|
||||
isCollapsed ? "p-2" : "p-4"
|
||||
)}
|
||||
>
|
||||
{/* Settings and Help row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
isCollapsed ? "flex-col gap-1" : "gap-2"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center rounded-md hover:bg-accent transition-colors",
|
||||
isCollapsed ? "h-8 w-8 justify-center" : "flex-1 gap-2 px-3 py-1.5 text-sm justify-start"
|
||||
)}
|
||||
onClick={() => setActiveView("settings")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{!isCollapsed && t("sidebar.actions.settings")}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/AndyMik90/Auto-Claude/issues", "_blank")
|
||||
}
|
||||
aria-label={t("sidebar.aria.help")}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Task button */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-full rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors flex items-center justify-center",
|
||||
isCollapsed ? "h-8 w-8 mx-auto" : "px-4 py-2 text-sm"
|
||||
)}
|
||||
onClick={() => setNewTaskDialogOpen(true)}
|
||||
disabled={!activeProjectId}
|
||||
>
|
||||
<Plus className={isCollapsed ? "h-4 w-4" : "mr-2 h-4 w-4"} />
|
||||
{!isCollapsed && t("sidebar.actions.newTask")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, ArrowRight } from "lucide-react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const { t } = useTranslation("layout");
|
||||
|
||||
const connectDemoProject = () => {
|
||||
const demoProject = {
|
||||
id: "demo-project",
|
||||
name: "Auto Claude",
|
||||
path: "/demo",
|
||||
repoUrl: "https://github.com/AndyMik90/Auto-Claude",
|
||||
description: "Autonomous coding framework",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
useProjectStore.getState().setProjects([demoProject] as any);
|
||||
useProjectStore.getState().openProjectTab(demoProject.id);
|
||||
useUIStore.getState().setActiveView("kanban");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-3 text-2xl font-bold">{t("welcome.title")}</h1>
|
||||
<p className="mb-8 text-muted-foreground">
|
||||
{t("welcome.description")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={connectDemoProject}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("welcome.connectProject")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("welcome.subtext")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AppShell } from "./AppShell";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { ProjectTabBar } from "./ProjectTabBar";
|
||||
export { WelcomeScreen } from "./WelcomeScreen";
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Sparkles,
|
||||
Key,
|
||||
FolderOpen,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = "welcome" | "api-key" | "project" | "complete";
|
||||
|
||||
const STEP_IDS: { id: Step; titleKey: string; icon: React.ElementType }[] = [
|
||||
{ id: "welcome", titleKey: "onboarding.steps.welcome", icon: Sparkles },
|
||||
{ id: "api-key", titleKey: "onboarding.steps.apiKey", icon: Key },
|
||||
{ id: "project", titleKey: "onboarding.steps.project", icon: FolderOpen },
|
||||
{ id: "complete", titleKey: "onboarding.steps.complete", icon: CheckCircle2 },
|
||||
];
|
||||
|
||||
export function OnboardingWizard({ open, onClose }: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>("welcome");
|
||||
const { t } = useTranslation("layout");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const currentIndex = STEP_IDS.findIndex((s) => s.id === currentStep);
|
||||
|
||||
const goNext = () => {
|
||||
if (currentIndex < STEP_IDS.length - 1) {
|
||||
setCurrentStep(STEP_IDS[currentIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentStep(STEP_IDS[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
|
||||
<div className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl">
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-center gap-2 border-b border-border px-6 py-4">
|
||||
{STEP_IDS.map((step, idx) => {
|
||||
const Icon = step.icon;
|
||||
const isActive = idx === currentIndex;
|
||||
const isComplete = idx < currentIndex;
|
||||
return (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full transition-colors",
|
||||
isActive && "bg-primary text-primary-foreground",
|
||||
isComplete && "bg-green-500/10 text-green-600",
|
||||
!isActive && !isComplete && "bg-secondary text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{idx < STEP_IDS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-2 h-px w-8",
|
||||
idx < currentIndex ? "bg-green-500" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{currentStep === "welcome" && (
|
||||
<div className="text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t("onboarding.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("onboarding.titleDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "api-key" && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t("onboarding.apiConfiguration.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t("onboarding.apiConfiguration.description")}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("onboarding.apiConfiguration.authMethod")}</label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-3">
|
||||
<button className="rounded-lg border-2 border-primary bg-primary/5 p-4 text-left">
|
||||
<p className="text-sm font-medium">{t("onboarding.apiConfiguration.claudeOAuth")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("onboarding.apiConfiguration.claudeOAuthDescription")}
|
||||
</p>
|
||||
</button>
|
||||
<button className="rounded-lg border border-border p-4 text-left hover:border-border/80 transition-colors">
|
||||
<p className="text-sm font-medium">{t("onboarding.apiConfiguration.apiKey")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("onboarding.apiConfiguration.apiKeyDescription")}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "project" && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t("onboarding.connectProject.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t("onboarding.connectProject.description")}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<button className="w-full flex items-center gap-3 rounded-lg border-2 border-dashed border-border p-6 hover:border-primary/50 hover:bg-primary/5 transition-colors">
|
||||
<FolderOpen className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium">{t("onboarding.connectProject.selectDirectory")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.connectProject.selectDirectoryDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "complete" && (
|
||||
<div className="text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-green-500/10">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t("onboarding.complete.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("onboarding.complete.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border px-6 py-4">
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={currentStep === "welcome" ? onClose : goPrev}
|
||||
>
|
||||
{currentStep === "welcome" ? (
|
||||
t("onboarding.actions.skipSetup")
|
||||
) : (
|
||||
<>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t("onboarding.actions.back")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={currentStep === "complete" ? onClose : goNext}
|
||||
>
|
||||
{currentStep === "complete" ? (
|
||||
t("onboarding.actions.getStarted")
|
||||
) : (
|
||||
<>
|
||||
{t("onboarding.actions.continue")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Map,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Calendar,
|
||||
Target,
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
|
||||
interface RoadmapViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface Feature {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
phase: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
status: "planned" | "in_progress" | "completed";
|
||||
effort: string;
|
||||
}
|
||||
|
||||
// Placeholder data for UI layout -- will be replaced with API data
|
||||
const PLACEHOLDER_PHASES = [
|
||||
{
|
||||
nameKey: "roadmap.phases.phase1" as const,
|
||||
features: [
|
||||
{ id: "1", title: "Core Authentication", description: "User login, registration, and session management", phase: "Phase 1", priority: "high" as const, status: "completed" as const, effort: "Large" },
|
||||
{ id: "2", title: "Database Schema", description: "Initial database models and migrations", phase: "Phase 1", priority: "high" as const, status: "completed" as const, effort: "Medium" },
|
||||
],
|
||||
},
|
||||
{
|
||||
nameKey: "roadmap.phases.phase2" as const,
|
||||
features: [
|
||||
{ id: "3", title: "Task Management", description: "CRUD operations for tasks and subtasks", phase: "Phase 2", priority: "high" as const, status: "in_progress" as const, effort: "Large" },
|
||||
{ id: "4", title: "Real-time Updates", description: "WebSocket integration for live updates", phase: "Phase 2", priority: "medium" as const, status: "planned" as const, effort: "Medium" },
|
||||
],
|
||||
},
|
||||
{
|
||||
nameKey: "roadmap.phases.phase3" as const,
|
||||
features: [
|
||||
{ id: "5", title: "GitHub Integration", description: "Issue sync and PR management", phase: "Phase 3", priority: "medium" as const, status: "planned" as const, effort: "Large" },
|
||||
{ id: "6", title: "CI/CD Pipeline", description: "Automated deployment workflows", phase: "Phase 3", priority: "low" as const, status: "planned" as const, effort: "Small" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function RoadmapView({ projectId }: RoadmapViewProps) {
|
||||
const { t } = useTranslation("views");
|
||||
const [activeTab, setActiveTab] = useState<"kanban" | "timeline" | "list">("kanban");
|
||||
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null);
|
||||
const [isEmpty] = useState(false);
|
||||
|
||||
const statusLabel = (status: Feature["status"]) => {
|
||||
if (status === "in_progress") return t("roadmap.status.inProgress");
|
||||
if (status === "completed") return t("roadmap.status.completed");
|
||||
return t("roadmap.status.planned");
|
||||
};
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Map className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">{t("roadmap.empty.title")}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{t("roadmap.empty.description")}
|
||||
</p>
|
||||
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("roadmap.empty.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">{t("roadmap.title")}</h1>
|
||||
<div className="flex items-center rounded-lg border border-border bg-card/50">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors rounded-l-lg",
|
||||
activeTab === "kanban" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("kanban")}
|
||||
>
|
||||
<LayoutGrid className="h-3 w-3" />
|
||||
{t("roadmap.tabs.board")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
activeTab === "timeline" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("timeline")}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
{t("roadmap.tabs.timeline")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors rounded-r-lg",
|
||||
activeTab === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("list")}
|
||||
>
|
||||
<List className="h-3 w-3" />
|
||||
{t("roadmap.tabs.list")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("roadmap.refresh")}
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("roadmap.addFeature")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeTab === "kanban" && (
|
||||
<div className="space-y-8">
|
||||
{PLACEHOLDER_PHASES.map((phase) => (
|
||||
<div key={phase.nameKey}>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
{t(phase.nameKey)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{phase.features.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="rounded-lg border border-border bg-card p-4 cursor-pointer hover:shadow-md transition-all"
|
||||
onClick={() => setSelectedFeature(feature)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{feature.title}</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
feature.status === "completed" && "bg-green-500/10 text-green-600",
|
||||
feature.status === "in_progress" && "bg-yellow-500/10 text-yellow-600",
|
||||
feature.status === "planned" && "bg-secondary text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{statusLabel(feature.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground line-clamp-2">
|
||||
{feature.description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
feature.priority === "high" && "bg-red-500/10 text-red-600",
|
||||
feature.priority === "medium" && "bg-yellow-500/10 text-yellow-600",
|
||||
feature.priority === "low" && "bg-blue-500/10 text-blue-600"
|
||||
)}
|
||||
>
|
||||
{feature.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{feature.effort}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "timeline" && (
|
||||
<div className="space-y-4">
|
||||
{PLACEHOLDER_PHASES.map((phase, idx) => (
|
||||
<div key={phase.nameKey} className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold",
|
||||
idx === 0 && "bg-green-500/10 text-green-600",
|
||||
idx === 1 && "bg-yellow-500/10 text-yellow-600",
|
||||
idx === 2 && "bg-blue-500/10 text-blue-600",
|
||||
)}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
{idx < PLACEHOLDER_PHASES.length - 1 && (
|
||||
<div className="flex-1 w-px bg-border my-2" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 pb-6">
|
||||
<h3 className="text-sm font-semibold mb-2">{t(phase.nameKey)}</h3>
|
||||
<div className="space-y-2">
|
||||
{phase.features.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-card/50 p-2.5 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setSelectedFeature(feature)}
|
||||
>
|
||||
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm flex-1">{feature.title}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "list" && (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card/50">
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.feature")}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.phase")}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.priority")}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.status")}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.effort")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PLACEHOLDER_PHASES.flatMap((phase) =>
|
||||
phase.features.map((feature) => (
|
||||
<tr
|
||||
key={feature.id}
|
||||
className="border-b border-border hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => setSelectedFeature(feature)}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-medium">{feature.title}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{feature.phase}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
feature.priority === "high" && "bg-red-500/10 text-red-600",
|
||||
feature.priority === "medium" && "bg-yellow-500/10 text-yellow-600",
|
||||
feature.priority === "low" && "bg-blue-500/10 text-blue-600"
|
||||
)}>
|
||||
{feature.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
feature.status === "completed" && "bg-green-500/10 text-green-600",
|
||||
feature.status === "in_progress" && "bg-yellow-500/10 text-yellow-600",
|
||||
feature.status === "planned" && "bg-secondary text-muted-foreground"
|
||||
)}>
|
||||
{statusLabel(feature.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{feature.effort}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Detail Side Panel */}
|
||||
{selectedFeature && (
|
||||
<div className="fixed inset-y-0 right-0 z-50 w-96 border-l border-border bg-card shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="text-sm font-semibold">{t("roadmap.detail.title")}</h2>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
|
||||
onClick={() => setSelectedFeature(null)}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{selectedFeature.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedFeature.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("roadmap.detail.priority")}</p>
|
||||
<p className="text-sm font-medium capitalize">{selectedFeature.priority}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("roadmap.detail.effort")}</p>
|
||||
<p className="text-sm font-medium">{selectedFeature.effort}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("roadmap.detail.phase")}</p>
|
||||
<p className="text-sm font-medium">{selectedFeature.phase}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("roadmap.detail.status")}</p>
|
||||
<p className="text-sm font-medium capitalize">{selectedFeature.status.replace("_", " ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
{t("roadmap.detail.convertToTask")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
Palette,
|
||||
Globe,
|
||||
Shield,
|
||||
Bell,
|
||||
Github,
|
||||
Key,
|
||||
Database,
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@auto-claude/ui";
|
||||
import { useSettingsStore, saveSettings } from "@/stores/settings-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SettingsSection =
|
||||
| "general"
|
||||
| "appearance"
|
||||
| "accounts"
|
||||
| "github"
|
||||
| "notifications"
|
||||
| "advanced";
|
||||
|
||||
const SECTION_IDS: { id: SettingsSection; icon: React.ElementType }[] = [
|
||||
{ id: "general", icon: Settings },
|
||||
{ id: "appearance", icon: Palette },
|
||||
{ id: "accounts", icon: Key },
|
||||
{ id: "github", icon: Github },
|
||||
{ id: "notifications", icon: Bell },
|
||||
{ id: "advanced", icon: Database },
|
||||
];
|
||||
|
||||
export function SettingsView() {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const SECTIONS = SECTION_IDS.map((s) => ({
|
||||
...s,
|
||||
label: t(`sections.${s.id}.title`),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-56 border-r border-border bg-card/50 p-4">
|
||||
<h1 className="text-sm font-semibold mb-4 px-3">{t("title")}</h1>
|
||||
<nav className="space-y-1">
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
activeSection === section.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{section.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-2xl">
|
||||
{activeSection === "general" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("sections.general.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sections.general.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("fields.language")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fields.languageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select className="rounded-md border border-border bg-background px-3 py-1.5 text-sm">
|
||||
<option value="en">{t("languages.en")}</option>
|
||||
<option value="fr">{t("languages.fr")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "appearance" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("sections.appearance.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sections.appearance.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Theme */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium mb-3">{t("fields.theme")}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["light", "dark", "system"] as const).map((theme) => {
|
||||
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
|
||||
return (
|
||||
<button
|
||||
key={theme}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-colors",
|
||||
settings.theme === theme
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-border/80"
|
||||
)}
|
||||
onClick={() => saveSettings({ theme })}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-xs font-medium capitalize">
|
||||
{t(`themes.${theme}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "accounts" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("sections.accounts.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sections.accounts.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Key className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">{t("fields.claudeApi")}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t("fields.claudeApiDescription")}
|
||||
</p>
|
||||
<button className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
{t("actions.configure")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "github" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("sections.github.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sections.github.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Github className="h-4 w-4" />
|
||||
<p className="text-sm font-medium">{t("fields.repository")}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{t("fields.repositoryLabel")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("placeholders.ownerRepo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{t("fields.mainBranch")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("placeholders.main")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "notifications" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("sections.notifications.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sections.notifications.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(["taskCompleted", "taskFailed", "reviewNeeded"] as const).map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-lg border border-border p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t(`notifications.${key}.label`)}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(`notifications.${key}.description`)}</p>
|
||||
</div>
|
||||
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-primary transition-colors">
|
||||
<span className="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-6" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "advanced" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("sections.advanced.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sections.advanced.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">{t("fields.memorySystem")}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t("fields.memorySystemDescription")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-green-500/10 text-green-600 px-2 py-0.5 text-xs">
|
||||
{t("status.active")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("status.usingLadybugDb")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useCloudMode } from '../useCloudMode';
|
||||
|
||||
describe('useCloudMode', () => {
|
||||
beforeEach(() => {
|
||||
// Reset environment variables before each test
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
// Clear module cache to get fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('self-hosted mode', () => {
|
||||
it('should return isCloud=false when NEXT_PUBLIC_CONVEX_URL is not set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
|
||||
// Dynamically import to get fresh module
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.isCloud).toBe(false);
|
||||
});
|
||||
|
||||
it('should return default API URL when NEXT_PUBLIC_API_URL is not set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.apiUrl).toBe('http://localhost:8000');
|
||||
});
|
||||
|
||||
it('should return custom API URL when NEXT_PUBLIC_API_URL is set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.apiUrl).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode', () => {
|
||||
it('should return isCloud=true when NEXT_PUBLIC_CONVEX_URL is set', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.isCloud).toBe(true);
|
||||
});
|
||||
|
||||
it('should still provide API URL in cloud mode', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
|
||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.isCloud).toBe(true);
|
||||
expect(result.current.apiUrl).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value immutability', () => {
|
||||
it('should return a readonly object', async () => {
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
// TypeScript should enforce this, but we can verify the structure
|
||||
expect(result.current).toHaveProperty('isCloud');
|
||||
expect(result.current).toHaveProperty('apiUrl');
|
||||
expect(Object.keys(result.current)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return consistent values across multiple calls', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
|
||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result, rerender } = renderHook(() => freshHook());
|
||||
|
||||
const firstCall = result.current;
|
||||
rerender();
|
||||
const secondCall = result.current;
|
||||
|
||||
expect(firstCall.isCloud).toBe(secondCall.isCloud);
|
||||
expect(firstCall.apiUrl).toBe(secondCall.apiUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string CONVEX_URL', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = '';
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.isCloud).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only CONVEX_URL', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = ' ';
|
||||
|
||||
const { useCloudMode: freshHook } = await import('../useCloudMode');
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
// Whitespace is truthy, so CLOUD_MODE will be true
|
||||
expect(result.current.isCloud).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useCurrentUser } from '../useCurrentUser';
|
||||
import * as convexReact from 'convex/react';
|
||||
|
||||
// Mock the Convex API
|
||||
vi.mock('../../convex/_generated/api', () => ({
|
||||
api: {
|
||||
users: {
|
||||
me: 'users:me'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe('useCurrentUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call useQuery with the users.me API endpoint', () => {
|
||||
const mockUseQuery = vi.fn().mockReturnValue(undefined);
|
||||
vi.spyOn(convexReact, 'useQuery').mockImplementation(mockUseQuery);
|
||||
|
||||
renderHook(() => useCurrentUser());
|
||||
|
||||
// Verify useQuery was called once with the API endpoint
|
||||
expect(mockUseQuery).toHaveBeenCalledTimes(1);
|
||||
// The API endpoint is passed as first argument
|
||||
expect(mockUseQuery.mock.calls[0].length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return user data when authenticated', () => {
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
tier: 'pro' as const,
|
||||
};
|
||||
|
||||
vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result.current).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should return undefined when not authenticated', () => {
|
||||
vi.spyOn(convexReact, 'useQuery').mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null when query is loading', () => {
|
||||
vi.spyOn(convexReact, 'useQuery').mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('should update when user data changes', () => {
|
||||
const mockUser1 = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
tier: 'free' as const,
|
||||
};
|
||||
|
||||
const mockUser2 = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
tier: 'pro' as const,
|
||||
};
|
||||
|
||||
const mockUseQuery = vi.spyOn(convexReact, 'useQuery');
|
||||
mockUseQuery.mockReturnValue(mockUser1);
|
||||
|
||||
const { result, rerender } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result.current).toEqual(mockUser1);
|
||||
|
||||
// Simulate user upgrade
|
||||
mockUseQuery.mockReturnValue(mockUser2);
|
||||
rerender();
|
||||
|
||||
expect(result.current).toEqual(mockUser2);
|
||||
});
|
||||
|
||||
it('should handle user with all tier types', () => {
|
||||
const tiers = ['free', 'pro', 'team', 'enterprise'] as const;
|
||||
|
||||
tiers.forEach(tier => {
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
tier,
|
||||
};
|
||||
|
||||
vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result.current?.tier).toBe(tier);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle user without tier (defaults to free)', () => {
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
// tier not set - should default to free in the backend
|
||||
};
|
||||
|
||||
vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result.current).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent renders correctly', () => {
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
tier: 'pro' as const,
|
||||
};
|
||||
|
||||
const mockUseQuery = vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
|
||||
|
||||
const { result: result1 } = renderHook(() => useCurrentUser());
|
||||
const { result: result2 } = renderHook(() => useCurrentUser());
|
||||
|
||||
expect(result1.current).toEqual(mockUser);
|
||||
expect(result2.current).toEqual(mockUser);
|
||||
expect(mockUseQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Re-export all hooks for easy importing
|
||||
export { useSpecs, useSpec } from "./useSpecs";
|
||||
export { useTeams, useTeam, useTeamMembers } from "./useTeams";
|
||||
export { usePersonas, usePersona } from "./usePersonas";
|
||||
export { usePRQueue, usePR } from "./usePRQueue";
|
||||
export { useAgentSession, useSpecSessions } from "./useAgentSession";
|
||||
export { useCloudMode } from "./useCloudMode";
|
||||
export { useCurrentUser } from "./useCurrentUser";
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
|
||||
/**
|
||||
* Hook for managing agent sessions via Convex.
|
||||
* Provides real-time terminal streaming via Convex subscriptions.
|
||||
* Used by @auto-claude/ui Terminal component.
|
||||
*/
|
||||
export function useAgentSession(sessionId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// This automatically subscribes to real-time updates!
|
||||
// const session = useQuery(api.agentSessions.getSession, { sessionId });
|
||||
|
||||
return {
|
||||
session: null,
|
||||
output: "",
|
||||
lastChunk: "",
|
||||
status: "completed" as const,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSpecSessions(specId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const sessions = useQuery(api.agentSessions.getSpecSessions, { specId });
|
||||
// const startSession = useMutation(api.agentSessions.startSession);
|
||||
|
||||
const sessions: never[] = [];
|
||||
|
||||
return {
|
||||
sessions,
|
||||
isLoading: false,
|
||||
startSession: async () => {
|
||||
// TODO: return startSession({ specId });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { CLOUD_MODE, API_URL } from "@/lib/cloud-mode";
|
||||
|
||||
/**
|
||||
* Hook to detect whether the app is running in cloud mode (Convex-powered)
|
||||
* or self-hosted mode (local Python backend).
|
||||
*
|
||||
* Usage:
|
||||
* const { isCloud, apiUrl } = useCloudMode();
|
||||
* if (isCloud) { // use Convex hooks }
|
||||
* else { // use REST fetches against apiUrl }
|
||||
*/
|
||||
export function useCloudMode() {
|
||||
return {
|
||||
isCloud: CLOUD_MODE,
|
||||
apiUrl: API_URL,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
|
||||
export function useCurrentUser() {
|
||||
const user = useQuery(api.users.me);
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
|
||||
type PRStatus = "open" | "reviewing" | "approved" | "merged";
|
||||
|
||||
/**
|
||||
* Hook for managing PR queue via Convex.
|
||||
* Team+ feature - used by @auto-claude/ui components.
|
||||
*/
|
||||
export function usePRQueue(teamId: string, status?: PRStatus) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const prs = useQuery(api.prQueue.getTeamPRs, { teamId, status });
|
||||
// const updateStatus = useMutation(api.prQueue.updatePRStatus);
|
||||
// const assignPR = useMutation(api.prQueue.assignPR);
|
||||
|
||||
const prs: never[] = [];
|
||||
|
||||
return {
|
||||
prs,
|
||||
isLoading: false,
|
||||
updateStatus: async (prId: string, newStatus: PRStatus) => {
|
||||
// TODO: return updateStatus({ prId, status: newStatus });
|
||||
},
|
||||
assignPR: async (prId: string, assigneeId: string | null) => {
|
||||
// TODO: return assignPR({ prId, assigneeId: assigneeId ?? undefined });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function usePR(prId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const pr = useQuery(api.prQueue.getPR, { prId });
|
||||
|
||||
return {
|
||||
pr: null,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
|
||||
/**
|
||||
* Hook for managing personas via Convex.
|
||||
* Pro+ feature - used by @auto-claude/ui components.
|
||||
*/
|
||||
export function usePersonas() {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const personas = useQuery(api.personas.getUserPersonas);
|
||||
// const createPersona = useMutation(api.personas.createPersona);
|
||||
// const updatePersona = useMutation(api.personas.updatePersona);
|
||||
// const deletePersona = useMutation(api.personas.deletePersona);
|
||||
|
||||
const personas: never[] = [];
|
||||
|
||||
return {
|
||||
personas,
|
||||
isLoading: false,
|
||||
createPersona: async (name: string, description: string, traits: string[]) => {
|
||||
// TODO: return createPersona({ name, description, traits });
|
||||
},
|
||||
updatePersona: async (
|
||||
id: string,
|
||||
updates: { name?: string; description?: string; traits?: string[] }
|
||||
) => {
|
||||
// TODO: return updatePersona({ personaId: id, ...updates });
|
||||
},
|
||||
deletePersona: async (id: string) => {
|
||||
// TODO: return deletePersona({ personaId: id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function usePersona(personaId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const persona = useQuery(api.personas.getPersona, { personaId });
|
||||
|
||||
return {
|
||||
persona: null,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
|
||||
/**
|
||||
* Hook for managing specs via Convex.
|
||||
* Used by @auto-claude/ui components.
|
||||
*/
|
||||
export function useSpecs() {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const specs = useQuery(api.specs.getUserSpecs);
|
||||
// const createSpec = useMutation(api.specs.createSpec);
|
||||
// const updateSpec = useMutation(api.specs.updateSpec);
|
||||
// const deleteSpec = useMutation(api.specs.deleteSpec);
|
||||
|
||||
const specs: never[] = [];
|
||||
|
||||
return {
|
||||
specs,
|
||||
isLoading: false,
|
||||
createSpec: async (name: string, content: string) => {
|
||||
// TODO: return createSpec({ name, content });
|
||||
},
|
||||
updateSpec: async (id: string, updates: { name?: string; content?: string }) => {
|
||||
// TODO: return updateSpec({ specId: id, ...updates });
|
||||
},
|
||||
deleteSpec: async (id: string) => {
|
||||
// TODO: return deleteSpec({ specId: id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useSpec(specId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const spec = useQuery(api.specs.getSpec, { specId });
|
||||
|
||||
return {
|
||||
spec: null,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
|
||||
/**
|
||||
* Hook for managing teams via Convex.
|
||||
* Used by @auto-claude/ui components.
|
||||
*/
|
||||
export function useTeams() {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const teams = useQuery(api.teams.getUserTeams);
|
||||
// const createTeam = useMutation(api.teams.createTeam);
|
||||
|
||||
const teams: never[] = [];
|
||||
|
||||
return {
|
||||
teams,
|
||||
isLoading: false,
|
||||
createTeam: async (name: string) => {
|
||||
// TODO: return createTeam({ name });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useTeam(teamId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const team = useQuery(api.teams.getTeam, { teamId });
|
||||
// const members = useQuery(api.teams.getTeamMembers, { teamId });
|
||||
|
||||
return {
|
||||
team: null,
|
||||
members: [],
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTeamMembers(teamId: string) {
|
||||
// TODO: Uncomment when Convex API is generated
|
||||
// const members = useQuery(api.teams.getTeamMembers, { teamId });
|
||||
// const inviteMember = useMutation(api.teams.inviteMember);
|
||||
// const removeMember = useMutation(api.teams.removeMember);
|
||||
|
||||
return {
|
||||
members: [],
|
||||
isLoading: false,
|
||||
inviteMember: async (email: string, role: "admin" | "member") => {
|
||||
// TODO: return inviteMember({ teamId, email, role });
|
||||
},
|
||||
removeMember: async (userId: string) => {
|
||||
// TODO: return removeMember({ teamId, userId });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
|
||||
plugins: [
|
||||
convexClient(),
|
||||
...(typeof window !== "undefined" ? [crossDomainClient()] : []),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
|
||||
|
||||
/**
|
||||
* Server-side auth helpers powered by Convex + Better Auth.
|
||||
*
|
||||
* In cloud mode (NEXT_PUBLIC_CONVEX_URL & CONVEX_SITE_URL are set),
|
||||
* this provides the full auth integration. In self-hosted mode,
|
||||
* the helpers are stubs that return safe defaults so the build
|
||||
* doesn't crash.
|
||||
*/
|
||||
|
||||
const isCloudMode =
|
||||
!!process.env.NEXT_PUBLIC_CONVEX_URL && !!process.env.CONVEX_SITE_URL;
|
||||
|
||||
function getAuthHelpers() {
|
||||
if (!isCloudMode) {
|
||||
return null;
|
||||
}
|
||||
return convexBetterAuthNextJs({
|
||||
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
|
||||
convexSiteUrl: process.env.CONVEX_SITE_URL!,
|
||||
});
|
||||
}
|
||||
|
||||
let _helpers: ReturnType<typeof getAuthHelpers>;
|
||||
function helpers() {
|
||||
if (_helpers === undefined) {
|
||||
_helpers = getAuthHelpers();
|
||||
}
|
||||
return _helpers;
|
||||
}
|
||||
|
||||
// Lazy accessors: safe in both cloud and self-hosted mode.
|
||||
export const handler = {
|
||||
GET: async (req: Request) => {
|
||||
const h = helpers();
|
||||
if (!h) return new Response("Auth not configured", { status: 404 });
|
||||
return h.handler.GET(req);
|
||||
},
|
||||
POST: async (req: Request) => {
|
||||
const h = helpers();
|
||||
if (!h) return new Response("Auth not configured", { status: 404 });
|
||||
return h.handler.POST(req);
|
||||
},
|
||||
};
|
||||
|
||||
export async function getToken(): Promise<string | undefined> {
|
||||
return helpers()?.getToken();
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
return helpers()?.isAuthenticated() ?? false;
|
||||
}
|
||||
|
||||
export async function preloadAuthQuery(...args: any[]) {
|
||||
return helpers()?.preloadAuthQuery(...(args as [any]));
|
||||
}
|
||||
|
||||
export async function fetchAuthQuery(...args: any[]) {
|
||||
return helpers()?.fetchAuthQuery(...(args as [any]));
|
||||
}
|
||||
|
||||
export async function fetchAuthMutation(...args: any[]) {
|
||||
return helpers()?.fetchAuthMutation(...(args as [any]));
|
||||
}
|
||||
|
||||
export async function fetchAuthAction(...args: any[]) {
|
||||
return helpers()?.fetchAuthAction(...(args as [any]));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
describe('cloud-mode', () => {
|
||||
beforeEach(() => {
|
||||
// Reset environment variables before each test
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
// Clear module cache to get fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('CLOUD_MODE', () => {
|
||||
it('should be false when NEXT_PUBLIC_CONVEX_URL is not set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
const { CLOUD_MODE } = await import('./cloud-mode');
|
||||
expect(CLOUD_MODE).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when NEXT_PUBLIC_CONVEX_URL is set', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
|
||||
const { CLOUD_MODE } = await import('./cloud-mode');
|
||||
expect(CLOUD_MODE).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false when NEXT_PUBLIC_CONVEX_URL is empty string', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = '';
|
||||
const { CLOUD_MODE } = await import('./cloud-mode');
|
||||
expect(CLOUD_MODE).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONVEX_URL', () => {
|
||||
it('should return empty string when NEXT_PUBLIC_CONVEX_URL is not set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
const { CONVEX_URL } = await import('./cloud-mode');
|
||||
expect(CONVEX_URL).toBe('');
|
||||
});
|
||||
|
||||
it('should return the URL when NEXT_PUBLIC_CONVEX_URL is set', async () => {
|
||||
const testUrl = 'https://example.convex.cloud';
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = testUrl;
|
||||
const { CONVEX_URL } = await import('./cloud-mode');
|
||||
expect(CONVEX_URL).toBe(testUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API_URL', () => {
|
||||
it('should default to localhost:8000 when NEXT_PUBLIC_API_URL is not set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
const { API_URL } = await import('./cloud-mode');
|
||||
expect(API_URL).toBe('http://localhost:8000');
|
||||
});
|
||||
|
||||
it('should return custom URL when NEXT_PUBLIC_API_URL is set', async () => {
|
||||
const testUrl = 'https://api.example.com';
|
||||
process.env.NEXT_PUBLIC_API_URL = testUrl;
|
||||
const { API_URL } = await import('./cloud-mode');
|
||||
expect(API_URL).toBe(testUrl);
|
||||
});
|
||||
|
||||
it('should handle production API URLs correctly', async () => {
|
||||
const prodUrl = 'https://prod-api.autoclaude.com';
|
||||
process.env.NEXT_PUBLIC_API_URL = prodUrl;
|
||||
const { API_URL } = await import('./cloud-mode');
|
||||
expect(API_URL).toBe(prodUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment combinations', () => {
|
||||
it('should handle cloud mode with custom API URL', async () => {
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
|
||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
|
||||
const { CLOUD_MODE, CONVEX_URL, API_URL } = await import('./cloud-mode');
|
||||
expect(CLOUD_MODE).toBe(true);
|
||||
expect(CONVEX_URL).toBe('https://example.convex.cloud');
|
||||
expect(API_URL).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('should handle self-hosted mode with default API URL', async () => {
|
||||
delete process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
const { CLOUD_MODE, CONVEX_URL, API_URL } = await import('./cloud-mode');
|
||||
expect(CLOUD_MODE).toBe(false);
|
||||
expect(CONVEX_URL).toBe('');
|
||||
expect(API_URL).toBe('http://localhost:8000');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user