Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa878985f6 |
@@ -6,18 +6,18 @@
|
|||||||
"Eric Smithson",
|
"Eric Smithson",
|
||||||
"Seth Lima",
|
"Seth Lima",
|
||||||
"Rick Sanchez",
|
"Rick Sanchez",
|
||||||
"John Hammer"
|
"Jerry Smith"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Ariel's Team",
|
"name": "Ariel's Team",
|
||||||
"fruits": [
|
"fruits": [
|
||||||
"Jerry Smith",
|
|
||||||
"Charles Carmichael",
|
"Charles Carmichael",
|
||||||
"Michael Westen",
|
"Michael Westen",
|
||||||
"Shawn Spencer",
|
"Shawn Spencer",
|
||||||
"Eliot Alderson",
|
"Eliot Alderson",
|
||||||
"Brian D"
|
"Brian D",
|
||||||
|
"John Hammer"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
BIN
positions.db
BIN
positions.db
Binary file not shown.
90
public/history.html
Normal file
90
public/history.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Cuberoo History</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; padding: 0; background: #181818; color: white; overflow: auto !important; }
|
||||||
|
body { min-height: 100vh; }
|
||||||
|
.history-list { max-width: 600px; margin: 40px auto; background: #181818; border-radius: 10px; box-shadow: 0 4px 32px #000b; padding: 24px; }
|
||||||
|
.history-item { padding: 14px 0; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.history-item:last-child { border-bottom: none; }
|
||||||
|
.history-action { color: #d103f9; font-weight: bold; }
|
||||||
|
.history-timestamp { color: #aaa; font-size: 0.95em; margin-right: 18px; }
|
||||||
|
.revert-btn { background: #d103f9; color: white; border: none; border-radius: 6px; padding: 6px 18px; font-size: 1em; cursor: pointer; transition: background 0.15s; }
|
||||||
|
.revert-btn:hover { background: #a002b6; }
|
||||||
|
.history-header { text-align: center; color: #d103f9; font-size: 2em; margin-bottom: 24px; }
|
||||||
|
.back-link { color: #d103f9; text-decoration: none; font-size: 1.1em; display: block; margin-bottom: 18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="history-list">
|
||||||
|
<a href="index.html" class="back-link">← Back to Cuberoo</a>
|
||||||
|
<div class="history-header">Revision History</div>
|
||||||
|
<div id="historyContainer">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function loadHistory() {
|
||||||
|
const res = await fetch('/api/history');
|
||||||
|
const history = await res.json();
|
||||||
|
const container = document.getElementById('historyContainer');
|
||||||
|
if (!Array.isArray(history) || history.length === 0) {
|
||||||
|
container.innerHTML = '<div style="color:#aaa;">No history yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '';
|
||||||
|
history.forEach((item, idx) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'history-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<span class="history-timestamp">${new Date(item.timestamp).toLocaleString()}</span>
|
||||||
|
<span class="history-action">${item.action}</span>
|
||||||
|
<button class="revert-btn" data-idx="${idx}">Revert</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
// Add click handlers for revert
|
||||||
|
document.querySelectorAll('.revert-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const idx = parseInt(btn.getAttribute('data-idx'));
|
||||||
|
// Revert to the next revision (state before this one)
|
||||||
|
let revertId = null;
|
||||||
|
if (idx + 1 < history.length) {
|
||||||
|
revertId = history[idx + 1].id;
|
||||||
|
}
|
||||||
|
if (!confirm('Revert to the state BEFORE this change? This will overwrite the current board.')) return;
|
||||||
|
if (revertId) {
|
||||||
|
const res = await fetch('/api/revert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: revertId })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Reverted!');
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
} else {
|
||||||
|
alert('Failed to revert.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No earlier revision, clear board
|
||||||
|
const res = await fetch('/api/revert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: null })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Reverted to empty board!');
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
} else {
|
||||||
|
alert('Failed to revert.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadHistory();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Sidebar toggle for mobile -->
|
<!-- Sidebar toggle for mobile -->
|
||||||
<button id="sidebarToggle" style="display:none;margin-left:16px;font-size:1.6em;background:none;border:none;color:#d103f9;z-index:1100;">☰</button>
|
<button id="sidebarToggle" style="display:none;margin-left:16px;font-size:1.6em;background:none;border:none;color:#d103f9;z-index:1100;">☰</button>
|
||||||
|
<a href="history.html" style="margin-left:24px;color:#d103f9;font-weight:bold;text-decoration:none;font-size:1.1em;">History</a>
|
||||||
</header>
|
</header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- wrapper for the draggable map class -->
|
<!-- wrapper for the draggable map class -->
|
||||||
|
|||||||
@@ -378,29 +378,46 @@ function initializeFruitAndCategoryEvents() {
|
|||||||
sq.addEventListener('drop', async e => {
|
sq.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fruit = e.dataTransfer.getData('text/plain');
|
const fruit = e.dataTransfer.getData('text/plain');
|
||||||
|
|
||||||
// check if the fruit is already placed in another square
|
// check if the fruit is already placed in another square
|
||||||
const existingSquare = document.querySelector(`.square[data-fruit="${fruit}"]`);
|
const existingSquare = document.querySelector(`.square[data-fruit="${fruit}"]`);
|
||||||
if (existingSquare && existingSquare !== sq) {
|
if (existingSquare && existingSquare !== sq) {
|
||||||
|
// Use new /api/move endpoint for atomic move
|
||||||
|
const fromSquareId = existingSquare.dataset.squareId;
|
||||||
|
const toSquareId = sq.dataset.squareId;
|
||||||
|
await fetch('/api/move', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fromSquareId, toSquareId, fruit }),
|
||||||
|
});
|
||||||
|
// Update UI
|
||||||
existingSquare.innerHTML = '';
|
existingSquare.innerHTML = '';
|
||||||
delete existingSquare.dataset.fruit;
|
delete existingSquare.dataset.fruit;
|
||||||
const existingSquareId = existingSquare.dataset.squareId;
|
const numDivOld = document.createElement('div');
|
||||||
|
numDivOld.className = 'square-number';
|
||||||
|
numDivOld.textContent = fromSquareId;
|
||||||
|
existingSquare.appendChild(numDivOld);
|
||||||
|
placeFruitInSquare(sq, fruit);
|
||||||
|
// Add highlight animation to destination square
|
||||||
|
sq.classList.add('highlight-update');
|
||||||
|
setTimeout(() => {
|
||||||
|
sq.classList.remove('highlight-update');
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// Not a move, just a drop
|
||||||
|
placeFruitInSquare(sq, fruit);
|
||||||
|
// save to server
|
||||||
|
const squareId = sq.dataset.squareId;
|
||||||
await fetch('/api/positions', {
|
await fetch('/api/positions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ squareId: existingSquareId, fruit: '' }),
|
body: JSON.stringify({ squareId, fruit }),
|
||||||
});
|
});
|
||||||
|
// Add highlight animation to destination square
|
||||||
|
sq.classList.add('highlight-update');
|
||||||
|
setTimeout(() => {
|
||||||
|
sq.classList.remove('highlight-update');
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
placeFruitInSquare(sq, fruit);
|
|
||||||
|
|
||||||
// save to server
|
|
||||||
const squareId = sq.dataset.squareId;
|
|
||||||
await fetch('/api/positions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ squareId, fruit }),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add double-click handler for custom fruit
|
// Add double-click handler for custom fruit
|
||||||
|
|||||||
121
server.js
121
server.js
@@ -33,6 +33,33 @@ db.run(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add a table for history (revision log)
|
||||||
|
db.run(
|
||||||
|
`CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
action TEXT,
|
||||||
|
positions TEXT -- JSON string of all positions
|
||||||
|
)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Could not ensure history table', err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to save a revision to history
|
||||||
|
function saveHistory(action, cb) {
|
||||||
|
db.all('SELECT square_id, fruit FROM positions', (err, rows) => {
|
||||||
|
if (err) return cb && cb(err);
|
||||||
|
const mapping = {};
|
||||||
|
rows.forEach(r => (mapping[r.square_id] = r.fruit));
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO history (action, positions) VALUES (?, ?)',
|
||||||
|
[action, JSON.stringify(mapping)],
|
||||||
|
cb
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// get all saved positions
|
// get all saved positions
|
||||||
app.get('/api/positions', (req, res) => {
|
app.get('/api/positions', (req, res) => {
|
||||||
db.all('SELECT square_id, fruit FROM positions', (err, rows) => {
|
db.all('SELECT square_id, fruit FROM positions', (err, rows) => {
|
||||||
@@ -57,6 +84,8 @@ app.post('/api/positions', (req, res) => {
|
|||||||
[squareId, fruit],
|
[squareId, fruit],
|
||||||
function (err) {
|
function (err) {
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
if (err) return res.status(500).json({ error: err.message });
|
||||||
|
// Save to history
|
||||||
|
saveHistory(`Moved ${fruit} to '${squareId}'`, () => {});
|
||||||
// broadcast update via Socket.io
|
// broadcast update via Socket.io
|
||||||
io.emit('update', { squareId, fruit });
|
io.emit('update', { squareId, fruit });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -134,6 +163,98 @@ app.post('/api/delete-fruit', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API to get history
|
||||||
|
app.get('/api/history', (req, res) => {
|
||||||
|
db.all('SELECT id, timestamp, action FROM history ORDER BY id DESC', (err, rows) => {
|
||||||
|
if (err) return res.status(500).json({ error: err.message });
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to get a specific revision's positions
|
||||||
|
app.get('/api/history/:id', (req, res) => {
|
||||||
|
db.get('SELECT positions FROM history WHERE id = ?', [req.params.id], (err, row) => {
|
||||||
|
if (err) return res.status(500).json({ error: err.message });
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json(JSON.parse(row.positions));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API to revert to a specific revision
|
||||||
|
app.post('/api/revert', (req, res) => {
|
||||||
|
const { id } = req.body;
|
||||||
|
if (!id) {
|
||||||
|
// No id: revert to empty board
|
||||||
|
db.run('DELETE FROM positions', [], (err2) => {
|
||||||
|
if (err2) return res.status(500).json({ error: err2.message });
|
||||||
|
saveHistory('Reverted to empty board', () => {});
|
||||||
|
io.emit('update', { revert: true });
|
||||||
|
return res.json({ success: true });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.get('SELECT positions FROM history WHERE id = ?', [id], (err, row) => {
|
||||||
|
if (err) return res.status(500).json({ error: err.message });
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const positions = JSON.parse(row.positions);
|
||||||
|
// Clear all positions
|
||||||
|
db.run('DELETE FROM positions', [], (err2) => {
|
||||||
|
if (err2) return res.status(500).json({ error: err2.message });
|
||||||
|
// Insert all positions from the revision
|
||||||
|
const entries = Object.entries(positions);
|
||||||
|
let done = 0;
|
||||||
|
if (entries.length === 0) {
|
||||||
|
saveHistory(`Reverted to revision ${id}`, () => {});
|
||||||
|
io.emit('update', { revert: true });
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
entries.forEach(([squareId, fruit]) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO positions (square_id, fruit) VALUES (?, ?)`,
|
||||||
|
[squareId, fruit],
|
||||||
|
(err3) => {
|
||||||
|
done++;
|
||||||
|
if (done === entries.length) {
|
||||||
|
saveHistory(`Reverted to revision ${id}`, () => {});
|
||||||
|
io.emit('update', { revert: true });
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move a fruit from one square to another in a single revision
|
||||||
|
app.post('/api/move', (req, res) => {
|
||||||
|
const { fromSquareId, toSquareId, fruit } = req.body;
|
||||||
|
if (!fromSquareId || !toSquareId || !fruit) {
|
||||||
|
return res.status(400).json({ error: 'fromSquareId, toSquareId, and fruit required' });
|
||||||
|
}
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE positions SET fruit='' WHERE square_id=?`,
|
||||||
|
[fromSquareId],
|
||||||
|
(err1) => {
|
||||||
|
if (err1) return res.status(500).json({ error: err1.message });
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO positions (square_id, fruit)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(square_id) DO UPDATE SET fruit=excluded.fruit`,
|
||||||
|
[toSquareId, fruit],
|
||||||
|
(err2) => {
|
||||||
|
if (err2) return res.status(500).json({ error: err2.message });
|
||||||
|
saveHistory(`Moved ${fruit} from ${fromSquareId} to ${toSquareId}`, () => {});
|
||||||
|
io.emit('update', { fromSquareId, toSquareId, fruit });
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// start server
|
// start server
|
||||||
const PORT = process.env.PORT || 3085;
|
const PORT = process.env.PORT || 3085;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user