Files
pantry/api.php
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

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']);
}