aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--public/app.js167
-rw-r--r--public/index.html50
-rw-r--r--public/style.css284
4 files changed, 503 insertions, 2 deletions
diff --git a/README.md b/README.md
index 5ccb65f..a71a9fe 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-![screenshot](img_crud_logo.png)
+![img_crud_logo](https://200l.xyz/img/vcs/crud/img_crud_logo.png)
A minimal task management API with user authentication, built with express and better-sqlite3.
-![screenshot](img_screenshot.png)
+![img_screenshot](https://200l.xyz/img/vcs/crud/img_screenshot.png)
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..a943f85
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,167 @@
+/* Auth. */
+function switchTab(tab)
+{
+ const loginForm = document.getElementById('login-form');
+ const registerForm = document.getElementById('register-form');
+ const loginTab = document.getElementById('login-tab');
+ const registerTab = document.getElementById('register-tab');
+ if (tab === 'login')
+ {
+ loginForm.style.display = 'flex';
+ registerForm.style.display = 'none';
+ loginTab.classList.add('active');
+ registerTab.classList.remove('active');
+ }
+ else
+ {
+ loginForm.style.display = 'none';
+ registerForm.style.display = 'flex';
+ loginTab.classList.remove('active');
+ registerTab.classList.add('active');
+ }
+ document.getElementById('auth-error').textContent = '';
+}
+
+async function register(e)
+{
+ e.preventDefault();
+ const username = document.getElementById('reg-username').value;
+ const password = document.getElementById('reg-password').value;
+ const res = await fetch('/auth/register',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ });
+ if (res.ok)
+ {
+ document.getElementById('auth-error').textContent = '';
+ showTasks();
+ }
+ else
+ {
+ const data = await res.json();
+ document.getElementById('auth-error').textContent = data.error || 'Registration failed';
+ }
+}
+
+async function login(e)
+{
+ e.preventDefault();
+ const username = document.getElementById('login-username').value;
+ const password = document.getElementById('login-password').value;
+ const res = await fetch('/auth/login',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ });
+ if (res.ok)
+ {
+ document.getElementById('auth-error').textContent = '';
+ showTasks();
+ }
+ else
+ {
+ const data = await res.json();
+ document.getElementById('auth-error').textContent = data.error || 'Login failed';
+ }
+}
+
+async function logout()
+{
+ await fetch('/auth/logout', { method: 'POST' });
+ document.getElementById('tasks-section').style.display = 'none';
+ document.getElementById('auth-section').style.display = 'block';
+}
+
+/* Tasks. */
+async function showTasks()
+{
+ document.getElementById('auth-section').style.display = 'none';
+ document.getElementById('tasks-section').style.display = 'block';
+ await loadTasks();
+}
+
+async function loadTasks()
+{
+ const res = await fetch('/tasks');
+ const tasks = await res.json();
+ const list = document.getElementById('task-list');
+ if (tasks.length === 0)
+ {
+ list.innerHTML = '<p class="empty-msg">No tasks yet. Add one above!</p>';
+ return;
+ }
+ list.innerHTML = tasks.map(task => `
+ <li class="task-item">
+ <input type="checkbox" ${task.done ? 'checked' : ''} onchange="toggleDone(${task.id}, this.checked)">
+ <div class="task-info">
+ <div class="task-title ${task.done ? 'done' : ''}">${escapeHtml(task.title)}</div>
+ ${task.description ? `<div class="task-desc">${escapeHtml(task.description)}</div>` : ''}
+ </div>
+ <button class="delete-btn" onclick="deleteTask(${task.id})" title="Delete">&times;</button>
+ </li>
+ `).join('');
+}
+
+async function createTask(e)
+{
+ e.preventDefault();
+ const title = document.getElementById('task-title').value;
+ const description = document.getElementById('task-desc').value;
+ const res = await fetch('/tasks',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, description })
+ });
+ if (res.ok)
+ {
+ document.getElementById('task-title').value = '';
+ document.getElementById('task-desc').value = '';
+ await loadTasks();
+ }
+}
+
+async function toggleDone(id, done)
+{
+ const res = await fetch(`/tasks/${id}`,
+ {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ done: done ? 1 : 0 })
+ });
+ if (res.ok)
+ {
+ await loadTasks();
+ }
+}
+
+async function deleteTask(id)
+{
+ const res = await fetch(`/tasks/${id}`, { method: 'DELETE' });
+ if (res.ok)
+ {
+ await loadTasks();
+ }
+}
+
+/* Helpers. */
+function escapeHtml(str)
+{
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+/* Init. */
+/* Check if already logged in on page load. */
+(async function init()
+{
+ const res = await fetch('/auth/check');
+ if (res.ok)
+ {
+ showTasks();
+ }
+})();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..f9ab8c8
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<title>&#8203;</title>
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta charset="UTF-8">
+<link rel="stylesheet" href="/style.css">
+</head>
+<body>
+<div class="container">
+<h1>📝</h1>
+
+<!--- auth section. --->
+<div id="auth-section">
+ <div class="tabs">
+ <button class="tab-btn active" id="login-tab" onclick="switchTab('login')">Log in_::</button>
+ <button class="tab-btn" id="register-tab" onclick="switchTab('register')">Register_::</button>
+ </div>
+ <form id="login-form" onsubmit="login(event)">
+ <input type="text" id="login-username" placeholder=":user" required>
+ <input type="password" id="login-password" placeholder=":pass" required>
+ <button type="submit">Login</button>
+ </form>
+ <form id="register-form" style="display:none" onsubmit="register(event)">
+ <input type="text" id="reg-username" placeholder=":user" required>
+ <input type="password" id="reg-password" placeholder=":pass" required>
+ <button type="submit">Register</button>
+ </form>
+ <p id="auth-error" class="error"></p>
+</div>
+
+<!--- tasks section. --->
+<div id="tasks-section" style="display:none">
+ <div class="header">
+ <h2>Your Tasks</h2>
+ <button onclick="logout()" class="logout-btn">Logout</button>
+ </div>
+ <!--- create task. --->
+ <form id="task-form" onsubmit="createTask(event)">
+ <input type="text" id="task-title" placeholder="Task title" required>
+ <input type="text" id="task-desc" placeholder="Description (optional)">
+ <button type="submit">Add Task</button>
+ </form>
+ <!--- task list. --->
+ <ul id="task-list"></ul>
+</div>
+
+<script src="/app.js"></script>
+</body>
+</html>
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..72d0f9f
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,284 @@
+*
+{
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body
+{
+ font-family: 'MS Sans Serif', Tahoma, Geneva, sans-serif;
+ background: linear-gradient(#cccccc, #ffffff);;
+ color: #000;
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ padding: 2rem 1rem;
+}
+
+.container
+{
+ width: 100%;
+ max-width: 600px;
+ background: #c0c0c0;
+ border-radius: 0;
+ box-shadow:
+ inset -1px -1px 0 #000,
+ inset 1px 1px 0 #fff,
+ inset -2px -2px 0 #808080,
+ inset 2px 2px 0 #dfdfdf,
+ 2px 2px 0 rgba(0, 0, 0, 0.4);
+ padding: 0;
+}
+
+h1
+{
+ text-align: left;
+ margin: 0;
+ padding: 0.35rem 0.6rem;
+ font-size: 0.95rem;
+ font-weight: bold;
+ color: #fff;
+ background: linear-gradient(to bottom, #5a9bd4 0%, #2c689c 48%, #1f5689 52%, #3a7fb8 100%);
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
+ border-bottom: 1px solid #000;
+}
+
+h2
+{
+ font-size: 0.95rem;
+ font-weight: bold;
+ margin-bottom: 0.6rem;
+}
+
+/* Tabs. */
+.tabs
+{
+ display: flex;
+ gap: 2px;
+ margin-bottom: 0.6rem;
+ padding: 0.6rem 0.6rem 0;
+}
+
+.tab-btn
+{
+ flex: 1;
+ padding: 0.4rem;
+ border: 1px solid #000;
+ background: linear-gradient(to bottom, #cfcfcf, #a0a0a0);
+ color: #000;
+ border-radius: 0;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-family: inherit;
+ box-shadow:
+ inset -1px -1px 0 #808080,
+ inset 1px 1px 0 #fff;
+ transition: none;
+}
+
+.tab-btn.active
+{
+ background: linear-gradient(to bottom, #7db8e8 0%, #3a7fb8 50%, #2c689c 51%, #5a9bd4 100%);
+ color: #fff;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+}
+
+/* Forms. */
+form
+{
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-bottom: 0.6rem;
+ padding: 0 0.6rem;
+}
+
+input[type="text"],
+input[type="password"]
+{
+ padding: 0.3rem;
+ border: 1px solid #000;
+ border-radius: 0;
+ font-size: 0.8rem;
+ font-family: inherit;
+ outline: none;
+ background: #fff;
+ box-shadow:
+ inset 1px 1px 0 #808080,
+ inset -1px -1px 0 #fff;
+ transition: none;
+}
+
+input:focus
+{
+ border-color: #000;
+}
+
+button[type="submit"]
+{
+ padding: 0.35rem;
+ background: linear-gradient(to bottom, #7db8e8 0%, #3a7fb8 50%, #2c689c 51%, #5a9bd4 100%);
+ color: #fff;
+ border: 1px solid #000;
+ border-radius: 0;
+ font-size: 0.8rem;
+ font-family: inherit;
+ cursor: pointer;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+ box-shadow:
+ inset -1px -1px 0 #1f5689,
+ inset 1px 1px 0 #b0d8f0;
+ transition: none;
+}
+
+button[type="submit"]:hover
+{
+ background: linear-gradient(to bottom, #8fc8f0 0%, #4a90d9 50%, #357ab8 51%, #6aabe0 100%);
+}
+
+button[type="submit"]:active
+{
+ box-shadow:
+ inset 1px 1px 0 #1f5689,
+ inset -1px -1px 0 #b0d8f0;
+}
+
+/* Header. */
+.header
+{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.6rem;
+ padding: 0 0.6rem;
+}
+
+.logout-btn
+{
+ padding: 0.3rem 0.8rem;
+ background: linear-gradient(to bottom, #e88080 0%, #c0392b 50%, #a5281b 51%, #d05040 100%);
+ color: #fff;
+ border: 1px solid #000;
+ border-radius: 0;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-family: inherit;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+ box-shadow:
+ inset -1px -1px 0 #802018,
+ inset 1px 1px 0 #f0b0a0;
+}
+
+.logout-btn:hover
+{
+ background: linear-gradient(to bottom, #f09090 0%, #d04030 50%, #b53020 51%, #e06050 100%);
+}
+
+.logout-btn:active
+{
+ box-shadow:
+ inset 1px 1px 0 #802018,
+ inset -1px -1px 0 #f0b0a0;
+}
+
+#task-list
+{
+ list-style: none;
+ margin-top: 0.6rem;
+ padding: 0 0.6rem;
+}
+
+.task-item
+{
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem;
+ border: 1px solid #000;
+ background: #fff;
+ box-shadow:
+ inset 1px 1px 0 #808080,
+ inset -1px -1px 0 #fff;
+ margin-bottom: 3px;
+ transition: none;
+}
+
+.task-item:hover
+{
+ background: #e8eef5;
+}
+
+.task-item input[type="checkbox"]
+{
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+}
+
+.task-info
+{
+ flex: 1;
+}
+
+.task-title
+{
+ font-weight: bold;
+ font-size: 0.8rem;
+}
+
+.task-title.done
+{
+ text-decoration: line-through;
+ color: #808080;
+}
+
+.task-desc
+{
+ font-size: 0.75rem;
+ color: #606060;
+ margin-top: 0.1rem;
+}
+
+.delete-btn
+{
+ background: #c0c0c0;
+ border: 1px solid #000;
+ color: #000;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-family: inherit;
+ padding: 0.1rem 0.4rem;
+ box-shadow:
+ inset -1px -1px 0 #808080,
+ inset 1px 1px 0 #fff;
+}
+
+.delete-btn:hover
+{
+ color: #c0392b;
+}
+
+.delete-btn:active
+{
+ box-shadow:
+ inset 1px 1px 0 #808080,
+ inset -1px -1px 0 #fff;
+}
+
+/* Messages. */
+.error
+{
+ color: #c0392b;
+ font-size: 0.8rem;
+ text-align: center;
+ padding: 0 0.6rem;
+}
+
+.empty-msg
+{
+ text-align: center;
+ color: #606060;
+ padding: 1.5rem 0;
+ font-size: 0.8rem;
+}