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:
Michilis
2026-03-09 18:57:51 +00:00
parent 75105b8b46
commit bd24545b7b
61 changed files with 6595 additions and 90 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

43
frontend/dist/assets/index-DXAGcZdG.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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>

View File

@@ -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),
}),
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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"}