Repository navigation
Day 14
Congratulations! You have learned a lot in 13 days:
| Day | You learned |
|---|---|
| 0–2 | Running a server, routing with switch, a shared page template |
| 3–4 | Reading user input with GET (QueryString) and POST (Form) |
| 5 | Storing data in a server-side List and the Post-Redirect-Get pattern |
| 6 | Keeping your site safe with WebApp.HtmlEncode
|
| 7 | Designing your own class
|
| 8 | Saving to a file |
| 9 | Content types: CSS, pictures, plain text |
| 10 | Cookies |
| 11–12 |
fetch, plain text, and JSON |
| 13 | The Newtonsoft.Json library and [JsonProperty]
|
Today there is no new lesson. Instead, you will build a complete app by yourself, starting from an empty Program.cs: a To-Do List.
Try to build it on your own first. Look back at earlier days when you get stuck. A complete reference solution is at the bottom — but only peek when you have really tried!
- Show a list of tasks at
/. - Have a form to add a new task.
- Each task has a Done / Undo button that ticks it off (show done tasks
crossed out). - Each task has a Delete button.
- Tasks are saved to a file, so they survive a server restart.
- It must be safe: typing
<script>as a task must not run any code. - It should look nice, using a
/style.cssroute.
Build it in small steps. Run and test after every step!
Create a class TodoItem with three properties:
-
Id(int) — a unique number, so the buttons know which task to change. -
Text(string) — what needs to be done. -
Done(bool) —trueif it is finished.
Hint: Day 7.
Create a static List<TodoItem> with two or three tasks already in it. Build the HTML for the list with a foreach loop and a StringBuilder, inside a RenderPage template.
Hint: Day 2 and Day 5. Don't forget HtmlEncode (Day 6)!
Add a form with method="post" and action="/add". In the /add route, read the text from Request.Form, create a new TodoItem, add it to the list, then Response.Redirect("/").
Hint: Day 4 and Day 5. To give each task a new Id, keep a static int nextId and increase it by one every time.
Each task needs two small forms, each with a hidden field that carries the task's Id:
<form action="/toggle" method="post">
<input type="hidden" name="id" value="3" />
<button type="submit">Done</button>
</form>In the /toggle route, read the id with int.Parse(Request.Form["id"]), find the task with that Id, and flip Done (item.Done = !item.Done;). Do the same for /delete, but remove the task from the list.
Hint: To remove an item while looping is not allowed! Find it first, then remove it after the loop. Or use todoItems.RemoveAll(t => t.Id == id);.
Save the tasks to todo.txt, one per line: Id|Done|Text
1|False|Buy milk
2|True|Learn C#
Because tasks can change or be deleted, it is easiest to rewrite the whole file after every change, with File.WriteAllLines(path, lines). Load the file in Main before the server starts.
Hint: Day 8, Part 1. bool.Parse("True") turns text back into a bool.
Add a /style.css route with Response.ContentType = "text/css" and link it from your template.
Hint: Day 9.
- Add three tasks. They appear in the list.
- Click Done on one. It is crossed out. Click Undo. It is back to normal.
- Click Delete. It disappears.
- Stop the server and start it again. Your tasks are still there.
- Open
todo.txtin Notepad. You can read your tasks. - Add a task called
<b>bold</b>. It is shown as text, not bold. - Refresh the page after adding a task. The browser does not ask "Resend form data?" (that is the Post-Redirect-Get pattern working!).
- Open the app in two browser windows. Changes in one appear in the other after a refresh.
Finished? Try these:
- Counter: Show "2 of 5 tasks done" above the list.
-
Clear finished: Add a button that deletes every task where
Done == true. - Cookie: Ask for the visitor's name once and show "Alice's To-Do List" using a cookie (Day 10).
-
JSON API: Add
/api/todosthat returns all tasks as JSON usingJsonConvert.SerializeObject(Day 13), then build a/livepage that refreshes the list every 3 seconds. -
Empty tasks: What happens if someone submits only spaces? Make sure your app ignores them (
string.IsNullOrWhiteSpace).
Try it yourself first! When you are done (or really stuck), compare with this version.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using cshttp;
class TodoItem
{
public int Id { get; set; }
public string Text { get; set; }
public bool Done { get; set; }
}
static class Program
{
static List<TodoItem> todoItems = new List<TodoItem>();
static int nextId = 1;
static string dataFile = WebApp.MapPath("~/todo.txt");
static void Main()
{
LoadTodos();
Console.WriteLine("To-do file: " + dataFile);
WebApp.Run(8080, OnRequest);
}
// ---------- Saving and loading ----------
static void LoadTodos()
{
if (!File.Exists(dataFile))
{
return;
}
foreach (string line in File.ReadAllLines(dataFile))
{
// Id | Done | Text
string[] parts = line.Split(new char[] { '|' }, 3);
if (parts.Length != 3)
{
continue;
}
TodoItem item = new TodoItem
{
Id = int.Parse(parts[0]),
Done = bool.Parse(parts[1]),
Text = parts[2]
};
todoItems.Add(item);
// Make sure new tasks never reuse an old Id
if (item.Id >= nextId)
{
nextId = item.Id + 1;
}
}
}
static void SaveTodos()
{
List<string> lines = new List<string>();
foreach (TodoItem item in todoItems)
{
lines.Add(item.Id + "|" + item.Done + "|" + item.Text);
}
File.WriteAllLines(dataFile, lines);
}
// ---------- Routing ----------
static void OnRequest()
{
string path = Request.Path.ToLower();
switch (path)
{
case "/":
HandleList();
return;
case "/add":
HandleAdd();
return;
case "/toggle":
HandleToggle();
return;
case "/delete":
HandleDelete();
return;
case "/style.css":
HandleStyle();
return;
default:
Response.StatusCode = 404;
RenderPage("Not Found", "<h1>404 Not Found</h1><p><a href=\"/\">Back to the list</a></p>");
return;
}
}
// ---------- Pages ----------
static void RenderPage(string title, string bodyContent)
{
string html = $@"<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
<link rel=""stylesheet"" href=""/style.css"" />
</head>
<body>
{bodyContent}
<footer>Server Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}</footer>
</body>
</html>";
Response.Write(html);
Response.End();
}
static void HandleList()
{
StringBuilder listHtml = new StringBuilder();
int total = todoItems.Count;
int doneCount = 0;
foreach (TodoItem item in todoItems)
{
string safeText = WebApp.HtmlEncode(item.Text);
string cssClass = item.Done ? "done" : "";
string toggleLabel = item.Done ? "Undo" : "Done";
if (item.Done)
{
doneCount++;
}
listHtml.Append($@"
<li>
<span class=""{cssClass}"">{safeText}</span>
<form action=""/toggle"" method=""post"">
<input type=""hidden"" name=""id"" value=""{item.Id}"" />
<button type=""submit"">{toggleLabel}</button>
</form>
<form action=""/delete"" method=""post"">
<input type=""hidden"" name=""id"" value=""{item.Id}"" />
<button type=""submit"" class=""delete"">Delete</button>
</form>
</li>");
}
if (total == 0)
{
listHtml.Append("<li class=\"empty\">Nothing to do. Add your first task above!</li>");
}
string content = $@"
<h1>My To-Do List</h1>
<form action=""/add"" method=""post"" class=""add-form"">
<input type=""text"" name=""text"" placeholder=""What do you need to do?"" required autofocus />
<button type=""submit"">Add</button>
</form>
<p class=""count"">{doneCount} of {total} tasks done</p>
<ul>{listHtml.ToString()}</ul>";
RenderPage("My To-Do List", content);
}
// ---------- Actions (POST, then redirect back to the list) ----------
static void HandleAdd()
{
if (Request.HttpMethod == "POST")
{
string text = Request.Form["text"];
if (!string.IsNullOrWhiteSpace(text))
{
// Keep it on one line, so it fits in one line of our file
text = text.Replace("\r", " ").Replace("\n", " ").Trim();
TodoItem item = new TodoItem { Id = nextId, Text = text, Done = false };
nextId++;
todoItems.Add(item);
SaveTodos();
}
}
Response.Redirect("/");
}
static void HandleToggle()
{
int id;
if (Request.HttpMethod == "POST" && int.TryParse(Request.Form["id"], out id))
{
foreach (TodoItem item in todoItems)
{
if (item.Id == id)
{
item.Done = !item.Done;
break;
}
}
SaveTodos();
}
Response.Redirect("/");
}
static void HandleDelete()
{
int id;
if (Request.HttpMethod == "POST" && int.TryParse(Request.Form["id"], out id))
{
todoItems.RemoveAll(t => t.Id == id);
SaveTodos();
}
Response.Redirect("/");
}
// ---------- Stylesheet ----------
static void HandleStyle()
{
Response.ContentType = "text/css";
Response.Write(@"
body {
font-family: Arial, sans-serif;
max-width: 520px;
margin: 40px auto;
padding: 0 16px;
background: #f4f6fb;
color: #333;
}
h1 {
color: #2c5282;
}
.add-form {
display: flex;
gap: 8px;
}
.add-form input {
flex: 1;
padding: 8px;
}
button {
padding: 6px 12px;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
align-items: center;
gap: 8px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
}
li span {
flex: 1;
}
li form {
margin: 0;
}
.done {
text-decoration: line-through;
color: #999;
}
.delete {
color: #c53030;
}
.empty {
color: #999;
justify-content: center;
}
.count, footer {
color: gray;
font-size: small;
}");
}
}-
int.TryParseinstead ofint.Parse: If someone sends a broken id (likeabc),int.Parsewould crash with an error.int.TryParsesimply returnsfalse, and we ignore the request. Never trust input — even hidden fields can be changed by the visitor! -
Every action ends with
Response.Redirect("/"): That's the Post-Redirect-Get pattern from Day 5. Refreshing the page never repeats an action. -
HtmlEncodeonitem.Text, but not onitem.Id: The Id is anintthat we created, so it can only ever be digits.
You've built a complete, safe, saved, multi-user web app in C# — from an empty file! 🎉
From here, you can explore:
- Databases (like SQLite or MySQL) instead of text files, for bigger apps.
- User accounts and logins, with passwords stored safely (hashed).
- Serving real files like photos and downloads.
-
Concurrency &
lock, to protect data when thousands of visitors arrive at once.
Every one of them uses the same building blocks you already know: Request, Response, switch, classes, and encoding user input. Well done!
Technical Specs
14 Days Tutorial