batman
This commit is contained in:
parent
a2f3871da4
commit
290992dfce
BIN
fav_icon.ico
Normal file
BIN
fav_icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
BIN
favicon.webp
Normal file
BIN
favicon.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 688 KiB |
527
index.html
Normal file
527
index.html
Normal file
@ -0,0 +1,527 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="fav_icon.ico" type="image/x-icon">
|
||||||
|
<title>Milk Production Tracker</title>
|
||||||
|
<style>
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu bar container */
|
||||||
|
#menu-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 40px;
|
||||||
|
background: #007bff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu-btn:hover,
|
||||||
|
#menu-btn:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#floating-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 40px;
|
||||||
|
/* below menu bar */
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#floating-menu button {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#floating-menu button:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-heading {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 12px 0 12px 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 10px 10px 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
/* 40px menu bar + 36px heading + 12px margin */
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: white;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item>div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.delete-btn,
|
||||||
|
button.restore-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.restore-btn {
|
||||||
|
fill: #080;
|
||||||
|
}
|
||||||
|
|
||||||
|
#input-area {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="number"] {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button#submit-btn {
|
||||||
|
background: #007bff;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button#submit-btn:disabled {
|
||||||
|
background: #a0c8ff;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 400px) {
|
||||||
|
button#submit-btn {
|
||||||
|
flex-basis: auto;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
#input-area {
|
||||||
|
position: static;
|
||||||
|
padding: 12px 16px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="datetime-local"] {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="menu-bar">
|
||||||
|
<button id="menu-btn" aria-haspopup="true" aria-expanded="false" aria-controls="floating-menu">Menu</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="floating-menu" role="menu" aria-label="Navigation menu">
|
||||||
|
<button id="nav-entries" role="menuitem">Trash</button>
|
||||||
|
<button id="nav-import" role="menuitem">Import</button>
|
||||||
|
<button id="nav-export" role="menuitem">Export</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-heading" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div id="list" aria-live="polite" aria-label="Milk production entries"></div>
|
||||||
|
|
||||||
|
<div id="input-area">
|
||||||
|
<input type="datetime-local" id="timestamp" aria-label="Timestamp" />
|
||||||
|
<input type="number" id="amount" min="0" step="1" aria-label="Amount in milliliters" placeholder="ml" />
|
||||||
|
<button id="submit-btn" disabled>Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const STORAGE_KEY = 'milkProductionEntries';
|
||||||
|
const REMOVED_KEY = 'milkProductionRemoved';
|
||||||
|
|
||||||
|
let entries = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
|
||||||
|
let removed = JSON.parse(localStorage.getItem(REMOVED_KEY)) || [];
|
||||||
|
|
||||||
|
const listEl = document.getElementById('list');
|
||||||
|
const timestampInput = document.getElementById('timestamp');
|
||||||
|
timestampInput.value = new Date().toISOString().slice(0, 16);
|
||||||
|
const amountInput = document.getElementById('amount');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
|
||||||
|
const menuBtn = document.getElementById('menu-btn');
|
||||||
|
const floatingMenu = document.getElementById('floating-menu');
|
||||||
|
const navEntriesBtn = document.getElementById('nav-entries');
|
||||||
|
const navExportBtn = document.getElementById('nav-export');
|
||||||
|
const navImportBtn = document.getElementById('nav-import');
|
||||||
|
const pageHeading = document.getElementById('page-heading');
|
||||||
|
const inputArea = document.getElementById('input-area');
|
||||||
|
|
||||||
|
let currentView = 'entries';
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||||
|
localStorage.setItem(REMOVED_KEY, JSON.stringify(removed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d)) return ts;
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntries() {
|
||||||
|
pageHeading.textContent = '';
|
||||||
|
inputArea.style.display = 'flex';
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const emptyMsg = document.createElement('div');
|
||||||
|
emptyMsg.textContent = 'No milk production entries yet.';
|
||||||
|
emptyMsg.style.color = '#666';
|
||||||
|
emptyMsg.style.textAlign = 'center';
|
||||||
|
emptyMsg.style.marginTop = '20px';
|
||||||
|
listEl.appendChild(emptyMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)).forEach((entry, i) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'item';
|
||||||
|
item.setAttribute('data-index', i);
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
const ts = document.createElement('div');
|
||||||
|
ts.className = 'timestamp';
|
||||||
|
ts.textContent = formatTimestamp(entry.timestamp);
|
||||||
|
const amt = document.createElement('div');
|
||||||
|
amt.className = 'amount';
|
||||||
|
amt.textContent = `${entry.amount} ml`;
|
||||||
|
|
||||||
|
info.appendChild(ts);
|
||||||
|
info.appendChild(amt);
|
||||||
|
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.className = 'delete-btn';
|
||||||
|
delBtn.setAttribute('aria-label', 'Delete entry');
|
||||||
|
delBtn.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#c00"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
delBtn.addEventListener('click', () => {
|
||||||
|
removed.push(entries[i]);
|
||||||
|
entries.splice(i, 1);
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(delBtn);
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrash() {
|
||||||
|
pageHeading.textContent = 'Trash Bin';
|
||||||
|
inputArea.style.display = 'none'; // hide input area in trash view
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (removed.length === 0) {
|
||||||
|
const emptyMsg = document.createElement('div');
|
||||||
|
emptyMsg.textContent = 'Trash bin is empty.';
|
||||||
|
emptyMsg.style.color = '#666';
|
||||||
|
emptyMsg.style.textAlign = 'center';
|
||||||
|
emptyMsg.style.marginTop = '20px';
|
||||||
|
listEl.appendChild(emptyMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removed.sort(((a, b) => new Date(b.timestamp) - new Date(a.timestamp))).forEach((entry, i) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'item';
|
||||||
|
item.setAttribute('data-index', i);
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
const ts = document.createElement('div');
|
||||||
|
ts.className = 'timestamp';
|
||||||
|
ts.textContent = formatTimestamp(entry.timestamp);
|
||||||
|
const amt = document.createElement('div');
|
||||||
|
amt.className = 'amount';
|
||||||
|
amt.textContent = `${entry.amount} ml`;
|
||||||
|
|
||||||
|
info.appendChild(ts);
|
||||||
|
info.appendChild(amt);
|
||||||
|
|
||||||
|
const restoreBtn = document.createElement('button');
|
||||||
|
restoreBtn.className = 'restore-btn';
|
||||||
|
restoreBtn.setAttribute('aria-label', 'Restore entry');
|
||||||
|
restoreBtn.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#080"><path d="M13 3a9 9 0 1 0 8.95 9.95h-2.02a7 7 0 1 1-6.93-7v4l5-5-5-5v4z"/></svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
restoreBtn.addEventListener('click', () => {
|
||||||
|
entries.push(removed[i]);
|
||||||
|
removed.splice(i, 1);
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(restoreBtn);
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (currentView === 'entries') {
|
||||||
|
renderEntries();
|
||||||
|
menuBtn.textContent = 'Menu';
|
||||||
|
} else if (currentView === 'trash') {
|
||||||
|
renderTrash();
|
||||||
|
menuBtn.textContent = 'Menu';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInputs() {
|
||||||
|
const tsVal = timestampInput.value;
|
||||||
|
const amtVal = amountInput.value;
|
||||||
|
if (!tsVal) return false;
|
||||||
|
if (!amtVal) return false;
|
||||||
|
const amtNum = Number(amtVal);
|
||||||
|
if (!Number.isInteger(amtNum) || amtNum < 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubmitState() {
|
||||||
|
submitBtn.disabled = !validateInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
timestampInput.addEventListener('input', updateSubmitState);
|
||||||
|
amountInput.addEventListener('input', updateSubmitState);
|
||||||
|
|
||||||
|
submitBtn.addEventListener('click', () => {
|
||||||
|
if (!validateInputs()) return;
|
||||||
|
const newEntry = {
|
||||||
|
timestamp: timestampInput.value,
|
||||||
|
amount: Number(amountInput.value)
|
||||||
|
};
|
||||||
|
entries.push(newEntry);
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
timestampInput.value = new Date().toISOString().slice(0, 16);
|
||||||
|
amountInput.value = '';
|
||||||
|
updateSubmitState();
|
||||||
|
});
|
||||||
|
|
||||||
|
menuBtn.addEventListener('click', () => {
|
||||||
|
const expanded = menuBtn.getAttribute('aria-expanded') === 'true';
|
||||||
|
if (expanded) {
|
||||||
|
floatingMenu.style.display = 'none';
|
||||||
|
menuBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
} else {
|
||||||
|
floatingMenu.style.display = 'block';
|
||||||
|
menuBtn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!floatingMenu.contains(e.target) && e.target !== menuBtn) {
|
||||||
|
floatingMenu.style.display = 'none';
|
||||||
|
menuBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function uploadJSONFile() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'application/json';
|
||||||
|
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const uploadedData = JSON.parse(reader.result);
|
||||||
|
if (!Array.isArray(uploadedData)) {
|
||||||
|
console.error('Uploaded JSON is not an array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDataString = localStorage.getItem(STORAGE_KEY);
|
||||||
|
let existingData = [];
|
||||||
|
if (existingDataString) {
|
||||||
|
try {
|
||||||
|
existingData = JSON.parse(existingDataString);
|
||||||
|
if (!Array.isArray(existingData)) {
|
||||||
|
console.error('Existing localStorage data is not an array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to parse existing localStorage data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSet = new Set(existingData.map(item => JSON.stringify(item)));
|
||||||
|
|
||||||
|
const filteredUploaded = uploadedData.filter(item => {
|
||||||
|
const str = JSON.stringify(item);
|
||||||
|
if (existingSet.has(str)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
existingSet.add(str);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedData = existingData.concat(filteredUploaded);
|
||||||
|
entries = mergedData;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedData));
|
||||||
|
|
||||||
|
render();
|
||||||
|
updateSubmitState();
|
||||||
|
console.log('Data merged successfully, duplicates ignored');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invalid JSON file', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJSONFile(filename = 'data.json') {
|
||||||
|
const blob = new Blob([JSON.stringify(entries)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
navEntriesBtn.addEventListener('click', () => {
|
||||||
|
currentView = currentView === 'entries' ? 'trash' : 'entries';
|
||||||
|
navEntriesBtn.innerHTML = currentView === 'entries' ? 'Trash' : 'Entries';
|
||||||
|
floatingMenu.style.display = 'none';
|
||||||
|
menuBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
navExportBtn.addEventListener('click', () => {
|
||||||
|
downloadJSONFile();
|
||||||
|
floatingMenu.style.display = 'none';
|
||||||
|
menuBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
navImportBtn.addEventListener('click', () => {
|
||||||
|
uploadJSONFile();
|
||||||
|
floatingMenu.style.display = 'none';
|
||||||
|
menuBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
updateSubmitState();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user