Files
pantry/index.html
Eric Wagoner 459878f045 Fix category filter bug and add tab accessibility
The category tabs were not filtering because the generic tab event handler
was overwriting currentFilter with undefined (category tabs use data-category
not data-filter). Fixed by targeting only [data-filter] tabs and clearing
categoryFilter when switching to non-category tabs.

Added proper ARIA attributes for screen reader accessibility:
- role="tablist" on nav-tabs container
- role="tab" and aria-selected on all tab buttons
- Dynamic aria-selected updates on tab clicks

Also includes API support for category field and deploy script update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 12:28:36 -05:00

1557 lines
55 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pantry Inventory</title>
<!-- OpenGraph -->
<meta property="og:title" content="Pantry">
<meta property="og:description" content="Kitchen Inventory Tracker">
<meta property="og:image" content="https://pantry.kestrelsnest.social/og-image.png">
<meta property="og:url" content="https://pantry.kestrelsnest.social">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Pantry">
<meta name="twitter:description" content="Kitchen Inventory Tracker">
<meta name="twitter:image" content="https://pantry.kestrelsnest.social/og-image.png">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--cream: #F5F0E8;
--warm-white: #FDFCFA;
--terracotta: #C4684A;
--terracotta-light: #D4856B;
--forest: #2D4739;
--forest-light: #3D5A4B;
--oak: #8B7355;
--oak-light: #A89078;
--charcoal: #2C2C2C;
--soft-gray: #9A9590;
--border: rgba(139, 115, 85, 0.2);
--shadow: rgba(44, 44, 44, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--cream);
color: var(--charcoal);
min-height: 100vh;
line-height: 1.5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 40px 20px 30px;
background: linear-gradient(180deg, var(--warm-white) 0%, var(--cream) 100%);
border-bottom: 1px solid var(--border);
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 700;
font-size: 2.2rem;
color: var(--forest);
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.subtitle {
font-size: 0.9rem;
color: var(--soft-gray);
font-weight: 400;
}
.search-section {
padding: 20px;
background: var(--warm-white);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.search-wrapper {
position: relative;
}
.search-input {
width: 100%;
padding: 14px 20px 14px 48px;
font-size: 1rem;
font-family: 'DM Sans', sans-serif;
border: 2px solid var(--border);
border-radius: 12px;
background: var(--cream);
color: var(--charcoal);
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: var(--forest);
background: var(--warm-white);
}
.search-input::placeholder {
color: var(--soft-gray);
}
.search-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
color: var(--soft-gray);
}
.nav-tabs {
display: flex;
gap: 8px;
padding: 16px 20px;
background: var(--warm-white);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.nav-tab {
padding: 8px 16px;
font-size: 0.85rem;
font-weight: 500;
border: none;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
background: var(--cream);
color: var(--charcoal);
}
.nav-tab:hover {
background: var(--oak-light);
color: white;
}
.nav-tab.active {
background: var(--forest);
color: white;
}
.inventory-list {
padding: 16px 20px;
}
.inventory-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
margin-bottom: 10px;
background: var(--warm-white);
border-radius: 12px;
border: 1px solid var(--border);
transition: all 0.2s ease;
cursor: pointer;
}
.inventory-item:hover {
border-color: var(--oak-light);
box-shadow: 0 4px 12px var(--shadow);
}
.inventory-item.out-of-stock {
opacity: 0.5;
background: var(--cream);
}
.item-info {
flex: 1;
}
.item-name {
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 1.1rem;
color: var(--charcoal);
margin-bottom: 4px;
}
.item-container {
font-size: 0.8rem;
color: var(--soft-gray);
display: flex;
align-items: center;
gap: 6px;
}
.container-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.category-tag {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
font-weight: 500;
}
.status-badge {
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-in-stock {
background: rgba(45, 71, 57, 0.1);
color: var(--forest);
}
.status-out {
background: rgba(196, 104, 74, 0.1);
color: var(--terracotta);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--soft-gray);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.4;
}
.fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 16px;
border: none;
background: var(--terracotta);
color: white;
font-size: 24px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(196, 104, 74, 0.4);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover {
background: var(--terracotta-light);
transform: translateY(-2px);
}
.fab.locked {
background: var(--soft-gray);
box-shadow: 0 4px 16px var(--shadow);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(44, 44, 44, 0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
backdrop-filter: blur(4px);
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--warm-white);
border-radius: 20px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(44, 44, 44, 0.3);
}
.modal-header {
padding: 24px 24px 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 1.3rem;
color: var(--forest);
}
.modal-close {
width: 32px;
height: 32px;
border: none;
background: var(--cream);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--charcoal);
transition: background 0.2s ease;
}
.modal-close:hover {
background: var(--border);
}
.modal-close:focus {
outline: 2px solid var(--forest);
outline-offset: 2px;
}
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--charcoal);
margin-bottom: 8px;
}
.form-input, .form-select {
width: 100%;
padding: 12px 16px;
font-size: 1rem;
font-family: 'DM Sans', sans-serif;
border: 2px solid var(--border);
border-radius: 10px;
background: var(--cream);
color: var(--charcoal);
transition: all 0.2s ease;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--forest);
background: var(--warm-white);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239A9590' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
}
.btn {
padding: 14px 24px;
font-size: 0.95rem;
font-weight: 600;
font-family: 'DM Sans', sans-serif;
border: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
}
.btn-primary {
background: var(--forest);
color: white;
}
.btn-primary:hover {
background: var(--forest-light);
}
.btn-danger {
background: var(--terracotta);
color: white;
margin-top: 12px;
}
.btn-danger:hover {
background: var(--terracotta-light);
}
.btn:focus {
outline: 3px solid var(--oak);
outline-offset: 2px;
}
.btn-secondary {
background: var(--cream);
color: var(--charcoal);
border: 2px solid var(--border);
}
.btn-secondary:hover {
border-color: var(--oak);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-group input {
width: 20px;
height: 20px;
accent-color: var(--forest);
}
.checkbox-group input:focus {
outline: 2px solid var(--forest);
outline-offset: 2px;
}
.pin-input {
letter-spacing: 8px;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
}
/* QR Code Section */
.qr-section {
padding: 40px 20px;
text-align: center;
}
#qrcode {
display: inline-block;
padding: 20px;
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px var(--shadow);
margin-bottom: 20px;
}
#qrcode img {
display: block;
}
.qr-url {
font-size: 0.85rem;
color: var(--soft-gray);
word-break: break-all;
margin-bottom: 20px;
}
.stats-bar {
display: flex;
justify-content: center;
gap: 24px;
padding: 16px 20px;
background: var(--forest);
color: white;
}
.stat {
text-align: center;
}
.stat-number {
font-family: 'Fraunces', serif;
font-size: 1.5rem;
font-weight: 700;
}
.stat-label {
font-size: 0.75rem;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-clickable {
cursor: pointer;
transition: transform 0.1s ease;
}
.stat-clickable:hover {
transform: scale(1.05);
}
.stat-clickable:active {
transform: scale(0.95);
}
.lock-indicator {
position: fixed;
top: 20px;
right: 20px;
padding: 8px 14px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
z-index: 50;
}
.lock-indicator.locked {
background: var(--cream);
color: var(--soft-gray);
border: 1px solid var(--border);
}
.lock-indicator.unlocked {
background: var(--forest);
color: white;
}
.container-legend {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 16px 20px;
background: var(--warm-white);
border-bottom: 1px solid var(--border);
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--soft-gray);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.15s ease;
}
.legend-item:hover {
background: var(--oak-light);
color: white;
}
.legend-item.active {
background: var(--forest);
color: white;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(253, 252, 250, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--forest);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-banner {
background: var(--terracotta);
color: white;
padding: 12px 20px;
text-align: center;
font-size: 0.9rem;
}
/* Accessibility: visually hidden but available to screen readers */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.toast {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: var(--charcoal);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 0.9rem;
z-index: 3000;
opacity: 0;
transition: opacity 0.3s ease;
}
.toast.show {
opacity: 1;
}
@media (max-width: 480px) {
h1 {
font-size: 1.8rem;
}
.container-legend {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="mainContent">
<a href="#inventoryList" class="visually-hidden" id="skipLink" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" onfocus="this.style.cssText='position:fixed;top:10px;left:10px;z-index:9999;background:var(--forest);color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:500;';" onblur="this.style.cssText='position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;';">Skip to inventory</a>
<div class="loading-overlay" id="loadingOverlay" role="status" aria-live="polite" aria-label="Loading">
<div class="loading-spinner" aria-hidden="true"></div>
</div>
<div class="error-banner" id="errorBanner" style="display: none;"></div>
<header>
<h1>🫙 Pantry</h1>
<p class="subtitle">Kitchen Inventory Tracker</p>
</header>
<div class="stats-bar">
<div class="stat">
<div class="stat-number" id="totalCount">0</div>
<div class="stat-label">Total Items</div>
</div>
<div class="stat">
<div class="stat-number" id="inStockCount">0</div>
<div class="stat-label">In Stock</div>
</div>
<div class="stat stat-clickable" id="outStat" onclick="copyShoppingList()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();copyShoppingList();}" tabindex="0" role="button" aria-label="Copy out of stock items to clipboard" title="Tap to copy shopping list">
<div class="stat-number" id="outCount">0</div>
<div class="stat-label">Out of Stock 📋</div>
</div>
</div>
<div class="search-section">
<div class="search-wrapper">
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<label for="searchInput" class="visually-hidden">Search ingredients</label>
<input type="text" class="search-input" id="searchInput" placeholder="Search ingredients..." aria-label="Search ingredients">
</div>
</div>
<div class="nav-tabs" id="navTabs" role="tablist" aria-label="Inventory filters">
<button class="nav-tab active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="nav-tab" data-filter="in-stock" role="tab" aria-selected="false">In Stock</button>
<button class="nav-tab" data-filter="out" role="tab" aria-selected="false">Out of Stock</button>
<!-- Category tabs inserted dynamically here -->
<button class="nav-tab" data-filter="recent" role="tab" aria-selected="false">Recent</button>
<button class="nav-tab" data-filter="qr" role="tab" aria-selected="false">QR Code</button>
</div>
<div class="container-legend" id="containerLegend"></div>
<div class="inventory-list" id="inventoryList"></div>
<div class="qr-section" id="qrSection" style="display: none;">
<h2 style="font-family: 'Fraunces', serif; color: var(--forest); margin-bottom: 20px;">Scan to Access Pantry</h2>
<div id="qrcode"></div>
<p class="qr-url" id="qrUrl"></p>
<p style="font-size: 0.9rem; color: var(--soft-gray);">Print this QR code and place it on your pantry shelf for quick access.</p>
</div>
<button class="lock-indicator locked" id="lockIndicator" onclick="toggleLock()" aria-label="Toggle edit mode">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<span id="lockText">View Only</span>
</button>
<button class="fab locked" id="fab" onclick="openAddModal()" aria-label="Add new item">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</div><!-- end mainContent -->
<!-- PIN Modal -->
<div class="modal-overlay" id="pinModal" role="dialog" aria-modal="true" aria-labelledby="pinModalTitle">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="pinModalTitle">Enter PIN to Edit</h3>
<button class="modal-close" onclick="closeModal('pinModal')" aria-label="Close dialog">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="pinInput">4-Digit PIN</label>
<input type="password" class="form-input pin-input" id="pinInput" maxlength="4" placeholder="••••" inputmode="numeric" aria-describedby="pinHelpText" onkeydown="if(event.key==='Enter'){event.preventDefault();verifyPin();}">
</div>
<button class="btn btn-primary" onclick="verifyPin()">Unlock</button>
<p id="pinHelpText" style="text-align: center; margin-top: 16px; font-size: 0.85rem; color: var(--soft-gray);"></p>
</div>
</div>
</div>
<!-- Set PIN Modal -->
<div class="modal-overlay" id="setPinModal" role="dialog" aria-modal="true" aria-labelledby="setPinModalTitle">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="setPinModalTitle">Set Edit PIN</h3>
<button class="modal-close" onclick="closeModal('setPinModal')" aria-label="Close dialog">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 20px; color: var(--soft-gray); font-size: 0.9rem;">
Set a PIN that your household will use to edit the inventory. Anyone can view without the PIN.
</p>
<div class="form-group">
<label class="form-label" for="newPinInput">New 4-Digit PIN</label>
<input type="password" class="form-input pin-input" id="newPinInput" maxlength="4" placeholder="••••" inputmode="numeric">
</div>
<div class="form-group">
<label class="form-label" for="confirmPinInput">Confirm PIN</label>
<input type="password" class="form-input pin-input" id="confirmPinInput" maxlength="4" placeholder="••••" inputmode="numeric" onkeydown="if(event.key==='Enter'){event.preventDefault();setPin();}">
</div>
<button class="btn btn-primary" onclick="setPin()">Save PIN</button>
</div>
</div>
</div>
<!-- Add/Edit Item Modal -->
<div class="modal-overlay" id="itemModal" role="dialog" aria-modal="true" aria-labelledby="itemModalTitle">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="itemModalTitle">Add Item</h3>
<button class="modal-close" onclick="closeModal('itemModal')" aria-label="Close dialog">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="itemName">Item Name</label>
<input type="text" class="form-input" id="itemName" placeholder="e.g., All Purpose Flour" onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem();}">
</div>
<div class="form-group">
<label class="form-label" for="itemContainer">Container Type</label>
<select class="form-select" id="itemContainer">
<!-- Options loaded from containers.json -->
</select>
</div>
<div class="form-group">
<label class="form-label" for="itemCategory">Category</label>
<select class="form-select" id="itemCategory">
<!-- Options loaded from categories.json -->
</select>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="itemOutOfStock">
<label for="itemOutOfStock">Out of Stock</label>
</div>
</div>
<input type="hidden" id="editingItemId">
<button class="btn btn-primary" onclick="saveItem()">Save Item</button>
<button class="btn btn-danger" id="deleteBtn" style="display: none;" onclick="deleteItem()">Delete Item</button>
</div>
</div>
</div>
<script>
const API_URL = 'api.php';
// Container types - loaded from containers.json
let containerTypes = {};
let containerCategories = [];
// Item categories - loaded from categories.json
let itemCategories = {};
let inventory = [];
let isUnlocked = false;
let currentPin = null;
let currentFilter = 'all';
let containerFilter = null;
let categoryFilter = null;
let hasPinSet = false;
// API calls
async function apiCall(action, data = {}) {
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...data })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
showError('Connection error. Please try again.');
throw error;
}
}
function showError(message) {
const banner = document.getElementById('errorBanner');
banner.textContent = message;
banner.style.display = 'block';
setTimeout(() => banner.style.display = 'none', 5000);
}
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
function hideLoading() {
document.getElementById('loadingOverlay').style.display = 'none';
}
function showLoading() {
document.getElementById('loadingOverlay').style.display = 'flex';
}
// Load container types from JSON file
async function loadContainers() {
try {
const response = await fetch('containers.json');
const data = await response.json();
containerCategories = data.categories;
// Build flat lookup object
containerCategories.forEach(cat => {
cat.containers.forEach(c => {
containerTypes[c.id] = { name: c.name, color: c.color, category: cat.name };
});
});
// Build the dropdown
buildContainerDropdown();
} catch (error) {
console.error('Error loading containers:', error);
}
}
// Build the container dropdown with optgroups
function buildContainerDropdown() {
const select = document.getElementById('itemContainer');
select.replaceChildren();
containerCategories.forEach(cat => {
const optgroup = document.createElement('optgroup');
optgroup.label = cat.name;
cat.containers.forEach(c => {
const option = document.createElement('option');
option.value = c.id;
option.textContent = c.name;
optgroup.appendChild(option);
});
select.appendChild(optgroup);
});
}
// Load item categories from JSON file
async function loadCategories() {
try {
const response = await fetch('categories.json');
itemCategories = await response.json();
buildCategoryDropdown();
buildCategoryTabs();
} catch (error) {
console.error('Error loading categories:', error);
}
}
// Build the category dropdown
function buildCategoryDropdown() {
const select = document.getElementById('itemCategory');
select.replaceChildren();
Object.entries(itemCategories).forEach(([key, cat]) => {
const option = document.createElement('option');
option.value = key;
option.textContent = cat.name;
select.appendChild(option);
});
}
// Build category filter tabs
function buildCategoryTabs() {
const navTabs = document.getElementById('navTabs');
// Remove old category tabs if they exist
navTabs.querySelectorAll('.nav-tab[data-category]').forEach(tab => tab.remove());
// Add category tabs after "Out of Stock" tab, sorted alphabetically
const outTab = navTabs.querySelector('[data-filter="out"]');
const sortedCategories = Object.entries(itemCategories)
.sort((a, b) => a[1].name.localeCompare(b[1].name))
.reverse(); // Reverse so insertAdjacentElement builds correct order
sortedCategories.forEach(([key, cat]) => {
const tab = document.createElement('button');
tab.className = 'nav-tab';
tab.dataset.category = key;
tab.textContent = cat.name;
tab.style.borderLeft = `3px solid ${cat.color}`;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.addEventListener('click', () => {
// Clear other active states and aria-selected
navTabs.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
currentFilter = 'category';
categoryFilter = key;
document.getElementById('inventoryList').style.display = 'block';
document.getElementById('containerLegend').style.display = 'grid';
document.getElementById('qrSection').style.display = 'none';
renderInventory();
});
outTab.insertAdjacentElement('afterend', tab);
});
}
// Initialize
async function init() {
try {
await loadContainers();
await loadCategories();
const result = await apiCall('get');
inventory = result.inventory || [];
hasPinSet = result.hasPin || false;
renderContainerLegend();
renderInventory();
updateStats();
generateQRCode();
setupEventListeners();
// Update PIN help text
updatePinHelpText();
} catch (error) {
console.error('Init error:', error);
} finally {
hideLoading();
}
}
function updatePinHelpText() {
const helpText = document.getElementById('pinHelpText');
if (!hasPinSet) {
helpText.innerHTML = 'No PIN set yet. <a href="#" onclick="openSetPinModal(); return false;" style="color: var(--terracotta);">Set one now</a>';
} else {
helpText.innerHTML = '';
}
}
function renderContainerLegend() {
const legend = document.getElementById('containerLegend');
legend.replaceChildren();
// Count items per container type
const counts = {};
inventory.forEach(item => {
counts[item.container] = (counts[item.container] || 0) + 1;
});
Object.entries(containerTypes).forEach(([key, value]) => {
const count = counts[key] || 0;
const item = document.createElement('div');
item.className = 'legend-item' + (containerFilter === key ? ' active' : '');
item.tabIndex = 0;
item.setAttribute('role', 'button');
item.setAttribute('aria-pressed', containerFilter === key ? 'true' : 'false');
item.setAttribute('aria-label', `Filter by ${value.category}: ${value.name}, ${count} items`);
const toggleFilter = () => {
containerFilter = containerFilter === key ? null : key;
renderContainerLegend();
renderInventory();
};
item.onclick = toggleFilter;
item.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleFilter();
}
};
const dot = document.createElement('div');
dot.className = 'container-dot';
dot.style.background = value.color;
dot.setAttribute('aria-hidden', 'true');
const span = document.createElement('span');
span.textContent = `${value.category}: ${value.name} (${count})`;
item.appendChild(dot);
item.appendChild(span);
legend.appendChild(item);
});
}
function renderInventory() {
const list = document.getElementById('inventoryList');
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
let filtered = inventory.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm);
const matchesFilter = currentFilter === 'all' ||
currentFilter === 'recent' ||
(currentFilter === 'in-stock' && !item.outOfStock) ||
(currentFilter === 'out' && item.outOfStock) ||
(currentFilter === 'category' && item.category === categoryFilter);
const matchesContainer = !containerFilter || item.container === containerFilter;
return matchesSearch && matchesFilter && matchesContainer;
});
// Sort based on filter
if (currentFilter === 'recent') {
// Sort by ID descending (newest first) and limit to 10
filtered.sort((a, b) => b.id - a.id);
filtered = filtered.slice(0, 10);
} else {
// Sort: in-stock first, then alphabetically
filtered.sort((a, b) => {
if (a.outOfStock !== b.outOfStock) return a.outOfStock ? 1 : -1;
return a.name.localeCompare(b.name);
});
}
if (filtered.length === 0) {
list.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p>No items found</p>
</div>
`;
return;
}
list.innerHTML = filtered.map(item => {
const cat = itemCategories[item.category];
return `
<div class="inventory-item ${item.outOfStock ? 'out-of-stock' : ''}" onclick="openEditModal(${item.id})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openEditModal(${item.id});}" tabindex="0" role="button" aria-label="${escapeHtml(item.name)}, ${cat ? cat.name : 'uncategorized'}, ${item.outOfStock ? 'out of stock' : 'in stock'}">
<div class="item-info">
<div class="item-name">${escapeHtml(item.name)}</div>
<div class="item-container">
<div class="container-dot" style="background: ${containerTypes[item.container]?.color || '#ccc'}" aria-hidden="true"></div>
${containerTypes[item.container] ? `${containerTypes[item.container].category}: ${containerTypes[item.container].name}` : 'Unknown'}
${cat ? `<span class="category-tag" style="background: ${cat.color}20; color: ${cat.color}; border: 1px solid ${cat.color}40;">${cat.name}</span>` : ''}
</div>
</div>
<span class="status-badge ${item.outOfStock ? 'status-out' : 'status-in-stock'}">
${item.outOfStock ? 'Out' : 'In Stock'}
</span>
</div>
`}).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function normalizeName(name) {
return name
.toLowerCase()
.replace(/[^\w\s]/g, '') // Remove punctuation
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
function findDuplicates(name, excludeId = null) {
const normalized = normalizeName(name);
return inventory.filter(item => {
if (excludeId && item.id === excludeId) return false;
return normalizeName(item.name) === normalized;
});
}
function updateStats() {
const total = inventory.length;
const inStock = inventory.filter(i => !i.outOfStock).length;
const out = inventory.filter(i => i.outOfStock).length;
document.getElementById('totalCount').textContent = total;
document.getElementById('inStockCount').textContent = inStock;
document.getElementById('outCount').textContent = out;
renderContainerLegend();
}
function copyShoppingList() {
const outOfStock = inventory
.filter(i => i.outOfStock)
.map(i => i.name)
.sort()
.join('\n');
if (!outOfStock) {
showToast('Nothing out of stock!');
return;
}
navigator.clipboard.writeText(outOfStock).then(() => {
showToast('Shopping list copied!');
}).catch(() => {
showToast('Could not copy to clipboard');
});
}
function generateQRCode() {
const qrContainer = document.getElementById('qrcode');
const url = window.location.href.split('?')[0];
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: url,
width: 200,
height: 200,
colorDark: '#2D4739',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
document.getElementById('qrUrl').textContent = url;
}
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', renderInventory);
document.querySelectorAll('.nav-tab[data-filter]').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
const filter = tab.dataset.filter;
currentFilter = filter;
categoryFilter = null; // Clear category filter when switching to non-category tabs
if (filter === 'qr') {
document.getElementById('inventoryList').style.display = 'none';
document.getElementById('containerLegend').style.display = 'none';
document.getElementById('qrSection').style.display = 'block';
} else {
document.getElementById('inventoryList').style.display = 'block';
document.getElementById('containerLegend').style.display = 'grid';
document.getElementById('qrSection').style.display = 'none';
renderInventory();
}
});
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal(modal.id);
});
});
// Enter key for PIN inputs
document.getElementById('pinInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') verifyPin();
});
}
function toggleLock() {
if (isUnlocked) {
isUnlocked = false;
currentPin = null;
updateLockUI();
showToast('Locked');
} else {
openModal('pinModal');
document.getElementById('pinInput').focus();
}
}
let previouslyFocusedElement = null;
let activeModalId = null;
function openModal(id) {
previouslyFocusedElement = document.activeElement;
activeModalId = id;
const modal = document.getElementById(id);
modal.classList.add('active');
// Make background content inert (unfocusable)
document.getElementById('mainContent').setAttribute('inert', '');
// Focus the primary input field in the modal
setTimeout(() => {
let focusTarget = null;
if (id === 'pinModal') {
focusTarget = document.getElementById('pinInput');
} else if (id === 'setPinModal') {
focusTarget = document.getElementById('newPinInput');
} else if (id === 'itemModal') {
focusTarget = document.getElementById('itemName');
}
if (focusTarget) {
focusTarget.focus();
} else {
const focusable = getFocusableElements(modal);
if (focusable.length > 0) {
focusable[0].focus();
}
}
}, 50);
// Add event listener for Escape key
document.addEventListener('keydown', handleModalKeydown);
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
document.removeEventListener('keydown', handleModalKeydown);
activeModalId = null;
// Remove inert from background content
document.getElementById('mainContent').removeAttribute('inert');
// Restore focus to previously focused element
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
previouslyFocusedElement = null;
}
}
function getFocusableElements(container) {
const selector = 'button, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])';
const elements = Array.from(container.querySelectorAll(selector));
return elements.filter(el => {
if (el.disabled) return false;
// Check if element is visible
if (el.offsetWidth === 0 && el.offsetHeight === 0) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
return true;
});
}
function handleModalKeydown(e) {
if (!activeModalId) return;
const modal = document.getElementById(activeModalId);
// Close on Escape
if (e.key === 'Escape') {
e.preventDefault();
closeModal(activeModalId);
return;
}
// Handle all Tab navigation manually
if (e.key === 'Tab') {
e.preventDefault();
const focusable = getFocusableElements(modal);
if (focusable.length === 0) return;
const currentIndex = focusable.indexOf(document.activeElement);
let nextIndex;
if (e.shiftKey) {
nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1;
}
focusable[nextIndex].focus();
}
}
async function verifyPin() {
const pin = document.getElementById('pinInput').value;
if (!hasPinSet) {
closeModal('pinModal');
openSetPinModal();
return;
}
try {
const result = await apiCall('verifyPin', { pin });
if (result.success) {
isUnlocked = true;
currentPin = pin;
updateLockUI();
closeModal('pinModal');
document.getElementById('pinInput').value = '';
showToast('Unlocked');
} else {
showToast('Incorrect PIN');
document.getElementById('pinInput').value = '';
}
} catch (error) {
console.error('PIN verify error:', error);
}
}
function openSetPinModal() {
closeModal('pinModal');
openModal('setPinModal');
document.getElementById('newPinInput').focus();
}
async function setPin() {
const newPin = document.getElementById('newPinInput').value;
const confirmPin = document.getElementById('confirmPinInput').value;
if (newPin.length !== 4 || !/^\d+$/.test(newPin)) {
showToast('PIN must be 4 digits');
return;
}
if (newPin !== confirmPin) {
showToast('PINs do not match');
return;
}
try {
const result = await apiCall('setPin', { pin: newPin });
if (result.success) {
hasPinSet = true;
isUnlocked = true;
currentPin = newPin;
updateLockUI();
closeModal('setPinModal');
document.getElementById('newPinInput').value = '';
document.getElementById('confirmPinInput').value = '';
showToast('PIN set! You are now in edit mode.');
}
} catch (error) {
console.error('Set PIN error:', error);
}
}
function updateLockUI() {
const indicator = document.getElementById('lockIndicator');
const fab = document.getElementById('fab');
const lockText = document.getElementById('lockText');
if (isUnlocked) {
indicator.classList.remove('locked');
indicator.classList.add('unlocked');
fab.classList.remove('locked');
lockText.textContent = 'Edit Mode';
} else {
indicator.classList.remove('unlocked');
indicator.classList.add('locked');
fab.classList.add('locked');
lockText.textContent = 'View Only';
}
}
function openAddModal() {
if (!isUnlocked) {
toggleLock();
return;
}
document.getElementById('itemModalTitle').textContent = 'Add Item';
document.getElementById('itemName').value = '';
document.getElementById('itemContainer').selectedIndex = 0;
document.getElementById('itemCategory').value = 'other';
document.getElementById('itemOutOfStock').checked = false;
document.getElementById('editingItemId').value = '';
document.getElementById('deleteBtn').style.display = 'none';
openModal('itemModal');
document.getElementById('itemName').focus();
}
function openEditModal(id) {
if (!isUnlocked) {
return; // View only mode
}
const item = inventory.find(i => i.id === id);
if (!item) return;
document.getElementById('itemModalTitle').textContent = 'Edit Item';
document.getElementById('itemName').value = item.name;
document.getElementById('itemContainer').value = item.container;
document.getElementById('itemCategory').value = item.category || 'other';
document.getElementById('itemOutOfStock').checked = item.outOfStock;
document.getElementById('editingItemId').value = id;
document.getElementById('deleteBtn').style.display = 'block';
openModal('itemModal');
}
async function saveItem() {
const name = document.getElementById('itemName').value.trim();
const container = document.getElementById('itemContainer').value;
const category = document.getElementById('itemCategory').value;
const outOfStock = document.getElementById('itemOutOfStock').checked;
const editingId = document.getElementById('editingItemId').value;
if (!name) {
showToast('Please enter an item name');
return;
}
// Check for duplicates when adding new items
if (!editingId) {
const duplicates = findDuplicates(name);
if (duplicates.length > 0) {
const dupNames = duplicates.map(d => d.name).join(', ');
if (!confirm(`Similar item exists: "${dupNames}"\n\nAdd anyway?`)) {
return;
}
}
}
showLoading();
try {
if (editingId) {
// Update existing
await apiCall('update', {
id: parseInt(editingId),
name,
container,
category,
outOfStock,
pin: currentPin
});
const item = inventory.find(i => i.id === parseInt(editingId));
if (item) {
item.name = name;
item.container = container;
item.category = category;
item.outOfStock = outOfStock;
}
showToast('Item updated');
} else {
// Add new
const result = await apiCall('add', { name, container, category, outOfStock, pin: currentPin });
if (result.success && result.item) {
inventory.push(result.item);
}
showToast('Item added');
}
renderInventory();
updateStats();
closeModal('itemModal');
} catch (error) {
console.error('Save error:', error);
} finally {
hideLoading();
}
}
async function deleteItem() {
const editingId = document.getElementById('editingItemId').value;
if (!editingId) return;
if (!confirm('Are you sure you want to delete this item?')) return;
showLoading();
try {
await apiCall('delete', { id: parseInt(editingId), pin: currentPin });
inventory = inventory.filter(i => i.id !== parseInt(editingId));
renderInventory();
updateStats();
closeModal('itemModal');
showToast('Item deleted');
} catch (error) {
console.error('Delete error:', error);
} finally {
hideLoading();
}
}
// Initialize on load
init();
</script>
</body>
</html>