Initial commit

This commit is contained in:
Yaojia Wang
2025-11-03 00:04:07 +01:00
parent 34b701de48
commit 097300e8ec
37 changed files with 3473 additions and 109 deletions

90
lib/api/client.ts Normal file
View File

@@ -0,0 +1,90 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api/v1';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public data?: any
) {
super(message);
this.name = 'ApiError';
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
response.status,
errorData.message || response.statusText,
errorData
);
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Add auth token if available
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
// Merge with options headers
if (options.headers) {
Object.assign(headers, options.headers);
}
const config: RequestInit = {
...options,
headers,
};
const response = await fetch(url, config);
return handleResponse<T>(response);
}
export const api = {
get: <T>(endpoint: string, options?: RequestInit) =>
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
post: <T>(endpoint: string, data?: any, options?: RequestInit) =>
apiRequest<T>(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(data),
}),
put: <T>(endpoint: string, data?: any, options?: RequestInit) =>
apiRequest<T>(endpoint, {
...options,
method: 'PUT',
body: JSON.stringify(data),
}),
patch: <T>(endpoint: string, data?: any, options?: RequestInit) =>
apiRequest<T>(endpoint, {
...options,
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: <T>(endpoint: string, options?: RequestInit) =>
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
};

29
lib/api/projects.ts Normal file
View File

@@ -0,0 +1,29 @@
import { api } from './client';
import type { Project, CreateProjectDto, UpdateProjectDto } from '@/types/project';
import type { KanbanBoard } from '@/types/kanban';
export const projectsApi = {
getAll: async (page = 1, pageSize = 20): Promise<Project[]> => {
return api.get(`/projects?page=${page}&pageSize=${pageSize}`);
},
getById: async (id: string): Promise<Project> => {
return api.get(`/projects/${id}`);
},
create: async (data: CreateProjectDto): Promise<Project> => {
return api.post('/projects', data);
},
update: async (id: string, data: UpdateProjectDto): Promise<Project> => {
return api.put(`/projects/${id}`, data);
},
delete: async (id: string): Promise<void> => {
return api.delete(`/projects/${id}`);
},
getKanban: async (id: string): Promise<KanbanBoard> => {
return api.get(`/projects/${id}/kanban`);
},
};

27
lib/hooks/use-kanban.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api/projects';
import type { KanbanBoard } from '@/types/kanban';
import { api } from '@/lib/api/client';
export function useKanbanBoard(projectId: string) {
return useQuery<KanbanBoard>({
queryKey: ['projects', projectId, 'kanban'],
queryFn: () => projectsApi.getKanban(projectId),
enabled: !!projectId,
staleTime: 2 * 60 * 1000, // 2 minutes
});
}
export function useUpdateTaskStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ taskId, newStatus }: { taskId: string; newStatus: string }) =>
api.patch(`/tasks/${taskId}/status`, { status: newStatus }),
onSuccess: (_, { taskId }) => {
// Invalidate kanban board queries to refetch
queryClient.invalidateQueries({ queryKey: ['kanban'] });
queryClient.invalidateQueries({ queryKey: ['tasks', taskId] });
},
});
}

79
lib/hooks/use-projects.ts Normal file
View File

@@ -0,0 +1,79 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api/projects';
import type { Project, CreateProjectDto, UpdateProjectDto } from '@/types/project';
export function useProjects(page = 1, pageSize = 20) {
return useQuery<Project[]>({
queryKey: ['projects', page, pageSize],
queryFn: () => projectsApi.getAll(page, pageSize),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useProject(id: string) {
return useQuery<Project>({
queryKey: ['projects', id],
queryFn: () => projectsApi.getById(id),
enabled: !!id,
});
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProjectDto) => projectsApi.create(data),
onSuccess: (newProject) => {
// Invalidate and refetch projects list
queryClient.invalidateQueries({ queryKey: ['projects'] });
// Optimistically update cache
queryClient.setQueryData<Project[]>(['projects'], (old) =>
old ? [...old, newProject] : [newProject]
);
},
});
}
export function useUpdateProject(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateProjectDto) => projectsApi.update(id, data),
onMutate: async (updatedData) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: ['projects', id] });
const previousProject = queryClient.getQueryData<Project>(['projects', id]);
queryClient.setQueryData<Project>(['projects', id], (old) => ({
...old!,
...updatedData,
}));
return { previousProject };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousProject) {
queryClient.setQueryData(['projects', id], context.previousProject);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => projectsApi.delete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.removeQueries({ queryKey: ['projects', id] });
},
});
}

View File

@@ -0,0 +1,27 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}