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>
324 lines
10 KiB
PHP
324 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* Pantry Inventory API
|
|
*
|
|
* Simple JSON file-based backend for the pantry inventory app.
|
|
* No database required - stores everything in inventory.json
|
|
*/
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
// CORS - restrict to same origin (remove for local development if needed)
|
|
$allowedOrigins = [
|
|
'https://pantry.kestrelsnest.social',
|
|
'http://localhost:8000'
|
|
];
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
if (in_array($origin, $allowedOrigins)) {
|
|
header("Access-Control-Allow-Origin: $origin");
|
|
}
|
|
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type');
|
|
|
|
// Handle preflight requests
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(200);
|
|
exit();
|
|
}
|
|
|
|
// File paths
|
|
define('DATA_FILE', __DIR__ . '/data/inventory.json');
|
|
define('PIN_FILE', __DIR__ . '/data/pin.txt');
|
|
define('DATA_DIR', __DIR__ . '/data');
|
|
|
|
// Ensure data directory exists
|
|
if (!is_dir(DATA_DIR)) {
|
|
mkdir(DATA_DIR, 0755, true);
|
|
}
|
|
|
|
// Default inventory (sample data using new container types)
|
|
$defaultInventory = [
|
|
['id' => 1, 'name' => 'Bulgur', 'container' => 'glass-flip-med-tall', 'outOfStock' => false],
|
|
['id' => 2, 'name' => 'Wild Rice Flour', 'container' => 'glass-flip-med-squat', 'outOfStock' => false],
|
|
['id' => 3, 'name' => 'Self Rising Flour', 'container' => 'plastic-oxo-large', 'outOfStock' => false],
|
|
['id' => 4, 'name' => 'Whole Wheat Flour', 'container' => 'plastic-oxo-large', 'outOfStock' => false],
|
|
['id' => 5, 'name' => 'Cake Flour', 'container' => 'plastic-clamp-large', 'outOfStock' => false],
|
|
['id' => 6, 'name' => 'GF Pancake Mix', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 7, 'name' => 'Coconut Flour', 'container' => 'glass-flip-small', 'outOfStock' => false],
|
|
['id' => 8, 'name' => 'All Purpose Flour', 'container' => 'plastic-oxo-large', 'outOfStock' => false],
|
|
['id' => 9, 'name' => 'Bread Flour', 'container' => 'plastic-clamp-tall', 'outOfStock' => false],
|
|
['id' => 10, 'name' => 'Powdered Sugar', 'container' => 'plastic-flip', 'outOfStock' => false],
|
|
['id' => 11, 'name' => 'Linguine', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 12, 'name' => 'Fettuccine', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 13, 'name' => 'Penne Rigate', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 14, 'name' => 'Ancho Chile', 'container' => 'glass-flip-large', 'outOfStock' => false],
|
|
['id' => 15, 'name' => 'Chocolate Chips', 'container' => 'glass-canning-quart', 'outOfStock' => false],
|
|
['id' => 16, 'name' => 'Dried Mushrooms', 'container' => 'glass-canning-pint', 'outOfStock' => false],
|
|
['id' => 17, 'name' => 'Bay Leaves', 'container' => 'spice-hex-large', 'outOfStock' => false],
|
|
['id' => 18, 'name' => 'Dates', 'container' => 'glass-canning-quart', 'outOfStock' => false],
|
|
['id' => 19, 'name' => 'Xanthan Gum', 'container' => 'spice-flip-tiny', 'outOfStock' => false],
|
|
['id' => 20, 'name' => 'M.S.G.', 'container' => 'spice-hex-small', 'outOfStock' => false],
|
|
['id' => 21, 'name' => 'Potato Starch', 'container' => 'glass-flip-small', 'outOfStock' => false],
|
|
['id' => 22, 'name' => 'Potato Flour', 'container' => 'glass-flip-small', 'outOfStock' => false],
|
|
['id' => 23, 'name' => 'Garbanzo Flour', 'container' => 'glass-flip-med-squat', 'outOfStock' => false],
|
|
['id' => 24, 'name' => 'Mochi Rice Flour', 'container' => 'glass-flip-small', 'outOfStock' => false],
|
|
['id' => 25, 'name' => 'Couscous', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 26, 'name' => 'Granola', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 27, 'name' => 'Cheerios', 'container' => 'original-package', 'outOfStock' => false],
|
|
['id' => 28, 'name' => 'Baking Powder', 'container' => 'spice-hex-large', 'outOfStock' => false],
|
|
['id' => 29, 'name' => 'Corn Starch', 'container' => 'glass-canning-small', 'outOfStock' => false],
|
|
['id' => 30, 'name' => 'Cocoa Powder', 'container' => 'spice-hex-large', 'outOfStock' => false],
|
|
];
|
|
|
|
/**
|
|
* Load inventory from file
|
|
*/
|
|
function loadInventory() {
|
|
global $defaultInventory;
|
|
|
|
if (file_exists(DATA_FILE)) {
|
|
$content = file_get_contents(DATA_FILE);
|
|
$data = json_decode($content, true);
|
|
if ($data !== null) {
|
|
return $data;
|
|
}
|
|
}
|
|
|
|
// Initialize with default inventory
|
|
saveInventory($defaultInventory);
|
|
return $defaultInventory;
|
|
}
|
|
|
|
/**
|
|
* Save inventory to file
|
|
*/
|
|
function saveInventory($inventory) {
|
|
file_put_contents(DATA_FILE, json_encode($inventory, JSON_PRETTY_PRINT));
|
|
}
|
|
|
|
/**
|
|
* Check if PIN is set
|
|
*/
|
|
function hasPinSet() {
|
|
return file_exists(PIN_FILE) && strlen(trim(file_get_contents(PIN_FILE))) === 4;
|
|
}
|
|
|
|
/**
|
|
* Get stored PIN
|
|
*/
|
|
function getPin() {
|
|
if (file_exists(PIN_FILE)) {
|
|
return trim(file_get_contents(PIN_FILE));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set PIN
|
|
*/
|
|
function setPin($pin) {
|
|
file_put_contents(PIN_FILE, $pin);
|
|
// Secure the file
|
|
chmod(PIN_FILE, 0600);
|
|
}
|
|
|
|
/**
|
|
* Verify PIN
|
|
*/
|
|
function verifyPin($pin) {
|
|
$storedPin = getPin();
|
|
return $storedPin !== null && $storedPin === $pin;
|
|
}
|
|
|
|
/**
|
|
* Get next available ID
|
|
*/
|
|
function getNextId($inventory) {
|
|
if (empty($inventory)) {
|
|
return 1;
|
|
}
|
|
$maxId = max(array_column($inventory, 'id'));
|
|
return $maxId + 1;
|
|
}
|
|
|
|
/**
|
|
* Sanitize string input
|
|
*/
|
|
function sanitize($str, $maxLength = 200) {
|
|
$str = trim($str);
|
|
if (strlen($str) > $maxLength) {
|
|
$str = substr($str, 0, $maxLength);
|
|
}
|
|
return $str;
|
|
}
|
|
|
|
/**
|
|
* Check if PIN is required and valid for write operations
|
|
*/
|
|
function requirePin($input) {
|
|
// If no PIN is set yet, allow writes (first-time setup)
|
|
if (!hasPinSet()) {
|
|
return true;
|
|
}
|
|
|
|
// PIN is set, so require it for write operations
|
|
$pin = $input['pin'] ?? '';
|
|
return verifyPin($pin);
|
|
}
|
|
|
|
// Main request handling
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
if (!$input || !isset($input['action'])) {
|
|
// Allow GET requests to view inventory
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
$inventory = loadInventory();
|
|
echo json_encode([
|
|
'success' => true,
|
|
'inventory' => $inventory,
|
|
'hasPin' => hasPinSet()
|
|
]);
|
|
exit();
|
|
}
|
|
|
|
echo json_encode(['error' => 'Invalid request']);
|
|
exit();
|
|
}
|
|
|
|
$action = $input['action'];
|
|
|
|
switch ($action) {
|
|
case 'get':
|
|
$inventory = loadInventory();
|
|
echo json_encode([
|
|
'success' => true,
|
|
'inventory' => $inventory,
|
|
'hasPin' => hasPinSet()
|
|
]);
|
|
break;
|
|
|
|
case 'setPin':
|
|
$pin = $input['pin'] ?? '';
|
|
|
|
if (strlen($pin) !== 4 || !ctype_digit($pin)) {
|
|
echo json_encode(['success' => false, 'error' => 'PIN must be 4 digits']);
|
|
break;
|
|
}
|
|
|
|
// Only allow setting PIN if not already set
|
|
if (hasPinSet()) {
|
|
echo json_encode(['success' => false, 'error' => 'PIN already set']);
|
|
break;
|
|
}
|
|
|
|
setPin($pin);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'verifyPin':
|
|
$pin = $input['pin'] ?? '';
|
|
$valid = verifyPin($pin);
|
|
echo json_encode(['success' => $valid]);
|
|
break;
|
|
|
|
case 'add':
|
|
if (!requirePin($input)) {
|
|
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
|
|
break;
|
|
}
|
|
|
|
$name = sanitize($input['name'] ?? '');
|
|
$container = sanitize($input['container'] ?? 'glass-bail-medium', 50);
|
|
$category = sanitize($input['category'] ?? 'other', 50);
|
|
$outOfStock = (bool)($input['outOfStock'] ?? false);
|
|
|
|
if (empty($name)) {
|
|
echo json_encode(['success' => false, 'error' => 'Name required']);
|
|
break;
|
|
}
|
|
|
|
$inventory = loadInventory();
|
|
$newItem = [
|
|
'id' => getNextId($inventory),
|
|
'name' => $name,
|
|
'container' => $container,
|
|
'category' => $category,
|
|
'outOfStock' => $outOfStock
|
|
];
|
|
|
|
$inventory[] = $newItem;
|
|
saveInventory($inventory);
|
|
|
|
echo json_encode(['success' => true, 'item' => $newItem]);
|
|
break;
|
|
|
|
case 'update':
|
|
if (!requirePin($input)) {
|
|
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
|
|
break;
|
|
}
|
|
|
|
$id = (int)($input['id'] ?? 0);
|
|
$name = sanitize($input['name'] ?? '');
|
|
$container = sanitize($input['container'] ?? '', 50);
|
|
$category = sanitize($input['category'] ?? '', 50);
|
|
$outOfStock = (bool)($input['outOfStock'] ?? false);
|
|
|
|
if ($id <= 0) {
|
|
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
|
|
break;
|
|
}
|
|
|
|
$inventory = loadInventory();
|
|
$found = false;
|
|
|
|
foreach ($inventory as &$item) {
|
|
if ($item['id'] === $id) {
|
|
if (!empty($name)) $item['name'] = $name;
|
|
if (!empty($container)) $item['container'] = $container;
|
|
if (!empty($category)) $item['category'] = $category;
|
|
$item['outOfStock'] = $outOfStock;
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$found) {
|
|
echo json_encode(['success' => false, 'error' => 'Item not found']);
|
|
break;
|
|
}
|
|
|
|
saveInventory($inventory);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
case 'delete':
|
|
if (!requirePin($input)) {
|
|
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
|
|
break;
|
|
}
|
|
|
|
$id = (int)($input['id'] ?? 0);
|
|
|
|
if ($id <= 0) {
|
|
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
|
|
break;
|
|
}
|
|
|
|
$inventory = loadInventory();
|
|
$originalCount = count($inventory);
|
|
$inventory = array_values(array_filter($inventory, fn($item) => $item['id'] !== $id));
|
|
|
|
if (count($inventory) === $originalCount) {
|
|
echo json_encode(['success' => false, 'error' => 'Item not found']);
|
|
break;
|
|
}
|
|
|
|
saveInventory($inventory);
|
|
echo json_encode(['success' => true]);
|
|
break;
|
|
|
|
default:
|
|
echo json_encode(['error' => 'Unknown action']);
|
|
}
|