Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/to-do-webapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

## Environment

This app expects several server-only environment variables to be set before running. To avoid accidental leaks, do not commit `.env.local` with secrets. Create a local file from your environment or use your secrets manager.

Required variables (server-only):

- `API_BASE_URL` — base URL of the backend API
- `API_BASE_PATH` — base path used by the generated API client
- `API_KEY` — API key for server-to-server calls (keep secret)

When running locally you can copy a provided example (if present) or create `.env.local` yourself:

```bash
# create a local env and edit values
cp .env.local.example .env.local 2>/dev/null || true
# then edit .env.local to set API_BASE_URL/API_BASE_PATH/API_KEY
```

The app validates required env vars on the server at startup and will fail fast with a clear error if any are missing.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More
Expand Down
3 changes: 2 additions & 1 deletion apps/to-do-webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"io-ts": "catalog:",
"next": "catalog:next",
"react": "catalog:react",
"react-dom": "catalog:react"
"react-dom": "catalog:react",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
Expand Down
23 changes: 23 additions & 0 deletions apps/to-do-webapp/src/app/api/tasks/[taskId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";

import { completeTask } from "@/lib/api";

export async function DELETE(
request: Request,
context: { params: Promise<Record<string, string>> | Record<string, string> },
) {
try {
// In Next.js App Router dynamic API handlers `context.params` may be a Promise.
// Await it before accessing properties to avoid the sync dynamic APIs error.
const resolvedParams = await context.params;
const taskId: string = resolvedParams?.taskId as string;

const { status } = await completeTask(taskId);
return new NextResponse(null, { status });
} catch {
return NextResponse.json(
{ message: "Error deleting task" },
{ status: 500 },
);
}
}
17 changes: 17 additions & 0 deletions apps/to-do-webapp/src/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";

import { insertTask } from "@/lib/api";

export async function POST(req: Request) {
try {
const body = await req.json();
const { title } = body;
const { status, value } = await insertTask(title);
return NextResponse.json(value, { status });
} catch {
return NextResponse.json(
{ message: "Error creating task" },
{ status: 500 },
);
}
}
63 changes: 6 additions & 57 deletions apps/to-do-webapp/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,11 @@
"use client";

import { Alert, Container, Divider, Typography } from "@mui/material";
import { useEffect, useState } from "react";

import ToDoList from "@/components/ToDoList";
import ToDoTextArea from "@/components/ToDoTextArea";
import TasksClient from "@/components/TasksClient";
import { getTaskList } from "@/lib/api";
import { TaskItem } from "@/lib/client/TaskItem";
import { TaskItemList } from "@/lib/client/TaskItemList";
export const dynamic = "force-dynamic";

export default function Home() {
const [tasks, setTasks] = useState<TaskItemList>([]);
const [error, setError] = useState<null | string>(null);

useEffect(() => {
const fetchTasks = async () => {
try {
const { status, value: taskList } = await getTaskList();
if (status === 200) {
setError(null);
setTasks(taskList);
} else {
setError("Failed to fetch tasks. Please try again later.");
setTasks([]);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
setError("Failed to fetch tasks. Please try again later.");
}
};
fetchTasks();
}, []);

// Update the list of tasks with the new task
const addTask = (task: TaskItem) => {
setTasks((prevTasks) => [...prevTasks, task]);
};

// Remove the completed task from the list
const completeTask = (taskId: string) => {
setTasks((tasks) => tasks.filter(({ id }) => id !== taskId));
};

return (
<Container maxWidth="sm" style={{ marginTop: "20px" }}>
<Typography component="h1" variant="h2">
My Tasks
</Typography>
<Divider sx={{ mb: "20px" }} />
export const dynamic = "force-dynamic";

<ToDoTextArea label={"Add a task to the list"} onAddTask={addTask} />
export default async function Home() {
const { status, value: taskList } = await getTaskList();
const initialTasks = status === 200 ? taskList : [];

{error ? (
<Alert severity="error">{error}</Alert>
) : (
<ToDoList onTaskComplete={completeTask} tasks={tasks} />
)}
</Container>
);
return <TasksClient initialTasks={initialTasks} />;
}
71 changes: 71 additions & 0 deletions apps/to-do-webapp/src/components/TasksClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { Alert, Container, Divider, Typography } from "@mui/material";
import { useState } from "react";

import type { TaskItemList } from "@/lib/client/TaskItemList";

import ToDoList from "@/components/ToDoList";
import ToDoTextArea from "@/components/ToDoTextArea";

export default function TasksClient({
initialTasks,
}: {
initialTasks: TaskItemList;
}) {
const [tasks, setTasks] = useState(initialTasks);
const [error, setError] = useState<null | string>(null);

const addTask = async (title: string): Promise<void> => {
setError(null);
try {
const res = await fetch("/api/tasks", {
body: JSON.stringify({ title }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
if (res.ok) {
const created = await res.json();
setTasks((p) => [...p, created]);
} else {
const body = await res.json().catch(() => null);
setError(body?.message ?? "Failed creating task");
}
} catch (err) {
console.error("TasksClient.addTask error", err);
setError(err instanceof Error ? err.message : "Failed creating task");
}
};

const completeTask = async (id: string): Promise<void> => {
setError(null);
try {
const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" });
if (res.ok) {
setTasks((p) => p.filter((t) => t.id !== id));
} else {
setError("Failed deleting task");
}
} catch (err) {
console.error("TasksClient.completeTask error", err);
setError(err instanceof Error ? err.message : "Failed deleting task");
}
};

return (
<Container maxWidth="sm" sx={{ mt: 3 }}>
<Typography component="h1" variant="h2">
My Tasks
</Typography>
<Divider sx={{ mb: 2 }} />

<ToDoTextArea label={"Add a task to the list"} onAddTask={addTask} />

{error ? (
<Alert severity="error">{error}</Alert>
) : (
<ToDoList onTaskComplete={completeTask} tasks={tasks} />
)}
</Container>
);
}
25 changes: 12 additions & 13 deletions apps/to-do-webapp/src/components/ToDoItem.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { Checkbox, ListItem, ListItemText } from "@mui/material";
import React from "react";

import { completeTask } from "@/lib/api";
import { TaskId } from "@/lib/client/TaskId";

interface TodoItemProps {
id: TaskId;
// Function to execute when a task is completed
onComplete: (id: TaskId) => void;
onComplete: (id: TaskId) => Promise<void>;
state: "COMPLETED" | "DELETED" | "INCOMPLETE";
title: string;
}
Expand All @@ -18,22 +16,23 @@ const ToDoItem: React.FC<TodoItemProps> = ({
state,
title,
}) => {
const handleTaskComplete = async (taskId: TaskId) => {
const [isLoading, setIsLoading] = React.useState(false);

const handleChange = async () => {
setIsLoading(true);
try {
const { status } = await completeTask(taskId);
if (status === 204) {
onComplete(id);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
/* error when deleting the task */
await onComplete(id);
} finally {
setIsLoading(false);
}
};

return (
<ListItem dense divider={true} key={id}>
<ListItem dense divider={true}>
<Checkbox
checked={state === "COMPLETED"}
onChange={() => handleTaskComplete(id)}
disabled={isLoading}
onChange={handleChange}
/>
<ListItemText primary={title} />
</ListItem>
Expand Down
11 changes: 9 additions & 2 deletions apps/to-do-webapp/src/components/ToDoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { TaskItemList } from "@/lib/client/TaskItemList";

interface TaskListProps {
// Function to execute when a task is completed
onTaskComplete: (id: TaskId) => void;
// Function to execute when a task is completed (async)
onTaskComplete: (id: TaskId) => Promise<void>;
tasks: TaskItemList;
}

Expand All @@ -19,7 +20,13 @@ const ToDoList: React.FC<TaskListProps> = ({
{tasks
.filter(({ state }) => state === "INCOMPLETE")
.map((item) => (
<ToDoItem {...item} key={item.id} onComplete={onTaskComplete} />
<ToDoItem
id={item.id}
key={item.id}
onComplete={onTaskComplete}
state={item.state}
title={item.title}
/>
))}
</List>
);
Expand Down
23 changes: 9 additions & 14 deletions apps/to-do-webapp/src/components/ToDoTextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Button, TextField } from "@mui/material";
import React, { useState } from "react";

import { insertTask } from "@/lib/api";
import { TaskItem } from "@/lib/client/TaskItem";
// TaskItem type removed; this component is presentational and only deals with titles

interface Props {
label: string;
// Function to update the list of tasks with the new task
onAddTask: (task: TaskItem) => void;
// Parent must return a promise so the component can await and show loading
onAddTask: (taskTitle: string) => Promise<void>;
}

const ToDoTextArea = ({ label, onAddTask }: Props) => {
Expand All @@ -18,14 +17,10 @@ const ToDoTextArea = ({ label, onAddTask }: Props) => {
const handleAddTask = async (text: string) => {
setIsLoading(true);
try {
const { status, value } = await insertTask(text);
if (status === 201) {
setTaskText("");
onAddTask(value);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
/* error when creating a task */
await onAddTask(text);
setTaskText("");
} catch {
// let parent show error
} finally {
setIsLoading(false);
}
Expand All @@ -52,10 +47,10 @@ const ToDoTextArea = ({ label, onAddTask }: Props) => {
<Button
disabled={isEmptyText || isLoading}
onClick={() => handleAddTask(taskText)}
style={{ marginTop: "10px" }}
sx={{ mt: 1 }}
variant="contained"
>
{isLoading ? "adding the task..." : "add"}
{isLoading ? "Adding..." : "Add"}
</Button>
</>
);
Expand Down
Loading