mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-08 15:48:19 -05:00
POC: TanStack Query POC implementation (#567)
* POC: TanStack Query implementation with conditional devtools - Replace manual useState polling with TanStack Query for projects/tasks - Add comprehensive query key factories for cache management - Implement optimistic updates with automatic rollback - Create progress polling hooks with smart completion detection - Add VITE_SHOW_DEVTOOLS environment variable for conditional devtools - Remove legacy hooks: useDatabaseMutation, usePolling, useProjectMutation - Update components to use mutation hooks directly (reduce prop drilling) - Enhanced QueryClient with optimized polling and caching settings 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: Remove unused DataTab component and PRP templates from DocsTab - Delete unused DataTab.tsx (956 lines) - no imports found in codebase - Remove PRP template system from DocsTab.tsx (424 lines removed) - Simplify document templates to basic markdown and meeting notes - Reduce DocsTab from 1,494 to 1,070 lines * feat: Add vertical slice architecture foundation for projects feature - Create features/projects/ directory structure - Add barrel exports and documentation for components, hooks, services, types, utils - Prepare for migrating 8,300+ lines of project-related code - Enable future feature flagging and modular architecture * remove: Delete entire PRP directory (4,611 lines) - Remove PRPViewer component and all related files - Delete 29 PRP-related files including sections, renderers, utilities - Clean up unused complex document rendering system - Simplifies codebase by removing over-engineered flip card viewer Files removed: - PRPViewer.tsx/css - Main component - sections/ - 13 specialized section components - components/ - 5 rendering components - utils/ - 6 utility files - renderers/ - Section rendering logic - types/ - PRP type definitions Part of frontend vertical slice refactoring effort. * refactor: Replace DraggableTaskCard with simplified vertical slice components - Remove complex DraggableTaskCard.tsx (268 lines) - Create TaskCard.tsx (87 lines) with glassmorphism styling preserved - Create TaskCardActions.tsx (83 lines) for separated action buttons - Move to features/projects/components/tasks/ vertical slice architecture Changes: - Remove flip animation complexity (100+ lines removed) - Preserve beautiful glassmorphism effects and hover states - Maintain drag-and-drop, selection, priority indicators - Fix card height issues and column stacking - Add visible task descriptions (no tooltip needed) - Update TaskBoardView and TaskTableView imports - Add lint:files npm script for targeted linting Result: 68% code reduction (268→87 lines) while preserving visual design All linting errors resolved, zero warnings on new components. * refactor: Remove PRP templates and PRPViewer from DocsTab - Remove PRP template system from DOCUMENT_TEMPLATES (424 lines) - Remove PRPViewer import and usage in beautiful view mode - Simplify document templates to basic markdown and meeting notes - Replace PRPViewer with temporary unavailable message - Reduce DocsTab from 1,494 to 1,070 lines Templates removed: - Complex PRP templates with structured sections - Over-engineered document generation logic - Unused template complexity Keeps essential functionality: - Basic markdown document template - Meeting notes template - Document creation and management - Template modal and selection Part of frontend cleanup removing unused PRP functionality. * refactor: Migrate to vertical slice architecture with Radix primitives - Migrated TasksTab, BoardView, TableView to features/projects/tasks - Created new UI primitives layer with Radix components - Replaced custom components with Radix primitives - Added MDXEditor to replace Milkdown - Removed Milkdown dependencies - Fixed all TypeScript errors in features directory - Established vertical slice pattern for features * refactor: Complete migration to vertical slice architecture - Migrated DocsTab to features/projects/documents - Replaced Milkdown with MDXEditor for markdown editing - Removed all crawling logic from DocsTab (documents only) - Migrated VersionHistoryModal to use Radix primitives - Removed old components/project-tasks directory - Fixed all TypeScript errors in features directory - Removed Milkdown dependencies from package.json * refactor: Align document system with backend JSONB storage reality - Create proper document hooks using project updates (not individual endpoints) - Refactor DocsTab to use TanStack Query for all data fetching - Remove non-existent document API endpoints from projectService - Implement optimistic updates for document operations - Fix document deletion to work with JSONB array structure Documents are stored as JSONB array in project.docs field, not as separate database records. This refactor aligns the frontend with this backend reality. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: Simplify DocumentEditor and improve Documents sidebar styling - Use MDXEditor with out-of-the-box settings (no hacky overrides) - Update Documents sidebar with Tron-like glassmorphism theme - Fix document content extraction for JSONB structure - Improve empty state and search input styling - Add proper icons and hover effects to match app theme 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Complete migration to vertical slice architecture with TanStack Query + Radix This completes the project refactoring with no backwards compatibility, making the migration fully complete as requested. ## Core Architecture Changes - Migrated all types from centralized src/types/ to feature-based architecture - Completed vertical slice organization with projects/tasks/documents hierarchy - Full TanStack Query integration across all data operations - Radix UI primitives integrated throughout feature components ## Type Safety & Error Handling (Alpha Principles) - Eliminated all unsafe 'any' types with proper TypeScript unions - Added comprehensive error boundaries with detailed error context - Implemented detailed error logging with variable context following alpha principles - Added optimistic updates with proper rollback patterns across all mutations ## Smart Data Management - Created smart polling system that respects page visibility/focus state - Optimized query invalidation strategy to prevent cascade invalidations - Added proper JSONB type unions for database fields (ProjectPRD, ProjectDocs, etc.) - Fixed task ordering with integer precision to avoid float precision issues ## Files Changed - Moved src/types/project.ts → src/features/projects/types/ - Updated all 60+ files with new import paths and type references - Added FeatureErrorBoundary.tsx for granular error handling - Created useSmartPolling.ts hook for intelligent polling behavior - Added comprehensive task ordering utilities with proper limits - Removed deprecated utility files (debounce.ts, taskOrdering.ts) ## Breaking Changes (No Backwards Compatibility) - Removed centralized types directory completely - Changed TaskPriority from "urgent" to "critical" - All components now use feature-scoped types and hooks - Full migration to TanStack Query patterns with no legacy fallbacks Fixes all critical issues from code review and completes the refactoring milestone. * Fix remaining centralized type imports in project components Updated all project feature components to use the new vertical slice type imports from '../types' instead of '../../../types/project'. This completes the final step of the migration with no backwards compatibility remaining: - ProjectsView.tsx - ProjectList.tsx - NewProjectModal.tsx - ProjectCard.tsx - useProjectQueries.ts All project-related code now uses feature-scoped types exclusively. * refactor: Complete vertical slice service architecture migration Breaks down monolithic projectService (558 lines) into focused, feature-scoped services following true vertical slice architecture with no backwards compatibility. ## Service Architecture Changes - projectService.ts → src/features/projects/services/projectService.ts (Project CRUD) - → src/features/projects/tasks/services/taskService.ts (Task management) - → src/features/projects/documents/services/documentService.ts (Document versioning) - → src/features/projects/shared/api.ts (Common utilities & error handling) ## Benefits Achieved - True vertical slice: Each feature owns its complete service stack - Better separation: Task operations isolated from project operations - Easier testing: Individual services can be mocked independently - Team scalability: Features can be developed independently - Code splitting: Better tree-shaking and bundle optimization - Clearer dependencies: Services import only what they need ## Files Changed - Created 4 new focused service files with proper separation of concerns - Updated 5+ hook files to use feature-scoped service imports - Removed monolithic src/services/projectService.ts (17KB) - Updated VersionHistoryModal to use documentService instead of commented TODOs - All service index files properly export their focused services ## Validation - Build passes successfully confirming all imports are correct - All existing functionality preserved with no breaking changes - Error handling patterns maintained across all new services - No remaining references to old monolithic service This completes the final step of vertical slice architecture migration. * feat: Add Biome linter for /features directory - Replace ESLint with Biome for 35x faster linting - Configure Biome for AI-friendly JSON output - Fix all auto-fixable issues (formatting, imports) - Add targeted suppressions for legitimate ARIA roles - Set practical formatting rules (120 char line width) - Add npm scripts for various Biome operations - Document Biome usage for AI assistants * chore: Configure IDE settings for Biome/ESLint separation - Add .zed/settings.json for Zed IDE configuration - Configure ESLint to ignore /src/features (handled by Biome) - Add .zed to .gitignore - Enable Biome LSP for features, ESLint for legacy code - Configure Ruff for Python files * fix: Resolve critical TypeScript errors in features directory - Fix property access errors with proper type narrowing - Move TaskCounts to tasks types (vertical slice architecture) - Add formatZodErrors helper for validation error handling - Fix query return types with explicit typing - Remove unused _githubRepoId variable - Resolve ambiguous exports between modules - Reduced TypeScript errors from 40 to 28 * fix: resolve final TypeScript error in features directory - Update UseTaskEditorReturn interface to properly type projectFeatures - Change from unknown[] to explicit shape with id, label, type, and color properties - All TypeScript errors in /src/features now resolved * docs: improve CLAUDE.md with comprehensive development commands and architecture details - Add detailed frontend and backend development commands - Document vertical slice architecture with folder structure - Include TanStack Query patterns and code examples - Add backend service layer and error handling patterns - Document smart polling hooks and HTTP polling architecture - Include specific commands for TypeScript checking and linting - Add MCP tools documentation and debugging steps * fix: Correct Radix UI Select disabled prop usage and drag-drop bounds - Move disabled prop from Select root to SelectTrigger for proper functionality - Remove redundant manual disabled styling (opacity-50, cursor-not-allowed) - Add aria-disabled for enhanced accessibility compliance - Fix TasksTab bounds check to allow dropping at end of columns - Components: TaskPriority, TaskAssignee, TasksTab 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: Improve API reliability and task management - Fix DELETE operations by handling 204 No Content responses in both callAPI and apiRequest - Replace custom calculateReorderPosition with battle-tested getReorderTaskOrder utility - Fix DeleteConfirmModal default open prop to prevent unexpected modal visibility - Add SSR guards to useSmartPolling hook to prevent crashes in non-browser environments 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: Add review task count support with clean UI design - Add review field to TaskCounts interface for type consistency - Update backend to return separate review counts instead of mapping to doing - Enhance ProjectCard to display review tasks in clean 3-column layout - Combine doing+review counts in project cards for optimal visual design - Maintain granular data for detailed views (Kanban board still shows separate review column) Resolves CodeRabbit suggestion about missing review status while preserving clean UI 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Enhance FeatureErrorBoundary with TanStack Query integration - Add onReset callback prop for external reset handlers - Fix getDerivedStateFromError TypeScript return type - Gate console logging to development/test environments only - Add accessibility attributes (role=alert, aria-live, aria-hidden) - Integrate QueryErrorResetBoundary in ProjectsViewWithBoundary 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve code formatting and consistency - Fix line breaks and formatting in TasksTab.tsx task reordering - Clean up import formatting in ProjectsView.tsx - Standardize quote usage in useSmartPolling.ts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Migrate toast notifications to Radix UI primitives in features directory - Add @radix-ui/react-toast dependency - Create toast.tsx primitive with glassmorphism styling - Implement useToast hook matching legacy API - Add ToastProvider component wrapping Radix primitives - Update all 13 feature files to use new toast system - Maintain dual toast systems (legacy for non-features, new for features) - Fix biome linting issues with auto-formatting This migration establishes Radix UI as the foundation for the features vertical slice architecture while maintaining backward compatibility. Co-Authored-By: Claude <noreply@anthropic.com> * chore: Remove accidentally committed PRP file PRP files are for local development planning only and should not be in version control * refactor: simplify documents feature to read-only viewer - Remove MDXEditor and all editing capabilities due to persistent state issues - Add DocumentViewer component for reliable read-only display - Add migration warning banner clarifying project documents will be lost - Remove all mutation hooks, services, and components - Clean up unused types and dead code - Fix linting issues (SVG accessibility, array keys) - Simplify to display existing JSONB documents from project.docs field This temporary read-only state allows users to view existing documents while the feature undergoes migration to a more robust storage solution. * fix: eliminate duplicate toast notifications and React key warnings - Remove duplicate toast calls from component callbacks (TasksTab, useTaskActions, etc) - Keep toast notifications only in mutation definitions for single source of truth - Add success toast for task status changes in useTaskQueries - Improve toast ID generation with timestamp + random string to prevent duplicates - Remove unused useToast imports from components This fixes the 'Encountered two children with the same key' warning by ensuring only one toast is created per action instead of multiple simultaneous toasts. * feat: add optimistic updates for task and project creation - Implement optimistic updates for useCreateTask mutation - Tasks now appear instantly with temporary ID - Replaced with real task from server on success - Rollback on error with proper error handling - Implement optimistic updates for useCreateProject mutation - Projects appear immediately in the list - Temporary ID replaced with real one on success - Proper rollback on failure - Both mutations follow existing patterns from update/delete operations - Provides instant visual feedback improving perceived performance - Eliminates 2-3 second delay before items appear in UI * style: apply Biome formatting and remove unused dependencies - Format code with Biome standards - Remove unused showToast from useCallback dependencies in TasksTab - Minor formatting adjustments for better readability * fix: remove unused showToast import from TasksTab - Remove unused useToast hook import and usage - Fixes Biome noUnusedVariables error * fix: sort projects by creation date instead of alphabetically - Change project list sorting to: pinned first, then newest first - Ensures new projects appear on the left (after pinned) as expected - Maintains chronological order instead of alphabetical - Better UX for seeing recently created projects * optimize: adjust polling intervals for better performance - Projects: 20s polling (was 10s), 15s stale time (was 3s) - Tasks: 5s polling (was 8s) for faster MCP updates, 10s stale time (was 2s) - Background: 60s for all (was 24-30s) when tab not focused - Hidden tabs: Polling disabled (unchanged) Benefits: - Tasks update faster (5s) to reflect MCP server changes quickly - Projects poll less frequently (20s) as they change less often - Longer stale times reduce unnecessary refetches during navigation - Background polling reduced to save resources when not actively using app * feat: Add ETag support to reduce bandwidth by 70-90% - Created ETag-aware API client (apiWithEtag.ts) with caching - Integrated with TanStack Query for seamless cache management - Updated all services to use ETag-aware API calls - Added cache invalidation after mutations - Handles 304 Not Modified responses efficiently - Includes colored console logging for debugging - Works with 5-second task polling and 20-second project polling * fix: TanStack Query improvements from CodeRabbit review - Fixed concurrent project creation bug by tracking specific temp IDs - Unified task counts query keys to fix cache invalidation - Added TypeScript generics to getQueryData calls for type safety - Added return type to useTaskCounts hook - Prevented double refetch with refetchOnWindowFocus: false - Improved cache cleanup with exact: false on removeQueries 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: improve ProjectList animations, sorting, and accessibility - Added initial/animate props to fix Framer Motion animations - Made sort deterministic with invalid date guards and ID tie-breaker - Added ARIA roles for better screen reader support: - role=status for loading state - role=alert for error state - role=list for project container - role=listitem for ProjectCard - Improved robustness against malformed date strings 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: use consistent ORDER_INCREMENT value for task ordering - Fixed bug where TasksTab used 100 while utils used 1000 for increments - Exported ORDER_INCREMENT constant from task-ordering utils - Updated TasksTab to import and use the shared constant - Ensures consistent task ordering behavior across the application 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: improve type safety and correctness in task mutations - Changed error handling to throw Error objects instead of strings - Added TypeScript generics to delete mutation for better type safety - Fixed incorrect Task shape by removing non-existent fields (deleted_at, subtasks) - Track specific tempId for optimistic updates to avoid replacing wrong tasks 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Delete report.md * fix: address CodeRabbit review feedback for TanStack Query implementation - Enable refetchOnWindowFocus for immediate data refresh when returning to tab - Add proper TypeScript generics to useUpdateTask mutation for server response merge - Normalize HTTP methods to uppercase in ETag cache to prevent cache key mismatches - Add ETAG_DEBUG flag to control console logging (only in dev mode) - Fix 304 cache miss handling with proper error and ETag cleanup - Update outdated comments and add explicit type annotations - Rename getETagCacheStats property from 'endpoints' to 'keys' for accuracy --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "../../ui/primitives/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../ui/primitives/dialog";
|
||||
import { Input } from "../../ui/primitives/input";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { useCreateProject } from "../hooks/useProjectQueries";
|
||||
import type { CreateProjectRequest } from "../types";
|
||||
|
||||
interface NewProjectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const NewProjectModal: React.FC<NewProjectModalProps> = ({ open, onOpenChange, onSuccess }) => {
|
||||
const projectNameId = useId();
|
||||
const projectDescriptionId = useId();
|
||||
|
||||
const [formData, setFormData] = useState<CreateProjectRequest>({
|
||||
title: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const createProjectMutation = useCreateProject();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.title.trim()) return;
|
||||
|
||||
createProjectMutation.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
setFormData({ title: "", description: "" });
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!createProjectMutation.isPending) {
|
||||
setFormData({ title: "", description: "" });
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold bg-gradient-to-r from-purple-400 to-fuchsia-500 text-transparent bg-clip-text">
|
||||
Create New Project
|
||||
</DialogTitle>
|
||||
<DialogDescription>Start a new project to organize your tasks and documents.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 my-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={projectNameId}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<Input
|
||||
id={projectNameId}
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
disabled={createProjectMutation.isPending}
|
||||
className={cn("w-full", "focus:border-purple-400 focus:shadow-[0_0_10px_rgba(168,85,247,0.2)]")}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={projectDescriptionId}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={projectDescriptionId}
|
||||
placeholder="Enter project description..."
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={createProjectMutation.isPending}
|
||||
className={cn(
|
||||
"w-full resize-none",
|
||||
"bg-white/50 dark:bg-black/70",
|
||||
"border border-gray-300 dark:border-gray-700",
|
||||
"text-gray-900 dark:text-white",
|
||||
"rounded-md py-2 px-3",
|
||||
"focus:outline-none focus:border-purple-400",
|
||||
"focus:shadow-[0_0_10px_rgba(168,85,247,0.2)]",
|
||||
"transition-all duration-300",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={createProjectMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={createProjectMutation.isPending || !formData.title.trim()}
|
||||
className="shadow-lg shadow-purple-500/20"
|
||||
>
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
258
archon-ui-main/src/features/projects/components/ProjectCard.tsx
Normal file
258
archon-ui-main/src/features/projects/components/ProjectCard.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Activity, CheckCircle2, ListTodo } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import type { Project } from "../types";
|
||||
import { ProjectCardActions } from "./ProjectCardActions";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: Project;
|
||||
isSelected: boolean;
|
||||
taskCounts: {
|
||||
todo: number;
|
||||
doing: number;
|
||||
review: number;
|
||||
done: number;
|
||||
};
|
||||
onSelect: (project: Project) => void;
|
||||
onPin: (e: React.MouseEvent, projectId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, projectId: string, title: string) => void;
|
||||
}
|
||||
|
||||
export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
project,
|
||||
isSelected,
|
||||
taskCounts,
|
||||
onSelect,
|
||||
onPin,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
role="listitem"
|
||||
onClick={() => onSelect(project)}
|
||||
className={cn(
|
||||
"relative rounded-xl backdrop-blur-md w-72 min-h-[180px] cursor-pointer overflow-visible group flex flex-col",
|
||||
"transition-all duration-300",
|
||||
project.pinned
|
||||
? "bg-gradient-to-b from-purple-100/80 via-purple-50/30 to-purple-100/50 dark:from-purple-900/30 dark:via-purple-900/20 dark:to-purple-900/10"
|
||||
: isSelected
|
||||
? "bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20"
|
||||
: "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||
"border",
|
||||
project.pinned
|
||||
? "border-purple-500/80 dark:border-purple-500/80 shadow-[0_0_15px_rgba(168,85,247,0.3)]"
|
||||
: isSelected
|
||||
? "border-purple-400/60 dark:border-purple-500/60"
|
||||
: "border-gray-200 dark:border-zinc-800/50",
|
||||
isSelected
|
||||
? "shadow-[0_0_15px_rgba(168,85,247,0.4),0_0_10px_rgba(147,51,234,0.3)] dark:shadow-[0_0_20px_rgba(168,85,247,0.5),0_0_15px_rgba(147,51,234,0.4)]"
|
||||
: "shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
"hover:shadow-[0_15px_40px_-15px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_15px_40px_-15px_rgba(0,0,0,0.9)]",
|
||||
isSelected ? "scale-[1.02]" : "hover:scale-[1.01]", // Use scale instead of translate to avoid clipping
|
||||
)}
|
||||
>
|
||||
{/* Subtle aurora glow effect for selected card */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-30 dark:opacity-40 pointer-events-none">
|
||||
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(168,85,247,0.8)_0%,rgba(147,51,234,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area with padding */}
|
||||
<div className="flex-1 p-4 pb-2">
|
||||
{/* Title section */}
|
||||
<div className="flex items-center justify-center mb-4 min-h-[48px]">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-medium text-center leading-tight line-clamp-2 transition-all duration-300",
|
||||
isSelected
|
||||
? "text-gray-900 dark:text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]"
|
||||
: project.pinned
|
||||
? "text-purple-700 dark:text-purple-300"
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
>
|
||||
{project.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Task count pills */}
|
||||
<div className="flex items-stretch gap-2 w-full">
|
||||
{/* Todo pill */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-pink-600 rounded-full blur-md",
|
||||
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-white/70 dark:bg-zinc-900/90 border-pink-300 dark:border-pink-500/50 dark:shadow-[0_0_10px_rgba(236,72,153,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(236,72,153,0.7)]"
|
||||
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||
<ListTodo
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
ToDo
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center border-l",
|
||||
isSelected ? "border-pink-300 dark:border-pink-500/30" : "border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
{taskCounts.todo || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Doing pill (includes review) */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-blue-600 rounded-full blur-md",
|
||||
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-white/70 dark:bg-zinc-900/90 border-blue-300 dark:border-blue-500/50 dark:shadow-[0_0_10px_rgba(59,130,246,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(59,130,246,0.7)]"
|
||||
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||
<Activity
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
Doing
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center border-l",
|
||||
isSelected ? "border-blue-300 dark:border-blue-500/30" : "border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
{(taskCounts.doing || 0) + (taskCounts.review || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Done pill */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-green-600 rounded-full blur-md",
|
||||
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-white/70 dark:bg-zinc-900/90 border-green-300 dark:border-green-500/50 dark:shadow-[0_0_10px_rgba(34,197,94,0.5)] hover:shadow-md dark:hover:shadow-[0_0_15px_rgba(34,197,94,0.7)]"
|
||||
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
Done
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center border-l",
|
||||
isSelected
|
||||
? "border-green-300 dark:border-green-500/30"
|
||||
: "border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
{taskCounts.done || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar with pinned indicator and actions - separate section */}
|
||||
<div className="flex items-center justify-between px-3 py-2 mt-auto border-t border-gray-200/30 dark:border-gray-700/20">
|
||||
{/* Pinned indicator badge */}
|
||||
{project.pinned ? (
|
||||
<div className="px-2 py-0.5 bg-purple-500 text-white text-[10px] font-bold rounded-full shadow-lg shadow-purple-500/30">
|
||||
DEFAULT
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons - fixed to bottom right */}
|
||||
<ProjectCardActions
|
||||
projectId={project.id}
|
||||
projectTitle={project.title}
|
||||
isPinned={project.pinned}
|
||||
onPin={(e) => onPin(e, project.id)}
|
||||
onDelete={(e) => onDelete(e, project.id, project.title)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Clipboard, Pin, Trash2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useToast } from "../../ui/hooks/useToast";
|
||||
import { cn, glassmorphism } from "../../ui/primitives/styles";
|
||||
import { SimpleTooltip } from "../../ui/primitives/tooltip";
|
||||
|
||||
interface ProjectCardActionsProps {
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
isPinned: boolean;
|
||||
onPin: (e: React.MouseEvent) => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectCardActions: React.FC<ProjectCardActionsProps> = ({
|
||||
projectId,
|
||||
projectTitle,
|
||||
isPinned,
|
||||
onPin,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleCopyId = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(projectId);
|
||||
showToast("Project ID copied to clipboard", "success");
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = projectId;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
showToast("Project ID copied to clipboard", "success");
|
||||
} catch {
|
||||
showToast("Failed to copy Project ID", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Delete Button */}
|
||||
<SimpleTooltip content={isDeleting ? "Deleting..." : "Delete project"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDeleting) onDelete(e);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-300",
|
||||
glassmorphism.priority.critical.background,
|
||||
glassmorphism.priority.critical.text,
|
||||
glassmorphism.priority.critical.hover,
|
||||
glassmorphism.priority.critical.glow,
|
||||
isDeleting && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
aria-label={isDeleting ? "Deleting project..." : `Delete ${projectTitle}`}
|
||||
>
|
||||
<Trash2 className={cn("w-3 h-3", isDeleting && "animate-pulse")} />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
|
||||
{/* Pin Button */}
|
||||
<SimpleTooltip content={isPinned ? "Unpin project" : "Pin as default"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPin(e);
|
||||
}}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-300",
|
||||
isPinned
|
||||
? "bg-purple-100/80 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 hover:bg-purple-200 dark:hover:bg-purple-500/30 hover:shadow-[0_0_10px_rgba(168,85,247,0.3)]"
|
||||
: glassmorphism.priority.medium.background +
|
||||
" " +
|
||||
glassmorphism.priority.medium.text +
|
||||
" " +
|
||||
glassmorphism.priority.medium.hover +
|
||||
" " +
|
||||
glassmorphism.priority.medium.glow,
|
||||
)}
|
||||
aria-label={isPinned ? "Unpin project" : "Pin as default"}
|
||||
>
|
||||
<Pin className={cn("w-3 h-3", isPinned && "fill-current")} />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
|
||||
{/* Copy Project ID Button */}
|
||||
<SimpleTooltip content="Copy Project ID">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyId}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-300",
|
||||
glassmorphism.priority.low.background,
|
||||
glassmorphism.priority.low.text,
|
||||
glassmorphism.priority.low.hover,
|
||||
glassmorphism.priority.low.glow,
|
||||
)}
|
||||
aria-label="Copy Project ID"
|
||||
>
|
||||
<Clipboard className="w-3 h-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Button } from "../../ui/primitives/button";
|
||||
|
||||
interface ProjectHeaderProps {
|
||||
onNewProject: () => void;
|
||||
}
|
||||
|
||||
const titleVariants = {
|
||||
hidden: { opacity: 0, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.5, ease: [0.23, 1, 0.32, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.23, 1, 0.32, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectHeader: React.FC<ProjectHeaderProps> = ({ onNewProject }) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center justify-between mb-8"
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.h1
|
||||
className="text-3xl font-bold text-gray-800 dark:text-white flex items-center gap-3"
|
||||
variants={titleVariants}
|
||||
>
|
||||
<img
|
||||
src="/logo-neon.png"
|
||||
alt="Projects"
|
||||
className="w-7 h-7 filter drop-shadow-[0_0_8px_rgba(59,130,246,0.8)]"
|
||||
/>
|
||||
Projects
|
||||
</motion.h1>
|
||||
<Button onClick={onNewProject} variant="cyan" className="shadow-lg shadow-cyan-500/20">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
118
archon-ui-main/src/features/projects/components/ProjectList.tsx
Normal file
118
archon-ui-main/src/features/projects/components/ProjectList.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Button } from "../../ui/primitives";
|
||||
import type { Project } from "../types";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
|
||||
interface ProjectListProps {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
taskCounts: Record<string, { todo: number; doing: number; review: number; done: number }>;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onPinProject: (e: React.MouseEvent, projectId: string) => void;
|
||||
onDeleteProject: (e: React.MouseEvent, projectId: string, title: string) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.23, 1, 0.32, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({
|
||||
projects,
|
||||
selectedProject,
|
||||
taskCounts,
|
||||
isLoading,
|
||||
error,
|
||||
onProjectSelect,
|
||||
onPinProject,
|
||||
onDeleteProject,
|
||||
onRetry,
|
||||
}) => {
|
||||
// Sort projects - pinned first, then by creation date (newest first)
|
||||
const sortedProjects = React.useMemo(() => {
|
||||
return [...projects].sort((a, b) => {
|
||||
// Pinned projects always come first
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
|
||||
// Then sort by creation date (newest first)
|
||||
// This ensures new projects appear on the left after pinned ones
|
||||
const timeA = Number.isFinite(Date.parse(a.created_at)) ? Date.parse(a.created_at) : 0;
|
||||
const timeB = Number.isFinite(Date.parse(b.created_at)) ? Date.parse(b.created_at) : 0;
|
||||
const byDate = timeB - timeA; // Newer first
|
||||
return byDate !== 0 ? byDate : a.id.localeCompare(b.id); // Tie-break with ID for deterministic sort
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<motion.div initial="hidden" animate="visible" variants={itemVariants} className="mb-10">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center" role="status" aria-live="polite" aria-busy="true">
|
||||
<Loader2 className="w-8 h-8 text-purple-500 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading your projects...</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<motion.div initial="hidden" animate="visible" variants={itemVariants} className="mb-10">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center" role="alert" aria-live="assertive">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600 dark:text-red-400 mb-4">{error.message || "Failed to load projects"}</p>
|
||||
<Button onClick={onRetry} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedProjects.length === 0) {
|
||||
return (
|
||||
<motion.div initial="hidden" animate="visible" variants={itemVariants} className="mb-10">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
No projects yet. Create your first project to get started!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial="hidden" animate="visible" className="relative mb-10" variants={itemVariants}>
|
||||
<div className="overflow-x-auto overflow-y-visible pb-4 pt-2 scrollbar-thin">
|
||||
<div className="flex gap-4 min-w-max" role="list" aria-label="Projects">
|
||||
{sortedProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
isSelected={selectedProject?.id === project.id}
|
||||
taskCounts={taskCounts[project.id] || { todo: 0, doing: 0, review: 0, done: 0 }}
|
||||
onSelect={onProjectSelect}
|
||||
onPin={onPinProject}
|
||||
onDelete={onDeleteProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
20
archon-ui-main/src/features/projects/components/index.ts
Normal file
20
archon-ui-main/src/features/projects/components/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Project Components
|
||||
*
|
||||
* All React components for the projects feature.
|
||||
* Organized by sub-feature:
|
||||
*
|
||||
* - ProjectDashboard: Main project view orchestrator
|
||||
* - ProjectManagement: Project CRUD, selection, metadata
|
||||
* - TaskManagement: Task CRUD, status management
|
||||
* - TaskBoard: Kanban board with drag-drop
|
||||
* - TaskTable: Table view with filters/sorting
|
||||
* - DocumentManagement: Project documents and editing
|
||||
* - VersionHistory: Document versioning
|
||||
*/
|
||||
|
||||
export { NewProjectModal } from "./NewProjectModal";
|
||||
export { ProjectCard } from "./ProjectCard";
|
||||
export { ProjectCardActions } from "./ProjectCardActions";
|
||||
export { ProjectHeader } from "./ProjectHeader";
|
||||
export { ProjectList } from "./ProjectList";
|
||||
169
archon-ui-main/src/features/projects/documents/DocsTab.tsx
Normal file
169
archon-ui-main/src/features/projects/documents/DocsTab.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "../../ui/primitives";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { DocumentCard } from "./components/DocumentCard";
|
||||
import { DocumentViewer } from "./components/DocumentViewer";
|
||||
import { useProjectDocuments } from "./hooks";
|
||||
import type { ProjectDocument } from "./types";
|
||||
|
||||
interface DocsTabProps {
|
||||
project?: {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only documents tab
|
||||
* Displays existing documents from the project's JSONB field
|
||||
*/
|
||||
export const DocsTab = ({ project }: DocsTabProps) => {
|
||||
const projectId = project?.id || "";
|
||||
|
||||
// Fetch documents from project's docs field
|
||||
const { data: documents = [], isLoading } = useProjectDocuments(projectId);
|
||||
|
||||
// Document state
|
||||
const [selectedDocument, setSelectedDocument] = useState<ProjectDocument | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Auto-select first document when documents load
|
||||
useEffect(() => {
|
||||
if (documents.length > 0 && !selectedDocument) {
|
||||
setSelectedDocument(documents[0]);
|
||||
}
|
||||
}, [documents, selectedDocument]);
|
||||
|
||||
// Update selected document if it was updated
|
||||
useEffect(() => {
|
||||
if (selectedDocument && documents.length > 0) {
|
||||
const updated = documents.find((d) => d.id === selectedDocument.id);
|
||||
if (updated && updated !== selectedDocument) {
|
||||
setSelectedDocument(updated);
|
||||
}
|
||||
}
|
||||
}, [documents, selectedDocument]);
|
||||
|
||||
// Filter documents based on search
|
||||
const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-200px)]">
|
||||
{/* Migration Warning Banner */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-yellow-600 dark:text-yellow-400">
|
||||
<svg className="w-5 h-5 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-label="Warning">
|
||||
<title>Warning icon</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-yellow-800 dark:text-yellow-300">
|
||||
Project Documents Under Migration
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
|
||||
Editing and uploading project documents is currently disabled while we migrate to a new storage system.
|
||||
<strong className="font-semibold">
|
||||
{" "}
|
||||
Please backup your existing project documents elsewhere as they will be lost when the migration is
|
||||
complete.
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-1">
|
||||
Note: This only affects project-specific documents. Your knowledge base documents are safe and unaffected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1">
|
||||
{/* Left Sidebar - Document List */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-80 flex flex-col",
|
||||
"border-r border-gray-200 dark:border-gray-700",
|
||||
"bg-gray-50 dark:bg-gray-900",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 text-gray-800 dark:text-white">
|
||||
<FileText className="w-5 h-5" />
|
||||
Documents (Read-Only)
|
||||
</h2>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info message */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
Viewing {documents.length} document{documents.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">{searchQuery ? "No documents found" : "No documents in this project"}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocuments.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isActive={selectedDocument?.id === doc.id}
|
||||
onSelect={setSelectedDocument}
|
||||
onDelete={() => {}} // No delete in read-only mode
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Document Viewer */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-900">
|
||||
{selectedDocument ? (
|
||||
<DocumentViewer document={selectedDocument} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="w-16 h-16 text-gray-300 dark:text-gray-700 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{documents.length > 0 ? "Select a document to view" : "No documents available"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Clipboard,
|
||||
Code,
|
||||
Database,
|
||||
FileCode,
|
||||
FileText,
|
||||
Info,
|
||||
Rocket,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { Button } from "../../../ui/primitives";
|
||||
import type { DocumentCardProps, DocumentType } from "../types";
|
||||
|
||||
const getDocumentIcon = (type?: DocumentType) => {
|
||||
switch (type) {
|
||||
case "prp":
|
||||
return <Rocket className="w-4 h-4" />;
|
||||
case "technical":
|
||||
return <Code className="w-4 h-4" />;
|
||||
case "business":
|
||||
return <Briefcase className="w-4 h-4" />;
|
||||
case "meeting_notes":
|
||||
return <Users className="w-4 h-4" />;
|
||||
case "spec":
|
||||
return <FileText className="w-4 h-4" />;
|
||||
case "design":
|
||||
return <Database className="w-4 h-4" />;
|
||||
case "api":
|
||||
return <FileCode className="w-4 h-4" />;
|
||||
case "guide":
|
||||
return <BookOpen className="w-4 h-4" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type?: DocumentType) => {
|
||||
switch (type) {
|
||||
case "prp":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30";
|
||||
case "technical":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30";
|
||||
case "business":
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30";
|
||||
case "meeting_notes":
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30";
|
||||
case "spec":
|
||||
return "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/30";
|
||||
case "design":
|
||||
return "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30";
|
||||
case "api":
|
||||
return "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-indigo-500/30";
|
||||
case "guide":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
default:
|
||||
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: DocumentCardProps) => {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopyId = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(document.id);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
[document.id],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(document);
|
||||
},
|
||||
[document, onDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Complex card with nested interactive elements - semantic button would break layout
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(document);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
relative flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer
|
||||
transition-all duration-200 group
|
||||
${
|
||||
isActive
|
||||
? "bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 shadow-lg scale-105"
|
||||
: "bg-white/50 dark:bg-black/30 border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
onClick={() => onSelect(document)}
|
||||
onMouseEnter={() => setShowDelete(true)}
|
||||
onMouseLeave={() => setShowDelete(false)}
|
||||
>
|
||||
{/* Document Type Badge */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mb-2 border ${getTypeColor(
|
||||
document.document_type as DocumentType,
|
||||
)}`}
|
||||
>
|
||||
{getDocumentIcon(document.document_type as DocumentType)}
|
||||
<span>{document.document_type || "document"}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm line-clamp-2 mb-1">{document.title}</h4>
|
||||
|
||||
{/* Metadata */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
{new Date(document.updated_at || document.created_at || Date.now()).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
{/* ID Display Section - Always visible for active, hover for others */}
|
||||
<div
|
||||
className={`flex items-center justify-between mt-2 ${
|
||||
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
} transition-opacity duration-200`}
|
||||
>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 truncate max-w-[120px]" title={document.id}>
|
||||
{document.id.slice(0, 8)}...
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyId}
|
||||
className="p-1 h-auto min-h-0"
|
||||
title="Copy Document ID to clipboard"
|
||||
aria-label="Copy Document ID to clipboard"
|
||||
>
|
||||
{isCopied ? (
|
||||
<span className="text-green-500 text-xs">✓</span>
|
||||
) : (
|
||||
<Clipboard className="w-3 h-3" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
{showDelete && !isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="absolute top-2 right-2 p-1 h-auto min-h-0 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
aria-label={`Delete ${document.title}`}
|
||||
title="Delete document"
|
||||
>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DocumentCard.displayName = "DocumentCard";
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FileText } from "lucide-react";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import type { ProjectDocument } from "../types";
|
||||
|
||||
interface DocumentViewerProps {
|
||||
document: ProjectDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple read-only document viewer
|
||||
* Displays document content in a reliable way without complex editing
|
||||
*/
|
||||
export const DocumentViewer = ({ document }: DocumentViewerProps) => {
|
||||
// Extract content for display
|
||||
const renderContent = () => {
|
||||
if (!document.content) {
|
||||
return <p className="text-gray-500 italic">No content available</p>;
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof document.content === "string") {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">{document.content}</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle markdown field
|
||||
if ("markdown" in document.content && typeof document.content.markdown === "string") {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">
|
||||
{document.content.markdown}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle text field
|
||||
if ("text" in document.content && typeof document.content.text === "string") {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">
|
||||
{document.content.text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle structured content (JSON)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(document.content).map(([key, value]) => (
|
||||
<div key={key} className="border-l-2 border-gray-300 dark:border-gray-700 pl-4">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{key.replace(/_/g, " ").charAt(0).toUpperCase() + key.replace(/_/g, " ").slice(1)}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{typeof value === "string" ? (
|
||||
<p>{value}</p>
|
||||
) : Array.isArray(value) ? (
|
||||
<ul className="list-disc pl-5">
|
||||
{value.map((item, i) => (
|
||||
<li key={`${key}-item-${i}`}>
|
||||
{typeof item === "object" ? JSON.stringify(item, null, 2) : String(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-gray-500" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">{document.title}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Type: {document.document_type || "document"} • Last updated:{" "}
|
||||
{new Date(document.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{document.tags && document.tags.length > 0 && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{document.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded",
|
||||
"bg-gray-100 dark:bg-gray-800",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
"border border-gray-300 dark:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6 bg-white dark:bg-gray-900">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Document Management Components
|
||||
*
|
||||
* Components for document display and management following vertical slice architecture.
|
||||
* Uses Radix UI primitives for better accessibility and consistency.
|
||||
*/
|
||||
|
||||
export { DocumentCard } from "./DocumentCard";
|
||||
export { DocumentViewer } from "./DocumentViewer";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Document Hooks
|
||||
*
|
||||
* Read-only hooks for document display
|
||||
*/
|
||||
|
||||
export { useProjectDocuments } from "./useDocumentQueries";
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectService } from "../../services";
|
||||
import type { ProjectDocument } from "../types";
|
||||
|
||||
// Query keys
|
||||
const documentKeys = {
|
||||
all: (projectId: string) => ["projects", projectId, "documents"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get documents from project's docs JSONB field
|
||||
* Read-only - no mutations
|
||||
*/
|
||||
export function useProjectDocuments(projectId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: projectId ? documentKeys.all(projectId) : ["documents-undefined"],
|
||||
queryFn: async () => {
|
||||
if (!projectId) return [];
|
||||
const project = await projectService.getProject(projectId);
|
||||
return (project.docs || []) as ProjectDocument[];
|
||||
},
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
7
archon-ui-main/src/features/projects/documents/index.ts
Normal file
7
archon-ui-main/src/features/projects/documents/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Documents Feature Module
|
||||
*
|
||||
* Sub-feature of projects for managing project documentation
|
||||
*/
|
||||
|
||||
export { DocsTab } from "./DocsTab";
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Document Type Definitions
|
||||
*
|
||||
* Core types for document management within projects.
|
||||
*/
|
||||
|
||||
// Document content can be structured in various ways
|
||||
export type DocumentContent =
|
||||
| string // Plain text or markdown
|
||||
| { markdown: string } // Markdown content
|
||||
| { text: string } // Text content
|
||||
| {
|
||||
markdown?: string;
|
||||
text?: string;
|
||||
[key: string]: unknown; // Allow other fields but with known type
|
||||
} // Mixed content
|
||||
| Record<string, unknown>; // Generic object content
|
||||
|
||||
export interface ProjectDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: DocumentContent;
|
||||
document_type?: DocumentType | string;
|
||||
tags?: string[];
|
||||
updated_at: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export type DocumentType =
|
||||
| "prp"
|
||||
| "technical"
|
||||
| "business"
|
||||
| "meeting_notes"
|
||||
| "spec"
|
||||
| "design"
|
||||
| "note"
|
||||
| "api"
|
||||
| "guide";
|
||||
|
||||
export interface DocumentCardProps {
|
||||
document: ProjectDocument;
|
||||
isActive: boolean;
|
||||
onSelect: (doc: ProjectDocument) => void;
|
||||
onDelete: (doc: ProjectDocument) => void;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Document Types
|
||||
*
|
||||
* All document-related types for the projects feature.
|
||||
*/
|
||||
|
||||
// Document types
|
||||
export type { DocumentCardProps, DocumentType, ProjectDocument } from "./document";
|
||||
20
archon-ui-main/src/features/projects/hooks/index.ts
Normal file
20
archon-ui-main/src/features/projects/hooks/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Project Hooks
|
||||
*
|
||||
* All React hooks for the projects feature.
|
||||
* Includes:
|
||||
* - Data fetching hooks (useProjects, useTasks, useDocuments)
|
||||
* - Mutation hooks (useCreateProject, useUpdateTask, etc.)
|
||||
* - UI state hooks (useProjectSelection, useTaskFilters)
|
||||
* - Business logic hooks (useTaskDragDrop, useDocumentEditor)
|
||||
*/
|
||||
|
||||
export {
|
||||
projectKeys,
|
||||
useCreateProject,
|
||||
useDeleteProject,
|
||||
useProjectFeatures,
|
||||
useProjects,
|
||||
useTaskCounts,
|
||||
useUpdateProject,
|
||||
} from "./useProjectQueries";
|
||||
222
archon-ui-main/src/features/projects/hooks/useProjectQueries.ts
Normal file
222
archon-ui-main/src/features/projects/hooks/useProjectQueries.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSmartPolling } from "../../ui/hooks";
|
||||
import { useToast } from "../../ui/hooks/useToast";
|
||||
import { projectService, taskService } from "../services";
|
||||
import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../types";
|
||||
|
||||
// Query keys factory for better organization
|
||||
export const projectKeys = {
|
||||
all: ["projects"] as const,
|
||||
lists: () => [...projectKeys.all, "list"] as const,
|
||||
list: (filters?: unknown) => [...projectKeys.lists(), filters] as const,
|
||||
details: () => [...projectKeys.all, "detail"] as const,
|
||||
detail: (id: string) => [...projectKeys.details(), id] as const,
|
||||
tasks: (projectId: string) => [...projectKeys.detail(projectId), "tasks"] as const,
|
||||
taskCounts: () => ["taskCounts"] as const,
|
||||
features: (projectId: string) => [...projectKeys.detail(projectId), "features"] as const,
|
||||
documents: (projectId: string) => [...projectKeys.detail(projectId), "documents"] as const,
|
||||
};
|
||||
|
||||
// Fetch all projects with smart polling
|
||||
export function useProjects() {
|
||||
const { refetchInterval } = useSmartPolling(20000); // 20 second base interval for projects
|
||||
|
||||
return useQuery<Project[]>({
|
||||
queryKey: projectKeys.lists(),
|
||||
queryFn: () => projectService.listProjects(),
|
||||
refetchInterval, // Smart interval based on page visibility/focus
|
||||
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
|
||||
staleTime: 15000, // Consider data stale after 15 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch task counts for all projects
|
||||
export function useTaskCounts() {
|
||||
return useQuery<Awaited<ReturnType<typeof taskService.getTaskCountsForAllProjects>>>({
|
||||
queryKey: projectKeys.taskCounts(),
|
||||
queryFn: () => taskService.getTaskCountsForAllProjects(),
|
||||
refetchInterval: false, // Don't poll, only refetch manually
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch project features
|
||||
export function useProjectFeatures(projectId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: projectId ? projectKeys.features(projectId) : ["features-undefined"],
|
||||
queryFn: () => (projectId ? projectService.getProjectFeatures(projectId) : Promise.reject("No project ID")),
|
||||
enabled: !!projectId,
|
||||
staleTime: 30000, // Cache for 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Create project mutation with optimistic updates
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (projectData: CreateProjectRequest) => projectService.createProject(projectData),
|
||||
onMutate: async (newProjectData) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
|
||||
|
||||
// Create optimistic project with temporary ID
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const optimisticProject: Project = {
|
||||
id: tempId, // Temporary ID until real one comes back
|
||||
title: newProjectData.title,
|
||||
description: newProjectData.description,
|
||||
github_repo: newProjectData.github_repo,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
prd: undefined,
|
||||
features: [],
|
||||
data: undefined,
|
||||
docs: [],
|
||||
pinned: false,
|
||||
};
|
||||
|
||||
// Optimistically add the new project
|
||||
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
|
||||
if (!old) return [optimisticProject];
|
||||
// Add new project at the beginning of the list
|
||||
return [optimisticProject, ...old];
|
||||
});
|
||||
|
||||
return { previousProjects, tempId };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to create project:", error, { variables });
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousProjects) {
|
||||
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
|
||||
}
|
||||
|
||||
showToast(`Failed to create project: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (response, _variables, context) => {
|
||||
// Extract the actual project from the response
|
||||
const newProject = response.project;
|
||||
|
||||
// Replace optimistic project with real one from server
|
||||
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
|
||||
if (!old) return [newProject];
|
||||
// Replace only the specific temp project with real one
|
||||
return old
|
||||
.map((project) => (project.id === context?.tempId ? newProject : project))
|
||||
.filter(
|
||||
(project, index, self) =>
|
||||
// Remove any duplicates just in case
|
||||
index === self.findIndex((p) => p.id === project.id),
|
||||
);
|
||||
});
|
||||
|
||||
showToast("Project created successfully!", "success");
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch to ensure consistency after operation completes
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update project mutation (for pinning, etc.)
|
||||
export function useUpdateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ projectId, updates }: { projectId: string; updates: UpdateProjectRequest }) =>
|
||||
projectService.updateProject(projectId, updates),
|
||||
onMutate: async ({ projectId, updates }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
|
||||
if (!old) return old;
|
||||
|
||||
// If pinning a project, unpin all others first
|
||||
if (updates.pinned === true) {
|
||||
return old.map((p) => ({
|
||||
...p,
|
||||
pinned: p.id === projectId,
|
||||
}));
|
||||
}
|
||||
|
||||
return old.map((p) => (p.id === projectId ? { ...p, ...updates } : p));
|
||||
});
|
||||
|
||||
return { previousProjects };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousProjects) {
|
||||
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
|
||||
}
|
||||
showToast("Failed to update project", "error");
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
|
||||
|
||||
if (variables.updates.pinned !== undefined) {
|
||||
const message = variables.updates.pinned
|
||||
? `Pinned "${data.title}" as default project`
|
||||
: `Removed "${data.title}" from default selection`;
|
||||
showToast(message, "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete project mutation with optimistic updates
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (projectId: string) => projectService.deleteProject(projectId),
|
||||
onMutate: async (projectId) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousProjects = queryClient.getQueryData<Project[]>(projectKeys.lists());
|
||||
|
||||
// Optimistically remove the project
|
||||
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
|
||||
if (!old) return old;
|
||||
return old.filter((project) => project.id !== projectId);
|
||||
});
|
||||
|
||||
return { previousProjects };
|
||||
},
|
||||
onError: (error, projectId, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to delete project:", error, { projectId });
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousProjects) {
|
||||
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
|
||||
}
|
||||
|
||||
showToast(`Failed to delete project: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (_, projectId) => {
|
||||
// Don't refetch on success - trust optimistic update
|
||||
// Only remove the specific project's detail data (including nested keys)
|
||||
queryClient.removeQueries({ queryKey: projectKeys.detail(projectId), exact: false });
|
||||
showToast("Project deleted successfully", "success");
|
||||
},
|
||||
});
|
||||
}
|
||||
21
archon-ui-main/src/features/projects/index.ts
Normal file
21
archon-ui-main/src/features/projects/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Projects Feature Module
|
||||
*
|
||||
* Vertical slice containing all project-related functionality:
|
||||
* - Project management (CRUD, selection)
|
||||
* - Task management (CRUD, status, board, table views)
|
||||
* - Document management (docs, versioning)
|
||||
* - Project dashboard and routing
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from "./components";
|
||||
export * from "./documents";
|
||||
|
||||
// Hooks
|
||||
export * from "./hooks";
|
||||
|
||||
// Sub-features
|
||||
export * from "./tasks";
|
||||
// Views
|
||||
export { ProjectsView } from "./views/ProjectsView";
|
||||
62
archon-ui-main/src/features/projects/schemas/index.ts
Normal file
62
archon-ui-main/src/features/projects/schemas/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Base validation schemas
|
||||
export const ProjectColorSchema = z.enum(["cyan", "purple", "pink", "blue", "orange", "green"]);
|
||||
|
||||
// Project schemas
|
||||
export const CreateProjectSchema = z.object({
|
||||
title: z.string().min(1, "Project title is required").max(255, "Project title must be less than 255 characters"),
|
||||
description: z.string().max(1000, "Description must be less than 1000 characters").optional(),
|
||||
icon: z.string().optional(),
|
||||
color: ProjectColorSchema.optional(),
|
||||
github_repo: z.string().url("GitHub repo must be a valid URL").optional(),
|
||||
prd: z.record(z.unknown()).optional(),
|
||||
docs: z.array(z.unknown()).optional(),
|
||||
features: z.array(z.unknown()).optional(),
|
||||
data: z.array(z.unknown()).optional(),
|
||||
technical_sources: z.array(z.string()).optional(),
|
||||
business_sources: z.array(z.string()).optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const UpdateProjectSchema = CreateProjectSchema.partial();
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string().uuid("Project ID must be a valid UUID"),
|
||||
title: z.string().min(1),
|
||||
prd: z.record(z.unknown()).optional(),
|
||||
docs: z.array(z.unknown()).optional(),
|
||||
features: z.array(z.unknown()).optional(),
|
||||
data: z.array(z.unknown()).optional(),
|
||||
github_repo: z.string().url().optional().or(z.literal("")),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
technical_sources: z.array(z.unknown()).optional(), // Can be strings or full objects
|
||||
business_sources: z.array(z.unknown()).optional(), // Can be strings or full objects
|
||||
|
||||
// Extended UI properties
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
color: ProjectColorSchema.optional(),
|
||||
progress: z.number().min(0).max(100).optional(),
|
||||
pinned: z.boolean(),
|
||||
updated: z.string().optional(), // Human-readable format
|
||||
});
|
||||
|
||||
// Validation helper functions
|
||||
export function validateProject(data: unknown) {
|
||||
return ProjectSchema.safeParse(data);
|
||||
}
|
||||
|
||||
export function validateCreateProject(data: unknown) {
|
||||
return CreateProjectSchema.safeParse(data);
|
||||
}
|
||||
|
||||
export function validateUpdateProject(data: unknown) {
|
||||
return UpdateProjectSchema.safeParse(data);
|
||||
}
|
||||
|
||||
// Export type inference helpers
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
export type ProjectInput = z.infer<typeof ProjectSchema>;
|
||||
13
archon-ui-main/src/features/projects/services/index.ts
Normal file
13
archon-ui-main/src/features/projects/services/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Project Services
|
||||
*
|
||||
* All API communication and business logic for the projects feature.
|
||||
* Replaces the monolithic src/services/projectService.ts with focused services.
|
||||
*/
|
||||
|
||||
// Export shared utilities
|
||||
export * from "../shared/api";
|
||||
// Re-export other services for convenience
|
||||
export { taskService } from "../tasks/services/taskService";
|
||||
// Export project-specific services
|
||||
export { projectService } from "./projectService";
|
||||
188
archon-ui-main/src/features/projects/services/projectService.ts
Normal file
188
archon-ui-main/src/features/projects/services/projectService.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Project Management Service
|
||||
* Focused service for project CRUD operations only
|
||||
*/
|
||||
|
||||
import { validateCreateProject, validateUpdateProject } from "../schemas";
|
||||
import { formatRelativeTime, formatZodErrors, ValidationError } from "../shared/api";
|
||||
import { callAPIWithETag, invalidateETagCache } from "../shared/apiWithEtag";
|
||||
import type { CreateProjectRequest, Project, ProjectFeatures, UpdateProjectRequest } from "../types";
|
||||
|
||||
export const projectService = {
|
||||
/**
|
||||
* Get all projects
|
||||
*/
|
||||
async listProjects(): Promise<Project[]> {
|
||||
try {
|
||||
// Fetching projects from API
|
||||
const response = await callAPIWithETag<{ projects: Project[] }>("/api/projects");
|
||||
// API response received
|
||||
|
||||
const projects = response.projects || [];
|
||||
// Processing projects array
|
||||
|
||||
// Process raw pinned values
|
||||
|
||||
// Add computed UI properties
|
||||
const processedProjects = projects.map((project: Project) => {
|
||||
// Process the raw pinned value
|
||||
|
||||
const processed = {
|
||||
...project,
|
||||
// Ensure pinned is properly handled as boolean
|
||||
pinned: project.pinned === true,
|
||||
progress: project.progress || 0,
|
||||
updated: project.updated || formatRelativeTime(project.updated_at),
|
||||
};
|
||||
return processed;
|
||||
});
|
||||
|
||||
// All projects processed
|
||||
return processedProjects;
|
||||
} catch (error) {
|
||||
console.error("Failed to list projects:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific project by ID
|
||||
*/
|
||||
async getProject(projectId: string): Promise<Project> {
|
||||
try {
|
||||
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`);
|
||||
|
||||
return {
|
||||
...project,
|
||||
progress: project.progress || 0,
|
||||
updated: project.updated || formatRelativeTime(project.updated_at),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to get project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async createProject(projectData: CreateProjectRequest): Promise<{
|
||||
project_id: string;
|
||||
project: Project;
|
||||
status: string;
|
||||
message: string;
|
||||
}> {
|
||||
// Validate input
|
||||
// Validate project data
|
||||
const validation = validateCreateProject(projectData);
|
||||
if (!validation.success) {
|
||||
// Validation failed
|
||||
throw new ValidationError(formatZodErrors(validation.error));
|
||||
}
|
||||
// Validation passed
|
||||
|
||||
try {
|
||||
// Sending project creation request
|
||||
const response = await callAPIWithETag<{
|
||||
project_id: string;
|
||||
project: Project;
|
||||
status: string;
|
||||
message: string;
|
||||
}>("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(validation.data),
|
||||
});
|
||||
|
||||
// Invalidate project list cache after creation
|
||||
invalidateETagCache("/api/projects");
|
||||
|
||||
// Project creation response received
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[PROJECT SERVICE] Failed to initiate project creation:", error);
|
||||
if (error instanceof Error) {
|
||||
console.error("[PROJECT SERVICE] Error details:", {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
async updateProject(projectId: string, updates: UpdateProjectRequest): Promise<Project> {
|
||||
// Validate input
|
||||
// Updating project with provided data
|
||||
const validation = validateUpdateProject(updates);
|
||||
if (!validation.success) {
|
||||
// Validation failed
|
||||
throw new ValidationError(formatZodErrors(validation.error));
|
||||
}
|
||||
|
||||
try {
|
||||
// Sending update request to API
|
||||
const project = await callAPIWithETag<Project>(`/api/projects/${projectId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(validation.data),
|
||||
});
|
||||
|
||||
// Invalidate caches after update
|
||||
invalidateETagCache("/api/projects");
|
||||
invalidateETagCache(`/api/projects/${projectId}`);
|
||||
|
||||
// API update response received
|
||||
|
||||
// Ensure pinned property is properly handled as boolean
|
||||
const processedProject = {
|
||||
...project,
|
||||
pinned: project.pinned === true,
|
||||
progress: project.progress || 0,
|
||||
updated: formatRelativeTime(project.updated_at),
|
||||
};
|
||||
|
||||
// Project update processed
|
||||
|
||||
return processedProject;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async deleteProject(projectId: string): Promise<void> {
|
||||
try {
|
||||
await callAPIWithETag(`/api/projects/${projectId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Invalidate caches after deletion
|
||||
invalidateETagCache("/api/projects");
|
||||
invalidateETagCache(`/api/projects/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get features from a project's features JSONB field
|
||||
*/
|
||||
async getProjectFeatures(projectId: string): Promise<{ features: ProjectFeatures; count: number }> {
|
||||
try {
|
||||
const response = await callAPIWithETag<{
|
||||
features: ProjectFeatures;
|
||||
count: number;
|
||||
}>(`/api/projects/${projectId}/features`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get features for project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
130
archon-ui-main/src/features/projects/shared/api.ts
Normal file
130
archon-ui-main/src/features/projects/shared/api.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Shared API utilities for project features
|
||||
* Common error handling and API calling functions
|
||||
*/
|
||||
|
||||
// API configuration - use relative URL to go through Vite proxy
|
||||
const API_BASE_URL = "/api";
|
||||
|
||||
// Error classes
|
||||
export class ProjectServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string,
|
||||
public statusCode?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProjectServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ProjectServiceError {
|
||||
constructor(message: string) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MCPToolError extends ProjectServiceError {
|
||||
constructor(
|
||||
message: string,
|
||||
public toolName: string,
|
||||
) {
|
||||
super(message, "MCP_TOOL_ERROR", 500);
|
||||
this.name = "MCPToolError";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format validation errors
|
||||
interface ValidationErrorDetail {
|
||||
path: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ValidationErrorObject {
|
||||
errors: ValidationErrorDetail[];
|
||||
}
|
||||
|
||||
export function formatValidationErrors(errors: ValidationErrorObject): string {
|
||||
return errors.errors.map((error: ValidationErrorDetail) => `${error.path.join(".")}: ${error.message}`).join(", ");
|
||||
}
|
||||
|
||||
// Helper to convert Zod errors to ValidationErrorObject format
|
||||
export function formatZodErrors(zodError: { issues: Array<{ path: (string | number)[]; message: string }> }): string {
|
||||
const validationErrors: ValidationErrorObject = {
|
||||
errors: zodError.issues.map((issue) => ({
|
||||
path: issue.path.map(String),
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
return formatValidationErrors(validationErrors);
|
||||
}
|
||||
|
||||
// Helper function to call FastAPI endpoints directly
|
||||
export async function callAPI<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
try {
|
||||
// Remove /api prefix if it exists since API_BASE_URL already includes it
|
||||
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
|
||||
const response = await fetch(`${API_BASE_URL}${cleanEndpoint}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to get error details from response body
|
||||
let errorMessage = `HTTP error! status: ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
errorMessage = errorJson.detail || errorJson.error || errorMessage;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors, use default message
|
||||
}
|
||||
|
||||
throw new ProjectServiceError(errorMessage, "HTTP_ERROR", response.status);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses (common for DELETE operations)
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Check if response has error field (from FastAPI error format)
|
||||
if (result.error) {
|
||||
throw new ProjectServiceError(result.error, "API_ERROR", response.status);
|
||||
}
|
||||
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectServiceError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ProjectServiceError(
|
||||
`Failed to call API ${endpoint}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
"NETWORK_ERROR",
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function for relative time formatting
|
||||
export function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "just now";
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`;
|
||||
|
||||
return `${Math.floor(diffInSeconds / 604800)} weeks ago`;
|
||||
}
|
||||
181
archon-ui-main/src/features/projects/shared/apiWithEtag.ts
Normal file
181
archon-ui-main/src/features/projects/shared/apiWithEtag.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* ETag-aware API client for TanStack Query integration
|
||||
* Reduces bandwidth by 70-90% through HTTP 304 responses
|
||||
*/
|
||||
|
||||
import { ProjectServiceError } from "./api";
|
||||
|
||||
// API configuration
|
||||
const API_BASE_URL = "/api";
|
||||
|
||||
// ETag and data cache stores
|
||||
const etagCache = new Map<string, string>();
|
||||
const dataCache = new Map<string, unknown>();
|
||||
|
||||
// Debug flag for console logging (only in dev or when VITE_SHOW_DEVTOOLS is enabled)
|
||||
const ETAG_DEBUG = import.meta.env?.DEV === true;
|
||||
|
||||
// Generate cache key from endpoint and options
|
||||
function getCacheKey(endpoint: string, options: RequestInit = {}): string {
|
||||
// Include method in cache key (GET vs POST, etc), normalized to uppercase
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
return `${method}:${endpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ETag-aware API call function
|
||||
* Handles 304 Not Modified responses by returning cached data
|
||||
*/
|
||||
export async function callAPIWithETag<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
try {
|
||||
// Clean endpoint
|
||||
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
|
||||
const fullUrl = `${API_BASE_URL}${cleanEndpoint}`;
|
||||
const cacheKey = getCacheKey(fullUrl, options);
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
|
||||
// Get stored ETag for this endpoint
|
||||
const storedEtag = etagCache.get(cacheKey);
|
||||
|
||||
// Build headers with If-None-Match if we have an ETag
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// Only add If-None-Match for GET requests
|
||||
if (storedEtag && method === "GET") {
|
||||
headers["If-None-Match"] = storedEtag;
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle 304 Not Modified - return cached data
|
||||
if (response.status === 304) {
|
||||
const cachedData = dataCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
// Console log for debugging
|
||||
if (ETAG_DEBUG) {
|
||||
console.log(`%c[ETag] Cache hit (304) for ${cleanEndpoint}`, "color: #10b981; font-weight: bold");
|
||||
}
|
||||
return cachedData as T;
|
||||
}
|
||||
// Cache miss on 304 - this shouldn't happen but handle gracefully
|
||||
if (ETAG_DEBUG) {
|
||||
console.error(`[ETag] 304 received but no cached data for ${cleanEndpoint}`);
|
||||
}
|
||||
// Clear the stale ETag to prevent this from happening again
|
||||
etagCache.delete(cacheKey);
|
||||
throw new ProjectServiceError(
|
||||
`Cache miss on 304 response for ${cleanEndpoint}. Please retry the request.`,
|
||||
"CACHE_MISS",
|
||||
304,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (!response.ok && response.status !== 304) {
|
||||
let errorMessage = `HTTP error! status: ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
errorMessage = errorJson.detail || errorJson.error || errorMessage;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
throw new ProjectServiceError(errorMessage, "HTTP_ERROR", response.status);
|
||||
}
|
||||
|
||||
// Handle 204 No Content (DELETE operations)
|
||||
if (response.status === 204) {
|
||||
// Clear caches for this endpoint on successful deletion
|
||||
etagCache.delete(cacheKey);
|
||||
dataCache.delete(cacheKey);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// Parse response data
|
||||
const result = await response.json();
|
||||
|
||||
// Check for API errors
|
||||
if (result.error) {
|
||||
throw new ProjectServiceError(result.error, "API_ERROR", response.status);
|
||||
}
|
||||
|
||||
// Store ETag if present (only for GET requests)
|
||||
const newEtag = response.headers.get("ETag");
|
||||
if (newEtag && method === "GET") {
|
||||
etagCache.set(cacheKey, newEtag);
|
||||
// Store the data along with ETag
|
||||
dataCache.set(cacheKey, result);
|
||||
if (ETAG_DEBUG) {
|
||||
console.log(
|
||||
`%c[ETag] Cached new data for ${cleanEndpoint}`,
|
||||
"color: #3b82f6; font-weight: bold",
|
||||
`ETag: ${newEtag.substring(0, 12)}...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectServiceError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ProjectServiceError(
|
||||
`Failed to call API ${endpoint}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
"NETWORK_ERROR",
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ETag caches - useful for logout or data refresh
|
||||
*/
|
||||
export function clearETagCache(): void {
|
||||
etagCache.clear();
|
||||
dataCache.clear();
|
||||
if (ETAG_DEBUG) {
|
||||
console.debug("[ETag] Cache cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific endpoint cache
|
||||
* Useful after mutations that affect specific resources
|
||||
*/
|
||||
export function invalidateETagCache(endpoint: string, method = "GET"): void {
|
||||
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
|
||||
const fullUrl = `${API_BASE_URL}${cleanEndpoint}`;
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
const cacheKey = `${normalizedMethod}:${fullUrl}`;
|
||||
|
||||
etagCache.delete(cacheKey);
|
||||
dataCache.delete(cacheKey);
|
||||
if (ETAG_DEBUG) {
|
||||
console.debug(`[ETag] Cache invalidated for ${cleanEndpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging
|
||||
*/
|
||||
export function getETagCacheStats(): {
|
||||
etagCount: number;
|
||||
dataCacheSize: number;
|
||||
keys: string[];
|
||||
} {
|
||||
return {
|
||||
etagCount: etagCache.size,
|
||||
dataCacheSize: dataCache.size,
|
||||
keys: Array.from(etagCache.keys()),
|
||||
};
|
||||
}
|
||||
322
archon-ui-main/src/features/projects/tasks/TasksTab.tsx
Normal file
322
archon-ui-main/src/features/projects/tasks/TasksTab.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { LayoutGrid, Plus, Table } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
||||
import { Button } from "../../ui/primitives";
|
||||
import { cn, glassmorphism } from "../../ui/primitives/styles";
|
||||
import { TaskEditModal } from "./components/TaskEditModal";
|
||||
import { useDeleteTask, useProjectTasks, useUpdateTask } from "./hooks";
|
||||
import type { Task } from "./types";
|
||||
import { getReorderTaskOrder, ORDER_INCREMENT, validateTaskOrder } from "./utils";
|
||||
import { BoardView, TableView } from "./views";
|
||||
|
||||
interface TasksTabProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const TasksTab = ({ projectId }: TasksTabProps) => {
|
||||
const [viewMode, setViewMode] = useState<"table" | "board">("board");
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Fetch tasks using TanStack Query
|
||||
const { data: tasks = [], isLoading: isLoadingTasks } = useProjectTasks(projectId);
|
||||
|
||||
// Mutations for task operations
|
||||
const updateTaskMutation = useUpdateTask(projectId);
|
||||
const deleteTaskMutation = useDeleteTask(projectId);
|
||||
|
||||
// Modal management functions
|
||||
const openEditModal = (task: Task) => {
|
||||
setEditingTask(task);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingTask(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setEditingTask(null);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
// Delete modal management functions
|
||||
const openDeleteModal = (task: Task) => {
|
||||
setTaskToDelete(task);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setTaskToDelete(null);
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const confirmDeleteTask = () => {
|
||||
if (!taskToDelete) return;
|
||||
|
||||
deleteTaskMutation.mutate(taskToDelete.id, {
|
||||
onSuccess: () => {
|
||||
closeDeleteModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete task:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Get default order for new tasks in a status
|
||||
const getDefaultTaskOrder = useCallback((statusTasks: Task[]) => {
|
||||
if (statusTasks.length === 0) return ORDER_INCREMENT;
|
||||
const maxOrder = Math.max(...statusTasks.map((t) => t.task_order));
|
||||
return maxOrder + ORDER_INCREMENT;
|
||||
}, []);
|
||||
|
||||
// Task reordering - immediate update
|
||||
const handleTaskReorder = useCallback(
|
||||
async (taskId: string, targetIndex: number, status: Task["status"]) => {
|
||||
// Get all tasks in the target status, sorted by current order
|
||||
const statusTasks = (tasks as Task[])
|
||||
.filter((task) => task.status === status)
|
||||
.sort((a, b) => a.task_order - b.task_order);
|
||||
|
||||
const movingTaskIndex = statusTasks.findIndex((task) => task.id === taskId);
|
||||
if (movingTaskIndex === -1 || targetIndex < 0 || targetIndex > statusTasks.length) return;
|
||||
if (movingTaskIndex === targetIndex) return;
|
||||
|
||||
// Calculate new position using battle-tested utility
|
||||
const newPosition = getReorderTaskOrder(statusTasks, taskId, targetIndex);
|
||||
|
||||
// Update immediately with optimistic updates
|
||||
try {
|
||||
await updateTaskMutation.mutateAsync({
|
||||
taskId,
|
||||
updates: {
|
||||
task_order: newPosition,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to reorder task:", error, {
|
||||
taskId,
|
||||
newPosition,
|
||||
});
|
||||
// Error toast handled by mutation
|
||||
}
|
||||
},
|
||||
[tasks, updateTaskMutation],
|
||||
);
|
||||
|
||||
// Move task to different status
|
||||
const moveTask = useCallback(
|
||||
async (taskId: string, newStatus: Task["status"]) => {
|
||||
const movingTask = (tasks as Task[]).find((task) => task.id === taskId);
|
||||
if (!movingTask || movingTask.status === newStatus) return;
|
||||
|
||||
try {
|
||||
// Calculate position for new status
|
||||
const tasksInNewStatus = (tasks as Task[]).filter((t) => t.status === newStatus);
|
||||
const newOrder = getDefaultTaskOrder(tasksInNewStatus);
|
||||
|
||||
// Update via mutation (handles optimistic updates)
|
||||
await updateTaskMutation.mutateAsync({
|
||||
taskId,
|
||||
updates: {
|
||||
status: newStatus,
|
||||
task_order: newOrder,
|
||||
},
|
||||
});
|
||||
|
||||
// Success handled by mutation
|
||||
} catch (error) {
|
||||
console.error("Failed to move task:", error, { taskId, newStatus });
|
||||
// Error toast handled by mutation
|
||||
}
|
||||
},
|
||||
[tasks, updateTaskMutation, getDefaultTaskOrder],
|
||||
);
|
||||
|
||||
const completeTask = useCallback(
|
||||
(taskId: string) => {
|
||||
moveTask(taskId, "done");
|
||||
},
|
||||
[moveTask],
|
||||
);
|
||||
|
||||
// Inline update for task fields
|
||||
const updateTaskInline = async (taskId: string, updates: Partial<Task>) => {
|
||||
try {
|
||||
// Validate task_order if present (ensures integer precision)
|
||||
const processedUpdates = { ...updates };
|
||||
if (processedUpdates.task_order !== undefined) {
|
||||
processedUpdates.task_order = validateTaskOrder(processedUpdates.task_order);
|
||||
}
|
||||
|
||||
await updateTaskMutation.mutateAsync({
|
||||
taskId,
|
||||
updates: processedUpdates,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update task:", error, { taskId, updates });
|
||||
// Error toast handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTasks) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="min-h-[70vh] relative">
|
||||
{/* Main content - Table or Board view */}
|
||||
<div className="relative h-[calc(100vh-220px)] overflow-auto">
|
||||
{viewMode === "table" ? (
|
||||
<TableView
|
||||
tasks={tasks as Task[]}
|
||||
projectId={projectId}
|
||||
onTaskView={openEditModal}
|
||||
onTaskComplete={completeTask}
|
||||
onTaskDelete={openDeleteModal}
|
||||
onTaskReorder={handleTaskReorder}
|
||||
onTaskUpdate={updateTaskInline}
|
||||
/>
|
||||
) : (
|
||||
<BoardView
|
||||
tasks={tasks as Task[]}
|
||||
projectId={projectId}
|
||||
onTaskMove={moveTask}
|
||||
onTaskReorder={handleTaskReorder}
|
||||
onTaskEdit={openEditModal}
|
||||
onTaskDelete={openDeleteModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed View Controls using Radix primitives */}
|
||||
<ViewControls viewMode={viewMode} onViewChange={setViewMode} onAddTask={openCreateModal} />
|
||||
|
||||
{/* Edit/Create Task Modal */}
|
||||
<TaskEditModal isModalOpen={isModalOpen} editingTask={editingTask} projectId={projectId} onClose={closeModal} />
|
||||
|
||||
{/* Delete Task Modal */}
|
||||
<DeleteConfirmModal
|
||||
open={showDeleteModal}
|
||||
itemName={taskToDelete?.title || ""}
|
||||
onConfirm={confirmDeleteTask}
|
||||
onCancel={closeDeleteModal}
|
||||
type="task"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Extracted ViewControls component using Radix primitives
|
||||
interface ViewControlsProps {
|
||||
viewMode: "table" | "board";
|
||||
onViewChange: (mode: "table" | "board") => void;
|
||||
onAddTask: () => void;
|
||||
}
|
||||
|
||||
const ViewControls = ({ viewMode, onViewChange, onAddTask }: ViewControlsProps) => {
|
||||
return (
|
||||
<div className="fixed bottom-6 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Add Task Button with Glassmorphism */}
|
||||
<Button
|
||||
onClick={onAddTask}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"pointer-events-auto relative",
|
||||
glassmorphism.background.subtle,
|
||||
glassmorphism.border.default,
|
||||
glassmorphism.shadow.elevated,
|
||||
"text-cyan-600 dark:text-cyan-400",
|
||||
"hover:text-cyan-700 dark:hover:text-cyan-300",
|
||||
"transition-all duration-300",
|
||||
)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<span>Add Task</span>
|
||||
{/* Glow effect */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-[2px]",
|
||||
"bg-gradient-to-r from-transparent via-cyan-500 to-transparent",
|
||||
"shadow-[0_0_10px_2px_rgba(34,211,238,0.4)]",
|
||||
"dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* View Toggle Controls with Glassmorphism */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center overflow-hidden pointer-events-auto",
|
||||
glassmorphism.background.subtle,
|
||||
glassmorphism.border.default,
|
||||
glassmorphism.shadow.elevated,
|
||||
"rounded-lg",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewChange("table")}
|
||||
className={cn(
|
||||
"px-5 py-2.5 flex items-center gap-2 relative transition-all duration-300",
|
||||
viewMode === "table"
|
||||
? "text-cyan-600 dark:text-cyan-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300",
|
||||
)}
|
||||
>
|
||||
<Table className="w-4 h-4" />
|
||||
<span>Table</span>
|
||||
{viewMode === "table" && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px]",
|
||||
"bg-cyan-500",
|
||||
"shadow-[0_0_10px_2px_rgba(34,211,238,0.4)]",
|
||||
"dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewChange("board")}
|
||||
className={cn(
|
||||
"px-5 py-2.5 flex items-center gap-2 relative transition-all duration-300",
|
||||
viewMode === "board"
|
||||
? "text-purple-600 dark:text-purple-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
<span>Board</span>
|
||||
{viewMode === "board" && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px]",
|
||||
"bg-purple-500",
|
||||
"shadow-[0_0_10px_2px_rgba(168,85,247,0.4)]",
|
||||
"dark:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/primitives";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
|
||||
interface EditableTableCellProps {
|
||||
value: string;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
type?: "text" | "select" | "status" | "assignee";
|
||||
options?: readonly string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
isUpdating?: boolean;
|
||||
}
|
||||
|
||||
// Status options for the status select
|
||||
const STATUS_OPTIONS = ["todo", "doing", "review", "done"] as const;
|
||||
|
||||
// Assignee options
|
||||
const ASSIGNEE_OPTIONS = ["User", "Archon", "AI IDE Agent"] as const;
|
||||
|
||||
export const EditableTableCell = ({
|
||||
value,
|
||||
onSave,
|
||||
type = "text",
|
||||
options,
|
||||
placeholder = "Click to edit",
|
||||
className,
|
||||
isUpdating = false,
|
||||
}: EditableTableCellProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Update edit value when prop changes
|
||||
useEffect(() => {
|
||||
setEditValue(value);
|
||||
}, [value]);
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editValue === value) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(editValue);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save:", error);
|
||||
// Reset on error
|
||||
setEditValue(value);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// Get the appropriate options based on type
|
||||
const selectOptions = type === "status" ? STATUS_OPTIONS : type === "assignee" ? ASSIGNEE_OPTIONS : options || [];
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Table cell transforms into input on click - can't use semantic button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !isUpdating && setIsEditing(true)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !isUpdating) {
|
||||
e.preventDefault();
|
||||
setIsEditing(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer px-2 py-1 min-h-[28px]",
|
||||
"hover:bg-gray-100/50 dark:hover:bg-gray-800/50",
|
||||
"rounded transition-colors",
|
||||
"flex items-center",
|
||||
isUpdating && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
title={value || placeholder}
|
||||
>
|
||||
<span className={cn(!value && "text-gray-400 italic")}>{value || placeholder}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render select for select types
|
||||
if (type === "select" || type === "status" || type === "assignee") {
|
||||
return (
|
||||
<Select
|
||||
value={editValue}
|
||||
onValueChange={(newValue) => {
|
||||
setEditValue(newValue);
|
||||
// Auto-save on select
|
||||
setTimeout(() => {
|
||||
setEditValue(newValue);
|
||||
onSave(newValue);
|
||||
setIsEditing(false);
|
||||
}, 0);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"w-full h-7 text-sm",
|
||||
"border-cyan-400 dark:border-cyan-600",
|
||||
"focus:ring-1 focus:ring-cyan-400",
|
||||
className,
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Render input for text type
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
"h-7 text-sm",
|
||||
"border-cyan-400 dark:border-cyan-600",
|
||||
"focus:ring-1 focus:ring-cyan-400",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* FeatureSelect Component
|
||||
*
|
||||
* Radix-based feature selection with autocomplete
|
||||
* Replaces the legacy FeatureInput component
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { ComboBox, type ComboBoxOption } from "../../../ui/primitives";
|
||||
|
||||
interface FeatureSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
projectFeatures: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
isLoadingFeatures?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FeatureSelect = memo(
|
||||
({
|
||||
value,
|
||||
onChange,
|
||||
projectFeatures,
|
||||
isLoadingFeatures = false,
|
||||
placeholder = "Select or create feature...",
|
||||
className,
|
||||
}: FeatureSelectProps) => {
|
||||
// Transform features to ComboBox options
|
||||
const options: ComboBoxOption[] = React.useMemo(
|
||||
() =>
|
||||
projectFeatures.map((feature) => ({
|
||||
value: feature.label,
|
||||
label: feature.label,
|
||||
description: feature.type ? `Type: ${feature.type}` : undefined,
|
||||
})),
|
||||
[projectFeatures],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search features..."
|
||||
emptyMessage="No features found."
|
||||
className={className}
|
||||
isLoading={isLoadingFeatures}
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FeatureSelect.displayName = "FeatureSelect";
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useRef } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import type { Task } from "../types";
|
||||
import { getColumnColor, getColumnGlow, ItemTypes } from "../utils/task-styles";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
status: Task["status"];
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
projectId: string;
|
||||
onTaskMove: (taskId: string, newStatus: Task["status"]) => void;
|
||||
onTaskReorder: (taskId: string, targetIndex: number, status: Task["status"]) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskDelete?: (task: Task) => void;
|
||||
hoveredTaskId: string | null;
|
||||
onTaskHover: (taskId: string | null) => void;
|
||||
}
|
||||
|
||||
export const KanbanColumn = ({
|
||||
status,
|
||||
title,
|
||||
tasks,
|
||||
projectId,
|
||||
onTaskMove,
|
||||
onTaskReorder,
|
||||
onTaskEdit,
|
||||
onTaskDelete,
|
||||
hoveredTaskId,
|
||||
onTaskHover,
|
||||
}: KanbanColumnProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: ItemTypes.TASK,
|
||||
drop: (item: { id: string; status: Task["status"] }) => {
|
||||
if (item.status !== status) {
|
||||
onTaskMove(item.id, status);
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
drop(ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col h-full",
|
||||
"bg-gradient-to-b from-white/20 to-transparent dark:from-black/30 dark:to-transparent",
|
||||
"backdrop-blur-sm",
|
||||
"transition-all duration-200",
|
||||
isOver && "bg-gradient-to-b from-cyan-500/5 to-purple-500/5 dark:from-cyan-400/10 dark:to-purple-400/10",
|
||||
isOver && "border-t-2 border-t-cyan-400/50 dark:border-t-cyan-400/70",
|
||||
isOver &&
|
||||
"shadow-[inset_0_2px_20px_rgba(34,211,238,0.15)] dark:shadow-[inset_0_2px_30px_rgba(34,211,238,0.25)]",
|
||||
isOver && "backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Column Header with Glassmorphism */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-3 sticky top-0 z-10",
|
||||
"bg-gradient-to-b from-white/80 to-white/60 dark:from-black/80 dark:to-black/60",
|
||||
"backdrop-blur-md",
|
||||
"border-b border-gray-200/50 dark:border-gray-700/50",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
<h3 className={cn("font-mono text-sm font-medium", getColumnColor(status))}>{title}</h3>
|
||||
{/* Column header glow effect */}
|
||||
<div
|
||||
className={cn("absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px]", getColumnGlow(status))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tasks Container */}
|
||||
<div className="px-2 flex-1 overflow-y-auto space-y-2 py-3 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
|
||||
{tasks.length === 0 ? (
|
||||
<div className={cn("text-center py-8 text-gray-400 dark:text-gray-600 text-sm", "opacity-60")}>No tasks</div>
|
||||
) : (
|
||||
tasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
projectId={projectId}
|
||||
onTaskReorder={onTaskReorder}
|
||||
onEdit={onTaskEdit}
|
||||
onDelete={onTaskDelete}
|
||||
hoveredTaskId={hoveredTaskId}
|
||||
onTaskHover={onTaskHover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Bot, User } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "../../../ui/primitives";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import type { Assignee } from "../types";
|
||||
|
||||
interface TaskAssigneeProps {
|
||||
assignee: Assignee;
|
||||
onAssigneeChange?: (newAssignee: Assignee) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ASSIGNEE_OPTIONS: Assignee[] = ["User", "Archon", "AI IDE Agent"];
|
||||
|
||||
// Get icon for each assignee type
|
||||
const getAssigneeIcon = (assigneeName: Assignee, size: "sm" | "md" = "sm") => {
|
||||
const sizeClass = size === "sm" ? "w-3 h-3" : "w-4 h-4";
|
||||
|
||||
switch (assigneeName) {
|
||||
case "User":
|
||||
return <User className={cn(sizeClass, "text-blue-400")} />;
|
||||
case "AI IDE Agent":
|
||||
return <Bot className={cn(sizeClass, "text-purple-400")} />;
|
||||
case "Archon":
|
||||
return <img src="/logo-neon.png" alt="Archon" className={sizeClass} />;
|
||||
default:
|
||||
return <User className={cn(sizeClass, "text-blue-400")} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get glow effect for each assignee type
|
||||
const getAssigneeStyles = (assigneeName: Assignee) => {
|
||||
switch (assigneeName) {
|
||||
case "User":
|
||||
return {
|
||||
glow: "shadow-[0_0_10px_rgba(59,130,246,0.4)]",
|
||||
hoverGlow: "hover:shadow-[0_0_12px_rgba(59,130,246,0.5)]",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
};
|
||||
case "AI IDE Agent":
|
||||
return {
|
||||
glow: "shadow-[0_0_10px_rgba(168,85,247,0.4)]",
|
||||
hoverGlow: "hover:shadow-[0_0_12px_rgba(168,85,247,0.5)]",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
};
|
||||
case "Archon":
|
||||
return {
|
||||
glow: "shadow-[0_0_10px_rgba(34,211,238,0.4)]",
|
||||
hoverGlow: "hover:shadow-[0_0_12px_rgba(34,211,238,0.5)]",
|
||||
color: "text-cyan-600 dark:text-cyan-400",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
glow: "shadow-[0_0_10px_rgba(59,130,246,0.4)]",
|
||||
hoverGlow: "hover:shadow-[0_0_12px_rgba(59,130,246,0.5)]",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const TaskAssignee: React.FC<TaskAssigneeProps> = ({ assignee, onAssigneeChange, isLoading = false }) => {
|
||||
const styles = getAssigneeStyles(assignee);
|
||||
|
||||
// If no change handler, just show a static display
|
||||
if (!onAssigneeChange) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full",
|
||||
"bg-white/80 dark:bg-black/70",
|
||||
"border border-gray-300/50 dark:border-gray-700/50",
|
||||
"backdrop-blur-md",
|
||||
styles.glow,
|
||||
)}
|
||||
>
|
||||
{getAssigneeIcon(assignee, "md")}
|
||||
</div>
|
||||
<span className="text-gray-600 dark:text-gray-400 text-xs">{assignee}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={assignee} onValueChange={(value) => onAssigneeChange(value as Assignee)}>
|
||||
<SelectTrigger
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"h-auto py-0.5 px-1.5 gap-1.5",
|
||||
"border-0 shadow-none bg-transparent",
|
||||
"hover:bg-gray-100/50 dark:hover:bg-gray-900/50",
|
||||
"transition-all duration-200 rounded-md",
|
||||
"min-w-fit w-auto",
|
||||
)}
|
||||
showChevron={false}
|
||||
aria-label={`Assignee: ${assignee}${isLoading ? " (updating...)" : ""}`}
|
||||
aria-disabled={isLoading}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full",
|
||||
"bg-white/80 dark:bg-black/70",
|
||||
"border border-gray-300/50 dark:border-gray-700/50",
|
||||
"backdrop-blur-md transition-shadow duration-200",
|
||||
styles.glow,
|
||||
styles.hoverGlow,
|
||||
)}
|
||||
>
|
||||
{getAssigneeIcon(assignee, "md")}
|
||||
</div>
|
||||
<span className={cn("text-xs", styles.color)}>{assignee}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent className="min-w-[140px]">
|
||||
{ASSIGNEE_OPTIONS.map((option) => {
|
||||
const optionStyles = getAssigneeStyles(option);
|
||||
|
||||
return (
|
||||
<SelectItem key={option} value={option}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full",
|
||||
"bg-white/80 dark:bg-black/70",
|
||||
"border border-gray-300/50 dark:border-gray-700/50",
|
||||
optionStyles.glow,
|
||||
)}
|
||||
>
|
||||
{getAssigneeIcon(option, "md")}
|
||||
</div>
|
||||
<span className={cn("text-sm", optionStyles.color)}>{option}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Tag } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTaskActions } from "../hooks";
|
||||
import type { Assignee, Task } from "../types";
|
||||
import { getOrderColor, getOrderGlow, ItemTypes } from "../utils/task-styles";
|
||||
import { TaskAssignee } from "./TaskAssignee";
|
||||
import { TaskCardActions } from "./TaskCardActions";
|
||||
import { type Priority, TaskPriority } from "./TaskPriority";
|
||||
|
||||
export interface TaskCardProps {
|
||||
task: Task;
|
||||
index: number;
|
||||
projectId: string; // Need this for mutations
|
||||
onTaskReorder: (taskId: string, targetIndex: number, status: Task["status"]) => void;
|
||||
onEdit?: (task: Task) => void; // Optional edit handler
|
||||
onDelete?: (task: Task) => void; // Optional delete handler
|
||||
hoveredTaskId?: string | null;
|
||||
onTaskHover?: (taskId: string | null) => void;
|
||||
selectedTasks?: Set<string>;
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
task,
|
||||
index,
|
||||
projectId,
|
||||
onTaskReorder,
|
||||
onEdit,
|
||||
onDelete,
|
||||
hoveredTaskId,
|
||||
onTaskHover,
|
||||
selectedTasks,
|
||||
onTaskSelect,
|
||||
}) => {
|
||||
// Local state for frontend-only priority
|
||||
// NOTE: Priority is display-only and doesn't sync with backend yet
|
||||
const [localPriority, setLocalPriority] = useState<Priority>("medium");
|
||||
|
||||
// Use business logic hook
|
||||
const { changeAssignee, isUpdating } = useTaskActions(projectId);
|
||||
|
||||
// Handlers - now just call hook methods
|
||||
const handleEdit = useCallback(() => {
|
||||
// Call the onEdit prop if provided, otherwise log
|
||||
if (onEdit) {
|
||||
onEdit(task);
|
||||
} else {
|
||||
// Edit task - no handler provided
|
||||
}
|
||||
}, [onEdit, task]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (onDelete) {
|
||||
onDelete(task);
|
||||
} else {
|
||||
// Delete task - no handler provided
|
||||
}
|
||||
}, [onDelete, task]);
|
||||
|
||||
const handlePriorityChange = useCallback((priority: Priority) => {
|
||||
// Frontend-only priority change
|
||||
setLocalPriority(priority);
|
||||
}, []);
|
||||
|
||||
const handleAssigneeChange = useCallback(
|
||||
(newAssignee: Assignee) => {
|
||||
changeAssignee(task.id, newAssignee);
|
||||
},
|
||||
[changeAssignee, task.id],
|
||||
);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.TASK,
|
||||
item: { id: task.id, status: task.status, index },
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [, drop] = useDrop({
|
||||
accept: ItemTypes.TASK,
|
||||
hover: (draggedItem: { id: string; status: Task["status"]; index: number }, monitor) => {
|
||||
if (!monitor.isOver({ shallow: true })) return;
|
||||
if (draggedItem.id === task.id) return;
|
||||
if (draggedItem.status !== task.status) return;
|
||||
|
||||
const draggedIndex = draggedItem.index;
|
||||
const hoveredIndex = index;
|
||||
|
||||
if (draggedIndex === hoveredIndex) return;
|
||||
|
||||
// Move the task immediately for visual feedback
|
||||
onTaskReorder(draggedItem.id, hoveredIndex, task.status);
|
||||
|
||||
// Update the dragged item's index to prevent re-triggering
|
||||
draggedItem.index = hoveredIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const isHighlighted = hoveredTaskId === task.id;
|
||||
const isSelected = selectedTasks?.has(task.id) || false;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
onTaskHover?.(task.id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
onTaskHover?.(null);
|
||||
};
|
||||
|
||||
const handleTaskClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.stopPropagation();
|
||||
onTaskSelect?.(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Glassmorphism styling constants
|
||||
const cardBaseStyles =
|
||||
"bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-gray-700 rounded-lg backdrop-blur-md";
|
||||
const transitionStyles = "transition-all duration-200 ease-in-out";
|
||||
|
||||
// Subtle highlight effect for related tasks
|
||||
const highlightGlow = isHighlighted ? "border-cyan-400/50 shadow-[0_0_8px_rgba(34,211,238,0.2)]" : "";
|
||||
|
||||
// Selection styling with glassmorphism
|
||||
const selectionGlow = isSelected
|
||||
? "border-blue-500 shadow-[0_0_12px_rgba(59,130,246,0.4)] bg-blue-50/30 dark:bg-blue-900/20"
|
||||
: "";
|
||||
|
||||
// Beautiful hover effect with glowing borders
|
||||
const hoverEffectClasses =
|
||||
"group-hover:border-cyan-400/70 dark:group-hover:border-cyan-500/50 group-hover:shadow-[0_0_15px_rgba(34,211,238,0.4)] dark:group-hover:shadow-[0_0_15px_rgba(34,211,238,0.6)]";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Drag-and-drop card with react-dnd - requires div for drag handle
|
||||
<div
|
||||
ref={(node) => drag(drop(node))}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`w-full min-h-[140px] cursor-move relative ${isDragging ? "opacity-50 scale-90" : "scale-100 opacity-100"} ${transitionStyles} group`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleTaskClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (onEdit) {
|
||||
onEdit(task);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} ${selectionGlow} w-full min-h-[140px] h-full`}
|
||||
>
|
||||
{/* Priority indicator with beautiful glow */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-[3px] ${getOrderColor(task.task_order)} ${getOrderGlow(task.task_order)} rounded-l-lg opacity-80 group-hover:w-[4px] group-hover:opacity-100 transition-all duration-300`}
|
||||
/>
|
||||
|
||||
{/* Content container with fixed padding */}
|
||||
<div className="flex flex-col h-full p-3">
|
||||
{/* Header with feature and actions */}
|
||||
<div className="flex items-center gap-2 mb-2 pl-1.5">
|
||||
{task.feature && (
|
||||
<div
|
||||
className="px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1 backdrop-blur-md"
|
||||
style={{
|
||||
backgroundColor: `${task.featureColor}20`,
|
||||
color: task.featureColor,
|
||||
boxShadow: `0 0 10px ${task.featureColor}20`,
|
||||
}}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{task.feature}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<TaskCardActions
|
||||
taskId={task.id}
|
||||
taskTitle={task.title}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4
|
||||
className="text-xs font-medium text-gray-900 dark:text-white mb-2 pl-1.5 line-clamp-2 overflow-hidden"
|
||||
title={task.title}
|
||||
>
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
{/* Description - visible when task has description */}
|
||||
{task.description && (
|
||||
<div className="pl-1.5 pr-3 mb-2 flex-1">
|
||||
<p
|
||||
className="text-xs text-gray-600 dark:text-gray-400 line-clamp-3 break-words whitespace-pre-wrap opacity-75"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer when no description */}
|
||||
{!task.description && <div className="flex-1"></div>}
|
||||
|
||||
{/* Footer with assignee - glassmorphism styling */}
|
||||
<div className="flex items-center justify-between mt-auto pt-2 pl-1.5 pr-3">
|
||||
<TaskAssignee assignee={task.assignee} onAssigneeChange={handleAssigneeChange} isLoading={isUpdating} />
|
||||
|
||||
{/* Priority display (frontend-only for now) */}
|
||||
<TaskPriority priority={localPriority} onPriorityChange={handlePriorityChange} isLoading={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Clipboard, Edit, Trash2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useToast } from "../../../ui/hooks/useToast";
|
||||
import { cn, glassmorphism } from "../../../ui/primitives/styles";
|
||||
import { SimpleTooltip } from "../../../ui/primitives/tooltip";
|
||||
|
||||
interface TaskCardActionsProps {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const TaskCardActions: React.FC<TaskCardActionsProps> = ({
|
||||
taskId,
|
||||
taskTitle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleCopyId = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(taskId);
|
||||
showToast("Task ID copied to clipboard", "success");
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = taskId;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
showToast("Task ID copied to clipboard", "success");
|
||||
} catch {
|
||||
showToast("Failed to copy Task ID", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SimpleTooltip content={isDeleting ? "Deleting..." : "Delete task"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDeleting) onDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-300",
|
||||
glassmorphism.priority.critical.background,
|
||||
glassmorphism.priority.critical.text,
|
||||
glassmorphism.priority.critical.hover,
|
||||
glassmorphism.priority.critical.glow,
|
||||
isDeleting && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
aria-label={isDeleting ? "Deleting task..." : `Delete ${taskTitle}`}
|
||||
>
|
||||
<Trash2 className={cn("w-3 h-3", isDeleting && "animate-pulse")} />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
|
||||
<SimpleTooltip content="Edit task">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-300",
|
||||
"bg-cyan-100/80 dark:bg-cyan-500/20",
|
||||
"text-cyan-600 dark:text-cyan-400",
|
||||
"hover:bg-cyan-200 dark:hover:bg-cyan-500/30",
|
||||
"hover:shadow-[0_0_10px_rgba(34,211,238,0.3)]",
|
||||
)}
|
||||
aria-label={`Edit ${taskTitle}`}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
|
||||
<SimpleTooltip content="Copy Task ID">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyId}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-300",
|
||||
glassmorphism.priority.low.background,
|
||||
glassmorphism.priority.low.text,
|
||||
glassmorphism.priority.low.hover,
|
||||
glassmorphism.priority.low.glow,
|
||||
)}
|
||||
aria-label="Copy Task ID"
|
||||
>
|
||||
<Clipboard className="w-3 h-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
FormField,
|
||||
FormGrid,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TextArea,
|
||||
} from "../../../ui/primitives";
|
||||
import { useTaskEditor } from "../hooks";
|
||||
import type { Assignee, Task } from "../types";
|
||||
import { FeatureSelect } from "./FeatureSelect";
|
||||
import type { Priority } from "./TaskPriority";
|
||||
|
||||
interface TaskEditModalProps {
|
||||
isModalOpen: boolean;
|
||||
editingTask: Task | null;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ASSIGNEE_OPTIONS = ["User", "Archon", "AI IDE Agent"] as const;
|
||||
|
||||
export const TaskEditModal = memo(
|
||||
({ isModalOpen, editingTask, projectId, onClose, onSaved, onOpenChange }: TaskEditModalProps) => {
|
||||
const [localTask, setLocalTask] = useState<Partial<Task> | null>(null);
|
||||
|
||||
// Use business logic hook
|
||||
const { projectFeatures, saveTask, isLoadingFeatures, isSaving: isSavingTask } = useTaskEditor(projectId);
|
||||
|
||||
// Sync local state with editingTask when it changes
|
||||
useEffect(() => {
|
||||
if (editingTask) {
|
||||
setLocalTask(editingTask);
|
||||
} else {
|
||||
// Reset for new task
|
||||
setLocalTask({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
assignee: "User" as Assignee,
|
||||
feature: "",
|
||||
priority: "medium" as Priority, // Frontend-only priority
|
||||
});
|
||||
}
|
||||
}, [editingTask]);
|
||||
|
||||
// Memoized handlers for input changes
|
||||
const handleTitleChange = useCallback((value: string) => {
|
||||
setLocalTask((prev) => (prev ? { ...prev, title: value } : null));
|
||||
}, []);
|
||||
|
||||
const handleDescriptionChange = useCallback((value: string) => {
|
||||
setLocalTask((prev) => (prev ? { ...prev, description: value } : null));
|
||||
}, []);
|
||||
|
||||
const handleFeatureChange = useCallback((value: string) => {
|
||||
setLocalTask((prev) => (prev ? { ...prev, feature: value } : null));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// All validation is now in the hook
|
||||
saveTask(localTask, editingTask, () => {
|
||||
onSaved?.();
|
||||
onClose();
|
||||
});
|
||||
}, [localTask, editingTask, saveTask, onSaved, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={onOpenChange || ((open) => !open && onClose())}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTask?.id ? "Edit Task" : "New Task"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField>
|
||||
<Label required>Title</Label>
|
||||
<Input
|
||||
value={localTask?.title || ""}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label>Description</Label>
|
||||
<TextArea
|
||||
value={localTask?.description || ""}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="Enter task description"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormGrid columns={2}>
|
||||
<FormField>
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={localTask?.status || "todo"}
|
||||
onValueChange={(value) =>
|
||||
setLocalTask((prev) => (prev ? { ...prev, status: value as Task["status"] } : null))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">Todo</SelectItem>
|
||||
<SelectItem value="doing">Doing</SelectItem>
|
||||
<SelectItem value="review">Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
value={(localTask as Task & { priority?: Priority })?.priority || "medium"}
|
||||
onValueChange={(value) =>
|
||||
setLocalTask((prev) => (prev ? { ...prev, priority: value as Priority } : null))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
<FormGrid columns={2}>
|
||||
<FormField>
|
||||
<Label>Assignee</Label>
|
||||
<Select
|
||||
value={localTask?.assignee || "User"}
|
||||
onValueChange={(value) =>
|
||||
setLocalTask((prev) => (prev ? { ...prev, assignee: value as Assignee } : null))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSIGNEE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label>Feature</Label>
|
||||
<FeatureSelect
|
||||
value={localTask?.feature || ""}
|
||||
onChange={handleFeatureChange}
|
||||
projectFeatures={projectFeatures}
|
||||
isLoadingFeatures={isLoadingFeatures}
|
||||
placeholder="Select or create feature..."
|
||||
className="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose} variant="outline" disabled={isSavingTask}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="cyan"
|
||||
loading={isSavingTask}
|
||||
disabled={isSavingTask || !localTask?.title}
|
||||
>
|
||||
{editingTask?.id ? "Update Task" : "Create Task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TaskEditModal.displayName = "TaskEditModal";
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* TaskPriority Component
|
||||
*
|
||||
* Display-only priority selector for tasks.
|
||||
* NOTE: Priority is currently frontend-only and doesn't affect task ordering.
|
||||
* Task ordering is handled separately via drag-and-drop with task_order field.
|
||||
* This is purely for visual categorization until backend priority support is added.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "../../../ui/primitives/select";
|
||||
import { cn, glassmorphism } from "../../../ui/primitives/styles";
|
||||
|
||||
export type Priority = "critical" | "high" | "medium" | "low";
|
||||
|
||||
interface TaskPriorityProps {
|
||||
priority?: Priority;
|
||||
onPriorityChange?: (priority: Priority) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Priority options for the dropdown
|
||||
const PRIORITY_OPTIONS: Array<{
|
||||
value: Priority;
|
||||
label: string;
|
||||
color: string;
|
||||
}> = [
|
||||
{ value: "critical", label: "Critical", color: "text-red-600" },
|
||||
{ value: "high", label: "High", color: "text-orange-600" },
|
||||
{ value: "medium", label: "Medium", color: "text-blue-600" },
|
||||
{ value: "low", label: "Low", color: "text-gray-600" },
|
||||
];
|
||||
|
||||
export const TaskPriority: React.FC<TaskPriorityProps> = ({
|
||||
priority = "medium",
|
||||
onPriorityChange,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// Get priority-specific styling with Tron glow
|
||||
const getPriorityStyles = (priorityValue: Priority) => {
|
||||
switch (priorityValue) {
|
||||
case "critical":
|
||||
return {
|
||||
background: glassmorphism.priority.critical.background,
|
||||
text: glassmorphism.priority.critical.text,
|
||||
hover: glassmorphism.priority.critical.hover,
|
||||
glow: glassmorphism.priority.critical.glow,
|
||||
iconColor: "text-red-500",
|
||||
};
|
||||
case "high":
|
||||
return {
|
||||
background: glassmorphism.priority.high.background,
|
||||
text: glassmorphism.priority.high.text,
|
||||
hover: glassmorphism.priority.high.hover,
|
||||
glow: glassmorphism.priority.high.glow,
|
||||
iconColor: "text-orange-500",
|
||||
};
|
||||
case "medium":
|
||||
return {
|
||||
background: glassmorphism.priority.medium.background,
|
||||
text: glassmorphism.priority.medium.text,
|
||||
hover: glassmorphism.priority.medium.hover,
|
||||
glow: glassmorphism.priority.medium.glow,
|
||||
iconColor: "text-blue-500",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
background: glassmorphism.priority.low.background,
|
||||
text: glassmorphism.priority.low.text,
|
||||
hover: glassmorphism.priority.low.hover,
|
||||
glow: glassmorphism.priority.low.glow,
|
||||
iconColor: "text-gray-500",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const currentStyles = getPriorityStyles(priority);
|
||||
const currentOption = PRIORITY_OPTIONS.find((opt) => opt.value === priority) || PRIORITY_OPTIONS[2]; // Default to medium
|
||||
|
||||
// If no change handler, just show a static button
|
||||
if (!onPriorityChange) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium",
|
||||
"transition-all duration-300",
|
||||
currentStyles.background,
|
||||
currentStyles.text,
|
||||
"opacity-75 cursor-not-allowed",
|
||||
)}
|
||||
title={`Priority: ${currentOption.label}`}
|
||||
aria-label={`Priority: ${currentOption.label}`}
|
||||
>
|
||||
<AlertCircle className={cn("w-3 h-3", currentStyles.iconColor)} aria-hidden="true" />
|
||||
<span>{currentOption.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={priority} onValueChange={(value) => onPriorityChange(value as Priority)}>
|
||||
<SelectTrigger
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"h-auto px-2 py-1 rounded-full text-xs font-medium min-w-[80px]",
|
||||
"border-0 shadow-none", // Remove default border and shadow
|
||||
"transition-all duration-300",
|
||||
currentStyles.background,
|
||||
currentStyles.text,
|
||||
currentStyles.hover,
|
||||
currentStyles.glow,
|
||||
"backdrop-blur-md",
|
||||
)}
|
||||
showChevron={false}
|
||||
aria-label={`Priority: ${currentOption.label}${isLoading ? " (updating...)" : ""}`}
|
||||
aria-disabled={isLoading}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle className={cn("w-3 h-3", currentStyles.iconColor)} />
|
||||
<span>{currentOption.label}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent className="min-w-[120px]">
|
||||
{PRIORITY_OPTIONS.map((option) => {
|
||||
const optionStyles = getPriorityStyles(option.value);
|
||||
|
||||
return (
|
||||
<SelectItem key={option.value} value={option.value} className={option.color}>
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle className={cn("w-3 h-3", optionStyles.iconColor)} />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Task Management Components
|
||||
*
|
||||
* Simplified and refactored task components following vertical slice architecture.
|
||||
* Removed complex flip animations and over-engineering for better maintainability.
|
||||
*/
|
||||
|
||||
export { EditableTableCell } from "./EditableTableCell";
|
||||
export { FeatureSelect } from "./FeatureSelect";
|
||||
export { KanbanColumn } from "./KanbanColumn";
|
||||
export { TaskAssignee } from "./TaskAssignee";
|
||||
export type { TaskCardProps } from "./TaskCard";
|
||||
export { TaskCard } from "./TaskCard";
|
||||
export { TaskCardActions } from "./TaskCardActions";
|
||||
export { TaskEditModal } from "./TaskEditModal";
|
||||
export { TaskPriority as TaskPriorityComponent } from "./TaskPriority";
|
||||
19
archon-ui-main/src/features/projects/tasks/hooks/index.ts
Normal file
19
archon-ui-main/src/features/projects/tasks/hooks/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Task Hooks
|
||||
*
|
||||
* Business logic hooks for task management operations.
|
||||
* These hooks encapsulate the business logic that should NOT live in components.
|
||||
*/
|
||||
|
||||
// Business logic hooks
|
||||
export { useTaskActions } from "./useTaskActions";
|
||||
export { useTaskEditor } from "./useTaskEditor";
|
||||
|
||||
// TanStack Query hooks
|
||||
export {
|
||||
taskKeys,
|
||||
useCreateTask,
|
||||
useDeleteTask,
|
||||
useProjectTasks,
|
||||
useUpdateTask,
|
||||
} from "./useTaskQueries";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Assignee, Task, UseTaskActionsReturn } from "../types";
|
||||
import { useDeleteTask, useUpdateTask } from "./useTaskQueries";
|
||||
|
||||
export const useTaskActions = (projectId: string): UseTaskActionsReturn => {
|
||||
const updateTaskMutation = useUpdateTask(projectId);
|
||||
const deleteTaskMutation = useDeleteTask(projectId);
|
||||
|
||||
// Delete confirmation state - store full task object for proper modal display
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
|
||||
|
||||
// Assignee change handler
|
||||
const changeAssignee = useCallback(
|
||||
(taskId: string, newAssignee: string) => {
|
||||
updateTaskMutation.mutate({
|
||||
taskId,
|
||||
updates: { assignee: newAssignee as Assignee },
|
||||
});
|
||||
},
|
||||
[updateTaskMutation],
|
||||
);
|
||||
|
||||
// Delete task handler with confirmation flow - now accepts full task object
|
||||
const initiateDelete = useCallback((task: Task) => {
|
||||
setTaskToDelete(task);
|
||||
setShowDeleteConfirm(true);
|
||||
}, []);
|
||||
|
||||
// Confirm and execute deletion
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!taskToDelete) return;
|
||||
|
||||
deleteTaskMutation.mutate(taskToDelete.id, {
|
||||
onSuccess: () => {
|
||||
// Success toast handled by mutation
|
||||
setShowDeleteConfirm(false);
|
||||
setTaskToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete task:", error, { taskToDelete });
|
||||
// Error toast handled by mutation
|
||||
// Modal stays open on error so user can retry
|
||||
},
|
||||
});
|
||||
}, [deleteTaskMutation, taskToDelete]);
|
||||
|
||||
// Cancel deletion
|
||||
const cancelDelete = useCallback(() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setTaskToDelete(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Actions
|
||||
changeAssignee,
|
||||
initiateDelete,
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
|
||||
// State
|
||||
showDeleteConfirm,
|
||||
taskToDelete,
|
||||
|
||||
// Loading states
|
||||
isUpdating: updateTaskMutation.isPending,
|
||||
isDeleting: deleteTaskMutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "../../../ui/hooks/useToast";
|
||||
import { useProjectFeatures } from "../../hooks/useProjectQueries";
|
||||
import type { Assignee, CreateTaskRequest, Task, UpdateTaskRequest, UseTaskEditorReturn } from "../types";
|
||||
import { useCreateTask, useUpdateTask } from "./useTaskQueries";
|
||||
|
||||
export const useTaskEditor = (projectId: string): UseTaskEditorReturn => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// TanStack Query hooks
|
||||
const { data: featuresData, isLoading: isLoadingFeatures } = useProjectFeatures(projectId);
|
||||
const createTaskMutation = useCreateTask();
|
||||
const updateTaskMutation = useUpdateTask(projectId);
|
||||
|
||||
// Transform features data
|
||||
const projectFeatures = (featuresData?.features || []) as Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
const isSaving = createTaskMutation.isPending || updateTaskMutation.isPending;
|
||||
|
||||
// Get default order for new tasks based on status
|
||||
const getDefaultTaskOrder = useCallback((status: Task["status"]) => {
|
||||
// Simple priority mapping: todo=50, doing=25, review=75, done=100
|
||||
const statusOrderMap = { todo: 50, doing: 25, review: 75, done: 100 };
|
||||
return statusOrderMap[status] || 50;
|
||||
}, []);
|
||||
|
||||
// Build update object with only changed fields
|
||||
const buildTaskUpdates = useCallback((localTask: Partial<Task>, editingTask: Task) => {
|
||||
const updates: UpdateTaskRequest = {};
|
||||
|
||||
if (localTask.title !== editingTask.title) updates.title = localTask.title;
|
||||
if (localTask.description !== editingTask.description) updates.description = localTask.description;
|
||||
if (localTask.status !== editingTask.status) updates.status = localTask.status;
|
||||
if (localTask.assignee !== editingTask.assignee) updates.assignee = localTask.assignee || "User";
|
||||
if (localTask.task_order !== editingTask.task_order) updates.task_order = localTask.task_order;
|
||||
if (localTask.feature !== editingTask.feature) updates.feature = localTask.feature || "";
|
||||
|
||||
return updates;
|
||||
}, []);
|
||||
|
||||
// Build create request object
|
||||
const buildCreateRequest = useCallback(
|
||||
(localTask: Partial<Task>): CreateTaskRequest => {
|
||||
return {
|
||||
project_id: projectId,
|
||||
title: localTask.title || "",
|
||||
description: localTask.description || "",
|
||||
status: (localTask.status as Task["status"]) || "todo",
|
||||
assignee: (localTask.assignee as Assignee) || "User",
|
||||
feature: localTask.feature || "",
|
||||
task_order: localTask.task_order || getDefaultTaskOrder((localTask.status as Task["status"]) || "todo"),
|
||||
};
|
||||
},
|
||||
[projectId, getDefaultTaskOrder],
|
||||
);
|
||||
|
||||
// Save task (create or update) with full validation
|
||||
const saveTask = useCallback(
|
||||
async (localTask: Partial<Task> | null, editingTask: Task | null, onSuccess?: () => void) => {
|
||||
// Validation moved here from component
|
||||
if (!localTask) {
|
||||
showToast("No task data provided", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localTask.title?.trim()) {
|
||||
showToast("Task title is required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingTask?.id) {
|
||||
// Update existing task
|
||||
const updates = buildTaskUpdates(localTask, editingTask);
|
||||
|
||||
updateTaskMutation.mutate(
|
||||
{
|
||||
taskId: editingTask.id,
|
||||
updates,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Success toast handled by mutation
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update task:", error);
|
||||
// Error toast handled by mutation
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Create new task
|
||||
const newTaskData = buildCreateRequest(localTask);
|
||||
|
||||
createTaskMutation.mutate(newTaskData, {
|
||||
onSuccess: () => {
|
||||
// Success toast handled by mutation
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create task:", error);
|
||||
// Error toast handled by mutation
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[buildTaskUpdates, buildCreateRequest, updateTaskMutation, createTaskMutation, showToast],
|
||||
);
|
||||
|
||||
return {
|
||||
// Data
|
||||
projectFeatures,
|
||||
|
||||
// Actions
|
||||
saveTask,
|
||||
|
||||
// Loading states
|
||||
isLoadingFeatures,
|
||||
isSaving,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSmartPolling } from "../../../ui/hooks";
|
||||
import { useToast } from "../../../ui/hooks/useToast";
|
||||
import { projectKeys } from "../../hooks/useProjectQueries";
|
||||
import { taskService } from "../services";
|
||||
import type { CreateTaskRequest, Task, UpdateTaskRequest } from "../types";
|
||||
|
||||
// Query keys factory for tasks
|
||||
export const taskKeys = {
|
||||
all: (projectId: string) => ["projects", projectId, "tasks"] as const,
|
||||
};
|
||||
|
||||
// Fetch tasks for a specific project
|
||||
export function useProjectTasks(projectId: string | undefined, enabled = true) {
|
||||
const { refetchInterval } = useSmartPolling(5000); // 5 second base interval for faster MCP updates
|
||||
|
||||
return useQuery<Task[]>({
|
||||
queryKey: projectId ? taskKeys.all(projectId) : ["tasks-undefined"],
|
||||
queryFn: async () => {
|
||||
if (!projectId) throw new Error("No project ID");
|
||||
return taskService.getTasksByProject(projectId);
|
||||
},
|
||||
enabled: !!projectId && enabled,
|
||||
refetchInterval, // Smart interval based on page visibility/focus
|
||||
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
|
||||
staleTime: 10000, // Consider data stale after 10 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Create task mutation with optimistic updates
|
||||
export function useCreateTask() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData),
|
||||
onMutate: async (newTaskData) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.all(newTaskData.project_id) });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTasks = queryClient.getQueryData(taskKeys.all(newTaskData.project_id));
|
||||
|
||||
// Create optimistic task with temporary ID
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const optimisticTask: Task = {
|
||||
id: tempId, // Temporary ID until real one comes back
|
||||
...newTaskData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
// Ensure all required fields have defaults
|
||||
task_order: newTaskData.task_order ?? 100,
|
||||
status: newTaskData.status ?? "todo",
|
||||
assignee: newTaskData.assignee ?? "User",
|
||||
} as Task;
|
||||
|
||||
// Optimistically add the new task
|
||||
queryClient.setQueryData(taskKeys.all(newTaskData.project_id), (old: Task[] | undefined) => {
|
||||
if (!old) return [optimisticTask];
|
||||
return [...old, optimisticTask];
|
||||
});
|
||||
|
||||
return { previousTasks, tempId };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to create task:", error, { variables });
|
||||
// Rollback on error
|
||||
if (context?.previousTasks) {
|
||||
queryClient.setQueryData(taskKeys.all(variables.project_id), context.previousTasks);
|
||||
}
|
||||
showToast(`Failed to create task: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
// Replace optimistic task with real one from server
|
||||
queryClient.setQueryData(taskKeys.all(variables.project_id), (old: Task[] | undefined) => {
|
||||
if (!old) return [data];
|
||||
// Replace only the specific temp task with real one
|
||||
return old
|
||||
.map((task) => (task.id === context?.tempId ? data : task))
|
||||
.filter(
|
||||
(task, index, self) =>
|
||||
// Remove any duplicates just in case
|
||||
index === self.findIndex((t) => t.id === task.id),
|
||||
);
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
|
||||
showToast("Task created successfully", "success");
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Always refetch to ensure consistency after operation completes
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.all(variables.project_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update task mutation with optimistic updates
|
||||
export function useUpdateTask(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<Task, Error, { taskId: string; updates: UpdateTaskRequest }, { previousTasks?: Task[] }>({
|
||||
mutationFn: ({ taskId, updates }: { taskId: string; updates: UpdateTaskRequest }) =>
|
||||
taskService.updateTask(taskId, updates),
|
||||
onMutate: async ({ taskId, updates }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.all(projectId));
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<Task[]>(taskKeys.all(projectId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((task) => (task.id === taskId ? { ...task, ...updates } : task));
|
||||
});
|
||||
|
||||
return { previousTasks };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to update task:", error, { variables });
|
||||
// Rollback on error
|
||||
if (context?.previousTasks) {
|
||||
queryClient.setQueryData(taskKeys.all(projectId), context.previousTasks);
|
||||
}
|
||||
showToast(`Failed to update task: ${errorMessage}`, "error");
|
||||
// Refetch on error to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.all(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
|
||||
},
|
||||
onSuccess: (data, { updates }) => {
|
||||
// Merge server response to keep timestamps and computed fields in sync
|
||||
queryClient.setQueryData<Task[]>(taskKeys.all(projectId), (old) =>
|
||||
old ? old.map((t) => (t.id === data.id ? data : t)) : old,
|
||||
);
|
||||
// Only invalidate counts if status changed (which affects counts)
|
||||
if (updates.status) {
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
|
||||
// Show toast for significant status changes
|
||||
showToast(`Task moved to ${updates.status}`, "success");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete task mutation
|
||||
export function useDeleteTask(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<void, Error, string, { previousTasks?: Task[] }>({
|
||||
mutationFn: (taskId: string) => taskService.deleteTask(taskId),
|
||||
onMutate: async (taskId) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.all(projectId));
|
||||
|
||||
// Optimistically remove the task
|
||||
queryClient.setQueryData<Task[]>(taskKeys.all(projectId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.filter((task) => task.id !== taskId);
|
||||
});
|
||||
|
||||
return { previousTasks };
|
||||
},
|
||||
onError: (error, taskId, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to delete task:", error, { taskId });
|
||||
// Rollback on error
|
||||
if (context?.previousTasks) {
|
||||
queryClient.setQueryData(taskKeys.all(projectId), context.previousTasks);
|
||||
}
|
||||
showToast(`Failed to delete task: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast("Task deleted successfully", "success");
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch counts after deletion
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
|
||||
},
|
||||
});
|
||||
}
|
||||
10
archon-ui-main/src/features/projects/tasks/index.ts
Normal file
10
archon-ui-main/src/features/projects/tasks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Tasks Feature Module
|
||||
*
|
||||
* Sub-feature of projects for managing project tasks
|
||||
*/
|
||||
|
||||
export * from "./components";
|
||||
export * from "./hooks";
|
||||
export { TasksTab } from "./TasksTab";
|
||||
export * from "./types";
|
||||
80
archon-ui-main/src/features/projects/tasks/schemas/index.ts
Normal file
80
archon-ui-main/src/features/projects/tasks/schemas/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Base validation schemas
|
||||
export const DatabaseTaskStatusSchema = z.enum(["todo", "doing", "review", "done"]);
|
||||
export const TaskPrioritySchema = z.enum(["low", "medium", "high", "critical"]);
|
||||
|
||||
// Assignee schema - simplified to predefined options
|
||||
export const AssigneeSchema = z.enum(["User", "Archon", "AI IDE Agent"]);
|
||||
|
||||
// Task schemas
|
||||
export const CreateTaskSchema = z.object({
|
||||
project_id: z.string().uuid("Project ID must be a valid UUID"),
|
||||
parent_task_id: z.string().uuid("Parent task ID must be a valid UUID").optional(),
|
||||
title: z.string().min(1, "Task title is required").max(255, "Task title must be less than 255 characters"),
|
||||
description: z.string().max(10000, "Task description must be less than 10000 characters").default(""),
|
||||
status: DatabaseTaskStatusSchema.default("todo"),
|
||||
assignee: AssigneeSchema.default("User"),
|
||||
task_order: z.number().int().min(0).default(0),
|
||||
feature: z.string().max(100, "Feature name must be less than 100 characters").optional(),
|
||||
featureColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-F]{6}$/i, "Feature color must be a valid hex color")
|
||||
.optional(),
|
||||
priority: TaskPrioritySchema.default("medium"),
|
||||
sources: z.array(z.any()).default([]),
|
||||
code_examples: z.array(z.any()).default([]),
|
||||
});
|
||||
|
||||
export const UpdateTaskSchema = CreateTaskSchema.partial().omit({
|
||||
project_id: true,
|
||||
});
|
||||
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string().uuid("Task ID must be a valid UUID"),
|
||||
project_id: z.string().uuid("Project ID must be a valid UUID"),
|
||||
parent_task_id: z.string().uuid().optional(),
|
||||
title: z.string().min(1),
|
||||
description: z.string(),
|
||||
status: DatabaseTaskStatusSchema,
|
||||
assignee: AssigneeSchema,
|
||||
task_order: z.number().int().min(0),
|
||||
sources: z.array(z.any()).default([]),
|
||||
code_examples: z.array(z.any()).default([]),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
|
||||
// Extended UI properties
|
||||
feature: z.string().optional(),
|
||||
featureColor: z.string().optional(),
|
||||
priority: TaskPrioritySchema.optional(),
|
||||
});
|
||||
|
||||
// Update task status schema (for drag & drop operations)
|
||||
export const UpdateTaskStatusSchema = z.object({
|
||||
task_id: z.string().uuid("Task ID must be a valid UUID"),
|
||||
status: DatabaseTaskStatusSchema,
|
||||
});
|
||||
|
||||
// Validation helper functions
|
||||
export function validateTask(data: unknown) {
|
||||
return TaskSchema.safeParse(data);
|
||||
}
|
||||
|
||||
export function validateCreateTask(data: unknown) {
|
||||
return CreateTaskSchema.safeParse(data);
|
||||
}
|
||||
|
||||
export function validateUpdateTask(data: unknown) {
|
||||
return UpdateTaskSchema.safeParse(data);
|
||||
}
|
||||
|
||||
export function validateUpdateTaskStatus(data: unknown) {
|
||||
return UpdateTaskStatusSchema.safeParse(data);
|
||||
}
|
||||
|
||||
// Export type inference helpers
|
||||
export type CreateTaskInput = z.infer<typeof CreateTaskSchema>;
|
||||
export type UpdateTaskInput = z.infer<typeof UpdateTaskSchema>;
|
||||
export type UpdateTaskStatusInput = z.infer<typeof UpdateTaskStatusSchema>;
|
||||
export type TaskInput = z.infer<typeof TaskSchema>;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Task Services
|
||||
*
|
||||
* Service layer for task operations.
|
||||
* Part of the vertical slice architecture migration.
|
||||
*/
|
||||
|
||||
export { taskService } from "./taskService";
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Task Management Service
|
||||
* Focused service for task CRUD operations only
|
||||
*/
|
||||
|
||||
import { formatZodErrors, ValidationError } from "../../shared/api";
|
||||
import { callAPIWithETag, invalidateETagCache } from "../../shared/apiWithEtag";
|
||||
|
||||
import { validateCreateTask, validateUpdateTask, validateUpdateTaskStatus } from "../schemas";
|
||||
import type { CreateTaskRequest, DatabaseTaskStatus, Task, TaskCounts, UpdateTaskRequest } from "../types";
|
||||
|
||||
export const taskService = {
|
||||
/**
|
||||
* Get all tasks for a project
|
||||
*/
|
||||
async getTasksByProject(projectId: string): Promise<Task[]> {
|
||||
try {
|
||||
const tasks = await callAPIWithETag<Task[]>(`/api/projects/${projectId}/tasks`);
|
||||
|
||||
// Return tasks as-is; UI uses DB status values (todo/doing/review/done)
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get tasks for project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific task by ID
|
||||
*/
|
||||
async getTask(taskId: string): Promise<Task> {
|
||||
try {
|
||||
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`);
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get task ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
async createTask(taskData: CreateTaskRequest): Promise<Task> {
|
||||
// Validate input
|
||||
const validation = validateCreateTask(taskData);
|
||||
if (!validation.success) {
|
||||
throw new ValidationError(formatZodErrors(validation.error));
|
||||
}
|
||||
|
||||
try {
|
||||
// The validation.data already has defaults from schema
|
||||
const requestData = validation.data;
|
||||
|
||||
const task = await callAPIWithETag<Task>("/api/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
// Invalidate task list cache for the project
|
||||
invalidateETagCache(`/api/projects/${taskData.project_id}/tasks`);
|
||||
invalidateETagCache("/api/tasks/counts");
|
||||
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error("Failed to create task:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing task
|
||||
*/
|
||||
async updateTask(taskId: string, updates: UpdateTaskRequest): Promise<Task> {
|
||||
// Validate input
|
||||
const validation = validateUpdateTask(updates);
|
||||
if (!validation.success) {
|
||||
throw new ValidationError(formatZodErrors(validation.error));
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(validation.data),
|
||||
});
|
||||
|
||||
// Invalidate related caches
|
||||
// Note: We don't know the project_id here, so TanStack Query will handle invalidation
|
||||
invalidateETagCache("/api/tasks/counts");
|
||||
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update task ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task status (for drag & drop operations)
|
||||
*/
|
||||
async updateTaskStatus(taskId: string, status: DatabaseTaskStatus): Promise<Task> {
|
||||
// Validate input
|
||||
const validation = validateUpdateTaskStatus({
|
||||
task_id: taskId,
|
||||
status: status,
|
||||
});
|
||||
if (!validation.success) {
|
||||
throw new ValidationError(formatZodErrors(validation.error));
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the standard update task endpoint with JSON body
|
||||
const task = await callAPIWithETag<Task>(`/api/tasks/${taskId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
// Invalidate task counts cache when status changes
|
||||
invalidateETagCache("/api/tasks/counts");
|
||||
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update task status ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
*/
|
||||
async deleteTask(taskId: string): Promise<void> {
|
||||
try {
|
||||
await callAPIWithETag<void>(`/api/tasks/${taskId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Invalidate task counts cache after deletion
|
||||
invalidateETagCache("/api/tasks/counts");
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete task ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task order for better drag-and-drop support
|
||||
*/
|
||||
async updateTaskOrder(taskId: string, newOrder: number, newStatus?: DatabaseTaskStatus): Promise<Task> {
|
||||
try {
|
||||
const updates: UpdateTaskRequest = {
|
||||
task_order: newOrder,
|
||||
};
|
||||
|
||||
if (newStatus) {
|
||||
updates.status = newStatus;
|
||||
}
|
||||
|
||||
const task = await this.updateTask(taskId, updates);
|
||||
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update task order for ${taskId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks by status across all projects
|
||||
*/
|
||||
async getTasksByStatus(status: DatabaseTaskStatus): Promise<Task[]> {
|
||||
try {
|
||||
// Note: This method requires cross-project access
|
||||
// For now, we'll throw an error suggesting to use project-scoped queries
|
||||
throw new Error("getTasksByStatus requires cross-project access. Use getTasksByProject instead.");
|
||||
} catch (error) {
|
||||
console.error(`Failed to get tasks by status ${status}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get task counts for all projects in a single batch request
|
||||
* Optimized endpoint to avoid N+1 query problem
|
||||
*/
|
||||
async getTaskCountsForAllProjects(): Promise<Record<string, TaskCounts>> {
|
||||
try {
|
||||
const response = await callAPIWithETag<Record<string, TaskCounts>>("/api/projects/task-counts");
|
||||
return response || {};
|
||||
} catch (error) {
|
||||
console.error("Failed to get task counts for all projects:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
46
archon-ui-main/src/features/projects/tasks/types/hooks.ts
Normal file
46
archon-ui-main/src/features/projects/tasks/types/hooks.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Hook Type Definitions
|
||||
*
|
||||
* Type definitions for task-related hooks
|
||||
*/
|
||||
|
||||
import type { Task } from "./task";
|
||||
|
||||
/**
|
||||
* Return type for useTaskActions hook
|
||||
*/
|
||||
export interface UseTaskActionsReturn {
|
||||
// Actions
|
||||
changeAssignee: (taskId: string, newAssignee: string) => void;
|
||||
initiateDelete: (task: Task) => void;
|
||||
confirmDelete: () => void;
|
||||
cancelDelete: () => void;
|
||||
|
||||
// State
|
||||
showDeleteConfirm: boolean;
|
||||
taskToDelete: Task | null;
|
||||
|
||||
// Loading states
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useTaskEditor hook
|
||||
*/
|
||||
export interface UseTaskEditorReturn {
|
||||
// Data
|
||||
projectFeatures: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
|
||||
// Actions
|
||||
saveTask: (localTask: Partial<Task> | null, editingTask: Task | null, onSuccess?: () => void) => void;
|
||||
|
||||
// Loading states
|
||||
isLoadingFeatures: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
20
archon-ui-main/src/features/projects/tasks/types/index.ts
Normal file
20
archon-ui-main/src/features/projects/tasks/types/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Task Types
|
||||
*
|
||||
* All task-related types for the projects feature.
|
||||
*/
|
||||
|
||||
// Hook return types
|
||||
export type { UseTaskActionsReturn, UseTaskEditorReturn } from "./hooks";
|
||||
// Core task types (vertical slice architecture)
|
||||
export type {
|
||||
Assignee,
|
||||
CreateTaskRequest,
|
||||
DatabaseTaskStatus,
|
||||
Task,
|
||||
TaskCodeExample,
|
||||
TaskCounts,
|
||||
TaskPriority,
|
||||
TaskSource,
|
||||
UpdateTaskRequest,
|
||||
} from "./task";
|
||||
39
archon-ui-main/src/features/projects/tasks/types/priority.ts
Normal file
39
archon-ui-main/src/features/projects/tasks/types/priority.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Priority System Types
|
||||
*
|
||||
* Defines user-facing priority levels separate from task_order (which handles drag-and-drop positioning).
|
||||
* Priority is for display and user understanding, not for ordering logic.
|
||||
*/
|
||||
|
||||
export type TaskPriority = "critical" | "high" | "medium" | "low";
|
||||
|
||||
export interface TaskPriorityOption {
|
||||
value: number; // Maps to task_order values for backwards compatibility
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const TASK_PRIORITY_OPTIONS: readonly TaskPriorityOption[] = [
|
||||
{ value: 1, label: "Critical", color: "text-red-600" },
|
||||
{ value: 25, label: "High", color: "text-orange-600" },
|
||||
{ value: 50, label: "Medium", color: "text-blue-600" },
|
||||
{ value: 100, label: "Low", color: "text-gray-600" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Convert task_order value to TaskPriority enum
|
||||
*/
|
||||
export function getTaskPriorityFromTaskOrder(taskOrder: number): TaskPriority {
|
||||
if (taskOrder <= 1) return "critical";
|
||||
if (taskOrder <= 25) return "high";
|
||||
if (taskOrder <= 50) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task priority display properties from task_order
|
||||
*/
|
||||
export function getTaskPriorityOption(taskOrder: number): TaskPriorityOption {
|
||||
const priority = TASK_PRIORITY_OPTIONS.find((p) => p.value >= taskOrder);
|
||||
return priority || TASK_PRIORITY_OPTIONS[TASK_PRIORITY_OPTIONS.length - 1]; // Default to 'Low'
|
||||
}
|
||||
93
archon-ui-main/src/features/projects/tasks/types/task.ts
Normal file
93
archon-ui-main/src/features/projects/tasks/types/task.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Core Task Types
|
||||
*
|
||||
* Main task interfaces and types following vertical slice architecture
|
||||
*/
|
||||
|
||||
// Import priority type from priority.ts to avoid duplication
|
||||
import type { TaskPriority } from "./priority";
|
||||
export type { TaskPriority };
|
||||
|
||||
// Database status enum - using database values directly
|
||||
export type DatabaseTaskStatus = "todo" | "doing" | "review" | "done";
|
||||
|
||||
// Assignee type - simplified to predefined options
|
||||
export type Assignee = "User" | "Archon" | "AI IDE Agent";
|
||||
|
||||
// Task counts for project overview
|
||||
export interface TaskCounts {
|
||||
todo: number;
|
||||
doing: number;
|
||||
review: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
// Task source and code example types (replacing any)
|
||||
export type TaskSource =
|
||||
| {
|
||||
url: string;
|
||||
type: string;
|
||||
relevance: string;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type TaskCodeExample =
|
||||
| {
|
||||
file: string;
|
||||
function: string;
|
||||
purpose: string;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
|
||||
// Base Task interface (matches database schema)
|
||||
export interface Task {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: DatabaseTaskStatus;
|
||||
assignee: Assignee;
|
||||
task_order: number;
|
||||
feature?: string;
|
||||
sources?: TaskSource[];
|
||||
code_examples?: TaskCodeExample[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Soft delete fields
|
||||
archived?: boolean;
|
||||
archived_at?: string;
|
||||
archived_by?: string;
|
||||
|
||||
// Extended UI properties
|
||||
featureColor?: string;
|
||||
priority?: TaskPriority;
|
||||
}
|
||||
|
||||
// Request types
|
||||
export interface CreateTaskRequest {
|
||||
project_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status?: DatabaseTaskStatus;
|
||||
assignee?: Assignee;
|
||||
task_order?: number;
|
||||
feature?: string;
|
||||
featureColor?: string;
|
||||
priority?: TaskPriority;
|
||||
sources?: TaskSource[];
|
||||
code_examples?: TaskCodeExample[];
|
||||
}
|
||||
|
||||
export interface UpdateTaskRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: DatabaseTaskStatus;
|
||||
assignee?: Assignee;
|
||||
task_order?: number;
|
||||
feature?: string;
|
||||
featureColor?: string;
|
||||
priority?: TaskPriority;
|
||||
sources?: TaskSource[];
|
||||
code_examples?: TaskCodeExample[];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./task-ordering";
|
||||
export * from "./task-styles";
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Task ordering utilities that ensure integer precision
|
||||
*
|
||||
* Following alpha principles: detailed errors and no silent failures
|
||||
*/
|
||||
|
||||
import type { Task } from "../types";
|
||||
|
||||
export const ORDER_INCREMENT = 1000; // Large increment to avoid precision issues
|
||||
const MAX_ORDER = Number.MAX_SAFE_INTEGER - ORDER_INCREMENT;
|
||||
|
||||
/**
|
||||
* Calculate a default task order for new tasks in a status column
|
||||
* Always returns an integer to avoid float precision issues
|
||||
*/
|
||||
export function getDefaultTaskOrder(existingTasks: Task[]): number {
|
||||
if (existingTasks.length === 0) {
|
||||
return ORDER_INCREMENT; // Start at 1000 for first task
|
||||
}
|
||||
|
||||
// Find the maximum order in the existing tasks
|
||||
const maxOrder = Math.max(...existingTasks.map((task) => task.task_order || 0));
|
||||
|
||||
// Ensure we don't exceed safe integer limits
|
||||
if (maxOrder >= MAX_ORDER) {
|
||||
throw new Error(`Task order limit exceeded. Maximum safe order is ${MAX_ORDER}, got ${maxOrder}`);
|
||||
}
|
||||
|
||||
return maxOrder + ORDER_INCREMENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate task order when inserting between two tasks
|
||||
* Returns an integer that maintains proper ordering
|
||||
*/
|
||||
export function getInsertTaskOrder(beforeTask: Task | null, afterTask: Task | null): number {
|
||||
const beforeOrder = beforeTask?.task_order || 0;
|
||||
const afterOrder = afterTask?.task_order || beforeOrder + ORDER_INCREMENT * 2;
|
||||
|
||||
// If there's enough space between tasks, insert in the middle
|
||||
const gap = afterOrder - beforeOrder;
|
||||
if (gap > 1) {
|
||||
const middleOrder = beforeOrder + Math.floor(gap / 2);
|
||||
return middleOrder;
|
||||
}
|
||||
|
||||
// If no gap, push everything after up by increment
|
||||
return afterOrder + ORDER_INCREMENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a task within the same status column
|
||||
* Ensures integer precision and proper spacing
|
||||
*/
|
||||
export function getReorderTaskOrder(tasks: Task[], taskId: string, newIndex: number): number {
|
||||
const filteredTasks = tasks.filter((t) => t.id !== taskId);
|
||||
|
||||
if (filteredTasks.length === 0) {
|
||||
return ORDER_INCREMENT;
|
||||
}
|
||||
|
||||
// Sort tasks by current order
|
||||
const sortedTasks = [...filteredTasks].sort((a, b) => (a.task_order || 0) - (b.task_order || 0));
|
||||
|
||||
// Handle edge cases
|
||||
if (newIndex <= 0) {
|
||||
// Moving to first position
|
||||
const firstOrder = sortedTasks[0]?.task_order || ORDER_INCREMENT;
|
||||
return Math.max(ORDER_INCREMENT, firstOrder - ORDER_INCREMENT);
|
||||
}
|
||||
|
||||
if (newIndex >= sortedTasks.length) {
|
||||
// Moving to last position
|
||||
const lastOrder = sortedTasks[sortedTasks.length - 1]?.task_order || 0;
|
||||
return lastOrder + ORDER_INCREMENT;
|
||||
}
|
||||
|
||||
// Moving to middle position
|
||||
const beforeTask = sortedTasks[newIndex - 1];
|
||||
const afterTask = sortedTasks[newIndex];
|
||||
|
||||
return getInsertTaskOrder(beforeTask, afterTask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task order value
|
||||
* Ensures it's a safe integer for database storage
|
||||
*/
|
||||
export function validateTaskOrder(order: number): number {
|
||||
if (!Number.isInteger(order)) {
|
||||
console.warn(`Task order ${order} is not an integer, rounding to ${Math.round(order)}`);
|
||||
return Math.round(order);
|
||||
}
|
||||
|
||||
if (order > MAX_ORDER || order < 0) {
|
||||
throw new Error(`Task order ${order} is outside safe range [0, ${MAX_ORDER}]`);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Bot, User } from "lucide-react";
|
||||
import type { Assignee } from "../types";
|
||||
|
||||
// Drag and drop constants
|
||||
export const ItemTypes = {
|
||||
TASK: "task",
|
||||
};
|
||||
|
||||
// Get icon for assignee
|
||||
export const getAssigneeIcon = (assigneeName: Assignee) => {
|
||||
switch (assigneeName) {
|
||||
case "User":
|
||||
return <User className="w-4 h-4 text-blue-400" />;
|
||||
case "AI IDE Agent":
|
||||
return <Bot className="w-4 h-4 text-purple-400" />;
|
||||
case "Archon":
|
||||
return <img src="/logo-neon.png" alt="Archon" className="w-4 h-4" />;
|
||||
default:
|
||||
return <User className="w-4 h-4 text-blue-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get glow effect for assignee
|
||||
export const getAssigneeGlow = (assigneeName: Assignee) => {
|
||||
switch (assigneeName) {
|
||||
case "User":
|
||||
return "shadow-[0_0_10px_rgba(59,130,246,0.4)]";
|
||||
case "AI IDE Agent":
|
||||
return "shadow-[0_0_10px_rgba(168,85,247,0.4)]";
|
||||
case "Archon":
|
||||
return "shadow-[0_0_10px_rgba(34,211,238,0.4)]";
|
||||
default:
|
||||
return "shadow-[0_0_10px_rgba(59,130,246,0.4)]";
|
||||
}
|
||||
};
|
||||
|
||||
// Get color based on task priority/order
|
||||
export const getOrderColor = (order: number) => {
|
||||
if (order <= 3) return "bg-rose-500";
|
||||
if (order <= 6) return "bg-orange-500";
|
||||
if (order <= 10) return "bg-blue-500";
|
||||
return "bg-emerald-500";
|
||||
};
|
||||
|
||||
// Get glow effect based on task priority/order
|
||||
export const getOrderGlow = (order: number) => {
|
||||
if (order <= 3) return "shadow-[0_0_10px_rgba(244,63,94,0.7)]";
|
||||
if (order <= 6) return "shadow-[0_0_10px_rgba(249,115,22,0.7)]";
|
||||
if (order <= 10) return "shadow-[0_0_10px_rgba(59,130,246,0.7)]";
|
||||
return "shadow-[0_0_10px_rgba(16,185,129,0.7)]";
|
||||
};
|
||||
|
||||
// Get column header color based on status
|
||||
export const getColumnColor = (status: "todo" | "doing" | "review" | "done") => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return "text-gray-600 dark:text-gray-400";
|
||||
case "doing":
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
case "review":
|
||||
return "text-purple-600 dark:text-purple-400";
|
||||
case "done":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
}
|
||||
};
|
||||
|
||||
// Get column header glow based on status
|
||||
export const getColumnGlow = (status: "todo" | "doing" | "review" | "done") => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return "bg-gray-500/30";
|
||||
case "doing":
|
||||
return "bg-blue-500/30 shadow-[0_0_10px_2px_rgba(59,130,246,0.2)]";
|
||||
case "review":
|
||||
return "bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]";
|
||||
case "done":
|
||||
return "bg-green-500/30 shadow-[0_0_10px_2px_rgba(16,185,129,0.2)]";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { KanbanColumn } from "../components/KanbanColumn";
|
||||
import type { Task } from "../types";
|
||||
|
||||
interface BoardViewProps {
|
||||
tasks: Task[];
|
||||
projectId: string;
|
||||
onTaskMove: (taskId: string, newStatus: Task["status"]) => void;
|
||||
onTaskReorder: (taskId: string, targetIndex: number, status: Task["status"]) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskDelete?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export const BoardView = ({
|
||||
tasks,
|
||||
projectId,
|
||||
onTaskMove,
|
||||
onTaskReorder,
|
||||
onTaskEdit,
|
||||
onTaskDelete,
|
||||
}: BoardViewProps) => {
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
|
||||
// Simple task filtering for board view
|
||||
const getTasksByStatus = (status: Task["status"]) => {
|
||||
return tasks.filter((task) => task.status === status).sort((a, b) => a.task_order - b.task_order);
|
||||
};
|
||||
|
||||
// Column configuration
|
||||
const columns: Array<{ status: Task["status"]; title: string }> = [
|
||||
{ status: "todo", title: "Todo" },
|
||||
{ status: "doing", title: "Doing" },
|
||||
{ status: "review", title: "Review" },
|
||||
{ status: "done", title: "Done" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[70vh] relative">
|
||||
{/* Board Columns Grid */}
|
||||
<div className="grid grid-cols-4 gap-1 flex-1 p-2">
|
||||
{columns.map(({ status, title }) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
title={title}
|
||||
tasks={getTasksByStatus(status)}
|
||||
projectId={projectId}
|
||||
onTaskMove={onTaskMove}
|
||||
onTaskReorder={onTaskReorder}
|
||||
onTaskEdit={onTaskEdit}
|
||||
onTaskDelete={onTaskDelete}
|
||||
hoveredTaskId={hoveredTaskId}
|
||||
onTaskHover={setHoveredTaskId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
319
archon-ui-main/src/features/projects/tasks/views/TableView.tsx
Normal file
319
archon-ui-main/src/features/projects/tasks/views/TableView.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Check, Edit, Tag, Trash2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../ui/primitives";
|
||||
import { cn } from "../../../ui/primitives/styles";
|
||||
import { EditableTableCell } from "../components/EditableTableCell";
|
||||
import { TaskAssignee } from "../components/TaskAssignee";
|
||||
import { useDeleteTask, useUpdateTask } from "../hooks";
|
||||
import type { Assignee, Task } from "../types";
|
||||
import { getOrderColor, getOrderGlow, ItemTypes } from "../utils/task-styles";
|
||||
|
||||
interface TableViewProps {
|
||||
tasks: Task[];
|
||||
projectId: string;
|
||||
onTaskView?: (task: Task) => void;
|
||||
onTaskComplete?: (taskId: string) => void;
|
||||
onTaskDelete?: (task: Task) => void;
|
||||
onTaskReorder: (taskId: string, newOrder: number, status: Task["status"]) => void;
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface DraggableRowProps {
|
||||
task: Task;
|
||||
index: number;
|
||||
projectId: string;
|
||||
onTaskView?: (task: Task) => void;
|
||||
onTaskComplete?: (taskId: string) => void;
|
||||
onTaskDelete?: (task: Task) => void;
|
||||
onTaskReorder: (taskId: string, newOrder: number, status: Task["status"]) => void;
|
||||
}
|
||||
|
||||
const DraggableRow = ({
|
||||
task,
|
||||
index,
|
||||
projectId,
|
||||
onTaskView,
|
||||
onTaskComplete,
|
||||
onTaskDelete,
|
||||
onTaskReorder,
|
||||
}: DraggableRowProps) => {
|
||||
const updateTaskMutation = useUpdateTask(projectId);
|
||||
const deleteTaskMutation = useDeleteTask(projectId);
|
||||
const [localAssignee, setLocalAssignee] = useState<Assignee>(task.assignee);
|
||||
|
||||
// Drag and drop handlers
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.TASK,
|
||||
item: { id: task.id, index, status: task.status },
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: ItemTypes.TASK,
|
||||
hover: (draggedItem: { id: string; index: number; status: Task["status"] }, monitor) => {
|
||||
if (!monitor.isOver({ shallow: true })) return;
|
||||
if (draggedItem.id === task.id) return;
|
||||
if (draggedItem.status !== task.status) return;
|
||||
|
||||
const draggedIndex = draggedItem.index;
|
||||
const hoveredIndex = index;
|
||||
|
||||
if (draggedIndex === hoveredIndex) return;
|
||||
|
||||
// Move the task for visual feedback
|
||||
onTaskReorder(draggedItem.id, hoveredIndex, task.status);
|
||||
|
||||
// Update the dragged item's index
|
||||
draggedItem.index = hoveredIndex;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Handle field updates using mutations
|
||||
const handleUpdateField = async (field: keyof Task, value: string) => {
|
||||
const updates: Partial<Task> = { [field]: value };
|
||||
|
||||
await updateTaskMutation.mutateAsync({
|
||||
taskId: task.id,
|
||||
updates,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (newAssignee: Assignee) => {
|
||||
setLocalAssignee(newAssignee);
|
||||
updateTaskMutation.mutate({
|
||||
taskId: task.id,
|
||||
updates: { assignee: newAssignee },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (onTaskDelete) {
|
||||
onTaskDelete(task);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
if (onTaskComplete) {
|
||||
onTaskComplete(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onTaskView) {
|
||||
onTaskView(task);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={(node) => drag(drop(node))}
|
||||
className={cn(
|
||||
"group transition-all duration-200 cursor-move",
|
||||
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
|
||||
"hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70",
|
||||
"dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20",
|
||||
"border-b border-gray-200 dark:border-gray-800",
|
||||
isDragging && "opacity-50 scale-105 shadow-lg",
|
||||
isOver && "bg-cyan-100/50 dark:bg-cyan-900/20 border-cyan-400",
|
||||
)}
|
||||
>
|
||||
{/* Priority/Order Indicator */}
|
||||
<td className="w-1 p-0">
|
||||
<div className={cn("w-1 h-full", getOrderColor(task.task_order), getOrderGlow(task.task_order))} />
|
||||
</td>
|
||||
|
||||
{/* Title */}
|
||||
<td className="px-4 py-2">
|
||||
<EditableTableCell
|
||||
value={task.title}
|
||||
onSave={(value) => handleUpdateField("title", value)}
|
||||
placeholder="Enter task title"
|
||||
className="font-medium"
|
||||
isUpdating={updateTaskMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-2 w-32">
|
||||
<EditableTableCell
|
||||
value={task.status}
|
||||
onSave={(value) => handleUpdateField("status", value)}
|
||||
type="status"
|
||||
isUpdating={updateTaskMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Feature */}
|
||||
<td className="px-4 py-2 w-40">
|
||||
<div className="flex items-center gap-1">
|
||||
{task.feature && <Tag className="w-3 h-3 text-gray-500 dark:text-gray-400" />}
|
||||
<EditableTableCell
|
||||
value={task.feature || ""}
|
||||
onSave={(value) => handleUpdateField("feature", value)}
|
||||
placeholder="Add feature"
|
||||
className="text-sm"
|
||||
isUpdating={updateTaskMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Assignee */}
|
||||
<td className="px-4 py-2 w-36">
|
||||
<TaskAssignee
|
||||
assignee={localAssignee}
|
||||
onAssigneeChange={handleAssigneeChange}
|
||||
isLoading={updateTaskMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-2 w-28">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="xs" onClick={handleEdit} className="h-7 w-7 p-0">
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit task</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleComplete}
|
||||
className="h-7 w-7 p-0 text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark as complete</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleDelete}
|
||||
className="h-7 w-7 p-0 text-red-600 hover:text-red-700"
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete task</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export const TableView = ({
|
||||
tasks,
|
||||
projectId,
|
||||
onTaskView,
|
||||
onTaskComplete,
|
||||
onTaskDelete,
|
||||
onTaskReorder,
|
||||
}: TableViewProps) => {
|
||||
// Group tasks by status for better organization
|
||||
const groupedTasks = React.useMemo(() => {
|
||||
const groups: Record<Task["status"], Task[]> = {
|
||||
todo: [],
|
||||
doing: [],
|
||||
review: [],
|
||||
done: [],
|
||||
};
|
||||
|
||||
tasks.forEach((task) => {
|
||||
groups[task.status].push(task);
|
||||
});
|
||||
|
||||
// Sort each group by task_order
|
||||
Object.keys(groups).forEach((status) => {
|
||||
groups[status as Task["status"]].sort((a, b) => a.task_order - b.task_order);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [tasks]);
|
||||
|
||||
const statusOrder: Task["status"][] = ["todo", "doing", "review", "done"];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-b-2 border-gray-200 dark:border-gray-700">
|
||||
<th className="w-1"></th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Title</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Status</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-40">Feature</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-36">Assignee</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statusOrder.map((status) => {
|
||||
const statusTasks = groupedTasks[status];
|
||||
if (statusTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={status}>
|
||||
{/* Status group header */}
|
||||
<tr className="bg-gray-100/50 dark:bg-gray-800/50">
|
||||
<td colSpan={6} className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold uppercase tracking-wider",
|
||||
status === "todo" && "text-gray-600",
|
||||
status === "doing" && "text-blue-600",
|
||||
status === "review" && "text-purple-600",
|
||||
status === "done" && "text-green-600",
|
||||
)}
|
||||
>
|
||||
{status} ({statusTasks.length})
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Tasks in this status */}
|
||||
{statusTasks.map((task, index) => (
|
||||
<DraggableRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
projectId={projectId}
|
||||
onTaskView={onTaskView}
|
||||
onTaskComplete={onTaskComplete}
|
||||
onTaskDelete={onTaskDelete}
|
||||
onTaskReorder={onTaskReorder}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{tasks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-400">
|
||||
No tasks yet. Create your first task to get started.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BoardView } from "./BoardView";
|
||||
export { TableView } from "./TableView";
|
||||
25
archon-ui-main/src/features/projects/types/index.ts
Normal file
25
archon-ui-main/src/features/projects/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Project Feature Types
|
||||
*
|
||||
* Central barrel export for all project-related types.
|
||||
* Following vertical slice architecture - types are co-located with features.
|
||||
*/
|
||||
|
||||
// Document-related types from documents feature
|
||||
export type * from "../documents/types";
|
||||
|
||||
// Task-related types from tasks feature
|
||||
export type * from "../tasks/types";
|
||||
// Core project types (vertical slice architecture)
|
||||
export type {
|
||||
CreateProjectRequest,
|
||||
MCPToolResponse,
|
||||
PaginatedResponse,
|
||||
Project,
|
||||
ProjectCreationProgress,
|
||||
ProjectData,
|
||||
ProjectDocs,
|
||||
ProjectFeatures,
|
||||
ProjectPRD,
|
||||
UpdateProjectRequest,
|
||||
} from "./project";
|
||||
107
archon-ui-main/src/features/projects/types/project.ts
Normal file
107
archon-ui-main/src/features/projects/types/project.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Core Project Types
|
||||
*
|
||||
* Properly typed project interfaces following vertical slice architecture
|
||||
*/
|
||||
|
||||
// Project JSONB field types - replacing any with proper unions
|
||||
export type ProjectPRD = Record<string, unknown>;
|
||||
export type ProjectDocs = unknown[]; // Will be refined to ProjectDocument[] when fully migrated
|
||||
export type ProjectFeature = {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type ProjectFeatures = ProjectFeature[];
|
||||
export type ProjectData = unknown[];
|
||||
|
||||
// Project creation progress tracking
|
||||
export interface ProjectCreationProgress {
|
||||
progressId: string;
|
||||
status:
|
||||
| "starting"
|
||||
| "initializing_agents"
|
||||
| "generating_docs"
|
||||
| "processing_requirements"
|
||||
| "ai_generation"
|
||||
| "finalizing_docs"
|
||||
| "saving_to_database"
|
||||
| "completed"
|
||||
| "error";
|
||||
percentage: number;
|
||||
logs: string[];
|
||||
error?: string;
|
||||
step?: string;
|
||||
currentStep?: string;
|
||||
eta?: string;
|
||||
duration?: string;
|
||||
project?: Project; // Forward reference - will be resolved
|
||||
}
|
||||
|
||||
// Base Project interface (matches database schema)
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
prd?: ProjectPRD;
|
||||
docs?: ProjectDocs;
|
||||
features?: ProjectFeatures;
|
||||
data?: ProjectData;
|
||||
github_repo?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
technical_sources?: string[];
|
||||
business_sources?: string[];
|
||||
|
||||
// Extended UI properties
|
||||
description?: string;
|
||||
progress?: number;
|
||||
updated?: string; // Human-readable format
|
||||
pinned: boolean;
|
||||
|
||||
// Creation progress tracking for inline display
|
||||
creationProgress?: ProjectCreationProgress;
|
||||
}
|
||||
|
||||
// Request types
|
||||
export interface CreateProjectRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
github_repo?: string;
|
||||
pinned?: boolean;
|
||||
docs?: ProjectDocs;
|
||||
features?: ProjectFeatures;
|
||||
data?: ProjectData;
|
||||
technical_sources?: string[];
|
||||
business_sources?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateProjectRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
github_repo?: string;
|
||||
prd?: ProjectPRD;
|
||||
docs?: ProjectDocs;
|
||||
features?: ProjectFeatures;
|
||||
data?: ProjectData;
|
||||
technical_sources?: string[];
|
||||
business_sources?: string[];
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
// Utility types
|
||||
export interface MCPToolResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
12
archon-ui-main/src/features/projects/utils/index.ts
Normal file
12
archon-ui-main/src/features/projects/utils/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Project Utilities
|
||||
*
|
||||
* Shared utility functions for the projects feature.
|
||||
* Includes:
|
||||
* - Task status helpers
|
||||
* - Date formatting
|
||||
* - Project validation
|
||||
* - Constants and enums
|
||||
*/
|
||||
|
||||
// Utilities will be exported here as they're migrated
|
||||
239
archon-ui-main/src/features/projects/views/ProjectsView.tsx
Normal file
239
archon-ui-main/src/features/projects/views/ProjectsView.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
|
||||
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
|
||||
import { NewProjectModal } from "../components/NewProjectModal";
|
||||
import { ProjectHeader } from "../components/ProjectHeader";
|
||||
import { ProjectList } from "../components/ProjectList";
|
||||
import { DocsTab } from "../documents/DocsTab";
|
||||
import {
|
||||
projectKeys,
|
||||
useDeleteProject,
|
||||
useProjects,
|
||||
useTaskCounts,
|
||||
useUpdateProject,
|
||||
} from "../hooks/useProjectQueries";
|
||||
import { TasksTab } from "../tasks/TasksTab";
|
||||
import type { Project } from "../types";
|
||||
|
||||
interface ProjectsViewProps {
|
||||
className?: string;
|
||||
"data-id"?: string;
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.23, 1, 0.32, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
export function ProjectsView({ className = "", "data-id": dataId }: ProjectsViewProps) {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State management
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("tasks");
|
||||
const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
// React Query hooks
|
||||
const { data: projects = [], isLoading: isLoadingProjects, error: projectsError } = useProjects();
|
||||
const { data: taskCounts = {}, refetch: refetchTaskCounts } = useTaskCounts();
|
||||
|
||||
// Mutations
|
||||
const updateProjectMutation = useUpdateProject();
|
||||
const deleteProjectMutation = useDeleteProject();
|
||||
|
||||
// Sort projects - pinned first, then alphabetically
|
||||
const sortedProjects = useMemo(() => {
|
||||
return [...(projects as Project[])].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectSelect = useCallback(
|
||||
(project: Project) => {
|
||||
if (selectedProject?.id === project.id) return;
|
||||
|
||||
setSelectedProject(project);
|
||||
setActiveTab("tasks");
|
||||
navigate(`/projects/${project.id}`, { replace: true });
|
||||
},
|
||||
[selectedProject?.id, navigate],
|
||||
);
|
||||
|
||||
// Auto-select project based on URL or default to leftmost
|
||||
useEffect(() => {
|
||||
if (!sortedProjects.length) return;
|
||||
|
||||
// If there's a projectId in the URL, select that project
|
||||
if (projectId) {
|
||||
const project = sortedProjects.find((p) => p.id === projectId);
|
||||
if (project) {
|
||||
setSelectedProject(project);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, select the first (leftmost) project
|
||||
if (!selectedProject || !sortedProjects.find((p) => p.id === selectedProject.id)) {
|
||||
const defaultProject = sortedProjects[0];
|
||||
setSelectedProject(defaultProject);
|
||||
navigate(`/projects/${defaultProject.id}`, { replace: true });
|
||||
}
|
||||
}, [sortedProjects, projectId, selectedProject, navigate]);
|
||||
|
||||
// Refetch task counts when projects change
|
||||
useEffect(() => {
|
||||
if ((projects as Project[]).length > 0) {
|
||||
refetchTaskCounts();
|
||||
}
|
||||
}, [projects, refetchTaskCounts]);
|
||||
|
||||
// Handle pin toggle
|
||||
const handlePinProject = async (e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
const project = (projects as Project[]).find((p) => p.id === projectId);
|
||||
if (!project) return;
|
||||
|
||||
updateProjectMutation.mutate({
|
||||
projectId,
|
||||
updates: { pinned: !project.pinned },
|
||||
});
|
||||
};
|
||||
|
||||
// Handle delete project
|
||||
const handleDeleteProject = (e: React.MouseEvent, projectId: string, title: string) => {
|
||||
e.stopPropagation();
|
||||
setProjectToDelete({ id: projectId, title });
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDeleteProject = () => {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
deleteProjectMutation.mutate(projectToDelete.id, {
|
||||
onSuccess: () => {
|
||||
// Success toast handled by mutation
|
||||
setShowDeleteConfirm(false);
|
||||
setProjectToDelete(null);
|
||||
|
||||
// If we deleted the selected project, select another one
|
||||
if (selectedProject?.id === projectToDelete.id) {
|
||||
const remainingProjects = (projects as Project[]).filter((p) => p.id !== projectToDelete.id);
|
||||
if (remainingProjects.length > 0) {
|
||||
const nextProject = remainingProjects[0];
|
||||
setSelectedProject(nextProject);
|
||||
navigate(`/projects/${nextProject.id}`, { replace: true });
|
||||
} else {
|
||||
setSelectedProject(null);
|
||||
navigate("/projects", { replace: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const cancelDeleteProject = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setProjectToDelete(null);
|
||||
};
|
||||
|
||||
// Staggered entrance animation
|
||||
const isVisible = useStaggeredEntrance([1, 2, 3], 0.15);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={isVisible ? "visible" : "hidden"}
|
||||
variants={containerVariants}
|
||||
className={`max-w-full mx-auto ${className}`}
|
||||
data-id={dataId}
|
||||
>
|
||||
<ProjectHeader onNewProject={() => setIsNewProjectModalOpen(true)} />
|
||||
|
||||
<ProjectList
|
||||
projects={sortedProjects}
|
||||
selectedProject={selectedProject}
|
||||
taskCounts={taskCounts}
|
||||
isLoading={isLoadingProjects}
|
||||
error={projectsError as Error | null}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onPinProject={handlePinProject}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onRetry={() => queryClient.invalidateQueries({ queryKey: projectKeys.lists() })}
|
||||
/>
|
||||
|
||||
{/* Project Details Section */}
|
||||
{selectedProject && (
|
||||
<motion.div variants={itemVariants} className="relative">
|
||||
<Tabs defaultValue="tasks" value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="docs" className="py-3 font-mono transition-all duration-300" color="blue">
|
||||
Docs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="py-3 font-mono transition-all duration-300" color="orange">
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === "docs" && (
|
||||
<TabsContent value="docs" className="mt-0">
|
||||
<DocsTab project={selectedProject} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{activeTab === "tasks" && (
|
||||
<TabsContent value="tasks" className="mt-0">
|
||||
<TasksTab projectId={selectedProject.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<NewProjectModal
|
||||
open={isNewProjectModalOpen}
|
||||
onOpenChange={setIsNewProjectModalOpen}
|
||||
onSuccess={() => refetchTaskCounts()}
|
||||
/>
|
||||
|
||||
{showDeleteConfirm && projectToDelete && (
|
||||
<DeleteConfirmModal
|
||||
itemName={projectToDelete.title}
|
||||
onConfirm={confirmDeleteProject}
|
||||
onCancel={cancelDeleteProject}
|
||||
type="project"
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { FeatureErrorBoundary } from "../../ui/components";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
export const ProjectsViewWithBoundary = () => {
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<FeatureErrorBoundary featureName="Projects" onReset={reset}>
|
||||
<ProjectsView />
|
||||
</FeatureErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
138
archon-ui-main/src/features/ui/components/DeleteConfirmModal.tsx
Normal file
138
archon-ui-main/src/features/ui/components/DeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../primitives/alert-dialog";
|
||||
import { Button } from "../primitives/button";
|
||||
import { cn } from "../primitives/styles";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
itemName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
type: "project" | "task" | "client" | "document";
|
||||
size?: "compact" | "default" | "large";
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
||||
itemName,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
type,
|
||||
size = "default",
|
||||
open = false,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const TITLES: Record<DeleteConfirmModalProps["type"], string> = {
|
||||
project: "Delete Project",
|
||||
task: "Delete Task",
|
||||
client: "Delete MCP Client",
|
||||
document: "Delete Document",
|
||||
};
|
||||
|
||||
const MESSAGES: Record<DeleteConfirmModalProps["type"], (_n: string) => string> = {
|
||||
project: (_n) => `Are you sure you want to delete this project?`,
|
||||
task: (_n) => `Are you sure you want to delete this task?`,
|
||||
client: (_n) => `Are you sure you want to delete this client?`,
|
||||
document: (_n) => `Are you sure you want to delete this document?`,
|
||||
};
|
||||
|
||||
// Size-specific styling for icon
|
||||
const getIconStyles = () => {
|
||||
switch (size) {
|
||||
case "compact":
|
||||
return { container: "w-8 h-8", icon: "w-4 h-4" };
|
||||
case "large":
|
||||
return { container: "w-16 h-16", icon: "w-8 h-8" };
|
||||
default:
|
||||
return { container: "w-12 h-12", icon: "w-6 h-6" };
|
||||
}
|
||||
};
|
||||
|
||||
const iconStyles = getIconStyles();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange || ((o) => !o && onCancel())}>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
className={cn(
|
||||
size === "compact" && "max-w-sm",
|
||||
size === "large" && "max-w-lg",
|
||||
!size || (size === "default" && "max-w-md"),
|
||||
)}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<div className={`flex items-center gap-3 ${size === "compact" ? "mb-2" : "mb-3"}`}>
|
||||
<div
|
||||
className={cn(
|
||||
iconStyles.container,
|
||||
"rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Trash2 className={cn(iconStyles.icon, "text-red-600 dark:text-red-400")} />
|
||||
</div>
|
||||
<div>
|
||||
<AlertDialogTitle
|
||||
className={cn(
|
||||
size === "compact" && "text-base",
|
||||
size === "large" && "text-xl",
|
||||
!size || (size === "default" && "text-lg"),
|
||||
)}
|
||||
>
|
||||
{TITLES[type]}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
className={cn(
|
||||
size === "compact" && "text-xs",
|
||||
size === "large" && "text-base",
|
||||
!size || (size === "default" && "text-sm"),
|
||||
)}
|
||||
>
|
||||
This action cannot be undone
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-gray-700 dark:text-gray-300 mt-2",
|
||||
size === "compact" && "text-sm",
|
||||
size === "large" && "text-base",
|
||||
!size || (size === "default" && "text-base"),
|
||||
)}
|
||||
>
|
||||
{MESSAGES[type](itemName)}
|
||||
</p>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size={size === "compact" ? "sm" : size === "large" ? "lg" : "default"}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
variant="destructive"
|
||||
size={size === "compact" ? "sm" : size === "large" ? "lg" : "default"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Button } from "../primitives";
|
||||
import { cn, glassmorphism } from "../primitives/styles";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
featureName: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class FeatureErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log detailed error information for debugging in dev/test
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === "test") {
|
||||
// biome-ignore lint: intentional diagnostic log in development
|
||||
console.error(`Feature Error in ${this.props.featureName}:`, {
|
||||
error,
|
||||
errorInfo,
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const { error, errorInfo } = this.state;
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
return (
|
||||
<div className={cn("min-h-[400px] flex items-center justify-center p-8", glassmorphism.background.subtle)}>
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className="flex items-start gap-4" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-lg",
|
||||
"bg-red-500/10 dark:bg-red-500/20",
|
||||
"border border-red-500/20 dark:border-red-500/30",
|
||||
)}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-6 h-6 text-red-600 dark:text-red-400"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Feature Error: {this.props.featureName}
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
An error occurred in this feature. The error has been logged for investigation.
|
||||
</p>
|
||||
|
||||
{/* Show detailed error in alpha/development (following CLAUDE.md principles) */}
|
||||
{isDevelopment && error && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 p-4 rounded-lg overflow-auto max-h-[300px]",
|
||||
"bg-gray-100 dark:bg-gray-800",
|
||||
"border border-gray-300 dark:border-gray-600",
|
||||
"font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
<div className="text-red-600 dark:text-red-400 font-semibold mb-2">{error.toString()}</div>
|
||||
{error.stack && (
|
||||
<pre className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{error.stack}</pre>
|
||||
)}
|
||||
{errorInfo?.componentStack && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">
|
||||
<div className="text-gray-700 dark:text-gray-300 font-semibold mb-2">Component Stack:</div>
|
||||
<pre className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={this.handleReset} variant="default" size="sm" className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
54
archon-ui-main/src/features/ui/components/ToastProvider.tsx
Normal file
54
archon-ui-main/src/features/ui/components/ToastProvider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type React from "react";
|
||||
import { createToastContext, getToastIcon, ToastContext } from "../hooks/useToast";
|
||||
import {
|
||||
ToastProvider as RadixToastProvider,
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastViewport,
|
||||
} from "../primitives/toast";
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: React.ReactNode;
|
||||
duration?: number;
|
||||
swipeDirection?: "right" | "left" | "up" | "down";
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Provider Component
|
||||
* Wraps the app with Radix ToastProvider and manages toast state
|
||||
* Provides the same API as legacy ToastContext for easy migration
|
||||
*/
|
||||
export function ToastProvider({ children, duration = 4000, swipeDirection = "right" }: ToastProviderProps) {
|
||||
const { toasts, showToast, removeToast } = createToastContext();
|
||||
|
||||
return (
|
||||
<RadixToastProvider duration={duration} swipeDirection={swipeDirection}>
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toasts.map((toast) => {
|
||||
const Icon = getToastIcon(toast.type);
|
||||
const variantMap = {
|
||||
success: "success" as const,
|
||||
error: "error" as const,
|
||||
warning: "warning" as const,
|
||||
info: "default" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<Toast key={toast.id} variant={variantMap[toast.type]} duration={toast.duration || duration}>
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && <Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />}
|
||||
<div className="flex-1">
|
||||
<ToastDescription>{toast.message}</ToastDescription>
|
||||
</div>
|
||||
</div>
|
||||
<ToastClose onClick={() => removeToast(toast.id)} />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
</ToastContext.Provider>
|
||||
<ToastViewport />
|
||||
</RadixToastProvider>
|
||||
);
|
||||
}
|
||||
2
archon-ui-main/src/features/ui/components/index.ts
Normal file
2
archon-ui-main/src/features/ui/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DeleteConfirmModal } from "./DeleteConfirmModal";
|
||||
export { FeatureErrorBoundary } from "./FeatureErrorBoundary";
|
||||
2
archon-ui-main/src/features/ui/hooks/index.ts
Normal file
2
archon-ui-main/src/features/ui/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./useSmartPolling";
|
||||
export * from "./useThemeAware";
|
||||
66
archon-ui-main/src/features/ui/hooks/useSmartPolling.ts
Normal file
66
archon-ui-main/src/features/ui/hooks/useSmartPolling.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Smart polling hook that adjusts interval based on page visibility and focus
|
||||
*
|
||||
* Reduces unnecessary API calls when user is not actively using the app
|
||||
*/
|
||||
export function useSmartPolling(baseInterval: number = 10000) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [hasFocus, setHasFocus] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against SSR and non-browser environments
|
||||
if (typeof document === "undefined" || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
const handleFocus = () => setHasFocus(true);
|
||||
const handleBlur = () => setHasFocus(false);
|
||||
|
||||
// Set initial state
|
||||
setIsVisible(!document.hidden);
|
||||
setHasFocus(document.hasFocus());
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleFocus);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
// Cleanup with same guards
|
||||
if (typeof document !== "undefined" && typeof window !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate smart interval based on visibility and focus
|
||||
const getSmartInterval = (): number | false => {
|
||||
if (!isVisible) {
|
||||
// Page is hidden - disable polling
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasFocus) {
|
||||
// Page is visible but not focused - poll less frequently (1 minute)
|
||||
return 60000; // 60 seconds for background polling
|
||||
}
|
||||
|
||||
// Page is active - use normal interval
|
||||
return baseInterval;
|
||||
};
|
||||
|
||||
return {
|
||||
refetchInterval: getSmartInterval(),
|
||||
isActive: isVisible && hasFocus,
|
||||
isVisible,
|
||||
hasFocus,
|
||||
};
|
||||
}
|
||||
70
archon-ui-main/src/features/ui/hooks/useThemeAware.ts
Normal file
70
archon-ui-main/src/features/ui/hooks/useThemeAware.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Theme-aware utilities for Radix primitives
|
||||
* Works with existing ThemeContext
|
||||
*/
|
||||
|
||||
import { useTheme } from "../../../contexts/ThemeContext";
|
||||
|
||||
export function useThemeAware() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isLight = theme === "light";
|
||||
|
||||
// Get theme-specific values
|
||||
const getThemeValue = <T>(lightValue: T, darkValue: T): T => {
|
||||
return isDark ? darkValue : lightValue;
|
||||
};
|
||||
|
||||
// Get theme-specific colors for Tron effects
|
||||
const glowColors = {
|
||||
cyan: isDark
|
||||
? "rgba(34,211,238,0.7)" // Stronger glow in dark
|
||||
: "rgba(34,211,238,0.4)", // Softer glow in light
|
||||
blue: isDark ? "rgba(59,130,246,0.7)" : "rgba(59,130,246,0.4)",
|
||||
purple: isDark ? "rgba(168,85,247,0.7)" : "rgba(168,85,247,0.4)",
|
||||
};
|
||||
|
||||
// Get appropriate backdrop blur intensity
|
||||
const blurIntensity = isDark ? "backdrop-blur-md" : "backdrop-blur-sm";
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
isDark,
|
||||
isLight,
|
||||
getThemeValue,
|
||||
glowColors,
|
||||
blurIntensity,
|
||||
};
|
||||
}
|
||||
|
||||
// Theme-aware style presets for consistent look
|
||||
export const themeStyles = {
|
||||
// Card styles that adapt to theme
|
||||
card: {
|
||||
light: "bg-gradient-to-b from-white/80 to-white/60 border-gray-200",
|
||||
dark: "bg-gradient-to-b from-white/10 to-black/30 border-gray-700",
|
||||
both: "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-gray-700",
|
||||
},
|
||||
|
||||
// Panel styles (dropdowns, modals, etc.)
|
||||
panel: {
|
||||
light: "bg-gradient-to-b from-white/95 to-white/90 border-gray-200",
|
||||
dark: "bg-gradient-to-b from-gray-800/95 to-gray-900/95 border-gray-700",
|
||||
both: "bg-gradient-to-b from-white/95 to-white/90 dark:from-gray-800/95 dark:to-gray-900/95 border border-gray-200 dark:border-gray-700",
|
||||
},
|
||||
|
||||
// Text colors
|
||||
text: {
|
||||
primary: "text-gray-900 dark:text-white",
|
||||
secondary: "text-gray-600 dark:text-gray-400",
|
||||
muted: "text-gray-500 dark:text-gray-500",
|
||||
},
|
||||
|
||||
// Glow effects for Tron aesthetic
|
||||
glow: {
|
||||
cyan: "shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
blue: "shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]",
|
||||
purple: "shadow-[0_0_10px_2px_rgba(168,85,247,0.4)] dark:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]",
|
||||
},
|
||||
};
|
||||
82
archon-ui-main/src/features/ui/hooks/useToast.ts
Normal file
82
archon-ui-main/src/features/ui/hooks/useToast.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
// Toast types
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// Toast context type
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: Toast["type"], duration?: number) => void;
|
||||
}
|
||||
|
||||
// Create context
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Hook to show toast notifications
|
||||
* Provides the same API as legacy ToastContext for easy migration
|
||||
*/
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create toast context value with state management
|
||||
* Used internally by ToastProvider component
|
||||
*/
|
||||
export function createToastContext() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast["type"] = "info", duration = 4000) => {
|
||||
const id = Date.now().toString();
|
||||
const newToast: Toast = { id, message, type, duration };
|
||||
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
|
||||
// Auto-dismiss after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
};
|
||||
}
|
||||
|
||||
// Export context for provider
|
||||
export { ToastContext };
|
||||
|
||||
// Export toast type for external use
|
||||
export type { Toast, ToastContextType };
|
||||
|
||||
// Helper to get icon for toast type
|
||||
export function getToastIcon(type: Toast["type"]) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return CheckCircle;
|
||||
case "error":
|
||||
return XCircle;
|
||||
case "info":
|
||||
return Info;
|
||||
case "warning":
|
||||
return AlertCircle;
|
||||
}
|
||||
}
|
||||
136
archon-ui-main/src/features/ui/primitives/alert-dialog.tsx
Normal file
136
archon-ui-main/src/features/ui/primitives/alert-dialog.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import React from "react";
|
||||
import { cn, glassmorphism } from "./styles";
|
||||
|
||||
// Root
|
||||
export const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
// Portal
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
// Overlay with backdrop blur
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50",
|
||||
"bg-black/50 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
// Content with Tron glassmorphism
|
||||
export const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & {
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
const variantStyles = {
|
||||
default: cn(
|
||||
"before:bg-gradient-to-r before:from-cyan-500 before:to-fuchsia-500",
|
||||
"before:shadow-[0_0_10px_2px_rgba(34,211,238,0.4)]",
|
||||
"dark:before:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
),
|
||||
destructive: cn(
|
||||
"before:bg-red-500",
|
||||
"before:shadow-[0_0_10px_2px_rgba(239,68,68,0.4)]",
|
||||
"dark:before:shadow-[0_0_20px_5px_rgba(239,68,68,0.7)]",
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
|
||||
"p-6 rounded-md backdrop-blur-md",
|
||||
"w-full max-w-lg",
|
||||
glassmorphism.background.card,
|
||||
glassmorphism.border.default,
|
||||
glassmorphism.shadow.elevated,
|
||||
// Top gradient bar
|
||||
"before:content-[''] before:absolute before:top-0 before:left-0 before:right-0",
|
||||
"before:h-[2px] before:rounded-t-[4px]",
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
// Header
|
||||
export const AlertDialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
),
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
// Footer
|
||||
export const AlertDialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
// Title
|
||||
export 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-gray-900 dark:text-gray-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
// Description
|
||||
export 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-gray-600 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
// Action (main CTA button)
|
||||
export const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => <AlertDialogPrimitive.Action ref={ref} className={className} {...props} />);
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
// Cancel button
|
||||
export const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => <AlertDialogPrimitive.Cancel ref={ref} className={className} {...props} />);
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
132
archon-ui-main/src/features/ui/primitives/button.tsx
Normal file
132
archon-ui-main/src/features/ui/primitives/button.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "destructive" | "outline" | "ghost" | "link" | "cyan";
|
||||
size?: "default" | "sm" | "lg" | "icon" | "xs";
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", loading = false, disabled, children, ...props }, ref) => {
|
||||
const baseStyles = cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium",
|
||||
"transition-all duration-300",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
loading && "cursor-wait",
|
||||
);
|
||||
|
||||
const variants = {
|
||||
default: cn(
|
||||
"backdrop-blur-md",
|
||||
"bg-gradient-to-b from-cyan-500/90 to-cyan-600/90",
|
||||
"dark:from-cyan-400/80 dark:to-cyan-500/80",
|
||||
"text-white dark:text-gray-900",
|
||||
"border border-cyan-400/30 dark:border-cyan-300/30",
|
||||
"hover:from-cyan-400 hover:to-cyan-500",
|
||||
"dark:hover:from-cyan-300 dark:hover:to-cyan-400",
|
||||
"hover:shadow-[0_0_20px_rgba(34,211,238,0.5)]",
|
||||
"dark:hover:shadow-[0_0_25px_rgba(34,211,238,0.7)]",
|
||||
),
|
||||
destructive: cn(
|
||||
"backdrop-blur-md",
|
||||
"bg-gradient-to-b from-red-500/90 to-red-600/90",
|
||||
"dark:from-red-400/80 dark:to-red-500/80",
|
||||
"text-white",
|
||||
"border border-red-400/30 dark:border-red-300/30",
|
||||
"hover:from-red-400 hover:to-red-500",
|
||||
"dark:hover:from-red-300 dark:hover:to-red-400",
|
||||
"hover:shadow-[0_0_20px_rgba(239,68,68,0.5)]",
|
||||
"dark:hover:shadow-[0_0_25px_rgba(239,68,68,0.7)]",
|
||||
),
|
||||
outline: cn(
|
||||
"backdrop-blur-md",
|
||||
"bg-gradient-to-b from-white/50 to-white/30",
|
||||
"dark:from-gray-900/50 dark:to-black/50",
|
||||
"text-gray-900 dark:text-cyan-100",
|
||||
"border border-gray-300/50 dark:border-cyan-500/50",
|
||||
"hover:from-white/70 hover:to-white/50",
|
||||
"dark:hover:from-gray-900/70 dark:hover:to-black/70",
|
||||
"hover:border-cyan-500/50 dark:hover:border-cyan-400/50",
|
||||
"hover:shadow-[0_0_15px_rgba(34,211,238,0.3)]",
|
||||
"dark:hover:shadow-[0_0_20px_rgba(34,211,238,0.5)]",
|
||||
),
|
||||
ghost: cn(
|
||||
"text-gray-700 dark:text-cyan-100",
|
||||
"hover:bg-gray-100/50 dark:hover:bg-cyan-500/10",
|
||||
"hover:backdrop-blur-md",
|
||||
),
|
||||
link: cn(
|
||||
"text-cyan-600 dark:text-cyan-400",
|
||||
"underline-offset-4 hover:underline",
|
||||
"hover:text-cyan-500 dark:hover:text-cyan-300",
|
||||
),
|
||||
cyan: cn(
|
||||
"backdrop-blur-md",
|
||||
"bg-gradient-to-b from-cyan-100/80 to-white/60",
|
||||
"dark:from-cyan-500/20 dark:to-cyan-500/10",
|
||||
"text-cyan-700 dark:text-cyan-100",
|
||||
"border border-cyan-300/50 dark:border-cyan-500/50",
|
||||
"hover:from-cyan-200/90 hover:to-cyan-100/70",
|
||||
"dark:hover:from-cyan-400/30 dark:hover:to-cyan-500/20",
|
||||
"hover:shadow-[0_0_20px_rgba(34,211,238,0.5)]",
|
||||
"dark:hover:shadow-[0_0_25px_rgba(34,211,238,0.7)]",
|
||||
),
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
xs: "h-7 px-2 text-xs",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="Loading"
|
||||
role="img"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonProps, "size" | "children"> {
|
||||
icon: React.ReactNode;
|
||||
"aria-label": string;
|
||||
}
|
||||
|
||||
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(({ icon, className, ...props }, ref) => {
|
||||
return (
|
||||
<Button ref={ref} size="icon" className={cn("relative", className)} {...props}>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
IconButton.displayName = "IconButton";
|
||||
228
archon-ui-main/src/features/ui/primitives/combobox.tsx
Normal file
228
archon-ui-main/src/features/ui/primitives/combobox.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ComboBox Primitive
|
||||
*
|
||||
* A searchable dropdown component built with Radix UI Popover and Command
|
||||
* Provides autocomplete functionality with keyboard navigation
|
||||
*/
|
||||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "./button";
|
||||
import { cn } from "./styles";
|
||||
|
||||
export interface ComboBoxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ComboBoxProps {
|
||||
options: ComboBoxOption[];
|
||||
value?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
}
|
||||
|
||||
export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No results found.",
|
||||
className,
|
||||
isLoading = false,
|
||||
allowCustomValue = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Filter options based on search
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!search) return options;
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(searchLower) ||
|
||||
option.value.toLowerCase().includes(searchLower) ||
|
||||
option.description?.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
// Find current option label
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
const displayValue = selectedOption?.label || value || "";
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
// Handle custom value input
|
||||
const handleCustomValue = () => {
|
||||
if (allowCustomValue && search && !filteredOptions.some((opt) => opt.label === search)) {
|
||||
onValueChange(search);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
};
|
||||
|
||||
// Focus input when opening
|
||||
React.useEffect(() => {
|
||||
if (open && inputRef.current) {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!displayValue && "text-gray-500 dark:text-gray-400",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
displayValue || placeholder
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className={cn(
|
||||
"w-full min-w-[var(--radix-popover-trigger-width)] max-h-[300px] p-1",
|
||||
"bg-gradient-to-b from-white/95 to-white/90",
|
||||
"dark:from-gray-900/95 dark:to-black/95",
|
||||
"backdrop-blur-xl",
|
||||
"border border-gray-200 dark:border-gray-700",
|
||||
"rounded-lg shadow-xl",
|
||||
"shadow-cyan-500/10 dark:shadow-cyan-400/10",
|
||||
"z-50",
|
||||
)}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="p-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && allowCustomValue && search) {
|
||||
e.preventDefault();
|
||||
handleCustomValue();
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm",
|
||||
"bg-white/50 dark:bg-black/50",
|
||||
"border border-gray-200 dark:border-gray-700",
|
||||
"rounded-md",
|
||||
"text-gray-900 dark:text-white",
|
||||
"placeholder-gray-500 dark:placeholder-gray-400",
|
||||
"focus:outline-none focus:border-cyan-400",
|
||||
"focus:shadow-[0_0_10px_rgba(34,211,238,0.2)]",
|
||||
"transition-all duration-200",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options List */}
|
||||
<div className="overflow-y-auto max-h-[200px] p-1">
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
||||
Loading options...
|
||||
</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{emptyMessage}
|
||||
{allowCustomValue && search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomValue}
|
||||
className={cn(
|
||||
"mt-2 block w-full",
|
||||
"px-3 py-1.5 text-left text-sm",
|
||||
"bg-cyan-50/50 dark:bg-cyan-900/20",
|
||||
"text-cyan-600 dark:text-cyan-400",
|
||||
"rounded-md",
|
||||
"hover:bg-cyan-100/50 dark:hover:bg-cyan-800/30",
|
||||
"transition-colors duration-200",
|
||||
)}
|
||||
>
|
||||
Create "{search}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
"relative flex w-full items-center px-3 py-2",
|
||||
"text-sm rounded-md",
|
||||
"hover:bg-gray-100/80 dark:hover:bg-white/10",
|
||||
"text-gray-900 dark:text-white",
|
||||
"transition-colors duration-200",
|
||||
"focus:outline-none focus:bg-gray-100/80 dark:focus:bg-white/10",
|
||||
value === option.value && "bg-cyan-50/50 dark:bg-cyan-900/20",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100 text-cyan-600 dark:text-cyan-400" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{option.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComboBox.displayName = "ComboBox";
|
||||
133
archon-ui-main/src/features/ui/primitives/dialog.tsx
Normal file
133
archon-ui-main/src/features/ui/primitives/dialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
// Dialog Root and Trigger
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogPortal = DialogPrimitive.Portal;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
// Dialog Overlay with glassmorphism
|
||||
export 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",
|
||||
"backdrop-blur-sm bg-black/50 dark:bg-black/70",
|
||||
"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;
|
||||
|
||||
// Dialog Content with Tron-style glassmorphism
|
||||
export const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
|
||||
"p-6 rounded-md backdrop-blur-md",
|
||||
"w-full max-w-2xl",
|
||||
// Matching original glassmorphism
|
||||
"bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||
"border border-gray-200 dark:border-zinc-800/50",
|
||||
"shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
// Top gradient bar (matching original)
|
||||
"before:content-[''] before:absolute before:top-0 before:left-0 before:right-0",
|
||||
"before:h-[2px] before:rounded-t-[4px]",
|
||||
"before:bg-gradient-to-r before:from-cyan-500 before:to-fuchsia-500",
|
||||
"before:shadow-[0_0_10px_2px_rgba(34,211,238,0.4)]",
|
||||
"dark:before:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
// Top gradient glow (matching original)
|
||||
"after:content-[''] after:absolute after:top-0 after:left-0 after:right-0",
|
||||
"after:h-16 after:bg-gradient-to-b",
|
||||
"after:from-cyan-100 after:to-white dark:after:from-cyan-500/20 dark:after:to-fuchsia-500/5",
|
||||
"after:rounded-t-md after:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10">{children}</div>
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute right-4 top-4 z-20",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"hover:text-gray-700 dark:hover:text-white",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
// Dialog Header
|
||||
export const DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 text-center sm:text-left", "mb-4", className)} {...props} />
|
||||
),
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
// Dialog Footer
|
||||
export const DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "mt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
// Dialog Title
|
||||
export const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xl font-bold",
|
||||
"bg-gradient-to-r from-cyan-400 to-fuchsia-500",
|
||||
"text-transparent bg-clip-text",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
// Dialog Description
|
||||
export 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-gray-600 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
161
archon-ui-main/src/features/ui/primitives/dropdown-menu.tsx
Normal file
161
archon-ui-main/src/features/ui/primitives/dropdown-menu.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, Circle } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn, glassmorphism } from "./styles";
|
||||
|
||||
// Core components
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
// Dropdown Menu Content with Tron glassmorphism
|
||||
export const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[10000] min-w-[8rem] overflow-hidden rounded-lg p-1",
|
||||
// Glassmorphism
|
||||
glassmorphism.background.strong,
|
||||
glassmorphism.border.default,
|
||||
glassmorphism.shadow.lg,
|
||||
// Animation
|
||||
"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;
|
||||
|
||||
// Menu Item with hover effects
|
||||
export const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
icon?: React.ReactNode;
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, icon, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"transition-all duration-150 outline-none",
|
||||
glassmorphism.interactive.hover,
|
||||
"focus:bg-cyan-500/10 dark:focus:bg-cyan-400/10",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
// Checkbox Item
|
||||
export 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-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm",
|
||||
"transition-all duration-150 outline-none",
|
||||
glassmorphism.interactive.hover,
|
||||
"focus:bg-cyan-500/10 dark:focus:bg-cyan-400/10",
|
||||
"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;
|
||||
|
||||
// Radio Item
|
||||
export 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-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm",
|
||||
"transition-all duration-150 outline-none",
|
||||
glassmorphism.interactive.hover,
|
||||
"focus:bg-cyan-500/10 dark:focus:bg-cyan-400/10",
|
||||
"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;
|
||||
|
||||
// Label
|
||||
export 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-xs font-semibold text-gray-600 dark:text-gray-400", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
// Separator
|
||||
export 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-gray-200 dark:bg-gray-700", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
// Shortcut
|
||||
export const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn("ml-auto text-xs tracking-widest text-gray-500 dark:text-gray-400", className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
26
archon-ui-main/src/features/ui/primitives/index.ts
Normal file
26
archon-ui-main/src/features/ui/primitives/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Radix UI Primitives with Glassmorphism Styling
|
||||
*
|
||||
* This is our design system foundation for the /features directory.
|
||||
* All new components in features should use these primitives.
|
||||
*
|
||||
* Migration strategy:
|
||||
* - Old components in /components use legacy custom UI
|
||||
* - New components in /features use these Radix primitives
|
||||
* - Gradually migrate as we refactor
|
||||
*/
|
||||
|
||||
export * from "./alert-dialog";
|
||||
|
||||
// Export all primitives
|
||||
export * from "./button";
|
||||
export * from "./combobox";
|
||||
export * from "./dialog";
|
||||
export * from "./dropdown-menu";
|
||||
export * from "./input";
|
||||
export * from "./select";
|
||||
// Export style utilities
|
||||
export * from "./styles";
|
||||
export * from "./tabs";
|
||||
export * from "./toast";
|
||||
export * from "./tooltip";
|
||||
110
archon-ui-main/src/features/ui/primitives/input.tsx
Normal file
110
archon-ui-main/src/features/ui/primitives/input.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, error, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"w-full rounded-md py-2 px-3",
|
||||
"bg-white/50 dark:bg-black/70",
|
||||
"border border-gray-300 dark:border-gray-700",
|
||||
"text-gray-900 dark:text-white",
|
||||
"placeholder:text-gray-500 dark:placeholder:text-gray-400",
|
||||
"focus:outline-none focus:border-cyan-400",
|
||||
"focus:shadow-[0_0_10px_rgba(34,211,238,0.2)]",
|
||||
"transition-all duration-300",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
error && "border-red-500 dark:border-red-400 focus:border-red-500 focus:shadow-[0_0_10px_rgba(239,68,68,0.2)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full rounded-md py-2 px-3",
|
||||
"bg-white/50 dark:bg-black/70",
|
||||
"border border-gray-300 dark:border-gray-700",
|
||||
"text-gray-900 dark:text-white",
|
||||
"placeholder:text-gray-500 dark:placeholder:text-gray-400",
|
||||
"focus:outline-none focus:border-cyan-400",
|
||||
"focus:shadow-[0_0_10px_rgba(34,211,238,0.2)]",
|
||||
"transition-all duration-300",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"resize-y min-h-[80px]",
|
||||
error && "border-red-500 dark:border-red-400 focus:border-red-500 focus:shadow-[0_0_10px_rgba(239,68,68,0.2)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextArea.displayName = "TextArea";
|
||||
|
||||
// Label component for form fields
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, children, required, ...props }, ref) => {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: htmlFor is passed through props spread
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn("block text-sm font-medium", "text-gray-700 dark:text-gray-300", "mb-1", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Label.displayName = "Label";
|
||||
|
||||
// FormField wrapper for consistent spacing
|
||||
export interface FormFieldProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FormField: React.FC<FormFieldProps> = ({ children, className }) => {
|
||||
return <div className={cn("space-y-1", className)}>{children}</div>;
|
||||
};
|
||||
|
||||
// FormGrid for two-column layouts
|
||||
export interface FormGridProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
columns?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export const FormGrid: React.FC<FormGridProps> = ({ children, className, columns = 2 }) => {
|
||||
const gridCols = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-2",
|
||||
3: "grid-cols-3",
|
||||
};
|
||||
|
||||
return <div className={cn("grid gap-4", gridCols[columns], className)}>{children}</div>;
|
||||
};
|
||||
136
archon-ui-main/src/features/ui/primitives/select.tsx
Normal file
136
archon-ui-main/src/features/ui/primitives/select.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
// Select Root - just re-export
|
||||
export const Select = SelectPrimitive.Root;
|
||||
export const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
// Select Trigger with glassmorphism styling
|
||||
export const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
showChevron?: boolean;
|
||||
}
|
||||
>(({ className = "", children, showChevron = true, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-3 py-2 rounded-lg",
|
||||
"backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60",
|
||||
"dark:from-white/10 dark:to-black/30",
|
||||
"border border-gray-200 dark:border-gray-700",
|
||||
"transition-all duration-200",
|
||||
"hover:border-cyan-400/50 hover:shadow-[0_0_10px_rgba(34,211,238,0.2)]",
|
||||
"focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_15px_rgba(34,211,238,0.3)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"data-[placeholder]:text-gray-500 dark:data-[placeholder]:text-gray-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showChevron && (
|
||||
<SelectPrimitive.Icon className="ml-auto">
|
||||
<ChevronDown className="w-3 h-3 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
// Select Content with glassmorphism and Portal for z-index solution
|
||||
export 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-[10000] min-w-[8rem] overflow-hidden rounded-lg",
|
||||
// Matching our card glassmorphism
|
||||
"backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60",
|
||||
"dark:from-white/10 dark:to-black/30",
|
||||
"border border-gray-200 dark:border-zinc-800/50",
|
||||
// Tron shadow with subtle cyan glow
|
||||
"shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
"shadow-cyan-500/5 dark:shadow-cyan-500/10",
|
||||
// Text colors matching rest of app
|
||||
"text-gray-900 dark:text-gray-100",
|
||||
// Animation
|
||||
"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,
|
||||
)}
|
||||
position={position}
|
||||
sideOffset={5}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
// Select Item with hover effects
|
||||
export const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
>(({ className = "", children, icon, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex items-center text-sm outline-none",
|
||||
"transition-all duration-150 cursor-pointer rounded-md",
|
||||
"pl-8 pr-3 py-2", // Added left padding for checkmark space
|
||||
// Text colors
|
||||
"text-gray-700 dark:text-gray-200",
|
||||
// Hover state with subtle cyan tint
|
||||
"hover:bg-cyan-500/20 dark:hover:bg-cyan-400/20",
|
||||
"hover:text-gray-900 dark:hover:text-white",
|
||||
// Focus state
|
||||
"focus:bg-cyan-500/20 dark:focus:bg-cyan-400/20",
|
||||
"focus:text-gray-900 dark:focus:text-white",
|
||||
// Disabled state
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
// Selected/checked state with stronger cyan
|
||||
"data-[state=checked]:bg-cyan-500/30 dark:data-[state=checked]:bg-cyan-400/30",
|
||||
"data-[state=checked]:text-cyan-700 dark:data-[state=checked]:text-cyan-300",
|
||||
"data-[state=checked]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator className="absolute left-2 flex h-4 w-4 items-center justify-center">
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
<SelectPrimitive.ItemText className="flex items-center gap-2">
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
// Export group and label for completeness
|
||||
export const SelectGroup = SelectPrimitive.Group;
|
||||
export const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={`px-2 py-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
141
archon-ui-main/src/features/ui/primitives/styles.ts
Normal file
141
archon-ui-main/src/features/ui/primitives/styles.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Shared style utilities for Radix primitives
|
||||
* Tron-inspired glassmorphism design system
|
||||
*
|
||||
* Theme Support:
|
||||
* - All styles use Tailwind's dark: prefix for automatic theme switching
|
||||
* - Theme is managed by ThemeContext (light/dark)
|
||||
* - For runtime theme values, use useThemeAware hook
|
||||
*/
|
||||
|
||||
// Base glassmorphism classes with Tron aesthetic
|
||||
export const glassmorphism = {
|
||||
// Background variations - matching existing Card.tsx patterns
|
||||
background: {
|
||||
subtle: "backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||
strong: "backdrop-blur-md bg-gradient-to-b from-white/95 to-white/90 dark:from-gray-800/95 dark:to-gray-900/95",
|
||||
card: "backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||
// Tron-style colored backgrounds
|
||||
cyan: "backdrop-blur-md bg-gradient-to-b from-cyan-100/80 dark:from-cyan-500/20 to-white/60 dark:to-cyan-500/5",
|
||||
blue: "backdrop-blur-md bg-gradient-to-b from-blue-100/80 dark:from-blue-500/20 to-white/60 dark:to-blue-500/5",
|
||||
purple:
|
||||
"backdrop-blur-md bg-gradient-to-b from-purple-100/80 dark:from-purple-500/20 to-white/60 dark:to-purple-500/5",
|
||||
},
|
||||
|
||||
// Border styles with neon glow
|
||||
border: {
|
||||
default: "border border-gray-200 dark:border-gray-700",
|
||||
cyan: "border-cyan-300 dark:border-cyan-500/30",
|
||||
blue: "border-blue-300 dark:border-blue-500/30",
|
||||
purple: "border-purple-300 dark:border-purple-500/30",
|
||||
focus: "focus:border-cyan-500 focus:shadow-[0_0_20px_5px_rgba(34,211,238,0.5)]",
|
||||
hover: "hover:border-cyan-400/70 hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]",
|
||||
},
|
||||
|
||||
// Interactive states
|
||||
interactive: {
|
||||
base: "transition-all duration-200",
|
||||
hover: "hover:bg-cyan-500/10 dark:hover:bg-cyan-400/10",
|
||||
active: "active:bg-cyan-500/20 dark:active:bg-cyan-400/20",
|
||||
selected:
|
||||
"data-[state=checked]:bg-cyan-500/20 dark:data-[state=checked]:bg-cyan-400/20 data-[state=checked]:text-cyan-700 dark:data-[state=checked]:text-cyan-300",
|
||||
disabled: "disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
},
|
||||
|
||||
// Animation presets
|
||||
animation: {
|
||||
fadeIn:
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
slideIn: "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
slideFromTop: "data-[side=bottom]:slide-in-from-top-2",
|
||||
slideFromBottom: "data-[side=top]:slide-in-from-bottom-2",
|
||||
slideFromLeft: "data-[side=right]:slide-in-from-left-2",
|
||||
slideFromRight: "data-[side=left]:slide-in-from-right-2",
|
||||
},
|
||||
|
||||
// Shadow effects with Tron-style neon glow
|
||||
shadow: {
|
||||
sm: "shadow-sm dark:shadow-md",
|
||||
md: "shadow-md dark:shadow-lg",
|
||||
lg: "shadow-lg dark:shadow-2xl",
|
||||
elevated: "shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
|
||||
// Neon glow effects matching Card.tsx
|
||||
glow: {
|
||||
cyan: "shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
blue: "shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]",
|
||||
purple: "shadow-[0_0_10px_2px_rgba(168,85,247,0.4)] dark:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]",
|
||||
green: "shadow-[0_0_10px_2px_rgba(16,185,129,0.4)] dark:shadow-[0_0_20px_5px_rgba(16,185,129,0.7)]",
|
||||
pink: "shadow-[0_0_10px_2px_rgba(236,72,153,0.4)] dark:shadow-[0_0_20px_5px_rgba(236,72,153,0.7)]",
|
||||
orange: "shadow-[0_0_10px_2px_rgba(251,146,60,0.4)] dark:shadow-[0_0_20px_5px_rgba(251,146,60,0.7)]",
|
||||
},
|
||||
},
|
||||
|
||||
// Priority colors (matching our task system)
|
||||
priority: {
|
||||
critical: {
|
||||
background: "bg-red-100/80 dark:bg-red-500/20",
|
||||
text: "text-red-600 dark:text-red-400",
|
||||
hover: "hover:bg-red-200 dark:hover:bg-red-500/30",
|
||||
glow: "hover:shadow-[0_0_10px_rgba(239,68,68,0.3)]",
|
||||
},
|
||||
high: {
|
||||
background: "bg-orange-100/80 dark:bg-orange-500/20",
|
||||
text: "text-orange-600 dark:text-orange-400",
|
||||
hover: "hover:bg-orange-200 dark:hover:bg-orange-500/30",
|
||||
glow: "hover:shadow-[0_0_10px_rgba(249,115,22,0.3)]",
|
||||
},
|
||||
medium: {
|
||||
background: "bg-blue-100/80 dark:bg-blue-500/20",
|
||||
text: "text-blue-600 dark:text-blue-400",
|
||||
hover: "hover:bg-blue-200 dark:hover:bg-blue-500/30",
|
||||
glow: "hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]",
|
||||
},
|
||||
low: {
|
||||
background: "bg-gray-100/80 dark:bg-gray-500/20",
|
||||
text: "text-gray-600 dark:text-gray-400",
|
||||
hover: "hover:bg-gray-200 dark:hover:bg-gray-500/30",
|
||||
glow: "hover:shadow-[0_0_10px_rgba(107,114,128,0.3)]",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Compound styles for common patterns
|
||||
export const compoundStyles = {
|
||||
// Standard interactive element (buttons, menu items, etc.)
|
||||
interactiveElement: `
|
||||
${glassmorphism.interactive.base}
|
||||
${glassmorphism.interactive.hover}
|
||||
${glassmorphism.interactive.disabled}
|
||||
`,
|
||||
|
||||
// Floating panels (dropdowns, popovers, tooltips)
|
||||
floatingPanel: `
|
||||
${glassmorphism.background.strong}
|
||||
${glassmorphism.border.default}
|
||||
${glassmorphism.shadow.lg}
|
||||
${glassmorphism.animation.fadeIn}
|
||||
${glassmorphism.animation.slideIn}
|
||||
`,
|
||||
|
||||
// Form controls (inputs, selects, etc.)
|
||||
formControl: `
|
||||
${glassmorphism.background.subtle}
|
||||
${glassmorphism.border.default}
|
||||
${glassmorphism.border.hover}
|
||||
${glassmorphism.border.focus}
|
||||
${glassmorphism.interactive.base}
|
||||
${glassmorphism.interactive.disabled}
|
||||
`,
|
||||
|
||||
// Cards and containers
|
||||
card: `
|
||||
${glassmorphism.background.card}
|
||||
${glassmorphism.border.default}
|
||||
${glassmorphism.shadow.md}
|
||||
`,
|
||||
};
|
||||
|
||||
// Utility function to combine classes
|
||||
export function cn(...classes: (string | undefined | false)[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
103
archon-ui-main/src/features/ui/primitives/tabs.tsx
Normal file
103
archon-ui-main/src/features/ui/primitives/tabs.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import React from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
// Root
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
// List
|
||||
export const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List ref={ref} className={cn("relative", className)} role="tablist" {...props}>
|
||||
{/* Subtle neon glow effect */}
|
||||
<div className="absolute inset-0 rounded-lg opacity-30 blur-[1px] bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 pointer-events-none" />
|
||||
{props.children}
|
||||
</TabsPrimitive.List>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
// Trigger
|
||||
export const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
color?: "blue" | "purple" | "pink" | "orange" | "cyan" | "green";
|
||||
}
|
||||
>(({ className, color = "blue", ...props }, ref) => {
|
||||
const colorMap = {
|
||||
blue: {
|
||||
text: "data-[state=active]:text-blue-600 dark:data-[state=active]:text-blue-400",
|
||||
glow: "data-[state=active]:bg-blue-500 data-[state=active]:shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:data-[state=active]:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]",
|
||||
hover: "hover:text-blue-500 dark:hover:text-blue-400/70",
|
||||
},
|
||||
purple: {
|
||||
text: "data-[state=active]:text-purple-600 dark:data-[state=active]:text-purple-400",
|
||||
glow: "data-[state=active]:bg-purple-500 data-[state=active]:shadow-[0_0_10px_2px_rgba(168,85,247,0.4)] dark:data-[state=active]:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]",
|
||||
hover: "hover:text-purple-500 dark:hover:text-purple-400/70",
|
||||
},
|
||||
pink: {
|
||||
text: "data-[state=active]:text-pink-600 dark:data-[state=active]:text-pink-400",
|
||||
glow: "data-[state=active]:bg-pink-500 data-[state=active]:shadow-[0_0_10px_2px_rgba(236,72,153,0.4)] dark:data-[state=active]:shadow-[0_0_20px_5px_rgba(236,72,153,0.7)]",
|
||||
hover: "hover:text-pink-500 dark:hover:text-pink-400/70",
|
||||
},
|
||||
orange: {
|
||||
text: "data-[state=active]:text-orange-600 dark:data-[state=active]:text-orange-400",
|
||||
glow: "data-[state=active]:bg-orange-500 data-[state=active]:shadow-[0_0_10px_2px_rgba(249,115,22,0.4)] dark:data-[state=active]:shadow-[0_0_20px_5px_rgba(249,115,22,0.7)]",
|
||||
hover: "hover:text-orange-500 dark:hover:text-orange-400/70",
|
||||
},
|
||||
cyan: {
|
||||
text: "data-[state=active]:text-cyan-600 dark:data-[state=active]:text-cyan-400",
|
||||
glow: "data-[state=active]:bg-cyan-500 data-[state=active]:shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:data-[state=active]:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]",
|
||||
hover: "hover:text-cyan-500 dark:hover:text-cyan-400/70",
|
||||
},
|
||||
green: {
|
||||
text: "data-[state=active]:text-emerald-600 dark:data-[state=active]:text-emerald-400",
|
||||
glow: "data-[state=active]:bg-emerald-500 data-[state=active]:shadow-[0_0_10px_2px_rgba(16,185,129,0.4)] dark:data-[state=active]:shadow-[0_0_20px_5px_rgba(16,185,129,0.7)]",
|
||||
hover: "hover:text-emerald-500 dark:hover:text-emerald-400/70",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative px-24 py-10 font-mono transition-all duration-300 z-10",
|
||||
"text-gray-600 dark:text-gray-400",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
colorMap[color].text,
|
||||
colorMap[color].hover,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
{/* Active state neon indicator - only show when active */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 w-full h-[2px]",
|
||||
"data-[state=active]:block hidden",
|
||||
colorMap[color].glow,
|
||||
)}
|
||||
/>
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
// Content
|
||||
export const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
161
archon-ui-main/src/features/ui/primitives/toast.tsx
Normal file
161
archon-ui-main/src/features/ui/primitives/toast.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import * as ToastPrimitive from "@radix-ui/react-toast";
|
||||
import { AlertCircle, CheckCircle, Info, X, XCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn, glassmorphism } from "./styles";
|
||||
|
||||
// Toast Provider - wraps the app
|
||||
export const ToastProvider = ToastPrimitive.Provider;
|
||||
|
||||
// Toast Viewport - where toasts appear
|
||||
export const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-4 right-4 z-[100]",
|
||||
"flex flex-col gap-2",
|
||||
"w-full max-w-[420px]",
|
||||
"pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitive.Viewport.displayName;
|
||||
|
||||
// Toast Root
|
||||
export const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & {
|
||||
variant?: "default" | "success" | "error" | "warning";
|
||||
}
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
const variantStyles = {
|
||||
default: cn(glassmorphism.background.card, glassmorphism.border.default, glassmorphism.shadow.elevated),
|
||||
success: cn(
|
||||
"backdrop-blur-md bg-gradient-to-b from-green-100/80 dark:from-green-500/20 to-white/60 dark:to-green-500/5",
|
||||
"border-green-300 dark:border-green-500/30",
|
||||
"shadow-[0_0_10px_2px_rgba(16,185,129,0.4)] dark:shadow-[0_0_20px_5px_rgba(16,185,129,0.7)]",
|
||||
),
|
||||
error: cn(
|
||||
"backdrop-blur-md bg-gradient-to-b from-red-100/80 dark:from-red-500/20 to-white/60 dark:to-red-500/5",
|
||||
"border-red-300 dark:border-red-500/30",
|
||||
"shadow-[0_0_10px_2px_rgba(239,68,68,0.4)] dark:shadow-[0_0_20px_5px_rgba(239,68,68,0.7)]",
|
||||
),
|
||||
warning: cn(
|
||||
"backdrop-blur-md bg-gradient-to-b from-orange-100/80 dark:from-orange-500/20 to-white/60 dark:to-orange-500/5",
|
||||
"border-orange-300 dark:border-orange-500/30",
|
||||
"shadow-[0_0_10px_2px_rgba(251,146,60,0.4)] dark:shadow-[0_0_20px_5px_rgba(251,146,60,0.7)]",
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative group p-4 rounded-md border",
|
||||
"pointer-events-auto",
|
||||
"transition-all duration-200",
|
||||
glassmorphism.animation.fadeIn,
|
||||
"data-[swipe=cancel]:transform-none",
|
||||
"data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]",
|
||||
"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]",
|
||||
"data-[state=open]:slide-in-from-right",
|
||||
"data-[state=closed]:fade-out-80",
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitive.Root.displayName;
|
||||
|
||||
// Toast Action
|
||||
export const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"rounded-md px-3 py-1 text-sm font-medium",
|
||||
"bg-cyan-500/20 dark:bg-cyan-400/20",
|
||||
"border border-cyan-300 dark:border-cyan-500/30",
|
||||
"text-cyan-700 dark:text-cyan-300",
|
||||
"hover:bg-cyan-500/30 dark:hover:bg-cyan-400/30",
|
||||
glassmorphism.interactive.base,
|
||||
glassmorphism.interactive.disabled,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitive.Action.displayName;
|
||||
|
||||
// Toast Close
|
||||
export const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute top-2 right-2",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"hover:text-gray-700 dark:hover:text-white",
|
||||
"transition-colors",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"focus:opacity-100 focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</ToastPrimitive.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitive.Close.displayName;
|
||||
|
||||
// Toast Title
|
||||
export const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold text-gray-900 dark:text-white", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitive.Title.displayName;
|
||||
|
||||
// Toast Description
|
||||
export const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("mt-1 text-sm text-gray-600 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitive.Description.displayName;
|
||||
|
||||
// Helper function to get icon for toast type
|
||||
export function getToastIcon(type: "success" | "error" | "info" | "warning") {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />;
|
||||
case "error":
|
||||
return <XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />;
|
||||
case "info":
|
||||
return <Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />;
|
||||
case "warning":
|
||||
return <AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />;
|
||||
}
|
||||
}
|
||||
77
archon-ui-main/src/features/ui/primitives/tooltip.tsx
Normal file
77
archon-ui-main/src/features/ui/primitives/tooltip.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import React from "react";
|
||||
import { cn } from "./styles";
|
||||
|
||||
// Provider
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
// Root
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
// Content with Tron glassmorphism
|
||||
export const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs",
|
||||
// Tron-style glassmorphism with neon glow - dark in both themes
|
||||
"backdrop-blur-md",
|
||||
"bg-gradient-to-b from-gray-900/95 to-black/95",
|
||||
"dark:from-gray-900/95 dark:to-black/95",
|
||||
// Neon border with cyan glow
|
||||
"border border-cyan-500/50 dark:border-cyan-400/50",
|
||||
"shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:shadow-[0_0_15px_rgba(34,211,238,0.7)]",
|
||||
// Text colors - cyan in both modes for Tron effect
|
||||
"text-cyan-100 dark:text-cyan-100",
|
||||
// Subtle inner glow effect
|
||||
"before:absolute before:inset-0 before:rounded-md",
|
||||
"before:bg-gradient-to-b before:from-cyan-500/10 before:to-transparent",
|
||||
"before:pointer-events-none",
|
||||
// Animation with more dramatic entrance
|
||||
"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}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
// Simple tooltip wrapper for common use case
|
||||
export interface SimpleTooltipProps {
|
||||
children: React.ReactNode;
|
||||
content: string;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
delayDuration?: number;
|
||||
}
|
||||
|
||||
export const SimpleTooltip: React.FC<SimpleTooltipProps> = ({
|
||||
children,
|
||||
content,
|
||||
side = "top",
|
||||
align = "center",
|
||||
delayDuration = 200,
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip delayDuration={delayDuration}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} align={align}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user