first commit
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/node_modules/
|
||||||
|
/users.db
|
||||||
2485
package-lock.json
generated
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "websocket",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"sqlight": "^1.0.0-alpha.8",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
485
public/admin.html
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Admin - Shop Item Management</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--gh-bg: #0d1117;
|
||||||
|
--gh-panel: #161b22;
|
||||||
|
--gh-border: #30363d;
|
||||||
|
--gh-text: #c9d1d9;
|
||||||
|
--gh-muted: #8b949e;
|
||||||
|
--gh-accent: #58a6ff;
|
||||||
|
--gh-green: #238636;
|
||||||
|
--gh-red: #f85149;
|
||||||
|
--gh-radius: 8px;
|
||||||
|
--gh-shadow: 0 4px 32px #01040960;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: var(--gh-bg);
|
||||||
|
color: var(--gh-text);
|
||||||
|
font-family: "Segoe UI", "Liberation Sans", Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: var(--gh-accent);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0 32px 0;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--gh-muted);
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: #0d1117;
|
||||||
|
color: var(--gh-text);
|
||||||
|
border: 1px solid var(--gh-border);
|
||||||
|
border-radius: var(--gh-radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 1.05em;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
border: 1.5px solid var(--gh-accent);
|
||||||
|
box-shadow: 0 0 0 3px #1f6feb33;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gh-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
box-shadow: none;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
button.add {
|
||||||
|
background: var(--gh-green);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button.add:hover,
|
||||||
|
button.add:focus {
|
||||||
|
background: #2ea043;
|
||||||
|
box-shadow: 0 2px 8px #2ea04333;
|
||||||
|
}
|
||||||
|
button.edit {
|
||||||
|
background: var(--gh-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button.edit:hover,
|
||||||
|
button.edit:focus {
|
||||||
|
background: #1f6feb;
|
||||||
|
box-shadow: 0 2px 8px #1f6feb33;
|
||||||
|
}
|
||||||
|
button.delete {
|
||||||
|
background: var(--gh-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button.delete:hover,
|
||||||
|
button.delete:focus {
|
||||||
|
background: #da3633;
|
||||||
|
box-shadow: 0 2px 8px #da363333;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
background: var(--gh-panel);
|
||||||
|
border: 1px solid var(--gh-border);
|
||||||
|
border-radius: var(--gh-radius);
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--gh-shadow);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate; /* Use separate to enable border-radius on table */
|
||||||
|
border-spacing: 0;
|
||||||
|
margin-top: 32px;
|
||||||
|
background: var(--gh-panel);
|
||||||
|
border: 1px solid var(--gh-border);
|
||||||
|
border-radius: var(--gh-radius);
|
||||||
|
overflow: hidden; /* Ensures rounded corners apply to content */
|
||||||
|
box-shadow: var(--gh-shadow);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-bottom: 1px solid var(--gh-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #21262d;
|
||||||
|
color: var(--gh-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
tr.item-disabled {
|
||||||
|
background: #0d1117;
|
||||||
|
color: var(--gh-muted);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#msg {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--gh-green);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#msg.error {
|
||||||
|
color: var(--gh-red);
|
||||||
|
}
|
||||||
|
.edit-row input,
|
||||||
|
.edit-row select {
|
||||||
|
width: 100px; /* Adjust width for inline edit fields */
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
#admin-locked {
|
||||||
|
color: var(--gh-red);
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-top: 80px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
color: var(--gh-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
form,
|
||||||
|
table {
|
||||||
|
max-width: 95vw;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.edit-row input,
|
||||||
|
.edit-row select {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
display: none; /* Hide price column on very small screens */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Admin - Shop Item Management</h1>
|
||||||
|
<div id="admin-locked" style="display: none">
|
||||||
|
Access denied. Admins only.
|
||||||
|
</div>
|
||||||
|
<form id="addForm" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="itemName">Item Name</label>
|
||||||
|
<input id="itemName" name="itemName" type="text" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="itemPrice">Price</label>
|
||||||
|
<input
|
||||||
|
id="itemPrice"
|
||||||
|
name="itemPrice"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="itemQuantity">Quantity</label>
|
||||||
|
<input
|
||||||
|
id="itemQuantity"
|
||||||
|
name="itemQuantity"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="itemEnabled">Enabled</label>
|
||||||
|
<select id="itemEnabled" name="itemEnabled">
|
||||||
|
<option value="1" selected>Yes</option>
|
||||||
|
<option value="0">No</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="add">Add Item</button>
|
||||||
|
</form>
|
||||||
|
<div id="msg"></div>
|
||||||
|
<table id="itemsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Items will be loaded here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
// --- Admin lock logic ---
|
||||||
|
function getLoggedInUser() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem("user"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function checkAdmin() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
if (!user || user.username !== "ZareMate") {
|
||||||
|
document.getElementById("admin-locked").style.display = "";
|
||||||
|
document.getElementById("addForm").style.display = "none";
|
||||||
|
document.getElementById("itemsTable").style.display =
|
||||||
|
"none";
|
||||||
|
document.getElementById("msg").style.display = "none";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
document.getElementById("admin-locked").style.display = "none";
|
||||||
|
document.getElementById("addForm").style.display = "";
|
||||||
|
document.getElementById("itemsTable").style.display = "";
|
||||||
|
document.getElementById("msg").style.display = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!checkAdmin()) {
|
||||||
|
// Stop script execution if not admin
|
||||||
|
throw new Error("Not admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgEl = document.getElementById("msg");
|
||||||
|
const tableBody = document.querySelector("#itemsTable tbody");
|
||||||
|
let items = [];
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
function showMsg(msg, isError = false) {
|
||||||
|
msgEl.textContent = msg;
|
||||||
|
msgEl.className = isError ? "error" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchItems() {
|
||||||
|
showMsg("");
|
||||||
|
const res = await fetch("/api/shop/items/all");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
items = data.items;
|
||||||
|
renderTable();
|
||||||
|
} else {
|
||||||
|
showMsg(
|
||||||
|
"Failed to load items: " +
|
||||||
|
(data.error || "Unknown error"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
tableBody.innerHTML = "";
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (editingId === item.id) {
|
||||||
|
// Edit row
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = item.enabled
|
||||||
|
? ""
|
||||||
|
: "item-disabled edit-row";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="text" value="${item.name}" id="edit-name-${item.id}" /></td>
|
||||||
|
<td><input type="number" step="any" value="${item.price}" id="edit-price-${item.id}" style="width:80px;" /></td>
|
||||||
|
<td><input type="number" step="any" value="${item.quantity}" id="edit-quantity-${item.id}" style="width:80px;" /></td>
|
||||||
|
<td>
|
||||||
|
<select id="edit-enabled-${item.id}">
|
||||||
|
<option value="1" ${item.enabled ? "selected" : ""}>Yes</option>
|
||||||
|
<option value="0" ${!item.enabled ? "selected" : ""}>No</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="edit" onclick="saveEdit(${item.id})">Save</button>
|
||||||
|
<button onclick="cancelEdit()">Cancel</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(tr);
|
||||||
|
} else {
|
||||||
|
// Normal row
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = item.enabled ? "" : "item-disabled";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${item.name}</td>
|
||||||
|
<td>${item.price}</td>
|
||||||
|
<td>${item.enabled ? "Yes" : "No"}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="edit" onclick="startEdit(${item.id})">Edit</button>
|
||||||
|
<button class="delete" onclick="deleteItem(${item.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add item
|
||||||
|
document.getElementById("addForm").onsubmit = async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showMsg("");
|
||||||
|
const name = document.getElementById("itemName").value.trim();
|
||||||
|
const price = parseFloat(
|
||||||
|
document.getElementById("itemPrice").value,
|
||||||
|
);
|
||||||
|
const quantity = parseFloat(
|
||||||
|
document.getElementById("itemQuantity").value,
|
||||||
|
);
|
||||||
|
const enabled = parseInt(
|
||||||
|
document.getElementById("itemEnabled").value,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!name || isNaN(price)) {
|
||||||
|
showMsg("Please provide valid name and price.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/shop/items", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, price, enabled, quantity }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
showMsg("Item added!");
|
||||||
|
document.getElementById("addForm").reset();
|
||||||
|
fetchItems();
|
||||||
|
} else {
|
||||||
|
showMsg(
|
||||||
|
"Failed to add item: " +
|
||||||
|
(data.error || "Unknown error"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit helpers
|
||||||
|
window.startEdit = function (id) {
|
||||||
|
editingId = id;
|
||||||
|
renderTable();
|
||||||
|
};
|
||||||
|
window.cancelEdit = function () {
|
||||||
|
editingId = null;
|
||||||
|
renderTable();
|
||||||
|
};
|
||||||
|
window.saveEdit = async function (id) {
|
||||||
|
showMsg("");
|
||||||
|
const name = document
|
||||||
|
.getElementById(`edit-name-${id}`)
|
||||||
|
.value.trim();
|
||||||
|
const price = parseFloat(
|
||||||
|
document.getElementById(`edit-price-${id}`).value,
|
||||||
|
);
|
||||||
|
const enabled = parseInt(
|
||||||
|
document.getElementById(`edit-enabled-${id}`).value,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const quantity = parseInt(
|
||||||
|
document.getElementById(`edit-quantity-${id}`).value,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!name || isNaN(price) || isNaN(quantity)) {
|
||||||
|
showMsg("Please provide valid name and price.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/shop/items/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, price, enabled, quantity }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
showMsg("Item updated!");
|
||||||
|
editingId = null;
|
||||||
|
fetchItems();
|
||||||
|
} else {
|
||||||
|
showMsg(
|
||||||
|
"Failed to update item: " +
|
||||||
|
(data.error || "Unknown error"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete item
|
||||||
|
window.deleteItem = async function (id) {
|
||||||
|
if (!confirm("Are you sure you want to delete this item?"))
|
||||||
|
return;
|
||||||
|
showMsg("");
|
||||||
|
const res = await fetch(`/api/shop/items/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
showMsg("Item deleted!");
|
||||||
|
fetchItems();
|
||||||
|
} else {
|
||||||
|
showMsg(
|
||||||
|
"Failed to delete item: " +
|
||||||
|
(data.error || "Unknown error"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
if (checkAdmin()) {
|
||||||
|
fetchItems();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
338
public/css/index.css
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
body {
|
||||||
|
background: linear-gradient(145deg, #0f0f0f 0%, #1a1a1a 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: "Segoe UI", sans-serif;
|
||||||
|
margin: 24px;
|
||||||
|
transition: background 0.5s;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #5865f2;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
#items-list {
|
||||||
|
margin-top: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 50%;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: table;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.item-name {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
.item-name:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.item-price {
|
||||||
|
color: #23a559;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
.item-price:hover {
|
||||||
|
color: #178a43;
|
||||||
|
}
|
||||||
|
.buy-btn,
|
||||||
|
.buy64-btn,
|
||||||
|
.buy32-btn {
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||||
|
}
|
||||||
|
.buy-btn:hover,
|
||||||
|
.buy64-btn:hover,
|
||||||
|
.buy32-btn:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(88, 101, 242, 0.4);
|
||||||
|
}
|
||||||
|
#msg {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #23a559;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
#msg.error {
|
||||||
|
color: #ff5555;
|
||||||
|
}
|
||||||
|
.modal,
|
||||||
|
.cart-window,
|
||||||
|
.address-modal,
|
||||||
|
.transfer-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.modal-content,
|
||||||
|
.cart-content,
|
||||||
|
.address-content,
|
||||||
|
.transfer-content {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
padding: 40px 32px 32px 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.modal-content h2,
|
||||||
|
.cart-content h2,
|
||||||
|
.address-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #5865f2;
|
||||||
|
font-size: 1.5em;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: #ff5555;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
float: right;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 85, 85, 0.3);
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.cart-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 115px;
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
font-size: 1.6em;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 900;
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.cart-btn:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.cart-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 25px;
|
||||||
|
left: 25px;
|
||||||
|
background: #23a559;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(35, 165, 89, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
#cartItems {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
#cartItems div {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
#cartItems div:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.item-name {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.item-price {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#cartTotal {
|
||||||
|
margin: 24px 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #23a559;
|
||||||
|
}
|
||||||
|
#checkoutBtn {
|
||||||
|
background: #23a559;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 6px 16px rgba(35, 165, 89, 0.3);
|
||||||
|
}
|
||||||
|
#checkoutBtn:hover {
|
||||||
|
background: #178a43;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(35, 165, 89, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 900;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.login-btn:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.logout-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: #ff5555;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 900;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 85, 85, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.address-list {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.address-list label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.balance-Btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 365px;
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 900;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional modern styles */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"] {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
|
input[type="number"]:focus {
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.4);
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
button[type="submit"],
|
||||||
|
#transferBtn {
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||||
|
}
|
||||||
|
button[type="submit"]:hover,
|
||||||
|
#transferBtn:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.account-tab.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#accountTabs button {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#accountTabs button:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
#accountTabs button.active {
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #5865f2;
|
||||||
|
}
|
||||||
266
public/index.html
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Shop - Buy Items</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=shopping_cart"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="static/css/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="loginBtn" class="login-btn" style="display: none">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button id="logoutBtn" class="logout-btn" style="display: none">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
<button id="cartBtn" class="cart-btn">
|
||||||
|
<span class="material-symbols-outlined"> shopping_cart </span
|
||||||
|
><span id="cartCount" class="cart-count" style="display: none"
|
||||||
|
>0</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<!--balance-->
|
||||||
|
<div id="balanceBtn" class="balance-Btn" style="display: none">
|
||||||
|
<span id="balanceAmount" class="balance-amount">0</span>
|
||||||
|
</div>
|
||||||
|
<h1>Shop - Items to Buy</h1>
|
||||||
|
<button
|
||||||
|
id="accountBtn"
|
||||||
|
class="login-btn"
|
||||||
|
style="left: auto; right: 130px; top: 24px; display: none"
|
||||||
|
>
|
||||||
|
Manage Account
|
||||||
|
</button>
|
||||||
|
<table id="items-list" class="item-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Item Name</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="loading">
|
||||||
|
<td colspan="6">Loading items...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="msg"></div>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div id="loginModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close-btn" onclick="closeLoginModal()">×</button>
|
||||||
|
<h2>Login</h2>
|
||||||
|
<form id="loginForm" autocomplete="off">
|
||||||
|
<div style="margin-bottom: 12px">
|
||||||
|
<label for="loginUsername">Username</label>
|
||||||
|
<input
|
||||||
|
id="loginUsername"
|
||||||
|
name="loginUsername"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 12px">
|
||||||
|
<label for="loginPassword">Password</label>
|
||||||
|
<input
|
||||||
|
id="loginPassword"
|
||||||
|
name="loginPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" style="width: 100%">Login</button>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
id="loginMsg"
|
||||||
|
style="margin-top: 10px; color: #ff5555"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transfer modal -->
|
||||||
|
<div id="transferModal" class="transfer-modal">
|
||||||
|
<div class="transfer-content">
|
||||||
|
<button class="close-btn" onclick="closeTransferModal()">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2>Transfer</h2>
|
||||||
|
<div id="transferForm">
|
||||||
|
<div id="transferToList" style="margin-bottom: 12px"></div>
|
||||||
|
<div style="margin-bottom: 12px">
|
||||||
|
<label for="transferAmount">Amount</label>
|
||||||
|
<input
|
||||||
|
id="transferAmount"
|
||||||
|
name="transferAmount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="transferBtn" style="width: 100%">
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="transferMsg"
|
||||||
|
style="margin-top: 10px; color: #ff5555"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart Window -->
|
||||||
|
<div id="cartWindow" class="cart-window">
|
||||||
|
<div class="cart-content">
|
||||||
|
<button class="close-btn" onclick="closeCart()">×</button>
|
||||||
|
<h2>Your Cart</h2>
|
||||||
|
<div id="cartItems"></div>
|
||||||
|
<div id="cartTotal" style="margin: 12px 0"></div>
|
||||||
|
<button
|
||||||
|
id="checkoutBtn"
|
||||||
|
style="width: 100%; margin-top: 10px; background: #23a559"
|
||||||
|
>
|
||||||
|
Checkout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address Selection Modal -->
|
||||||
|
<div id="addressModal" class="address-modal">
|
||||||
|
<div class="address-content">
|
||||||
|
<button class="close-btn" onclick="closeAddressModal()">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2>Select Address</h2>
|
||||||
|
<form id="addressForm">
|
||||||
|
<div id="addressList" class="address-list"></div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style="
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #5865f2;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Confirm Order
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
id="addressMsg"
|
||||||
|
style="margin-top: 10px; color: #ff5555"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Management Modal -->
|
||||||
|
<div id="accountModal" class="modal">
|
||||||
|
<div
|
||||||
|
class="modal-content"
|
||||||
|
style="min-width: 340px; max-width: 95vw"
|
||||||
|
>
|
||||||
|
<button class="close-btn" onclick="closeAccountModal()">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2>Manage Account</h2>
|
||||||
|
<div
|
||||||
|
id="accountTabs"
|
||||||
|
style="display: flex; gap: 12px; margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<button id="tabPassword" style="flex: 1">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
<button id="tabAddresses" style="flex: 1">Addresses</button>
|
||||||
|
<button id="tabComputers" style="flex: 1">
|
||||||
|
Linked Computers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="accountTabPassword" class="account-tab">
|
||||||
|
<form id="changePasswordForm" autocomplete="off">
|
||||||
|
<div style="margin-bottom: 10px">
|
||||||
|
<label>Current Password</label>
|
||||||
|
<input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 10px">
|
||||||
|
<label>Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
id="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style="width: 100%; background: #23a559"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="changePasswordMsg" style="margin-top: 10px"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="accountTabAddresses"
|
||||||
|
class="account-tab"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div id="addressesList"></div>
|
||||||
|
<form
|
||||||
|
id="addAddressForm"
|
||||||
|
autocomplete="off"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="newAddress"
|
||||||
|
type="text"
|
||||||
|
placeholder="New address"
|
||||||
|
style="width: 70%"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" style="background: #5865f2">
|
||||||
|
Add Address
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="addressesMsg" style="margin-top: 10px"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="accountTabComputers"
|
||||||
|
class="account-tab"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div id="computersList"></div>
|
||||||
|
<div id="computersMsg" style="margin-top: 10px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="static/js/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
585
public/items.html
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Latest Items</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-main: #000;
|
||||||
|
--bg-secondary: #111;
|
||||||
|
--bg-tertiary: #23272a;
|
||||||
|
--text-main: #fff;
|
||||||
|
--text-muted: #b9bbbe;
|
||||||
|
--accent: #5865f2;
|
||||||
|
--accent-hover: #4752c4;
|
||||||
|
--border: #222;
|
||||||
|
--badge-bg: #5865f2;
|
||||||
|
--badge-text: #fff;
|
||||||
|
--table-header: #23272a;
|
||||||
|
--monospace:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono",
|
||||||
|
"Courier New", monospace;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-main);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
"Helvetica Neue",
|
||||||
|
Arial;
|
||||||
|
margin: 20px;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-main);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
p.lead {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.controls > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="search"],
|
||||||
|
select {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--badge-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--badge-text);
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--table-header);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #18191c;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--badge-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--badge-text);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: stack columns under 640px */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
table,
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
th,
|
||||||
|
td,
|
||||||
|
tr {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
tbody tr {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row-left {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.row-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
.id-muted {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Latest Items</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Shows the most recently received item lists from connected clients.
|
||||||
|
Data comes from <code>/api/items</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter item or source (case-insensitive)"
|
||||||
|
/>
|
||||||
|
<select id="groupBy" title="Group table">
|
||||||
|
<option value="source">Group by source (rows per item)</option>
|
||||||
|
<option value="item">
|
||||||
|
Group by item (aggregate across sources)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label
|
||||||
|
><input id="autoRefresh" type="checkbox" /> Auto-refresh</label
|
||||||
|
>
|
||||||
|
<button id="refreshBtn" class="primary">Refresh</button>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div class="small">
|
||||||
|
Last update: <span id="lastUpdate">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="small">
|
||||||
|
API: <span id="apiStatus" class="badge">unknown</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tableWrap">
|
||||||
|
<table id="itemsTable" aria-describedby="tableDesc">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th id="thSource">Source</th>
|
||||||
|
<th id="thItem">Item</th>
|
||||||
|
<th id="thCount">Count</th>
|
||||||
|
<th id="thUpdated">Last updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="tableDesc" class="small">
|
||||||
|
Live view of item lists reported by connected clients.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const api = "/api/items";
|
||||||
|
const refreshBtn = document.getElementById("refreshBtn");
|
||||||
|
const autoRefresh = document.getElementById("autoRefresh");
|
||||||
|
const searchEl = document.getElementById("search");
|
||||||
|
const groupByEl = document.getElementById("groupBy");
|
||||||
|
const apiStatus = document.getElementById("apiStatus");
|
||||||
|
const lastUpdateEl = document.getElementById("lastUpdate");
|
||||||
|
const tbody = document.getElementById("tbody");
|
||||||
|
|
||||||
|
let autoTimer = null;
|
||||||
|
let lastFetched = 0;
|
||||||
|
let latestData = {}; // raw latestItems object
|
||||||
|
|
||||||
|
// Helper: format timestamp ms -> human friendly
|
||||||
|
function formatTs(ms) {
|
||||||
|
// get time from epoch
|
||||||
|
const n = Number(ms) || 0;
|
||||||
|
const d = new Date(n);
|
||||||
|
if (isNaN(d.getTime())) return String(ms);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic formatter for Minecraft item IDs -> human friendly text
|
||||||
|
// Examples:
|
||||||
|
// minecraft:sugar_cane -> Sugar cane
|
||||||
|
// modid:custom_block -> Custom block
|
||||||
|
// oak_log -> Oak log
|
||||||
|
function formatItemId(id) {
|
||||||
|
if (!id && id !== 0) return "(unknown)";
|
||||||
|
id = String(id);
|
||||||
|
|
||||||
|
// Remove any NBT/variant suffixes that might follow a space or bracket e.g. "minecraft:stone{...}"
|
||||||
|
// Keep it simple: strip anything starting with '{' or '[' or ' ' after id body.
|
||||||
|
id = id.split("{")[0].split("[")[0].trim();
|
||||||
|
|
||||||
|
// If there is a namespace, take the part after colon
|
||||||
|
const parts = id.split(":");
|
||||||
|
let name = parts.length > 1 ? parts[1] : parts[0];
|
||||||
|
|
||||||
|
// Normalize common separators
|
||||||
|
name = name.replace(/[-.]/g, "_");
|
||||||
|
|
||||||
|
// Replace multiple underscores with single space
|
||||||
|
name = name.replace(/_+/g, " ").trim();
|
||||||
|
|
||||||
|
// If name contains uppercase letters (already pretty), keep spacing but fix underscores
|
||||||
|
if (/[A-Z]/.test(name)) {
|
||||||
|
// Replace underscores and keep casing; ensure single spaces
|
||||||
|
name = name.replace(/_+/g, " ").trim();
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase everything then title-case just the first word for a natural look:
|
||||||
|
// "sugar cane" -> "Sugar cane", "redstone_dust" -> "Redstone dust"
|
||||||
|
name = name.toLowerCase().split(" ").filter(Boolean);
|
||||||
|
|
||||||
|
if (name.length === 0) return id;
|
||||||
|
|
||||||
|
// Title-case only first word, leave rest lowercase (matches examples like "Sugar cane")
|
||||||
|
const first = name[0];
|
||||||
|
const rest = name.slice(1);
|
||||||
|
const firstCap =
|
||||||
|
first.charAt(0).toUpperCase() + first.slice(1);
|
||||||
|
const result = [firstCap].concat(rest).join(" ");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML in text nodes
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten latestItems object into rows: { source, item, itemDisplay, count, updated }
|
||||||
|
function buildRows(data) {
|
||||||
|
const rows = [];
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
const entry = data[key];
|
||||||
|
const when = formatTs(entry.ts);
|
||||||
|
const items = Array.isArray(entry.items)
|
||||||
|
? entry.items
|
||||||
|
: [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
rows.push({
|
||||||
|
source: entry.from,
|
||||||
|
item: "(no items)",
|
||||||
|
itemDisplay: "(no items)",
|
||||||
|
count: 0,
|
||||||
|
updated: when,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const it of items) {
|
||||||
|
const original =
|
||||||
|
it.name || it.id || it.item || "(unknown)";
|
||||||
|
const display = formatItemId(original);
|
||||||
|
rows.push({
|
||||||
|
source: entry.from,
|
||||||
|
item: original,
|
||||||
|
itemDisplay: display,
|
||||||
|
count: it.count || 0,
|
||||||
|
updated: when,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build aggregated rows by item (across sources)
|
||||||
|
function buildItemAggregates(rows) {
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = r.item; // aggregate by original id
|
||||||
|
if (!map[key])
|
||||||
|
map[key] = {
|
||||||
|
item: key,
|
||||||
|
itemDisplay: r.itemDisplay,
|
||||||
|
count: 0,
|
||||||
|
sources: {},
|
||||||
|
};
|
||||||
|
map[key].count += Number(r.count) || 0;
|
||||||
|
map[key].sources[r.source] =
|
||||||
|
(map[key].sources[r.source] || 0) +
|
||||||
|
(Number(r.count) || 0);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const k of Object.keys(map)) {
|
||||||
|
out.push({
|
||||||
|
item: map[k].item,
|
||||||
|
itemDisplay: map[k].itemDisplay,
|
||||||
|
count: map[k].count,
|
||||||
|
sources: map[k].sources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) =>
|
||||||
|
a.itemDisplay.localeCompare(b.itemDisplay),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const s = (searchEl.value || "").trim().toLowerCase();
|
||||||
|
const groupBy = groupByEl.value;
|
||||||
|
|
||||||
|
const rows = buildRows(latestData);
|
||||||
|
if (groupBy === "item") {
|
||||||
|
const aggs = buildItemAggregates(rows);
|
||||||
|
if (aggs.length === 0) {
|
||||||
|
tbody.innerHTML =
|
||||||
|
'<tr><td colspan="4" class="empty">No items</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Render one row per item, with sources shown in Last updated column
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
for (const a of aggs) {
|
||||||
|
const matchSearch =
|
||||||
|
a.itemDisplay.toLowerCase().includes(s) ||
|
||||||
|
Object.keys(a.sources).some((src) =>
|
||||||
|
src.toLowerCase().includes(s),
|
||||||
|
);
|
||||||
|
if (s && !matchSearch) continue;
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const tdSource = document.createElement("td");
|
||||||
|
tdSource.innerHTML = escapeHtml(
|
||||||
|
Object.keys(a.sources).join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tdItem = document.createElement("td");
|
||||||
|
// show display name and original id smaller and muted
|
||||||
|
tdItem.innerHTML =
|
||||||
|
"<strong>" +
|
||||||
|
escapeHtml(a.itemDisplay) +
|
||||||
|
"</strong>" +
|
||||||
|
'<div class="id-muted">' +
|
||||||
|
escapeHtml(a.item) +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
|
const tdCount = document.createElement("td");
|
||||||
|
tdCount.textContent = a.count;
|
||||||
|
|
||||||
|
const tdUpdated = document.createElement("td");
|
||||||
|
// show per-source counts in updated column (compact)
|
||||||
|
const parts = [];
|
||||||
|
for (const src of Object.keys(a.sources).sort()) {
|
||||||
|
parts.push(`${src}: ${a.sources[src]}`);
|
||||||
|
}
|
||||||
|
tdUpdated.textContent = parts.join(" • ");
|
||||||
|
|
||||||
|
tr.appendChild(tdSource);
|
||||||
|
tr.appendChild(tdItem);
|
||||||
|
tr.appendChild(tdCount);
|
||||||
|
tr.appendChild(tdUpdated);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// group by source -> item rows
|
||||||
|
if (rows.length === 0) {
|
||||||
|
tbody.innerHTML =
|
||||||
|
'<tr><td colspan="4" class="empty">No items</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
// sort rows by source then itemDisplay
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (a.source === b.source)
|
||||||
|
return a.itemDisplay.localeCompare(
|
||||||
|
b.itemDisplay,
|
||||||
|
);
|
||||||
|
return a.source.localeCompare(b.source);
|
||||||
|
});
|
||||||
|
for (const r of rows) {
|
||||||
|
const matchSearch =
|
||||||
|
r.source.toLowerCase().includes(s) ||
|
||||||
|
r.itemDisplay.toLowerCase().includes(s);
|
||||||
|
if (s && !matchSearch) continue;
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const tdSource = document.createElement("td");
|
||||||
|
tdSource.textContent = r.source;
|
||||||
|
|
||||||
|
const tdItem = document.createElement("td");
|
||||||
|
tdItem.innerHTML =
|
||||||
|
"<strong>" +
|
||||||
|
escapeHtml(r.itemDisplay) +
|
||||||
|
"</strong>" +
|
||||||
|
'<div class="id-muted">' +
|
||||||
|
escapeHtml(r.item) +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
|
const tdCount = document.createElement("td");
|
||||||
|
tdCount.textContent = r.count;
|
||||||
|
|
||||||
|
const tdUpdated = document.createElement("td");
|
||||||
|
tdUpdated.textContent = r.updated;
|
||||||
|
|
||||||
|
tr.appendChild(tdSource);
|
||||||
|
tr.appendChild(tdItem);
|
||||||
|
tr.appendChild(tdCount);
|
||||||
|
tr.appendChild(tdUpdated);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndRender() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(api, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json || typeof json !== "object")
|
||||||
|
throw new Error("Invalid JSON");
|
||||||
|
latestData = json.data || {};
|
||||||
|
lastFetched = Date.now();
|
||||||
|
lastUpdateEl.textContent = new Date(
|
||||||
|
lastFetched,
|
||||||
|
).toLocaleString();
|
||||||
|
apiStatus.textContent = "ok";
|
||||||
|
apiStatus.style.background = "#e6ffed";
|
||||||
|
apiStatus.style.color = "#06612a";
|
||||||
|
renderTable();
|
||||||
|
} catch (err) {
|
||||||
|
apiStatus.textContent = "error";
|
||||||
|
apiStatus.style.background = "#ffecec";
|
||||||
|
apiStatus.style.color = "#7a1a1a";
|
||||||
|
console.error("Failed to fetch /api/items", err);
|
||||||
|
tbody.innerHTML =
|
||||||
|
'<tr><td colspan="4" class="empty">Error fetching items</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
refreshBtn.addEventListener("click", fetchAndRender);
|
||||||
|
searchEl.addEventListener("input", renderTable);
|
||||||
|
groupByEl.addEventListener("change", renderTable);
|
||||||
|
autoRefresh.addEventListener("change", () => {
|
||||||
|
if (autoRefresh.checked) {
|
||||||
|
if (autoTimer) clearInterval(autoTimer);
|
||||||
|
autoTimer = setInterval(fetchAndRender, 3000);
|
||||||
|
} else {
|
||||||
|
if (autoTimer) {
|
||||||
|
clearInterval(autoTimer);
|
||||||
|
autoTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally open a WebSocket to receive broadcast messages and refresh on activity
|
||||||
|
(function tryOpenWs() {
|
||||||
|
try {
|
||||||
|
const proto =
|
||||||
|
location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = proto + "//" + location.host;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
console.debug("WS open", wsUrl);
|
||||||
|
});
|
||||||
|
ws.addEventListener("message", (ev) => {
|
||||||
|
// For simplicity, refresh the items list on any message
|
||||||
|
// You could optionally parse and only refresh on messages with items
|
||||||
|
fetchAndRender();
|
||||||
|
});
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
console.debug(
|
||||||
|
"WS closed; falling back to polling only",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
// ignore; polling will continue
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// WebSocket not supported or blocked; rely on polling
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchAndRender();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
799
public/js/index.js
Normal file
@ -0,0 +1,799 @@
|
|||||||
|
// --- Utility functions ---
|
||||||
|
function formatItemId(id) {
|
||||||
|
if (!id && id !== 0) return "(unknown)";
|
||||||
|
id = String(id);
|
||||||
|
|
||||||
|
// Remove any NBT/variant suffixes that might follow a space or bracket e.g. "minecraft:stone{...}"
|
||||||
|
// Keep it simple: strip anything starting with '{' or '[' or ' ' after id body.
|
||||||
|
id = id.split("{")[0].split("[")[0].trim();
|
||||||
|
|
||||||
|
// If there is a namespace, take the part after colon
|
||||||
|
const parts = id.split(":");
|
||||||
|
let name = parts.length > 1 ? parts[1] : parts[0];
|
||||||
|
|
||||||
|
// Normalize common separators
|
||||||
|
name = name.replace(/[-.]/g, "_");
|
||||||
|
|
||||||
|
// Replace multiple underscores with single space
|
||||||
|
name = name.replace(/_+/g, " ").trim();
|
||||||
|
|
||||||
|
// If name contains uppercase letters (already pretty), keep spacing but fix underscores
|
||||||
|
if (/[A-Z]/.test(name)) {
|
||||||
|
// Replace underscores and keep casing; ensure single spaces
|
||||||
|
name = name.replace(/_+/g, " ").trim();
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase everything then title-case just the first word for a natural look:
|
||||||
|
// "sugar cane" -> "Sugar cane", "redstone_dust" -> "Redstone dust"
|
||||||
|
name = name.toLowerCase().split(" ").filter(Boolean);
|
||||||
|
|
||||||
|
if (name.length === 0) return id;
|
||||||
|
|
||||||
|
// Title-case only first word, leave rest lowercase (matches examples like "Sugar cane")
|
||||||
|
const first = name[0];
|
||||||
|
const rest = name.slice(1);
|
||||||
|
const firstCap = first.charAt(0).toUpperCase() + first.slice(1);
|
||||||
|
const result = [firstCap].concat(rest).join(" ");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/users output is {{"ok":true,"users":[{"username":"ZareMate","password":"74b669f042afd3fccff6d3915f436b5bf3ad98836650ecfacf5062c7229115cb","balance":12858,"adresses":["PKP Centralny: Poczta - ZareMate","Factory 1: Manual"]},{"username":"qawe","password":"cd9ecb80ec16c7d73f7713f5e25a5d96b68bfe4debcfefc214f5351ee325e09f","balance":7027.52,"adresses":["Jajomyje 17: qawe"]},{"username":"skybloczek1","password":"937824fc6a52074b0a173319ec94c5e6c5a82cf8e63d9f401b15219c7e33248a","balance":22,"adresses":["Duchnice: skypa"]}]}}
|
||||||
|
async function getAllAddresses() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/users");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const addresses = data.users.map((user) => user.adresses).flat();
|
||||||
|
console.log(addresses);
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching addresses:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get id from name
|
||||||
|
function getId(name) {
|
||||||
|
// Implement id retrieval logic here
|
||||||
|
// name: Sugar cane -> minecraft:sugar_cane
|
||||||
|
// name: Redstone dust -> minecraft:redstone_dust
|
||||||
|
const parts = name.split(" ");
|
||||||
|
const first = parts[0].toLowerCase();
|
||||||
|
const rest = parts.slice(1).map((word) => word.toLowerCase());
|
||||||
|
const id = [first].concat(rest).join("_");
|
||||||
|
return `minecraft:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImage(id) {
|
||||||
|
// Implement image retrieval logic here
|
||||||
|
// id: mod:item_id
|
||||||
|
// /static/textures/mod/item_id.png
|
||||||
|
const parts = id.split(":");
|
||||||
|
const mod = parts[0];
|
||||||
|
const item = parts[1];
|
||||||
|
return `/static/textures/${mod}/${item}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(str) {
|
||||||
|
// Returns a promise that resolves to the hex digest
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(str);
|
||||||
|
return crypto.subtle.digest("SHA-256", data).then((buf) =>
|
||||||
|
Array.from(new Uint8Array(buf))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTransferModal() {
|
||||||
|
const LocalUser = getLoggedInUser();
|
||||||
|
if (!LocalUser) {
|
||||||
|
document.getElementById("transferMsg").textContent =
|
||||||
|
"Please log in to transfer balance.";
|
||||||
|
document.getElementById("transferModal").style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transferToList = document.getElementById("transferToList");
|
||||||
|
transferToList.innerHTML = "";
|
||||||
|
Promise.all([fetch("/api/users").then((res) => res.json())]).then(
|
||||||
|
([users]) => {
|
||||||
|
users.users.forEach((user) => {
|
||||||
|
if (user.username !== LocalUser.username) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "radio";
|
||||||
|
input.name = "transferTo";
|
||||||
|
input.id = user.username;
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.htmlFor = user.username;
|
||||||
|
label.textContent = user.username;
|
||||||
|
transferToList.appendChild(input);
|
||||||
|
transferToList.appendChild(label);
|
||||||
|
transferToList.appendChild(document.createElement("br"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
document.getElementById("transferMsg").textContent = "";
|
||||||
|
document.getElementById("transferModal").style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTransferModal() {
|
||||||
|
document.getElementById("transferModal").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// on onclick transferForm make POST request to /api/users/transfer
|
||||||
|
function submitTransferForm() {
|
||||||
|
const from = getLoggedInUser().username;
|
||||||
|
const to = document.querySelector("input[name='transferTo']:checked").id;
|
||||||
|
const amount = document.getElementById("transferAmount").value;
|
||||||
|
fetch("/api/users/transfer", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ from, to, amount }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.ok) {
|
||||||
|
updateBalance();
|
||||||
|
closeTransferModal();
|
||||||
|
} else {
|
||||||
|
document.getElementById("transferMsg").textContent = data.error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBalance() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
if (!user) {
|
||||||
|
document.getElementById("balanceAmount").textContent = 0;
|
||||||
|
document.getElementById("balanceBtn").style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch("/api/users/" + encodeURIComponent(user.username))
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.ok) {
|
||||||
|
document.getElementById("balanceAmount").textContent =
|
||||||
|
data.user.balance + " $";
|
||||||
|
document.getElementById("balanceBtn").style.display = "block";
|
||||||
|
} else {
|
||||||
|
document.getElementById("balanceAmount").textContent = 0;
|
||||||
|
document.getElementById("balanceBtn").style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("balanceBtn").addEventListener("click", () => {
|
||||||
|
openTransferModal();
|
||||||
|
});
|
||||||
|
document.getElementById("transferBtn").addEventListener("click", () => {
|
||||||
|
submitTransferForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to fetch user info (fresh)
|
||||||
|
async function fetchUserInfo(username) {
|
||||||
|
const res = await fetch("/api/users/" + encodeURIComponent(username));
|
||||||
|
const data = await res.json();
|
||||||
|
return data.ok ? data.user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(msg, error = false) {
|
||||||
|
const msgEl = document.getElementById("msg");
|
||||||
|
msgEl.textContent = msg;
|
||||||
|
msgEl.className = error ? "error" : "";
|
||||||
|
// after 2 seconds clear message
|
||||||
|
setTimeout(() => {
|
||||||
|
msgEl.textContent = "";
|
||||||
|
msgEl.className = "";
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Login logic ---
|
||||||
|
function openLoginModal() {
|
||||||
|
document.getElementById("loginModal").style.display = "flex";
|
||||||
|
document.getElementById("loginMsg").textContent = "";
|
||||||
|
}
|
||||||
|
function closeLoginModal() {
|
||||||
|
document.getElementById("loginModal").style.display = "none";
|
||||||
|
}
|
||||||
|
function updateLoginUI() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
document.getElementById("loginBtn").style.display = user ? "none" : "";
|
||||||
|
document.getElementById("logoutBtn").style.display = user ? "" : "none";
|
||||||
|
document.getElementById("accountBtn").style.display = user ? "" : "none";
|
||||||
|
updateBalance();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cartPosition() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
if (user) {
|
||||||
|
document.querySelector(".cart-btn").style.right = "315px";
|
||||||
|
} else {
|
||||||
|
document.querySelector(".cart-btn").style.right = "120px";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoggedInUser() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem("user"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setLoggedInUser(user) {
|
||||||
|
localStorage.setItem("user", JSON.stringify(user));
|
||||||
|
updateLoginUI();
|
||||||
|
cartPosition();
|
||||||
|
updateBalance();
|
||||||
|
}
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
updateLoginUI();
|
||||||
|
cartPosition();
|
||||||
|
updateBalance();
|
||||||
|
showMsg("Logged out.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Account Modal logic ---
|
||||||
|
document.getElementById("accountBtn").onclick = openAccountModal;
|
||||||
|
function openAccountModal() {
|
||||||
|
showAccountTab("Password");
|
||||||
|
loadAddressesTab();
|
||||||
|
loadComputersTab();
|
||||||
|
document.getElementById("accountModal").style.display = "flex";
|
||||||
|
}
|
||||||
|
function closeAccountModal() {
|
||||||
|
document.getElementById("accountModal").style.display = "none";
|
||||||
|
}
|
||||||
|
function showAccountTab(tab) {
|
||||||
|
["Password", "Addresses", "Computers"].forEach((t) => {
|
||||||
|
document.getElementById("accountTab" + t).style.display =
|
||||||
|
t === tab ? "" : "none";
|
||||||
|
document.getElementById("tab" + t).style.background =
|
||||||
|
t === tab ? "#23272a" : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById("tabPassword").onclick = () =>
|
||||||
|
showAccountTab("Password");
|
||||||
|
document.getElementById("tabAddresses").onclick = () => {
|
||||||
|
showAccountTab("Addresses");
|
||||||
|
loadAddressesTab();
|
||||||
|
};
|
||||||
|
document.getElementById("tabComputers").onclick = () => {
|
||||||
|
showAccountTab("Computers");
|
||||||
|
loadComputersTab();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Change Password ---
|
||||||
|
document.getElementById("changePasswordForm").onsubmit = async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const msgEl = document.getElementById("changePasswordMsg");
|
||||||
|
msgEl.textContent = "";
|
||||||
|
if (!user) {
|
||||||
|
msgEl.textContent = "Not logged in.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = document.getElementById("currentPassword").value;
|
||||||
|
const newpw = document.getElementById("newPassword").value;
|
||||||
|
const confirm = document.getElementById("confirmNewPassword").value;
|
||||||
|
if (!current || !newpw || !confirm) {
|
||||||
|
msgEl.textContent = "Fill all fields.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newpw !== confirm) {
|
||||||
|
msgEl.textContent = "Passwords do not match.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userInfo = await fetchUserInfo(user.username);
|
||||||
|
if (!userInfo) {
|
||||||
|
msgEl.textContent = "User not found.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentHash = await sha256(current);
|
||||||
|
if (userInfo.password !== currentHash) {
|
||||||
|
msgEl.textContent = "Current password incorrect.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newHash = await sha256(newpw);
|
||||||
|
const res = await fetch("/api/users/" + encodeURIComponent(user.username), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: newHash }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
msgEl.style.color = "#23a559";
|
||||||
|
msgEl.textContent = "Password changed!";
|
||||||
|
} else {
|
||||||
|
msgEl.style.color = "#ff5555";
|
||||||
|
msgEl.textContent = "Error: " + (data.error || "Unknown error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Addresses Tab ---
|
||||||
|
async function loadAddressesTab() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const listEl = document.getElementById("addressesList");
|
||||||
|
const msgEl = document.getElementById("addressesMsg");
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
msgEl.textContent = "";
|
||||||
|
if (!user) {
|
||||||
|
listEl.innerHTML = "Not logged in.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userInfo = await fetchUserInfo(user.username);
|
||||||
|
if (!userInfo || !Array.isArray(userInfo.adresses)) {
|
||||||
|
listEl.innerHTML = "No addresses found.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userInfo.adresses.length) {
|
||||||
|
listEl.innerHTML = "<div>No addresses.</div>";
|
||||||
|
} else {
|
||||||
|
userInfo.adresses.forEach((addr, idx) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.style.display = "flex";
|
||||||
|
div.style.alignItems = "center";
|
||||||
|
div.style.marginBottom = "6px";
|
||||||
|
div.innerHTML = `<span style="flex:1;">${addr}</span>
|
||||||
|
<button style="background:#ff5555;padding:2px 8px;border-radius:4px;border:none;color:#fff;cursor:pointer;" onclick="removeAddress(${idx})">Remove</button>`;
|
||||||
|
listEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.removeAddress = async function (idx) {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const msgEl = document.getElementById("addressesMsg");
|
||||||
|
msgEl.textContent = "";
|
||||||
|
if (!user) return;
|
||||||
|
const userInfo = await fetchUserInfo(user.username);
|
||||||
|
if (!userInfo || !Array.isArray(userInfo.adresses)) return;
|
||||||
|
userInfo.adresses.splice(idx, 1);
|
||||||
|
const res = await fetch("/api/users/" + encodeURIComponent(user.username), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ adresses: userInfo.adresses }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
msgEl.style.color = "#23a559";
|
||||||
|
msgEl.textContent = "Address removed.";
|
||||||
|
loadAddressesTab();
|
||||||
|
} else {
|
||||||
|
msgEl.style.color = "#ff5555";
|
||||||
|
msgEl.textContent = "Error: " + (data.error || "Unknown error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById("addAddressForm").onsubmit = async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const msgEl = document.getElementById("addressesMsg");
|
||||||
|
msgEl.textContent = "";
|
||||||
|
const newAddr = document.getElementById("newAddress").value.trim();
|
||||||
|
if (!user || !newAddr) return;
|
||||||
|
const userInfo = await fetchUserInfo(user.username);
|
||||||
|
if (!userInfo || !Array.isArray(userInfo.adresses)) return;
|
||||||
|
userInfo.adresses.push(newAddr);
|
||||||
|
const res = await fetch("/api/users/" + encodeURIComponent(user.username), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
adresses: userInfo.adresses,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// update local storage
|
||||||
|
localStorage.setItem("user", JSON.stringify(userInfo));
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
msgEl.style.color = "#23a559";
|
||||||
|
msgEl.textContent = "Address added.";
|
||||||
|
document.getElementById("newAddress").value = "";
|
||||||
|
loadAddressesTab();
|
||||||
|
} else {
|
||||||
|
msgEl.style.color = "#ff5555";
|
||||||
|
msgEl.textContent = "Error: " + (data.error || "Unknown error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Linked Computers Tab ---
|
||||||
|
async function loadComputersTab() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const listEl = document.getElementById("computersList");
|
||||||
|
const msgEl = document.getElementById("computersMsg");
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
msgEl.textContent = "";
|
||||||
|
if (!user) {
|
||||||
|
listEl.innerHTML = "Not logged in.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
"/api/users/" + encodeURIComponent(user.username) + "/computers",
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok || !Array.isArray(data.computers)) {
|
||||||
|
listEl.innerHTML = "No linked computers found.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.computers.length) {
|
||||||
|
listEl.innerHTML = "<div>No linked computers.</div>";
|
||||||
|
} else {
|
||||||
|
data.computers.forEach((comp) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.style.display = "flex";
|
||||||
|
div.style.alignItems = "center";
|
||||||
|
div.style.marginBottom = "6px";
|
||||||
|
div.innerHTML = `<span style="flex:1;">Computer #${comp.id}</span>
|
||||||
|
<button style="background:#ff5555;padding:2px 8px;border-radius:4px;border:none;color:#fff;cursor:pointer;" onclick="removeComputer(${comp.id})">Remove</button>`;
|
||||||
|
listEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.removeComputer = async function (id) {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const msgEl = document.getElementById("computersMsg");
|
||||||
|
msgEl.textContent = "";
|
||||||
|
if (!user) return;
|
||||||
|
const res = await fetch("/api/computers/" + id, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ linked_user: null }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
msgEl.style.color = "#23a559";
|
||||||
|
msgEl.textContent = "Computer unlinked.";
|
||||||
|
loadComputersTab();
|
||||||
|
} else {
|
||||||
|
msgEl.style.color = "#ff5555";
|
||||||
|
msgEl.textContent = "Error: " + (data.error || "Unknown error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("loginBtn").onclick = openLoginModal;
|
||||||
|
document.getElementById("logoutBtn").onclick = logout;
|
||||||
|
|
||||||
|
document.getElementById("loginForm").onsubmit = async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById("loginUsername").value.trim();
|
||||||
|
const password = document.getElementById("loginPassword").value;
|
||||||
|
if (!username || !password) return;
|
||||||
|
const hash = await sha256(password);
|
||||||
|
// Fetch user info to verify login
|
||||||
|
let res = await fetch("/api/users/" + encodeURIComponent(username));
|
||||||
|
let data = await res.json();
|
||||||
|
if (!data.ok || !data.user) {
|
||||||
|
document.getElementById("loginMsg").textContent = "User not found";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.user.password !== hash) {
|
||||||
|
document.getElementById("loginMsg").textContent = "Incorrect password";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoggedInUser({
|
||||||
|
username: data.user.username,
|
||||||
|
adresses: data.user.adresses,
|
||||||
|
});
|
||||||
|
closeLoginModal();
|
||||||
|
updateBalance();
|
||||||
|
cartPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Cart logic ---
|
||||||
|
function getCart() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem("cart")) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setCart(cart) {
|
||||||
|
localStorage.setItem("cart", JSON.stringify(cart));
|
||||||
|
updateCartUI();
|
||||||
|
}
|
||||||
|
function addToCart(item, qty = 1) {
|
||||||
|
let cart = getCart();
|
||||||
|
const idx = cart.findIndex((i) => i.id === item.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
cart[idx].qty += qty * item.quantity || 1;
|
||||||
|
} else {
|
||||||
|
cart.push({ ...item, qty: qty * item.quantity || 1 });
|
||||||
|
}
|
||||||
|
setCart(cart);
|
||||||
|
}
|
||||||
|
function removeFromCart(id) {
|
||||||
|
let cart = getCart().filter((i) => i.id !== id);
|
||||||
|
setCart(cart);
|
||||||
|
}
|
||||||
|
function updateCartUI() {
|
||||||
|
const cart = getCart();
|
||||||
|
const count = cart.reduce((a, i) => a + i.qty, 0);
|
||||||
|
document.getElementById("cartCount").textContent = count;
|
||||||
|
document.getElementById("cartCount").style.display = count ? "" : "none";
|
||||||
|
}
|
||||||
|
function openCart() {
|
||||||
|
renderCart();
|
||||||
|
document.getElementById("cartWindow").style.display = "flex";
|
||||||
|
}
|
||||||
|
function closeCart() {
|
||||||
|
document.getElementById("cartWindow").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("cartBtn").addEventListener("click", () => {
|
||||||
|
openCart();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getItemSellQuantity(id) {
|
||||||
|
const res = await fetch("/api/shop/items");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error || "Failed to load");
|
||||||
|
const item = data.items.find((i) => i.id === id);
|
||||||
|
return item ? item.quantity : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
const cart = getCart();
|
||||||
|
const cartItems = document.getElementById("cartItems");
|
||||||
|
const cartTotal = document.getElementById("cartTotal");
|
||||||
|
if (!cart.length) {
|
||||||
|
cartItems.innerHTML = "<div>Your cart is empty.</div>";
|
||||||
|
cartTotal.textContent = "";
|
||||||
|
document.getElementById("checkoutBtn").disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cartItems.innerHTML = "";
|
||||||
|
let total = 0;
|
||||||
|
Promise.all(
|
||||||
|
cart.map(async (item) => {
|
||||||
|
const sellQuantity = await getItemSellQuantity(item.id);
|
||||||
|
const orderAmount = item.qty / sellQuantity;
|
||||||
|
const price = item.price * orderAmount;
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.style.display = "flex";
|
||||||
|
div.style.justifyContent = "space-between";
|
||||||
|
div.style.alignItems = "center";
|
||||||
|
div.style.marginBottom = "8px";
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${getImage(item.name)}" alt="${item.name}"
|
||||||
|
style="width:50px;height:50px;margin-right:8px;image-rendering: pixelated;">
|
||||||
|
<span class="item-name">${formatItemId(item.name)} × ${item.qty}</span>
|
||||||
|
<span class="item-price">${price.toFixed(2)} $</span>
|
||||||
|
<button style="background:#ff5555;padding:2px 8px;border-radius:4px;border:none;color:#fff;cursor:pointer;"
|
||||||
|
onclick="removeFromCart(${item.id});renderCart();updateCartUI();">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
cartItems.appendChild(div);
|
||||||
|
return price;
|
||||||
|
}),
|
||||||
|
).then((prices) => {
|
||||||
|
total = prices.reduce((sum, p) => sum + p, 0);
|
||||||
|
cartTotal.textContent = "Total: " + total.toFixed(2) + " $";
|
||||||
|
document.getElementById("checkoutBtn").disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Address selection and checkout ---
|
||||||
|
function openAddressModal() {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
if (!user || !Array.isArray(user.adresses) || !user.adresses.length) {
|
||||||
|
document.getElementById("addressMsg").textContent =
|
||||||
|
"No addresses found for your account.";
|
||||||
|
document.getElementById("addressList").innerHTML = "";
|
||||||
|
document.getElementById("addressModal").style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("addressMsg").textContent = "";
|
||||||
|
const list = document.getElementById("addressList");
|
||||||
|
list.innerHTML = "";
|
||||||
|
user.adresses.forEach((addr, idx) => {
|
||||||
|
const id = "addr_" + idx;
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.innerHTML = `<input type="radio" name="address" value="${addr}" id="${id}" ${idx === 0 ? "checked" : ""}/> ${addr}`;
|
||||||
|
list.appendChild(label);
|
||||||
|
});
|
||||||
|
// add separator for other user addresses
|
||||||
|
const separator = document.createElement("hr");
|
||||||
|
separator.style.margin = "10px 0";
|
||||||
|
list.appendChild(separator);
|
||||||
|
|
||||||
|
getAllAddresses().then((addresses) => {
|
||||||
|
const otherAddr = addresses.filter((addr) => !user.adresses.includes(addr));
|
||||||
|
otherAddr.forEach((addr, idx) => {
|
||||||
|
const id = "addr_" + idx;
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.innerHTML = `<input type="radio" name="address" value="${addr}" id="${id}"/> ${addr}`;
|
||||||
|
list.appendChild(label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById("addressModal").style.display = "flex";
|
||||||
|
}
|
||||||
|
function closeAddressModal() {
|
||||||
|
document.getElementById("addressModal").style.display = "none";
|
||||||
|
}
|
||||||
|
document.getElementById("checkoutBtn").onclick = function () {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
if (!user) {
|
||||||
|
closeCart();
|
||||||
|
openLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openAddressModal();
|
||||||
|
};
|
||||||
|
document.getElementById("addressForm").onsubmit = function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const addr = document.querySelector('input[name="address"]:checked');
|
||||||
|
if (!addr) {
|
||||||
|
document.getElementById("addressMsg").textContent =
|
||||||
|
"Please select an address.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// from client send post to API
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
const cart = getCart();
|
||||||
|
const items = cart.map((item) => ({
|
||||||
|
id: item.name,
|
||||||
|
quantity: item.qty,
|
||||||
|
}));
|
||||||
|
const request = {
|
||||||
|
userId: user.username,
|
||||||
|
addressId: addr.value,
|
||||||
|
items: items,
|
||||||
|
};
|
||||||
|
console.log(request);
|
||||||
|
fetch("/api/shop/items/buy", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
// Conditional check
|
||||||
|
if (data.ok === true) {
|
||||||
|
console.log("Purchase successful!");
|
||||||
|
showMsg("Order placed for address: " + addr.value);
|
||||||
|
updateBalance();
|
||||||
|
} else {
|
||||||
|
console.log("Purchase failed or message did not match:", data);
|
||||||
|
showMsg("Order failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error during fetch:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
closeAddressModal();
|
||||||
|
closeCart();
|
||||||
|
setCart([]);
|
||||||
|
updateCartUI();
|
||||||
|
};
|
||||||
|
|
||||||
|
//get Item stock
|
||||||
|
async function getItemStock(itemId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/item/${itemId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error || "Failed to load");
|
||||||
|
return data.item.count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during fetch:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getCartItemCount(itemId) {
|
||||||
|
const cart = getCart();
|
||||||
|
const item = cart.find((item) => item.id === itemId);
|
||||||
|
return item ? item.count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
//check if item can be added to cart (if item_stock > cart_item_count)
|
||||||
|
async function canAddToCart(itemId) {
|
||||||
|
const item_stock = await getItemStock(itemId);
|
||||||
|
const cart_item_count = getCartItemCount(itemId);
|
||||||
|
return item_stock > cart_item_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertStockToStackPlusRest(stock, quantity) {
|
||||||
|
const stackSize = 64;
|
||||||
|
const stacks = Math.floor(stock / stackSize);
|
||||||
|
const rest = stock % stackSize;
|
||||||
|
return { stacks, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shop items logic ---
|
||||||
|
async function fetchItems() {
|
||||||
|
const tableBody = document.querySelector("#items-list tbody");
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
// Clear table and show loader
|
||||||
|
tableBody.innerHTML = "<tr><td colspan='6'>Loading items...</td></tr>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/shop/items");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error || "Failed to load");
|
||||||
|
|
||||||
|
if (!data.items.length) {
|
||||||
|
tableBody.innerHTML =
|
||||||
|
"<tr><td colspan='6'>No items available to buy.</td></tr>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableBody.innerHTML = ""; // Clear loader
|
||||||
|
// sort items alphabetically
|
||||||
|
const sortedItems = [...data.items].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await Promise.all(
|
||||||
|
sortedItems.map(async (item) => {
|
||||||
|
const item_stock = (await getItemStock(item.name)) || 0;
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><img src="${getImage(item.name)}" alt="${item.name}" style="width:50px;height:50px;image-rendering: pixelated;"></td>
|
||||||
|
<td class="item-name">${formatItemId(item.name)}</td>
|
||||||
|
<td class="item-quantity">${item.quantity}</td>
|
||||||
|
<td class="item-price">${item.price.toFixed(2)} $</td>
|
||||||
|
<td class="item-stock">${convertStockToStackPlusRest(item_stock, item.quantity).stacks ? `${convertStockToStackPlusRest(item_stock, item.quantity).stacks} stacks </br> ` : ""}${convertStockToStackPlusRest(item_stock, item.quantity).rest ? `${convertStockToStackPlusRest(item_stock, item.quantity).rest} in stock` : ""} ${!convertStockToStackPlusRest(item_stock, item.quantity).stacks && !convertStockToStackPlusRest(item_stock, item.quantity).rest ? "Out of stock" : ""}
|
||||||
|
<td><input type="number" min="1" max="${Math.floor(item_stock / item.quantity)}" id="quantity-input-${item.id}" style="width:40px;" value="1"></td>
|
||||||
|
<td><button class="buy-btn" data-id="${item.id}" data-name="${item.name}" data-price="${item.price}" data-quantity="${item.quantity}">Get</button></td>
|
||||||
|
`;
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tableBody.innerHTML = "";
|
||||||
|
rows.forEach((row) => tableBody.appendChild(row));
|
||||||
|
|
||||||
|
function getItemSellQuantity(item) {
|
||||||
|
const quantity = parseInt(
|
||||||
|
document.getElementById(`quantity-input-${item.id}`).value,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners for buy buttons
|
||||||
|
Array.from(tableBody.getElementsByClassName("buy-btn")).forEach((btn) => {
|
||||||
|
btn.onclick = function () {
|
||||||
|
const item = {
|
||||||
|
id: parseInt(this.dataset.id, 10),
|
||||||
|
name: this.dataset.name,
|
||||||
|
price: parseFloat(this.dataset.price),
|
||||||
|
quantity: parseInt(this.dataset.quantity, 10),
|
||||||
|
};
|
||||||
|
addToCart(item, getItemSellQuantity(item));
|
||||||
|
updateCartUI();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
tableBody.innerHTML =
|
||||||
|
"<tr><td colspan='6' style='color:#ff5555;'>Error loading items.</td></tr>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
updateLoginUI();
|
||||||
|
updateCartUI();
|
||||||
|
fetchItems();
|
||||||
|
updateBalance();
|
||||||
|
cartPosition();
|
||||||
|
|
||||||
|
// Close modals on outside click
|
||||||
|
window.onclick = function (event) {
|
||||||
|
["loginModal", "cartWindow", "addressModal"].forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (event.target === el) el.style.display = "none";
|
||||||
|
});
|
||||||
|
};
|
||||||
108
public/msg.html
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>WebSocket Test UI</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 16px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#messages {
|
||||||
|
border: 1px solid #222;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #23a559;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
background: #23272a;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #4752c4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WebSocket Chat</h1>
|
||||||
|
<div id="status">Not connected</div>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<input id="msg" placeholder="Type message..." style="width: 70%" />
|
||||||
|
<button id="send">Send</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const messagesEl = document.getElementById("messages");
|
||||||
|
const msgInput = document.getElementById("msg");
|
||||||
|
const sendBtn = document.getElementById("send");
|
||||||
|
|
||||||
|
// connect to same host and port the page was served from
|
||||||
|
const url =
|
||||||
|
(location.protocol === "https:" ? "wss://" : "ws://") +
|
||||||
|
location.host;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
|
function addLine(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
statusEl.textContent = "Connected to " + url;
|
||||||
|
addLine("[system] connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("message", (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === "welcome") {
|
||||||
|
addLine("[system] assigned id: " + data.id);
|
||||||
|
} else if (data.type === "message") {
|
||||||
|
addLine(`[${data.from}] ${data.text}`);
|
||||||
|
} else {
|
||||||
|
addLine("[unknown] " + e.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
addLine("[raw] " + e.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", () =>
|
||||||
|
addLine("[system] disconnected"),
|
||||||
|
);
|
||||||
|
ws.addEventListener("error", () => addLine("[system] error"));
|
||||||
|
|
||||||
|
sendBtn.onclick = () => {
|
||||||
|
const text = msgInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
ws.send(text);
|
||||||
|
msgInput.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// allow Enter to send
|
||||||
|
msgInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") sendBtn.click();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
187
public/register.html
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html></html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Register New Account</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--gh-bg: #0d1117;
|
||||||
|
--gh-panel: #161b22;
|
||||||
|
--gh-border: #30363d;
|
||||||
|
--gh-text: #c9d1d9;
|
||||||
|
--gh-muted: #8b949e;
|
||||||
|
--gh-accent: #58a6ff;
|
||||||
|
--gh-green: #238636;
|
||||||
|
--gh-red: #f85149;
|
||||||
|
--gh-radius: 8px;
|
||||||
|
--gh-shadow: 0 4px 32px #01040960;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: var(--gh-bg);
|
||||||
|
color: var(--gh-text);
|
||||||
|
font-family: "Segoe UI", "Liberation Sans", Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: var(--gh-accent);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0 32px 0;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--gh-muted);
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
input, button {
|
||||||
|
background: #0d1117;
|
||||||
|
color: var(--gh-text);
|
||||||
|
border: 1px solid var(--gh-border);
|
||||||
|
border-radius: var(--gh-radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 1.05em;
|
||||||
|
outline: none;
|
||||||
|
transition: border 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
border: 1.5px solid var(--gh-accent);
|
||||||
|
box-shadow: 0 0 0 3px #1f6feb33;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--gh-accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1em;
|
||||||
|
box-shadow: var(--gh-shadow);
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
button:hover, button:focus {
|
||||||
|
background: #1f6feb;
|
||||||
|
box-shadow: 0 2px 8px #1f6feb33;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
background: var(--gh-panel);
|
||||||
|
border: 1px solid var(--gh-border);
|
||||||
|
border-radius: var(--gh-radius);
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--gh-shadow);
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#msg {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--gh-green);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#msg.error {
|
||||||
|
color: var(--gh-red);
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
color: var(--gh-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Register New Account</h1>
|
||||||
|
<form id="regForm" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" required minlength="6" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addresses">Addresses (comma-separated)</label>
|
||||||
|
<input id="addresses" name="addresses" type="text" placeholder="e.g. home,work" />
|
||||||
|
<small>You can pin multiple addresses to your account.</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
<div id="msg"></div>
|
||||||
|
<script>
|
||||||
|
// SHA-256 hashing function using Web Crypto API
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
// Convert buffer to hex string
|
||||||
|
return Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.getElementById('regForm');
|
||||||
|
const msgEl = document.getElementById('msg');
|
||||||
|
form.onsubmit = async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
msgEl.textContent = '';
|
||||||
|
msgEl.className = '';
|
||||||
|
const username = form.username.value.trim();
|
||||||
|
const password = form.password.value;
|
||||||
|
const addressesRaw = form.addresses.value.trim();
|
||||||
|
const adresses = addressesRaw
|
||||||
|
? addressesRaw.split(',').map(s => s.trim()).filter(s => s.length > 0)
|
||||||
|
: [];
|
||||||
|
if (!username || !password) {
|
||||||
|
msgEl.textContent = 'Please fill all required fields.';
|
||||||
|
msgEl.className = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hashedPassword;
|
||||||
|
try {
|
||||||
|
hashedPassword = await hashPassword(password);
|
||||||
|
} catch (err) {
|
||||||
|
msgEl.textContent = 'Error hashing password.';
|
||||||
|
msgEl.className = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
adresses
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
window.location.href = "/";
|
||||||
|
} else {
|
||||||
|
msgEl.textContent = 'Error: ' + (data.error || 'Unknown error');
|
||||||
|
msgEl.className = 'error';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
msgEl.textContent = 'Network error: ' + err.message;
|
||||||
|
msgEl.className = 'error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/textures/ae2/16k_crafting_storage.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/1k_crafting_storage.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/256k_crafting_storage.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/4k_crafting_storage.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/64k_crafting_storage.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/advanced_card.png
Normal file
|
After Width: | Height: | Size: 395 B |
BIN
public/textures/ae2/annihilation_core.png
Normal file
|
After Width: | Height: | Size: 527 B |
BIN
public/textures/ae2/annihilation_plane.png
Normal file
|
After Width: | Height: | Size: 757 B |
BIN
public/textures/ae2/basic_card.png
Normal file
|
After Width: | Height: | Size: 400 B |
BIN
public/textures/ae2/black_covered_cable.png
Normal file
|
After Width: | Height: | Size: 573 B |
BIN
public/textures/ae2/black_covered_dense_cable.png
Normal file
|
After Width: | Height: | Size: 624 B |
BIN
public/textures/ae2/black_glass_cable.png
Normal file
|
After Width: | Height: | Size: 462 B |
BIN
public/textures/ae2/black_lumen_paint_ball.png
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/textures/ae2/black_paint_ball.png
Normal file
|
After Width: | Height: | Size: 248 B |
BIN
public/textures/ae2/black_smart_cable.png
Normal file
|
After Width: | Height: | Size: 513 B |
BIN
public/textures/ae2/black_smart_dense_cable.png
Normal file
|
After Width: | Height: | Size: 579 B |
BIN
public/textures/ae2/blank_pattern.png
Normal file
|
After Width: | Height: | Size: 466 B |
BIN
public/textures/ae2/blue_covered_cable.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
public/textures/ae2/blue_covered_dense_cable.png
Normal file
|
After Width: | Height: | Size: 665 B |
BIN
public/textures/ae2/blue_glass_cable.png
Normal file
|
After Width: | Height: | Size: 491 B |
BIN
public/textures/ae2/blue_lumen_paint_ball.png
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/textures/ae2/blue_paint_ball.png
Normal file
|
After Width: | Height: | Size: 249 B |
BIN
public/textures/ae2/blue_smart_cable.png
Normal file
|
After Width: | Height: | Size: 543 B |
BIN
public/textures/ae2/blue_smart_dense_cable.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
public/textures/ae2/brown_covered_cable.png
Normal file
|
After Width: | Height: | Size: 593 B |
BIN
public/textures/ae2/brown_covered_dense_cable.png
Normal file
|
After Width: | Height: | Size: 650 B |
BIN
public/textures/ae2/brown_glass_cable.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
public/textures/ae2/brown_lumen_paint_ball.png
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/textures/ae2/brown_paint_ball.png
Normal file
|
After Width: | Height: | Size: 245 B |
BIN
public/textures/ae2/brown_smart_cable.png
Normal file
|
After Width: | Height: | Size: 533 B |
BIN
public/textures/ae2/brown_smart_dense_cable.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
public/textures/ae2/cable_anchor.png
Normal file
|
After Width: | Height: | Size: 242 B |
BIN
public/textures/ae2/cable_energy_acceptor.png
Normal file
|
After Width: | Height: | Size: 621 B |
BIN
public/textures/ae2/cable_interface.png
Normal file
|
After Width: | Height: | Size: 528 B |
BIN
public/textures/ae2/cable_pattern_provider.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
public/textures/ae2/calculation_processor.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
public/textures/ae2/calculation_processor_press.png
Normal file
|
After Width: | Height: | Size: 460 B |
BIN
public/textures/ae2/capacity_card.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
public/textures/ae2/cell_component_16k.png
Normal file
|
After Width: | Height: | Size: 471 B |
BIN
public/textures/ae2/cell_component_1k.png
Normal file
|
After Width: | Height: | Size: 287 B |
BIN
public/textures/ae2/cell_component_256k.png
Normal file
|
After Width: | Height: | Size: 501 B |
BIN
public/textures/ae2/cell_component_4k.png
Normal file
|
After Width: | Height: | Size: 369 B |
BIN
public/textures/ae2/cell_component_64k.png
Normal file
|
After Width: | Height: | Size: 523 B |
BIN
public/textures/ae2/cell_workbench.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/certus_quartz_axe.png
Normal file
|
After Width: | Height: | Size: 443 B |
BIN
public/textures/ae2/certus_quartz_crystal.png
Normal file
|
After Width: | Height: | Size: 405 B |
BIN
public/textures/ae2/certus_quartz_cutting_knife.png
Normal file
|
After Width: | Height: | Size: 335 B |
BIN
public/textures/ae2/certus_quartz_dust.png
Normal file
|
After Width: | Height: | Size: 393 B |
BIN
public/textures/ae2/certus_quartz_hoe.png
Normal file
|
After Width: | Height: | Size: 395 B |
BIN
public/textures/ae2/certus_quartz_pickaxe.png
Normal file
|
After Width: | Height: | Size: 458 B |
BIN
public/textures/ae2/certus_quartz_shovel.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
public/textures/ae2/certus_quartz_sword.png
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
public/textures/ae2/certus_quartz_wrench.png
Normal file
|
After Width: | Height: | Size: 355 B |
BIN
public/textures/ae2/charged_certus_quartz_crystal.png
Normal file
|
After Width: | Height: | Size: 449 B |
BIN
public/textures/ae2/charged_staff.png
Normal file
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 362 B |
BIN
public/textures/ae2/charger.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/chest.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/textures/ae2/chipped_budding_quartz.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/textures/ae2/chiseled_quartz_block.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/textures/ae2/chiseled_quartz_slab.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/textures/ae2/chiseled_quartz_stairs.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/chiseled_quartz_wall.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/textures/ae2/color_applicator.png
Normal file
|
After Width: | Height: | Size: 792 B |
|
After Width: | Height: | Size: 792 B |
BIN
public/textures/ae2/condenser.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/controller.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/textures/ae2/conversion_monitor.png
Normal file
|
After Width: | Height: | Size: 472 B |
BIN
public/textures/ae2/crafting_accelerator.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/crafting_card.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
public/textures/ae2/crafting_monitor.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/crafting_terminal.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
public/textures/ae2/crafting_unit.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/crank.png
Normal file
|
After Width: | Height: | Size: 412 B |
BIN
public/textures/ae2/creative_energy_cell.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/textures/ae2/creative_storage_cell.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
public/textures/ae2/crystal_resonance_generator.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/textures/ae2/cut_quartz_block.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/textures/ae2/cut_quartz_slab.png
Normal file
|
After Width: | Height: | Size: 891 B |
BIN
public/textures/ae2/cut_quartz_stairs.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/textures/ae2/cut_quartz_wall.png
Normal file
|
After Width: | Height: | Size: 798 B |
BIN
public/textures/ae2/cyan_covered_cable.png
Normal file
|
After Width: | Height: | Size: 616 B |
BIN
public/textures/ae2/cyan_covered_dense_cable.png
Normal file
|
After Width: | Height: | Size: 668 B |
BIN
public/textures/ae2/cyan_glass_cable.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
public/textures/ae2/cyan_lumen_paint_ball.png
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/textures/ae2/cyan_paint_ball.png
Normal file
|
After Width: | Height: | Size: 249 B |
BIN
public/textures/ae2/cyan_smart_cable.png
Normal file
|
After Width: | Height: | Size: 535 B |
BIN
public/textures/ae2/cyan_smart_dense_cable.png
Normal file
|
After Width: | Height: | Size: 588 B |
BIN
public/textures/ae2/damaged_budding_quartz.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/textures/ae2/dark_monitor.png
Normal file
|
After Width: | Height: | Size: 391 B |