Compare commits

...

2 Commits

Author SHA1 Message Date
Yaojia Wang
de697d436b feat(frontend): Implement Issue management and Kanban board
Add comprehensive Issue management functionality with drag-and-drop Kanban board.

Changes:
- Created Issue API client (issues.ts) with CRUD operations
- Implemented React Query hooks for Issue data management
- Added IssueCard component with drag-and-drop support using @dnd-kit
- Created KanbanColumn component with droppable zones
- Built CreateIssueDialog with form validation using zod
- Implemented Kanban page at /projects/[id]/kanban with DnD status changes
- Added missing UI components (textarea, select, skeleton)
- Enhanced API client with helper methods (get, post, put, patch, delete)
- Installed dependencies: @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, @radix-ui/react-select, sonner
- Fixed SignalR ConnectionManager TypeScript error
- Preserved legacy KanbanBoard component for backward compatibility

Features:
- Drag and drop issues between Backlog, Todo, InProgress, and Done columns
- Real-time status updates via API
- Issue creation with type (Story, Task, Bug, Epic) and priority
- Visual feedback with priority colors and type icons
- Toast notifications for user actions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:50:01 +01:00
Yaojia Wang
149bb9bd88 feat(frontend): Implement Project Detail Page with edit and archive functionality
Add complete project detail page with real-time updates via SignalR.

Changes:
- Updated project detail page with edit and archive buttons
- Created EditProjectDialog component for updating projects
- Created ArchiveProjectDialog component for archiving projects
- Integrated SignalR real-time updates (onProjectUpdated, onProjectArchived)
- Added SignalR connection status indicator
- Enhanced useProjectHub hook to support callback options
- Improved UI layout with two-column card grid
- Added toast notifications for user feedback

Features:
- View project details (name, description, status, timestamps)
- Edit project name and description
- Archive active projects
- Real-time updates when project is modified by other users
- Automatic redirect when project is archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 10:40:58 +01:00
19 changed files with 1499 additions and 127 deletions

View File

