Initial template
This commit is contained in:
commit
3191680f41
5 changed files with 334 additions and 0 deletions
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY server.js ./
|
||||
COPY public/ ./public/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# todo-list
|
||||
|
||||
Todo list app with Express backend, Postgres database, and single-page HTML frontend
|
||||
13
package.json
Normal file
13
package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "todo-list",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"dotenv": "^16.3.1"
|
||||
}
|
||||
}
|
||||
216
public/index.html
Normal file
216
public/index.html
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Todo List</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #1a1a2e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-row input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.input-row button {
|
||||
padding: 12px 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.input-row button:hover { background: #4f46e5; }
|
||||
|
||||
.todo-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.todo-item.done .todo-text {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.todo-item input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: #6366f1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.todo-date {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-item .delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-item .delete-btn:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 40px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<h1>✅ Todo List</h1>
|
||||
<form class="input-row" id="addForm">
|
||||
<input type="text" id="todoInput" placeholder="What needs to be done?" autocomplete="off" required>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<ul class="todo-list" id="todoList"></ul>
|
||||
<p class="empty" id="emptyMsg" style="display:none">No todos yet — add one above!</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/api/todos';
|
||||
const todoList = document.getElementById('todoList');
|
||||
const addForm = document.getElementById('addForm');
|
||||
const todoInput = document.getElementById('todoInput');
|
||||
const emptyMsg = document.getElementById('emptyMsg');
|
||||
|
||||
async function loadTodos() {
|
||||
const res = await fetch(API);
|
||||
const todos = await res.json();
|
||||
todoList.innerHTML = '';
|
||||
if (todos.length === 0) {
|
||||
emptyMsg.style.display = 'block';
|
||||
} else {
|
||||
emptyMsg.style.display = 'none';
|
||||
todos.forEach(t => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'todo-item' + (t.done ? ' done' : '');
|
||||
li.dataset.id = t.id;
|
||||
|
||||
const date = new Date(t.created_at).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
li.innerHTML = `
|
||||
<input type="checkbox" ${t.done ? 'checked' : ''} />
|
||||
<span class="todo-text"></span>
|
||||
<span class="todo-date">${date}</span>
|
||||
<button class="delete-btn" title="Delete">✕</button>
|
||||
`;
|
||||
li.querySelector('.todo-text').textContent = t.text;
|
||||
|
||||
li.querySelector('input[type="checkbox"]').addEventListener('change', () => toggleTodo(t.id));
|
||||
li.querySelector('.delete-btn').addEventListener('click', () => deleteTodo(t.id));
|
||||
|
||||
todoList.appendChild(li);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function addTodo(text) {
|
||||
await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
loadTodos();
|
||||
}
|
||||
|
||||
async function toggleTodo(id) {
|
||||
await fetch(`${API}/${id}/toggle`, { method: 'PUT' });
|
||||
loadTodos();
|
||||
}
|
||||
|
||||
async function deleteTodo(id) {
|
||||
await fetch(`${API}/${id}`, { method: 'DELETE' });
|
||||
loadTodos();
|
||||
}
|
||||
|
||||
addForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const text = todoInput.value.trim();
|
||||
if (text) {
|
||||
addTodo(text);
|
||||
todoInput.value = '';
|
||||
todoInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
loadTodos();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
89
server.js
Normal file
89
server.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
async function initDB() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
done BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('Database table ready');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/todos
|
||||
app.get('/api/todos', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM todos ORDER BY created_at DESC');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch todos' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/todos
|
||||
app.post('/api/todos', async (req, res) => {
|
||||
const { text } = req.body;
|
||||
if (!text || typeof text !== 'string' || !text.trim()) {
|
||||
return res.status(400).json({ error: '"text" is required' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO todos (text) VALUES ($1) RETURNING *',
|
||||
[text.trim()]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to create todo' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/todos/:id/toggle
|
||||
app.put('/api/todos/:id/toggle', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE todos SET done = NOT done WHERE id = $1 RETURNING *',
|
||||
[req.params.id]
|
||||
);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Todo not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to toggle todo' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/todos/:id
|
||||
app.delete('/api/todos/:id', async (req, res) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('DELETE FROM todos WHERE id = $1', [req.params.id]);
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'Todo not found' });
|
||||
res.json({ message: 'Todo deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to delete todo' });
|
||||
}
|
||||
});
|
||||
|
||||
initDB().then(() => {
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||
});
|
||||
Loading…
Reference in a new issue