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