This commit is contained in:
@ -7,6 +7,9 @@
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Encrypted Todo List</h1>
|
||||
<button id="logoutBtn" style="display: none" onclick="logoutUser()">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="user-setup" class="container">
|
||||
@ -48,154 +51,234 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// State variables
|
||||
let currentUser = null;
|
||||
let allTodos = [];
|
||||
let showEncrypted = false;
|
||||
let allUsers = [];
|
||||
let ws = null;
|
||||
|
||||
// Try to restore user session from localStorage
|
||||
window.onload = function () {
|
||||
const savedUser = localStorage.getItem("currentUser");
|
||||
if (savedUser) {
|
||||
currentUser = savedUser;
|
||||
document.getElementById("user-setup").style.display =
|
||||
"none";
|
||||
document.getElementById("todo-app").style.display = "block";
|
||||
document.getElementById("logoutBtn").style.display =
|
||||
"inline-block";
|
||||
connectWebSocket();
|
||||
loadTodos();
|
||||
}
|
||||
};
|
||||
|
||||
function connectWebSocket() {
|
||||
if (!currentUser) return;
|
||||
ws = new WebSocket(
|
||||
`ws://${window.location.host}?userId=${currentUser}`,
|
||||
);
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case "users":
|
||||
allUsers = msg.data;
|
||||
updateUserDropdowns();
|
||||
break;
|
||||
case "todo_added":
|
||||
case "todo_shared":
|
||||
loadTodos();
|
||||
break;
|
||||
case "todo_deleted":
|
||||
allTodos = allTodos.filter(
|
||||
(todo) => todo.id !== msg.data.id,
|
||||
);
|
||||
renderTodos();
|
||||
break;
|
||||
case "todo_updated":
|
||||
allTodos = allTodos.map((todo) => {
|
||||
if (todo.id === msg.data.id) {
|
||||
return {
|
||||
...todo,
|
||||
completed: msg.data.completed,
|
||||
};
|
||||
}
|
||||
return todo;
|
||||
});
|
||||
renderTodos();
|
||||
break;
|
||||
case "todo_share_confirmed":
|
||||
// Reload todos to show updated sharing info
|
||||
loadTodos();
|
||||
break;
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
function updateUserDropdowns() {
|
||||
document.querySelectorAll(".share-select").forEach((select) => {
|
||||
const todoId = select.id.replace("share-", "");
|
||||
const todo = allTodos.find((t) => t.id === todoId);
|
||||
const availableUsers = allUsers.filter(
|
||||
(u) =>
|
||||
u !== currentUser &&
|
||||
(!todo ||
|
||||
!todo.participants ||
|
||||
!todo.participants.includes(u)),
|
||||
);
|
||||
|
||||
const prev = select.value;
|
||||
select.innerHTML = [
|
||||
'<option value="">Add participant...</option>',
|
||||
...availableUsers.map(
|
||||
(u) => `<option value="${u}">${u}</option>`,
|
||||
),
|
||||
].join("");
|
||||
select.value = prev;
|
||||
});
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const userId = document.getElementById("userId").value;
|
||||
const userId = document.getElementById("userId").value.trim();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/users", {
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (res.ok) {
|
||||
currentUser = userId;
|
||||
localStorage.setItem("currentUser", userId);
|
||||
document.getElementById("user-setup").style.display =
|
||||
"none";
|
||||
document.getElementById("todo-app").style.display =
|
||||
"block";
|
||||
loadUsers();
|
||||
document.getElementById("logoutBtn").style.display =
|
||||
"inline-block";
|
||||
connectWebSocket();
|
||||
loadTodos();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error);
|
||||
const err = await res.json();
|
||||
alert(err.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error creating user: " + error.message);
|
||||
} catch (err) {
|
||||
alert("Error creating user: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch("/api/users");
|
||||
allUsers = await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error loading users:", error);
|
||||
}
|
||||
function logoutUser() {
|
||||
localStorage.removeItem("currentUser");
|
||||
currentUser = null;
|
||||
if (ws) ws.close();
|
||||
document.getElementById("todo-app").style.display = "none";
|
||||
document.getElementById("user-setup").style.display = "block";
|
||||
document.getElementById("userId").value = "";
|
||||
document.getElementById("logoutBtn").style.display = "none";
|
||||
}
|
||||
|
||||
async function addTodo() {
|
||||
const text = document.getElementById("todoText").value;
|
||||
const text = document.getElementById("todoText").value.trim();
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/users/${currentUser}/todos`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const res = await fetch(`/api/users/${currentUser}/todos`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById("todoText").value = "";
|
||||
loadTodos();
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error adding todo: " + error.message);
|
||||
} catch (err) {
|
||||
alert("Error adding todo: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTodos() {
|
||||
try {
|
||||
// Get the decrypted todos
|
||||
const response = await fetch(
|
||||
`/api/users/${currentUser}/todos`,
|
||||
);
|
||||
const todos = await response.json();
|
||||
|
||||
// Get the raw encrypted todos
|
||||
const encryptedResponse = await fetch(
|
||||
`/api/users/${currentUser}/todos/encrypted`,
|
||||
);
|
||||
const encryptedTodos = await encryptedResponse.json();
|
||||
|
||||
// Create a map of encrypted todos by ID
|
||||
const encryptedMap = {};
|
||||
encryptedTodos.forEach((todo) => {
|
||||
encryptedMap[todo.id] = todo.encrypted;
|
||||
const [plainRes, encRes] = await Promise.all([
|
||||
fetch(`/api/users/${currentUser}/todos`),
|
||||
fetch(`/api/users/${currentUser}/todos/encrypted`),
|
||||
]);
|
||||
const todos = await plainRes.json();
|
||||
const encTodos = await encRes.json();
|
||||
const encMap = {};
|
||||
encTodos.forEach((t) => {
|
||||
encMap[t.id] = t.encrypted;
|
||||
});
|
||||
|
||||
// Combine the data
|
||||
allTodos = todos.map((todo) => ({
|
||||
...todo,
|
||||
encrypted: encryptedMap[todo.id] || {
|
||||
allTodos = todos.map((t) => ({
|
||||
...t,
|
||||
encrypted: encMap[t.id] || {
|
||||
body: "Encryption data not available",
|
||||
},
|
||||
}));
|
||||
|
||||
renderTodos();
|
||||
} catch (error) {
|
||||
console.error("Error loading todos:", error);
|
||||
} catch (err) {
|
||||
console.error("Error loading todos:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTodos() {
|
||||
const todosDiv = document.getElementById("todos");
|
||||
|
||||
if (allTodos.length === 0) {
|
||||
if (!allTodos.length) {
|
||||
todosDiv.innerHTML = "<p>No todos yet. Add one above.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
todosDiv.innerHTML = allTodos
|
||||
.map(
|
||||
(todo) => `
|
||||
<div class="todo-item">
|
||||
<div class="todo-content">
|
||||
${
|
||||
showEncrypted
|
||||
? `<div class="encrypted-data">
|
||||
<strong>Type:</strong> ${todo.encrypted.type}<br>
|
||||
<strong>Encrypted Data:</strong><br>${todo.encrypted.body}
|
||||
</div>`
|
||||
: `<div>
|
||||
<span>${todo.text}</span>
|
||||
${todo.sharedBy ? `<span class="shared-badge">Shared by ${todo.sharedBy}</span>` : ""}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="todo-actions">
|
||||
${
|
||||
!todo.sharedBy
|
||||
? `
|
||||
<div class="share-container">
|
||||
<select id="share-${todo.id}" class="share-select">
|
||||
<option value="">Share with...</option>
|
||||
${allUsers
|
||||
.filter((user) => user !== currentUser)
|
||||
.map(
|
||||
(user) =>
|
||||
`<option value="${user}">${user}</option>`,
|
||||
)
|
||||
.join("")}
|
||||
</select>
|
||||
<button onclick="shareTodo('${todo.id}')">Share</button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<button onclick="deleteTodo('${todo.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
<div class="todo-item${todo.completed ? " completed" : ""}">
|
||||
<div class="todo-content">
|
||||
<div class="todo-header">
|
||||
<input type="checkbox" class="todo-checkbox" ${todo.completed ? "checked" : ""} onchange="toggleTodoStatus('${todo.id}', this.checked)">
|
||||
${
|
||||
showEncrypted
|
||||
? `<div class="encrypted-data">
|
||||
<strong>Type:</strong> ${todo.encrypted.type}<br>
|
||||
<strong>Encrypted Data:</strong><br>${todo.encrypted.body}
|
||||
</div>`
|
||||
: `<div>
|
||||
<span class="todo-text">${todo.text}</span>
|
||||
<div class="todo-metadata">
|
||||
${todo.isCreator ? `<span class="creator-badge">You created this</span>` : `<span class="shared-badge">Created by ${todo.createdBy}</span>`}
|
||||
${todo.participants && todo.participants.length > 1 ? `<span class="participants-badge">${todo.participants.length} participant${todo.participants.length > 1 ? "s" : ""}: ${todo.participants.filter((p) => p !== currentUser).join(", ")}</span>` : ""}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="todo-actions">
|
||||
${
|
||||
todo.isCreator
|
||||
? `
|
||||
<div class="share-container">
|
||||
<select id="share-${todo.id}" class="share-select">
|
||||
<option value="">Add participant...</option>
|
||||
${allUsers
|
||||
.filter(
|
||||
(u) =>
|
||||
u !== currentUser &&
|
||||
!todo.participants.includes(u),
|
||||
)
|
||||
.map(
|
||||
(u) =>
|
||||
`<option value="${u}">${u}</option>`,
|
||||
)
|
||||
.join("")}
|
||||
</select>
|
||||
<button onclick="shareTodo('${todo.id}')">Add</button>
|
||||
</div>
|
||||
<button onclick="deleteTodo('${todo.id}')">Delete</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
@ -205,51 +288,90 @@
|
||||
renderTodos();
|
||||
}
|
||||
|
||||
async function shareTodo(todoId) {
|
||||
const selectElement = document.getElementById(
|
||||
`share-${todoId}`,
|
||||
);
|
||||
const recipientId = selectElement.value;
|
||||
|
||||
async function shareTodo(todoGroupId) {
|
||||
const select = document.getElementById(`share-${todoGroupId}`);
|
||||
const recipientId = select.value;
|
||||
if (!recipientId) {
|
||||
alert("Please select a user to share with");
|
||||
alert("Please select a user to add");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/users/${currentUser}/todos/${todoId}/share`,
|
||||
const res = await fetch(
|
||||
`/api/users/${currentUser}/todos/${todoGroupId}/share`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recipientId }),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
alert(`Todo shared with ${recipientId}`);
|
||||
selectElement.value = "";
|
||||
if (res.ok) {
|
||||
alert(`${recipientId} added to todo`);
|
||||
select.value = "";
|
||||
loadTodos(); // Reload to show updated participants
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error);
|
||||
const err = await res.json();
|
||||
if (err.error.includes("Only the creator")) {
|
||||
alert(
|
||||
"⚠️ Permission denied: Only the creator can add participants to this todo",
|
||||
);
|
||||
} else {
|
||||
alert(err.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error sharing todo: " + error.message);
|
||||
} catch (err) {
|
||||
alert("Error adding participant: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTodo(todoId) {
|
||||
async function deleteTodo(todoGroupId) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/users/${currentUser}/todos/${todoId}`,
|
||||
const res = await fetch(
|
||||
`/api/users/${currentUser}/todos/${todoGroupId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
if (res.ok) {
|
||||
loadTodos();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
if (err.error.includes("Only the creator")) {
|
||||
alert(
|
||||
"⚠️ Permission denied: Only the creator can delete this todo",
|
||||
);
|
||||
} else {
|
||||
alert(err.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error deleting todo: " + error.message);
|
||||
} catch (err) {
|
||||
alert("Error deleting todo: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTodoStatus(todoGroupId, completed) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/users/${currentUser}/todos/${todoGroupId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
// Update local state immediately for better UX
|
||||
allTodos = allTodos.map((todo) => {
|
||||
if (todo.id === todoGroupId) {
|
||||
return { ...todo, completed };
|
||||
}
|
||||
return todo;
|
||||
});
|
||||
renderTodos();
|
||||
// Note: WebSocket will handle updates for other participants
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error updating todo: " + err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -13,6 +13,34 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.todo-item.completed {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.todo-item.completed .todo-text {
|
||||
text-decoration: line-through;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.todo-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.todo-checkbox {
|
||||
margin-top: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.todo-content {
|
||||
@ -116,6 +144,7 @@ input:checked + .slider:before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@ -138,6 +167,44 @@ input:checked + .slider:before {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.shared-with-list {
|
||||
background-color: #4a6ea9;
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
margin-left: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.todo-metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.creator-badge {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.participants-badge {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75em;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
@ -151,3 +218,19 @@ input:checked + .slider:before {
|
||||
padding-bottom: 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#logoutBtn {
|
||||
margin-left: auto;
|
||||
padding: 8px 24px;
|
||||
background-color: #4a6ea9;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#logoutBtn:hover {
|
||||
background-color: #3a5a89;
|
||||
}
|
||||
|
Reference in New Issue
Block a user