Fix BASE_URL config loading, add tasks/projects; robust .env path resolution
- Config: try ENV_FILE, .env, ../.env for loading; trim trailing slash from BaseURL - Log BASE_URL at server startup for verification - .env.example: document BASE_URL - Tasks, projects, tags, migrations and related API/handlers Made-with: Cursor
This commit is contained in:
1
frontend/dist/assets/index-C8iK5eUL.css
vendored
Normal file
1
frontend/dist/assets/index-C8iK5eUL.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-D1jwjSlk.css
vendored
1
frontend/dist/assets/index-D1jwjSlk.css
vendored
File diff suppressed because one or more lines are too long
43
frontend/dist/assets/index-DLhHVFKH.js
vendored
43
frontend/dist/assets/index-DLhHVFKH.js
vendored
File diff suppressed because one or more lines are too long
43
frontend/dist/assets/index-DXAGcZdG.js
vendored
Normal file
43
frontend/dist/assets/index-DXAGcZdG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -5,10 +5,10 @@
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📅</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Calendar</title>
|
||||
<script type="module" crossorigin src="/assets/index-DLhHVFKH.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DXAGcZdG.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-BI0H_s0W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/date-huy51PAD.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D1jwjSlk.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C8iK5eUL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import CalendarPage from "./pages/CalendarPage";
|
||||
import ContactsPage from "./pages/ContactsPage";
|
||||
import TodoPage from "./pages/TodoPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
|
||||
function ProtectedRoute() {
|
||||
@@ -53,6 +54,21 @@ function AppLayout() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/todo"
|
||||
className={({ isActive }) =>
|
||||
`p-2.5 rounded-xl transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`
|
||||
}
|
||||
title="To-do"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={({ isActive }) =>
|
||||
@@ -104,6 +120,7 @@ export default function App() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<CalendarPage />} />
|
||||
<Route path="/todo" element={<TodoPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
Contact,
|
||||
ListResponse,
|
||||
PageInfo,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
@@ -295,4 +298,93 @@ export const api = {
|
||||
|
||||
deleteContact: (id: string) =>
|
||||
request<{ ok: boolean }>(`/contacts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tasks
|
||||
listTasks: (params?: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
project_id?: string;
|
||||
due_from?: string;
|
||||
due_to?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}) => {
|
||||
const sp = new URLSearchParams({ limit: "100" });
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.priority) sp.set("priority", params.priority);
|
||||
if (params?.project_id) sp.set("project_id", params.project_id);
|
||||
if (params?.due_from) sp.set("due_from", params.due_from);
|
||||
if (params?.due_to) sp.set("due_to", params.due_to);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.cursor) sp.set("cursor", params.cursor);
|
||||
return request<ListResponse<Task>>(`/tasks?${sp}`);
|
||||
},
|
||||
|
||||
createTask: (data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
parent_id?: string;
|
||||
}) =>
|
||||
request<{ task: Task }>("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getTask: (id: string) => request<{ task: Task }>(`/tasks/${id}`),
|
||||
|
||||
updateTask: (id: string, data: Partial<Task>) =>
|
||||
request<{ task: Task }>(`/tasks/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteTask: (id: string, permanent?: boolean) => {
|
||||
const q = permanent ? "?permanent=true" : "";
|
||||
return request<{ ok: boolean }>(`/tasks/${id}${q}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
markTaskComplete: (id: string) =>
|
||||
request<{ task: Task }>(`/tasks/${id}/complete`, { method: "POST" }),
|
||||
|
||||
markTaskUncomplete: (id: string, status?: string) =>
|
||||
request<{ task: Task }>(`/tasks/${id}/uncomplete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(status ? { status } : {}),
|
||||
}),
|
||||
|
||||
listTaskSubtasks: (id: string) =>
|
||||
request<ListResponse<Task>>(`/tasks/${id}/subtasks`),
|
||||
|
||||
// Projects
|
||||
listProjects: () => request<ListResponse<Project>>("/projects"),
|
||||
|
||||
createProject: (data: { name: string; color?: string }) =>
|
||||
request<{ project: Project }>("/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getProject: (id: string) => request<{ project: Project }>(`/projects/${id}`),
|
||||
|
||||
updateProject: (id: string, data: Partial<Project>) =>
|
||||
request<{ project: Project }>(`/projects/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteProject: (id: string) =>
|
||||
request<{ ok: boolean }>(`/projects/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tags
|
||||
listTags: () => request<ListResponse<Tag>>("/tags"),
|
||||
|
||||
createTag: (data: { name: string; color?: string }) =>
|
||||
request<{ tag: Tag }>("/tags", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
|
||||
100
frontend/src/components/KanbanBoard.tsx
Normal file
100
frontend/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import type { Task } from "../types";
|
||||
import TaskCard from "./TaskCard";
|
||||
|
||||
const KANBAN_COLUMNS = [
|
||||
{ id: "todo", title: "To Do", status: "todo" as const },
|
||||
{ id: "in_progress", title: "In Progress", status: "in_progress" as const },
|
||||
{ id: "done", title: "Done", status: "done" as const },
|
||||
];
|
||||
|
||||
const ARCHIVED_COLUMN = { id: "archived", title: "Archived", status: "archived" as const };
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
onStatusChange: (taskId: string, newStatus: string) => void;
|
||||
showArchived?: boolean;
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
onTaskClick,
|
||||
onStatusChange,
|
||||
showArchived = false,
|
||||
}: KanbanBoardProps) {
|
||||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
|
||||
|
||||
const columns = showArchived ? [ARCHIVED_COLUMN] : KANBAN_COLUMNS;
|
||||
const tasksByStatus = columns.reduce(
|
||||
(acc, col) => {
|
||||
acc[col.status] = tasks.filter((t) => t.status === col.status);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Task[]>
|
||||
);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, task: Task) => {
|
||||
setDraggedTask(task);
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, status: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverColumn(status);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, status: string) => {
|
||||
e.preventDefault();
|
||||
setDragOverColumn(null);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (taskId && draggedTask && draggedTask.status !== status) {
|
||||
onStatusChange(taskId, status);
|
||||
}
|
||||
setDraggedTask(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 flex-1 min-h-0">
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
onDragOver={(e) => handleDragOver(e, col.status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.status)}
|
||||
className={`flex-shrink-0 w-72 flex flex-col rounded-lg border-2 transition-colors ${
|
||||
dragOverColumn === col.status
|
||||
? "border-blue-400 dark:border-blue-500 bg-blue-50/50 dark:bg-blue-900/20"
|
||||
: "border-gray-200 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{col.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tasksByStatus[col.status]?.length ?? 0} tasks
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 p-3 overflow-y-auto space-y-2 min-h-[200px]">
|
||||
{(tasksByStatus[col.status] ?? []).map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => onTaskClick(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/ProjectSidebar.tsx
Normal file
67
frontend/src/components/ProjectSidebar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Project } from "../types";
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projects: Project[];
|
||||
selectedProjectId: string | null;
|
||||
onSelectProject: (id: string | null) => void;
|
||||
showArchived: boolean;
|
||||
onToggleArchived: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectSidebar({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
onSelectProject,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
}: ProjectSidebarProps) {
|
||||
return (
|
||||
<div className="w-56 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||
Projects
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
onClick={() => onSelectProject(null)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedProjectId === null
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
All Tasks
|
||||
</button>
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelectProject(p.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center gap-2 ${
|
||||
selectedProjectId === p.id
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<label className="flex items-center gap-2 px-3 py-2 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={onToggleArchived}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Show archived
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/TaskCard.tsx
Normal file
69
frontend/src/components/TaskCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Task } from "../types";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400",
|
||||
medium: "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300",
|
||||
high: "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300",
|
||||
critical: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onDragStart?: (e: React.DragEvent, task: Task) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function TaskCard({
|
||||
task,
|
||||
onDragStart,
|
||||
onClick,
|
||||
}: TaskCardProps) {
|
||||
const priorityClass = priorityColors[task.priority] || priorityColors.medium;
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={(e) => onDragStart?.(e, task)}
|
||||
onClick={onClick}
|
||||
className="group cursor-pointer rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-3 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-400">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 012 2v12a2 2 0 01-2 2h6a2 2 0 01-2-2V4a2 2 0 012-2h6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{task.title}
|
||||
</p>
|
||||
{task.due_date && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Due {format(new Date(task.due_date), "MMM d, yyyy")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${priorityClass}`}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
{task.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}20`,
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/src/components/TaskModal.tsx
Normal file
271
frontend/src/components/TaskModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import type { Task, Project, Tag } from "../types";
|
||||
import { api } from "../api";
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface TaskModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
task?: Task | null;
|
||||
projects: Project[];
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["todo", "in_progress", "done"] as const;
|
||||
const PRIORITY_OPTIONS = ["low", "medium", "high", "critical"] as const;
|
||||
|
||||
export default function TaskModal({
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
task,
|
||||
projects,
|
||||
tags,
|
||||
}: TaskModalProps) {
|
||||
const isEdit = !!task;
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState<Task["status"]>("todo");
|
||||
const [priority, setPriority] = useState<Task["priority"]>("medium");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError("");
|
||||
setShowDelete(false);
|
||||
if (task) {
|
||||
setTitle(task.title);
|
||||
setDescription(task.description || "");
|
||||
setStatus(task.status);
|
||||
setPriority(task.priority);
|
||||
setDueDate(task.due_date ? format(new Date(task.due_date), "yyyy-MM-dd") : "");
|
||||
setProjectId(task.project_id || "");
|
||||
} else {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStatus("todo");
|
||||
setPriority("medium");
|
||||
setDueDate("");
|
||||
setProjectId("");
|
||||
}
|
||||
}, [open, task]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateTask(task!.id, {
|
||||
title: title.trim(),
|
||||
description: description || undefined,
|
||||
status,
|
||||
priority,
|
||||
due_date: dueDate || undefined,
|
||||
project_id: projectId || undefined,
|
||||
});
|
||||
} else {
|
||||
await api.createTask({
|
||||
title: title.trim(),
|
||||
description: description || undefined,
|
||||
status,
|
||||
priority,
|
||||
due_date: dueDate || undefined,
|
||||
project_id: projectId || undefined,
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError((err as { error?: string })?.error || "Failed to save task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!task) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.deleteTask(task.id);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Failed to delete task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Edit Task" : "New Task"}
|
||||
wide
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Task title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Markdown supported"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as Task["status"])}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as Task["priority"])}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Project
|
||||
</label>
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<div>
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="text-red-600 dark:text-red-400 hover:underline text-sm"
|
||||
>
|
||||
Delete task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !title.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDelete && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p className="text-red-700 dark:text-red-300 text-sm mb-2">
|
||||
Delete this task? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="px-3 py-1 rounded bg-gray-200 dark:bg-gray-600 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 rounded bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
137
frontend/src/pages/TodoPage.tsx
Normal file
137
frontend/src/pages/TodoPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Task, Project, Tag } from "../types";
|
||||
import { api } from "../api";
|
||||
import KanbanBoard from "../components/KanbanBoard";
|
||||
import TaskModal from "../components/TaskModal";
|
||||
import ProjectSidebar from "../components/ProjectSidebar";
|
||||
|
||||
export default function TodoPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskModalOpen, setTaskModalOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
const params: Parameters<typeof api.listTasks>[0] = {};
|
||||
if (selectedProjectId) params.project_id = selectedProjectId;
|
||||
const data = await api.listTasks(params);
|
||||
setTasks((data.items || []) as Task[]);
|
||||
} catch {
|
||||
setTasks([]);
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.listProjects();
|
||||
setProjects(data.items || []);
|
||||
} catch {
|
||||
setProjects([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.listTags();
|
||||
setTags(data.items || []);
|
||||
} catch {
|
||||
setTags([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([loadTasks(), loadProjects(), loadTags()]).finally(() =>
|
||||
setLoading(false)
|
||||
);
|
||||
}, [loadTasks, loadProjects, loadTags]);
|
||||
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
if (newStatus === "done") {
|
||||
await api.markTaskComplete(taskId);
|
||||
} else {
|
||||
await api.markTaskUncomplete(taskId, newStatus);
|
||||
}
|
||||
loadTasks();
|
||||
} catch {
|
||||
setTasks((prev) => prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
loadTasks();
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const displayTasks = showArchived
|
||||
? tasks.filter((t) => t.status === "archived")
|
||||
: tasks.filter((t) => t.status !== "archived");
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
To-do List
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTask(null);
|
||||
setTaskModalOpen(true);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={setSelectedProjectId}
|
||||
showArchived={showArchived}
|
||||
onToggleArchived={() => setShowArchived((s) => !s)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
tasks={displayTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
onStatusChange={handleStatusChange}
|
||||
showArchived={showArchived}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
open={taskModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSaved={handleSaved}
|
||||
task={selectedTask}
|
||||
projects={projects}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,6 +137,48 @@ export type APIKeyScopes = {
|
||||
calendars?: ("read" | "write")[];
|
||||
events?: ("read" | "write")[];
|
||||
contacts?: ("read" | "write")[];
|
||||
tasks?: ("read" | "write")[];
|
||||
availability?: ("read")[];
|
||||
booking?: ("write")[];
|
||||
};
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "archived";
|
||||
export type TaskPriority = "low" | "medium" | "high" | "critical";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
due_date?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
owner_id: string;
|
||||
project_id?: string;
|
||||
parent_id?: string;
|
||||
subtasks?: Task[];
|
||||
tags?: Tag[];
|
||||
completion_percentage?: number;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
is_shared: boolean;
|
||||
deadline?: string;
|
||||
sort_order?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/AddCalendarUrlModal.tsx","./src/components/CalendarModal.tsx","./src/components/DayView.tsx","./src/components/ErrorBoundary.tsx","./src/components/EventModal.tsx","./src/components/MiniCalendar.tsx","./src/components/Modal.tsx","./src/components/MonthView.tsx","./src/components/WeekView.tsx","./src/context/AuthContext.tsx","./src/context/ThemeContext.tsx","./src/pages/CalendarPage.tsx","./src/pages/ContactsPage.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SettingsPage.tsx"],"version":"5.7.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/AddCalendarUrlModal.tsx","./src/components/CalendarModal.tsx","./src/components/DayView.tsx","./src/components/ErrorBoundary.tsx","./src/components/EventModal.tsx","./src/components/KanbanBoard.tsx","./src/components/MiniCalendar.tsx","./src/components/Modal.tsx","./src/components/MonthView.tsx","./src/components/ProjectSidebar.tsx","./src/components/TaskCard.tsx","./src/components/TaskModal.tsx","./src/components/WeekView.tsx","./src/context/AuthContext.tsx","./src/context/ThemeContext.tsx","./src/pages/CalendarPage.tsx","./src/pages/ContactsPage.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SettingsPage.tsx","./src/pages/TodoPage.tsx"],"version":"5.7.3"}
|
||||
Reference in New Issue
Block a user