@@ -1,108 +1,204 @@
'use client';
import Link from 'next/link';
import { FolderKanban, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { Plus, FolderKanban, Archive, TrendingUp, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useProjects } from '@/lib/hooks/use-projects';
import { CreateProjectDialog } from '@/components/features/projects/CreateProjectDialog';
export default function DashboardPage() {
const { data: projects } = useProjects();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: projects, isLoading } = useProjects();
// Calculate statistics
const stats = {
totalProjects: projects?.length || 0,
activeProjects: projects?.filter(p => p.status === 'Active').length || 0,
archivedProjects: projects?.filter(p => p.status === 'Archived').length || 0,
};
// Get recent projects (sort by creation time, take first 5)
const recentProjects = projects
?.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5) || [];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome to ColaFlow - Your AI-powered project management system
</p>
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back! Here&apos;s an overview of your projects.
</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Statistics Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projects?.length || 0}</div>
<p className="text-xs text-muted-foreground">
Active projects in your workspace
</p>
{isLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">{stats.totalProjects}</div>
<p className="text-xs text-muted-foreground">
Projects in your workspace
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Projects</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{projects?.filter((p) => p.status === 'Active').length || 0}
</div>
<p className="text-xs text-muted-foreground">
Currently in progress
</p>
{isLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold text-green-600">{stats.activeProjects}</div>
<p className="text-xs text-muted-foreground">
Currently in progress
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
<Plus className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">Archived Projects</CardTitle>
<Archive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Link href="/projects">
<Button className="w-full" variant="outline">
View All Projects
</Button>
</Link>
{isLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold text-gray-600">{stats.archivedProjects}</div>
<p className="text-xs text-muted-foreground">
Completed or on hold
</p>
</>
)}
</CardContent>
</Card>
</div>
{/* Recent Projects */}
<Card>
<CardHeader>
<CardTitle>Recent Projects</CardTitle>
<CardDescription>
Your most recently updated projects
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Projects</CardTitle>
<CardDescription>Your recently created projects</CardDescription>
</div>
<Button variant="ghost" asChild>
<Link href="/projects">
View All
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{projects && projects.length > 0 ? (
<div className="space-y-2">
{projects.slice(0, 5).map((project) => (
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-[200px]" />
<Skeleton className="h-3 w-[150px]" />
</div>
</div>
))}
</div>
) : recentProjects.length > 0 ? (
<div className="space-y-4">
{recentProjects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="block rounded-lg border p-3 transition-colors hover:bg-accent"
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-accent"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{project.name}</h3>
<p className="text-sm text-muted-foreground">{project.key}</p>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{project.name}</h3>
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
{project.status}
</Badge>
</div>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
project.status === 'Active'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{project.status}
</span>
<p className="text-sm text-muted-foreground">
{project.key} {project.description || 'No description'}
</p>
</div>
<div className="text-sm text-muted-foreground">
{new Date(project.createdAt).toLocaleDateString()}
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No projects yet. Create your first project to get started.
</p>
<div className="flex flex-col items-center justify-center py-8 text-center">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground mb-4">
No projects yet. Create your first project to get started.
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Project
</Button>
</div>
)}
</CardContent>
</Card>
{/* Quick Actions Card */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common tasks to get you started</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 md:grid-cols-2">
<Button
variant="outline"
className="justify-start"
onClick={() => setIsCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Create Project
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/projects">
<FolderKanban className="mr-2 h-4 w-4" />
View All Projects
</Link>
</Button>
</CardContent>
</Card>
<CreateProjectDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useParams } from 'next/navigation';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
closestCorners,
} from '@dnd-kit/core';
import { useState } from 'react';
import { useIssues, useChangeIssueStatus } from '@/lib/hooks/use-issues';
import { Button } from '@/components/ui/button';
import { Plus, Loader2 } from 'lucide-react';
import { Issue } from '@/lib/api/issues';
import { KanbanColumn } from '@/components/features/kanban/KanbanColumn';
import { IssueCard } from '@/components/features/kanban/IssueCard';
import { CreateIssueDialog } from '@/components/features/issues/CreateIssueDialog';
const COLUMNS = [
{ id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' },
{ id: 'Todo', title: 'To Do', color: 'bg-blue-100' },
{ id: 'InProgress', title: 'In Progress', color: 'bg-yellow-100' },
{ id: 'Done', title: 'Done', color: 'bg-green-100' },
];
export default function KanbanPage() {
const params = useParams();
const projectId = params.id as string;
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const { data: issues, isLoading } = useIssues(projectId);
const changeStatusMutation = useChangeIssueStatus(projectId);
// Group issues by status
const issuesByStatus = {
Backlog: issues?.filter((i) => i.status === 'Backlog') || [],
Todo: issues?.filter((i) => i.status === 'Todo') || [],
InProgress: issues?.filter((i) => i.status === 'InProgress') || [],
Done: issues?.filter((i) => i.status === 'Done') || [],
};
const handleDragStart = (event: DragStartEvent) => {
const issue = issues?.find((i) => i.id === event.active.id);
setActiveIssue(issue || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveIssue(null);
if (!over || active.id === over.id) return;
const newStatus = over.id as string;
const issue = issues?.find((i) => i.id === active.id);
if (issue && issue.status !== newStatus) {
changeStatusMutation.mutate({ issueId: issue.id, status: newStatus });
}
};
if (isLoading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Kanban Board</h1>
<p className="text-muted-foreground">
Drag and drop to update issue status
</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Issue
</Button>
</div>
<DndContext
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4">
{COLUMNS.map((column) => (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
issues={issuesByStatus[column.id as keyof typeof issuesByStatus]}
/>
))}
</div>
<DragOverlay>
{activeIssue && <IssueCard issue={activeIssue} />}
</DragOverlay>
</DndContext>
<CreateIssueDialog
projectId={projectId}
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</div>
);
}

View File

@@ -1,11 +1,19 @@
'use client';
import { use } from 'react';
import { use, useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, Loader2, KanbanSquare } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Loader2, KanbanSquare, Pencil, Archive } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useProject } from '@/lib/hooks/use-projects';
import { useProjectHub } from '@/lib/hooks/useProjectHub';
import { EditProjectDialog } from '@/components/features/projects/EditProjectDialog';
import { ArchiveProjectDialog } from '@/components/features/projects/ArchiveProjectDialog';
import type { Project } from '@/types/project';
import { toast } from 'sonner';
interface ProjectDetailPageProps {
params: Promise<{ id: string }>;
@@ -13,7 +21,29 @@ interface ProjectDetailPageProps {
export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
const { id } = use(params);
const router = useRouter();
const queryClient = useQueryClient();
const { data: project, isLoading, error } = useProject(id);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isArchiveDialogOpen, setIsArchiveDialogOpen] = useState(false);
// SignalR real-time updates
const { connectionState } = useProjectHub(id, {
onProjectUpdated: (updatedProject) => {
if (updatedProject.id === id) {
console.log('[ProjectDetail] Project updated via SignalR:', updatedProject);
queryClient.setQueryData(['projects', id], updatedProject);
toast.info('Project updated');
}
},
onProjectArchived: (data) => {
if (data.ProjectId === id) {
console.log('[ProjectDetail] Project archived via SignalR:', data);
toast.info('Project has been archived');
router.push('/projects');
}
},
});
if (isLoading) {
return (
@@ -44,48 +74,97 @@ export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
project.status === 'Active'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
{project.status}
</span>
</Badge>
</div>
<p className="text-muted-foreground">Key: {project.key}</p>
<p className="text-sm text-muted-foreground">Key: {project.key}</p>
</div>
<div className="flex gap-2">
<Link href={`/kanban/${project.id}`}>
<Button variant="outline">
<KanbanSquare className="mr-2 h-4 w-4" />
View Board
</Button>
</Link>
{project.status === 'Active' && (
<>
<Button variant="outline" onClick={() => setIsEditDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="destructive" onClick={() => setIsArchiveDialogOpen(true)}>
<Archive className="mr-2 h-4 w-4" />
Archive
</Button>
</>
)}
</div>
<Link href={`/kanban/${project.id}`}>
<Button>
<KanbanSquare className="mr-2 h-4 w-4" />
View Board
</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
<CardDescription>Information about this project</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
<p className="mt-1">{project.description || 'No description provided'}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Created</h3>
<p className="mt-1">{new Date(project.createdAt).toLocaleDateString()}</p>
</div>
{project.updatedAt && (
<div>
<h3 className="text-sm font-medium text-muted-foreground">Last Updated</h3>
<p className="mt-1">{new Date(project.updatedAt).toLocaleDateString()}</p>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{project.description || 'No description provided'}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
)}
</CardContent>
</Card>
{project.updatedAt && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Status</span>
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
{project.status}
</Badge>
</div>
</CardContent>
</Card>
</div>
{/* SignalR Connection Status */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div
className={`h-2 w-2 rounded-full ${
connectionState === 'connected' ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
<span>
{connectionState === 'connected' ? 'Real-time updates enabled' : 'Connecting...'}
</span>
</div>
{/* Dialogs */}
{project && (
<>
<EditProjectDialog
project={project}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
<ArchiveProjectDialog
projectId={project.id}
projectName={project.name}
open={isArchiveDialogOpen}
onOpenChange={setIsArchiveDialogOpen}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useCreateIssue } from '@/lib/hooks/use-issues';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
const createIssueSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
type: z.enum(['Story', 'Task', 'Bug', 'Epic']),
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
});
interface CreateIssueDialogProps {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateIssueDialog({
projectId,
open,
onOpenChange,
}: CreateIssueDialogProps) {
const form = useForm({
resolver: zodResolver(createIssueSchema),
defaultValues: {
title: '',
description: '',
type: 'Task' as const,
priority: 'Medium' as const,
},
});
const createMutation = useCreateIssue(projectId);
const onSubmit = (data: z.infer<typeof createIssueSchema>) => {
createMutation.mutate(data, {
onSuccess: () => {
form.reset();
onOpenChange(false);
},
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Issue</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Issue title..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe the issue..."
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Story">Story</SelectItem>
<SelectItem value="Task">Task</SelectItem>
<SelectItem value="Bug">Bug</SelectItem>
<SelectItem value="Epic">Epic</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Critical">Critical</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create Issue'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Issue } from '@/lib/api/issues';
interface IssueCardProps {
issue: Issue;
}
export function IssueCard({ issue }: IssueCardProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: issue.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const priorityColors = {
Low: 'bg-gray-100 text-gray-700',
Medium: 'bg-blue-100 text-blue-700',
High: 'bg-orange-100 text-orange-700',
Critical: 'bg-red-100 text-red-700',
};
const typeIcons = {
Story: '📖',
Task: '✓',
Bug: '🐛',
Epic: '🚀',
};
return (
<Card
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
>
<CardContent className="p-3 space-y-2">
<div className="flex items-start gap-2">
<span>{typeIcons[issue.type]}</span>
<h3 className="text-sm font-medium flex-1">{issue.title}</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={priorityColors[issue.priority]}>
{issue.priority}
</Badge>
<Badge variant="secondary">{issue.type}</Badge>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,12 +1,14 @@
'use client';
import { KanbanColumn } from './KanbanColumn';
import { TaskCard } from './TaskCard';
import type { KanbanBoard as KanbanBoardType } from '@/types/kanban';
interface KanbanBoardProps {
board: KanbanBoardType;
}
// Legacy KanbanBoard component using old Kanban type
// For new Issue-based Kanban, use the page at /projects/[id]/kanban
export function KanbanBoard({ board }: KanbanBoardProps) {
return (
<div className="space-y-4">
@@ -18,7 +20,27 @@ export function KanbanBoard({ board }: KanbanBoardProps) {
</div>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.columns.map((column) => (
<KanbanColumn key={column.status} column={column} />
<div
key={column.status}
className="flex min-w-[300px] flex-col rounded-lg border-2 bg-muted/50 p-4"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold">{column.title}</h3>
<span className="rounded-full bg-background px-2 py-0.5 text-xs font-medium">
{column.tasks.length}
</span>
</div>
<div className="flex-1 space-y-3">
{column.tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
{column.tasks.length === 0 && (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
<p className="text-sm text-muted-foreground">No tasks</p>
</div>
)}
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">

View File

@@ -1,39 +1,43 @@
'use client';
import { TaskCard } from './TaskCard';
import type { KanbanColumn as KanbanColumnType } from '@/types/kanban';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Issue } from '@/lib/api/issues';
import { IssueCard } from './IssueCard';
interface KanbanColumnProps {
column: KanbanColumnType;
id: string;
title: string;
issues: Issue[];
}
export function KanbanColumn({ column }: KanbanColumnProps) {
const statusColors = {
ToDo: 'border-gray-300',
InProgress: 'border-blue-300',
InReview: 'border-yellow-300',
Done: 'border-green-300',
Blocked: 'border-red-300',
};
export function KanbanColumn({ id, title, issues }: KanbanColumnProps) {
const { setNodeRef } = useDroppable({ id });
return (
<div className="flex min-w-[300px] flex-col rounded-lg border-2 bg-muted/50 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold">{column.title}</h3>
<span className="rounded-full bg-background px-2 py-0.5 text-xs font-medium">
{column.tasks.length}
</span>
</div>
<div className="flex-1 space-y-3">
{column.tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
{column.tasks.length === 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span>{title}</span>
<span className="text-muted-foreground">{issues.length}</span>
</CardTitle>
</CardHeader>
<CardContent ref={setNodeRef} className="space-y-2 min-h-[400px]">
<SortableContext
items={issues.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{issues.map((issue) => (
<IssueCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issues.length === 0 && (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
<p className="text-sm text-muted-foreground">No tasks</p>
<p className="text-sm text-muted-foreground">No issues</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useRouter } from 'next/navigation';
import { useDeleteProject } from '@/lib/hooks/use-projects';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
interface ArchiveProjectDialogProps {
projectId: string;
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ArchiveProjectDialog({
projectId,
projectName,
open,
onOpenChange,
}: ArchiveProjectDialogProps) {
const router = useRouter();
const deleteProject = useDeleteProject();
const handleArchive = async () => {
try {
await deleteProject.mutateAsync(projectId);
toast.success('Project archived successfully');
router.push('/projects');
} catch (error) {
toast.error('Failed to archive project');
console.error('Archive error:', error);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Archive Project</DialogTitle>
<DialogDescription>
Are you sure you want to archive{' '}
<strong className="font-semibold">{projectName}</strong>?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
This action will mark the project as archived, but it can be
restored later. All associated issues and data will be preserved.
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleArchive}
disabled={deleteProject.isPending}
>
{deleteProject.isPending ? 'Archiving...' : 'Archive Project'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useUpdateProject } from '@/lib/hooks/use-projects';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { Project } from '@/types/project';
import { toast } from 'sonner';
const updateProjectSchema = z.object({
name: z
.string()
.min(1, 'Project name is required')
.max(200, 'Project name cannot exceed 200 characters'),
description: z
.string()
.max(2000, 'Description cannot exceed 2000 characters'),
});
type UpdateProjectFormData = z.infer<typeof updateProjectSchema>;
interface EditProjectDialogProps {
project: Project;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditProjectDialog({
project,
open,
onOpenChange,
}: EditProjectDialogProps) {
const updateProject = useUpdateProject(project.id);
const form = useForm<UpdateProjectFormData>({
resolver: zodResolver(updateProjectSchema),
defaultValues: {
name: project.name,
description: project.description,
},
});
const onSubmit = async (data: UpdateProjectFormData) => {
try {
await updateProject.mutateAsync(data);
toast.success('Project updated successfully');
onOpenChange(false);
} catch (error) {
toast.error('Failed to update project');
console.error('Update error:', error);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
<DialogDescription>
Update project details. Changes will be saved immediately.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="My Awesome Project" {...field} />
</FormControl>
<FormDescription>
The name of your project.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="A brief description of the project..."
{...field}
/>
</FormControl>
<FormDescription>
A brief description for your project.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={updateProject.isPending}>
{updateProject.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

160
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,160 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -133,3 +133,31 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
);
// API helper functions
export const api = {
get: async <T>(url: string, config?: any): Promise<T> => {
const response = await apiClient.get(url, config);
return response.data;
},
post: async <T>(url: string, data?: any, config?: any): Promise<T> => {
const response = await apiClient.post(url, data, config);
return response.data;
},
put: async <T>(url: string, data?: any, config?: any): Promise<T> => {
const response = await apiClient.put(url, data, config);
return response.data;
},
patch: async <T>(url: string, data?: any, config?: any): Promise<T> => {
const response = await apiClient.patch(url, data, config);
return response.data;
},
delete: async <T>(url: string, config?: any): Promise<T> => {
const response = await apiClient.delete(url, config);
return response.data;
},
};

84
lib/api/issues.ts Normal file
View File

@@ -0,0 +1,84 @@
import { api } from './client';
export interface Issue {
id: string;
projectId: string;
title: string;
description: string;
type: 'Story' | 'Task' | 'Bug' | 'Epic';
status: 'Backlog' | 'Todo' | 'InProgress' | 'Done';
priority: 'Low' | 'Medium' | 'High' | 'Critical';
assigneeId?: string;
reporterId: string;
createdAt: string;
updatedAt?: string;
}
export interface CreateIssueDto {
title: string;
description: string;
type: string;
priority: string;
}
export interface UpdateIssueDto {
title: string;
description: string;
priority: string;
}
export interface ChangeStatusDto {
status: string;
}
export interface AssignIssueDto {
assigneeId: string | null;
}
export const issuesApi = {
list: async (projectId: string, status?: string): Promise<Issue[]> => {
const params = status ? `?status=${status}` : '';
return api.get<Issue[]>(`/api/v1/projects/${projectId}/issues${params}`);
},
getById: async (projectId: string, id: string): Promise<Issue> => {
return api.get<Issue>(`/api/v1/projects/${projectId}/issues/${id}`);
},
create: async (projectId: string, data: CreateIssueDto): Promise<Issue> => {
return api.post<Issue>(`/api/v1/projects/${projectId}/issues`, data);
},
update: async (
projectId: string,
id: string,
data: UpdateIssueDto
): Promise<Issue> => {
return api.put<Issue>(`/api/v1/projects/${projectId}/issues/${id}`, data);
},
changeStatus: async (
projectId: string,
id: string,
status: string
): Promise<void> => {
return api.put<void>(
`/api/v1/projects/${projectId}/issues/${id}/status`,
{ status }
);
},
assign: async (
projectId: string,
id: string,
assigneeId: string | null
): Promise<void> => {
return api.put<void>(`/api/v1/projects/${projectId}/issues/${id}/assign`, {
assigneeId,
});
},
delete: async (projectId: string, id: string): Promise<void> => {
return api.delete<void>(`/api/v1/projects/${projectId}/issues/${id}`);
},
};

102
lib/hooks/use-issues.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { issuesApi, Issue, CreateIssueDto, UpdateIssueDto } from '@/lib/api/issues';
import { toast } from 'sonner';
export function useIssues(projectId: string, status?: string) {
return useQuery({
queryKey: ['issues', projectId, status],
queryFn: () => issuesApi.list(projectId, status),
enabled: !!projectId,
});
}
export function useIssue(projectId: string, issueId: string) {
return useQuery({
queryKey: ['issue', projectId, issueId],
queryFn: () => issuesApi.getById(projectId, issueId),
enabled: !!projectId && !!issueId,
});
}
export function useCreateIssue(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateIssueDto) => issuesApi.create(projectId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue created successfully');
},
onError: () => {
toast.error('Failed to create issue');
},
});
}
export function useUpdateIssue(projectId: string, issueId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateIssueDto) =>
issuesApi.update(projectId, issueId, data),
onSuccess: (updatedIssue) => {
queryClient.setQueryData(['issue', projectId, issueId], updatedIssue);
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue updated successfully');
},
onError: () => {
toast.error('Failed to update issue');
},
});
}
export function useChangeIssueStatus(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ issueId, status }: { issueId: string; status: string }) =>
issuesApi.changeStatus(projectId, issueId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
},
onError: () => {
toast.error('Failed to change issue status');
},
});
}
export function useAssignIssue(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
issueId,
assigneeId,
}: {
issueId: string;
assigneeId: string | null;
}) => issuesApi.assign(projectId, issueId, assigneeId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue assigned successfully');
},
onError: () => {
toast.error('Failed to assign issue');
},
});
}
export function useDeleteIssue(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (issueId: string) => issuesApi.delete(projectId, issueId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue deleted successfully');
},
onError: () => {
toast.error('Failed to delete issue');
},
});
}

View File

@@ -4,8 +4,21 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
import { useAuthStore } from '@/stores/authStore';
import type { Project } from '@/types/project';
export function useProjectHub(projectId?: string) {
interface UseProjectHubOptions {
onProjectUpdated?: (project: Project) => void;
onProjectArchived?: (data: { ProjectId: string }) => void;
onIssueCreated?: (issue: any) => void;
onIssueUpdated?: (issue: any) => void;
onIssueDeleted?: (data: { IssueId: string }) => void;
onIssueStatusChanged?: (data: any) => void;
onUserJoinedProject?: (data: any) => void;
onUserLeftProject?: (data: any) => void;
onTypingIndicator?: (data: { UserId: string; IssueId: string; IsTyping: boolean }) => void;
}
export function useProjectHub(projectId?: string, options?: UseProjectHubOptions) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [connectionState, setConnectionState] = useState<
'disconnected' | 'connecting' | 'connected' | 'reconnecting'
@@ -25,42 +38,49 @@ export function useProjectHub(projectId?: string) {
// 监听项目事件
manager.on('ProjectUpdated', (data: any) => {
console.log('[ProjectHub] Project updated:', data);
// TODO: 触发项目数据重新加载
options?.onProjectUpdated?.(data);
});
manager.on('ProjectArchived', (data: { ProjectId: string }) => {
console.log('[ProjectHub] Project archived:', data);
options?.onProjectArchived?.(data);
});
manager.on('IssueCreated', (issue: any) => {
console.log('[ProjectHub] Issue created:', issue);
// TODO: 添加到看板
options?.onIssueCreated?.(issue);
});
manager.on('IssueUpdated', (issue: any) => {
console.log('[ProjectHub] Issue updated:', issue);
// TODO: 更新看板
options?.onIssueUpdated?.(issue);
});
manager.on('IssueDeleted', (data: { IssueId: string }) => {
console.log('[ProjectHub] Issue deleted:', data);
// TODO: 从看板移除
options?.onIssueDeleted?.(data);
});
manager.on('IssueStatusChanged', (data: any) => {
console.log('[ProjectHub] Issue status changed:', data);
// TODO: 移动看板卡片
options?.onIssueStatusChanged?.(data);
});
manager.on('UserJoinedProject', (data: any) => {
console.log('[ProjectHub] User joined:', data);
options?.onUserJoinedProject?.(data);
});
manager.on('UserLeftProject', (data: any) => {
console.log('[ProjectHub] User left:', data);
options?.onUserLeftProject?.(data);
});
manager.on(
'TypingIndicator',
(data: { UserId: string; IssueId: string; IsTyping: boolean }) => {
console.log('[ProjectHub] Typing indicator:', data);
// TODO: 显示正在输入提示
options?.onTypingIndicator?.(data);
}
);
@@ -70,7 +90,7 @@ export function useProjectHub(projectId?: string) {
unsubscribe();
manager.stop();
};
}, [isAuthenticated]);
}, [isAuthenticated, options]);
// 加入项目房间
const joinProject = useCallback(async (projectId: string) => {

View File

@@ -78,8 +78,10 @@ export class SignalRConnectionManager {
}
off(methodName: string, callback?: (...args: any[]) => void): void {
if (this.connection) {
if (this.connection && callback) {
this.connection.off(methodName, callback);
} else if (this.connection) {
this.connection.off(methodName);
}
}

155
package-lock.json generated
View File

@@ -8,11 +8,15 @@
"name": "colaflow-web",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^9.0.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2",
@@ -24,6 +28,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.66.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"
@@ -295,6 +300,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
@@ -1270,6 +1328,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -1717,6 +1781,49 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -1820,6 +1927,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@@ -1856,6 +1978,29 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -6948,6 +7093,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -10,11 +10,15 @@
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^9.0.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2",
@@ -26,6 +30,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.66.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"