Add kanban board view, project creation, and task assignment

This commit is contained in:
2026-01-28 17:57:57 +00:00
parent ff70948a54
commit 094e29d838
12 changed files with 459 additions and 26 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" /> <meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title> <title>Todo App</title>
<script type="module" crossorigin src="/assets/index-KBG7ug1d.js"></script> <script type="module" crossorigin src="/assets/index-B4OukgoH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DH_ujkYf.css"> <link rel="stylesheet" crossorigin href="/assets/index-CVystegy.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -9,6 +9,7 @@ import { InboxPage } from '@/pages/Inbox';
import { TodayPage } from '@/pages/Today'; import { TodayPage } from '@/pages/Today';
import { UpcomingPage } from '@/pages/Upcoming'; import { UpcomingPage } from '@/pages/Upcoming';
import { AdminPage } from '@/pages/Admin'; import { AdminPage } from '@/pages/Admin';
import { ProjectPage } from '@/pages/Project';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -51,6 +52,8 @@ function AppRoutes() {
<Route path="/today" element={<TodayPage />} /> <Route path="/today" element={<TodayPage />} />
<Route path="/upcoming" element={<UpcomingPage />} /> <Route path="/upcoming" element={<UpcomingPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/project/:id" element={<ProjectPage />} />
<Route path="/project/:id/board" element={<ProjectPage />} />
{/* Redirects */} {/* Redirects */}
<Route path="/" element={<Navigate to="/inbox" replace />} /> <Route path="/" element={<Navigate to="/inbox" replace />} />

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react'; import { Plus, Calendar, Flag, Tag, X, UserCircle } from 'lucide-react';
import type { Priority } from '@/types'; import type { Priority } from '@/types';
import { cn, getPriorityColor } from '@/lib/utils'; import { cn, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks'; import { useTasksStore } from '@/stores/tasks';
@@ -18,10 +18,17 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [dueDate, setDueDate] = useState(''); const [dueDate, setDueDate] = useState('');
const [priority, setPriority] = useState<Priority>('p4'); const [priority, setPriority] = useState<Priority>('p4');
const [assigneeId, setAssigneeId] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { createTask, projects } = useTasksStore(); const { createTask, projects, users, fetchUsers } = useTasksStore();
useEffect(() => {
if (isExpanded && users.length === 0) {
fetchUsers();
}
}, [isExpanded]);
useEffect(() => { useEffect(() => {
if (isExpanded && inputRef.current) { if (isExpanded && inputRef.current) {
@@ -43,6 +50,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
parentId, parentId,
dueDate: dueDate || undefined, dueDate: dueDate || undefined,
priority, priority,
assigneeId: assigneeId || undefined,
}); });
// Reset form // Reset form
@@ -50,6 +58,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
setDescription(''); setDescription('');
setDueDate(''); setDueDate('');
setPriority('p4'); setPriority('p4');
setAssigneeId('');
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -68,6 +77,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
setDescription(''); setDescription('');
setDueDate(''); setDueDate('');
setPriority('p4'); setPriority('p4');
setAssigneeId('');
onClose?.(); onClose?.();
}; };
@@ -141,6 +151,25 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
<option value="p4">Priority 4</option> <option value="p4">Priority 4</option>
</select> </select>
{/* Assignee selector */}
{users.length > 0 && (
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className={cn(
'appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50',
assigneeId ? 'text-blue-600' : 'text-gray-500'
)}
>
<option value="">Unassigned</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
)}
{/* Project selector (if not in a specific project context) */} {/* Project selector (if not in a specific project context) */}
{!projectId && projects.length > 0 && ( {!projectId && projects.length > 0 && (
<select <select

View File

@@ -1,20 +1,58 @@
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { import {
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight, Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
Hash, Settings, LogOut, User, FolderPlus, Tag Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useTasksStore } from '@/stores/tasks'; import { useTasksStore } from '@/stores/tasks';
const PROJECT_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
export function Sidebar() { export function Sidebar() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { projects, labels } = useTasksStore(); const { projects, labels, createProject } = useTasksStore();
const [projectsExpanded, setProjectsExpanded] = useState(true); const [projectsExpanded, setProjectsExpanded] = useState(true);
const [labelsExpanded, setLabelsExpanded] = useState(true); const [labelsExpanded, setLabelsExpanded] = useState(true);
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectColor, setNewProjectColor] = useState(PROJECT_COLORS[0]);
const [isCreatingProject, setIsCreatingProject] = useState(false);
const newProjectInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showNewProject && newProjectInputRef.current) {
newProjectInputRef.current.focus();
}
}, [showNewProject]);
const handleCreateProject = async () => {
if (!newProjectName.trim() || isCreatingProject) return;
setIsCreatingProject(true);
try {
const project = await createProject({ name: newProjectName.trim(), color: newProjectColor });
setNewProjectName('');
setNewProjectColor(PROJECT_COLORS[0]);
setShowNewProject(false);
navigate(`/project/${project.id}`);
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsCreatingProject(false);
}
};
const handleCancelNewProject = () => {
setShowNewProject(false);
setNewProjectName('');
setNewProjectColor(PROJECT_COLORS[0]);
};
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
@@ -74,13 +112,16 @@ export function Sidebar() {
> >
<span>Projects</span> <span>Projects</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link <button
to="/projects/new"
className="p-1 hover:bg-gray-200 rounded" className="p-1 hover:bg-gray-200 rounded"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
e.stopPropagation();
setShowNewProject(true);
setProjectsExpanded(true);
}}
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
</Link> </button>
{projectsExpanded ? ( {projectsExpanded ? (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="w-3 h-3" />
) : ( ) : (
@@ -97,7 +138,7 @@ export function Sidebar() {
to={`/project/${project.id}`} to={`/project/${project.id}`}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
location.pathname === `/project/${project.id}` location.pathname === `/project/${project.id}` || location.pathname === `/project/${project.id}/board`
? 'bg-blue-50 text-blue-600' ? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100'
)} )}
@@ -109,6 +150,63 @@ export function Sidebar() {
<span className="truncate">{project.name}</span> <span className="truncate">{project.name}</span>
</Link> </Link>
))} ))}
{/* Inline create project form */}
{showNewProject && (
<div className="px-2 py-2">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreateProject();
}}
className="border border-gray-200 rounded-lg p-2 bg-white shadow-sm space-y-2"
>
<input
ref={newProjectInputRef}
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="w-full text-sm text-gray-900 placeholder-gray-400 border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400"
/>
<div className="flex items-center gap-1 flex-wrap">
{PROJECT_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewProjectColor(color)}
className={cn(
'w-5 h-5 rounded-full border-2 transition-all',
newProjectColor === color ? 'border-gray-700 scale-110' : 'border-transparent'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={handleCancelNewProject}
className="px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
type="submit"
disabled={!newProjectName.trim() || isCreatingProject}
className={cn(
'px-2 py-1 text-xs font-medium rounded',
newProjectName.trim() && !isCreatingProject
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
>
{isCreatingProject ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -117,6 +117,16 @@ export function TaskItem({ task, onClick, showProject = false }: TaskItemProps)
</div> </div>
</div> </div>
{/* Assignee avatar */}
{task.assignee && (
<span
className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium"
title={task.assignee.name}
>
{task.assignee.name.charAt(0).toUpperCase()}
</span>
)}
{/* Priority flag */} {/* Priority flag */}
{task.priority !== 'p4' && ( {task.priority !== 'p4' && (
<Flag <Flag

135
src/pages/Board.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { Calendar, Flag, Plus } from 'lucide-react';
import type { Task, Project, Section } from '@/types';
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
import { AddTask } from '@/components/AddTask';
interface BoardViewProps {
project: Project;
sections: Section[];
tasks: Task[];
isLoading: boolean;
}
function TaskCard({ task }: { task: Task }) {
const { toggleComplete, setSelectedTask } = useTasksStore();
const overdue = !task.isCompleted && isOverdue(task.dueDate);
return (
<div
className="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedTask(task)}
>
<div className="flex items-start gap-2">
{/* Priority indicator */}
<span
className="mt-1 w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-medium text-gray-900',
task.isCompleted && 'line-through text-gray-500'
)}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{task.dueDate && (
<span className={cn(
'inline-flex items-center gap-1 text-xs',
overdue ? 'text-red-500' : 'text-gray-500'
)}>
<Calendar className="w-3 h-3" />
{formatDate(task.dueDate)}
</span>
)}
{task.assignee && (
<span
className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium flex-shrink-0"
title={task.assignee.name}
>
{task.assignee.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function BoardColumn({
title,
tasks,
projectId,
sectionId,
}: {
title: string;
tasks: Task[];
projectId: string;
sectionId?: string;
}) {
return (
<div className="flex-shrink-0 w-72 flex flex-col bg-gray-100 rounded-xl max-h-full">
{/* Column header */}
<div className="px-3 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">
{title}
<span className="ml-2 text-xs font-normal text-gray-400">{tasks.length}</span>
</h3>
</div>
{/* Tasks */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
{/* Add task */}
<div className="px-2 pb-2">
<AddTask projectId={projectId} sectionId={sectionId} />
</div>
</div>
);
}
export function BoardView({ project, sections, tasks, isLoading }: BoardViewProps) {
if (isLoading) {
return (
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
);
}
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
const columns = [
{ title: 'No Section', tasks: unsectionedTasks, sectionId: undefined },
...sections.map((section) => ({
title: section.name,
tasks: tasks.filter((t) => t.sectionId === section.id),
sectionId: section.id,
})),
];
return (
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
{columns.map((col) => (
<BoardColumn
key={col.sectionId || 'unsectioned'}
title={col.title}
tasks={col.tasks}
projectId={project.id}
sectionId={col.sectionId}
/>
))}
</div>
);
}

147
src/pages/Project.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { LayoutList, LayoutGrid, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
import { TaskItem } from '@/components/TaskItem';
import { AddTask } from '@/components/AddTask';
import { BoardView } from '@/pages/Board';
import { api } from '@/lib/api';
import type { Project as ProjectType, Section } from '@/types';
export function ProjectPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
const [project, setProject] = useState<ProjectType | null>(null);
const [sections, setSections] = useState<Section[]>([]);
const isBoardView = location.pathname.endsWith('/board');
useEffect(() => {
if (!id) return;
fetchTasks({ projectId: id, completed: false });
api.getProject(id).then((p) => {
setProject(p);
setSections(p.sections || []);
}).catch(console.error);
}, [id]);
if (!project) {
return (
<div className="flex items-center justify-center py-12 text-gray-500">
Loading project...
</div>
);
}
const toggleView = () => {
if (isBoardView) {
navigate(`/project/${id}`);
} else {
navigate(`/project/${id}/board`);
}
};
// Group tasks by section
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
const tasksBySection = sections.map((section) => ({
section,
tasks: tasks.filter((t) => t.sectionId === section.id),
}));
return (
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span
className="w-4 h-4 rounded"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => !isBoardView || toggleView()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors',
!isBoardView
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
)}
>
<LayoutList className="w-4 h-4" />
List
</button>
<button
onClick={() => isBoardView || toggleView()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors',
isBoardView
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
)}
>
<LayoutGrid className="w-4 h-4" />
Board
</button>
</div>
</div>
{isBoardView ? (
<BoardView
project={project}
sections={sections}
tasks={tasks}
isLoading={isLoading}
/>
) : (
/* List view */
<div className="space-y-6">
{isLoading ? (
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
) : (
<>
{/* Unsectioned tasks */}
{unsectionedTasks.length > 0 && (
<div className="space-y-1">
{unsectionedTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onClick={() => setSelectedTask(task)}
/>
))}
</div>
)}
{/* Add task for unsectioned */}
<AddTask projectId={id} />
{/* Sections */}
{tasksBySection.map(({ section, tasks: sectionTasks }) => (
<div key={section.id}>
<h3 className="text-sm font-semibold text-gray-700 mb-2 px-3 py-1 border-b border-gray-200">
{section.name}
</h3>
<div className="space-y-1">
{sectionTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onClick={() => setSelectedTask(task)}
/>
))}
</div>
<AddTask projectId={id} sectionId={section.id} />
</div>
))}
</>
)}
</div>
)}
</div>
);
}

View File

@@ -8,9 +8,11 @@ vi.mock('@/lib/api', () => ({
getTasks: vi.fn(), getTasks: vi.fn(),
getProjects: vi.fn(), getProjects: vi.fn(),
getLabels: vi.fn(), getLabels: vi.fn(),
getUsers: vi.fn(),
createTask: vi.fn(), createTask: vi.fn(),
updateTask: vi.fn(), updateTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
createProject: vi.fn(),
}, },
})); }));
@@ -62,6 +64,7 @@ describe('useTasksStore', () => {
tasks: [], tasks: [],
projects: [], projects: [],
labels: [], labels: [],
users: [],
isLoading: false, isLoading: false,
selectedTask: null, selectedTask: null,
activeProjectId: null, activeProjectId: null,
@@ -74,6 +77,7 @@ describe('useTasksStore', () => {
expect(state.tasks).toEqual([]); expect(state.tasks).toEqual([]);
expect(state.projects).toEqual([]); expect(state.projects).toEqual([]);
expect(state.labels).toEqual([]); expect(state.labels).toEqual([]);
expect(state.users).toEqual([]);
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
expect(state.selectedTask).toBeNull(); expect(state.selectedTask).toBeNull();
expect(state.activeProjectId).toBeNull(); expect(state.activeProjectId).toBeNull();

View File

@@ -1,11 +1,12 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Task, TaskCreate, TaskUpdate, Project, Label } from '@/types'; import type { Task, TaskCreate, TaskUpdate, Project, Label, User } from '@/types';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface TasksState { interface TasksState {
tasks: Task[]; tasks: Task[];
projects: Project[]; projects: Project[];
labels: Label[]; labels: Label[];
users: User[];
isLoading: boolean; isLoading: boolean;
// Selected items // Selected items
@@ -17,11 +18,13 @@ interface TasksState {
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>; fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
fetchProjects: () => Promise<void>; fetchProjects: () => Promise<void>;
fetchLabels: () => Promise<void>; fetchLabels: () => Promise<void>;
fetchUsers: () => Promise<void>;
createTask: (data: TaskCreate) => Promise<Task>; createTask: (data: TaskCreate) => Promise<Task>;
updateTask: (id: string, data: TaskUpdate) => Promise<void>; updateTask: (id: string, data: TaskUpdate) => Promise<void>;
deleteTask: (id: string) => Promise<void>; deleteTask: (id: string) => Promise<void>;
toggleComplete: (id: string) => Promise<void>; toggleComplete: (id: string) => Promise<void>;
createProject: (data: { name: string; color?: string; icon?: string }) => Promise<Project>;
setSelectedTask: (task: Task | null) => void; setSelectedTask: (task: Task | null) => void;
setActiveProject: (projectId: string | null) => void; setActiveProject: (projectId: string | null) => void;
@@ -32,6 +35,7 @@ export const useTasksStore = create<TasksState>((set, get) => ({
tasks: [], tasks: [],
projects: [], projects: [],
labels: [], labels: [],
users: [],
isLoading: false, isLoading: false,
selectedTask: null, selectedTask: null,
activeProjectId: null, activeProjectId: null,
@@ -66,6 +70,15 @@ export const useTasksStore = create<TasksState>((set, get) => ({
} }
}, },
fetchUsers: async () => {
try {
const users = await api.getUsers();
set({ users });
} catch (error) {
console.error('Failed to fetch users:', error);
}
},
createTask: async (data) => { createTask: async (data) => {
const task = await api.createTask(data); const task = await api.createTask(data);
set((state) => ({ tasks: [task, ...state.tasks] })); set((state) => ({ tasks: [task, ...state.tasks] }));
@@ -90,6 +103,12 @@ export const useTasksStore = create<TasksState>((set, get) => ({
})); }));
}, },
createProject: async (data) => {
const project = await api.createProject(data);
set((state) => ({ projects: [...state.projects, project] }));
return project;
},
toggleComplete: async (id) => { toggleComplete: async (id) => {
const task = get().tasks.find((t) => t.id === id); const task = get().tasks.find((t) => t.id === id);
if (!task) return; if (!task) return;

View File

@@ -1,4 +1,4 @@
/// <reference types="vitest" /> /// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'