Initial commit: Pantry inventory tracker

A simple web app for tracking kitchen pantry items with:
- Search and filter by stock status, spices, or container type
- PIN-protected editing
- Shopping list export (tap Out of Stock to copy)
- QR code for quick mobile access
- OpenGraph card for social sharing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2025-12-23 22:20:27 -05:00
commit 726325e501
9 changed files with 1842 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# User data
data/
# macOS
.DS_Store
# Editor files
*.swp
*.swo
*~
# Claude Code local settings
.claude/
# Archives
*.zip

50
CLAUDE.md Normal file
View File

@@ -0,0 +1,50 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Pantry is a simple web app for tracking kitchen pantry items. It's a single-page application with no build step, consisting of just two files:
- `index.html` - Frontend (HTML/CSS/JS all in one file)
- `api.php` - Backend API (JSON file-based storage)
## Architecture
**Frontend (`index.html`)**:
- Vanilla JavaScript, no frameworks
- Uses QRCode.js from CDN for QR code generation
- Custom CSS with CSS variables for theming (earthy color palette: cream, terracotta, forest green)
- Container types defined in `containerTypes` object (around line 754) - must be kept in sync with backend
- All API calls go through the `apiCall()` function which POSTs to `api.php`
**Backend (`api.php`)**:
- JSON file-based storage in `data/` directory
- Endpoints: `get`, `add`, `update`, `delete`, `setPin`, `verifyPin`
- Default inventory defined in `$defaultInventory` array (lines 31-64)
- Container types should match frontend when modified
**Data Storage**:
- `data/inventory.json` - Inventory items
- `data/pin.txt` - 4-digit PIN for edit mode (plain text, view-only protection)
## Development
This is a no-build project. To test locally:
```bash
php -S localhost:8000
```
Then open `http://localhost:8000` in a browser.
## Key Implementation Details
- PIN system is view-only protection (anyone can view, PIN required to edit)
- PIN stored in plain text - adequate for household use only
- Items have: `id`, `name`, `container` (type), `outOfStock` (boolean)
- Frontend sorts items: in-stock first, then alphabetically
- 8 container types with color-coded dots in UI
## Deployment
Designed for YunoHost "My Webapp" deployment. Upload `index.html` and `api.php` to the `www/` folder. The `data/` directory is created automatically and must be writable by the web server.

125
README.md Normal file
View File

@@ -0,0 +1,125 @@
# Pantry Inventory App
A simple web app for tracking kitchen pantry items across your household.
## Features
- **Search** - Instantly filter ingredients by name
- **Container tracking** - 8 container types with color-coded indicators
- **Stock status** - Mark items as in-stock or out-of-stock
- **PIN protection** - Anyone can view, but editing requires a 4-digit PIN
- **QR Code** - Generate a scannable code to post on your pantry shelf
- **Shared data** - All household members see the same inventory
## Deployment on YunoHost (My Webapp)
### 1. Install My Webapp
In YunoHost admin panel:
1. Go to **Applications****Install**
2. Search for "My Webapp"
3. Configure:
- **Label**: Pantry (or whatever you prefer)
- **Domain/Path**: e.g., `yourdomain.com/pantry`
- **PHP version**: 8.2 or higher
- **Database**: None needed
4. Install
### 2. Upload Files
Connect via SFTP using the credentials shown after installation, then upload to the `www` folder:
```
www/
├── index.html (the main app)
├── api.php (backend API)
└── data/ (created automatically)
├── inventory.json
└── pin.txt
```
### 3. Set Permissions
The `data` folder needs to be writable by the web server. This should happen automatically, but if you have issues:
```bash
# SSH into your YunoHost server
cd /var/www/pantry/www # adjust path as needed
mkdir -p data
chmod 755 data
```
### 4. First Use
1. Visit your app URL (e.g., `https://yourdomain.com/pantry`)
2. The app loads with a pre-populated inventory based on your photos
3. Tap the lock icon → Set a 4-digit PIN for your household
4. Share the PIN with family members who need edit access
5. Go to the QR Code tab and print it for your pantry
## File Structure
```
index.html - Frontend (HTML/CSS/JS, no build step)
api.php - Backend API (reads/writes JSON files)
data/
inventory.json - Your inventory data
pin.txt - Stored PIN (hashed would be better for production)
```
## Container Types
The app supports these container types (edit in both index.html and api.php if you want to change them):
| ID | Name |
|----|------|
| glass-bail-large | Glass Bail Jar (Large) |
| glass-bail-medium | Glass Bail Jar (Medium) |
| glass-bail-small | Glass Bail Jar (Small) |
| oxo-large | OXO Container (Large) |
| oxo-medium | OXO Container (Medium) |
| oxo-small | OXO Container (Small) |
| original-package | Original Package |
| mason-jar | Mason Jar |
## Customization
### Adding Container Types
1. In `index.html`, find the `containerTypes` object and add your new type
2. In the `<select>` dropdown for containers, add a new `<option>`
### Changing the Default Inventory
Edit the `$defaultInventory` array in `api.php`. This only applies on first run (before `inventory.json` exists).
### Resetting Everything
Delete the `data` folder contents to start fresh:
```bash
rm data/inventory.json data/pin.txt
```
## Security Notes
- The PIN is stored in plain text. This is fine for a household app where you just want to prevent accidental edits, but don't use this for anything sensitive.
- There's no authentication beyond the PIN - anyone with the URL can view your inventory.
- For a more secure setup, you could put the app behind YunoHost's SSO.
## Backup
YunoHost's My Webapp will backup the entire `www` folder including `data/`. Your inventory and PIN are automatically included in YunoHost backups.
## Troubleshooting
**"Connection error" message**
- Check that PHP is enabled for your My Webapp instance
- Verify the `data` folder is writable
**PIN not working**
- Check that `data/pin.txt` exists and contains exactly 4 digits
- Try deleting `data/pin.txt` to reset
**Inventory not saving**
- Check `data/inventory.json` permissions
- Look for PHP errors in YunoHost logs

277
api.php Normal file
View File

@@ -0,0 +1,277 @@
<?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');
header('Access-Control-Allow-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) {
return trim($str);
}
// 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':
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? 'glass-bail-medium');
$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,
'outOfStock' => $outOfStock
];
$inventory[] = $newItem;
saveInventory($inventory);
echo json_encode(['success' => true, 'item' => $newItem]);
break;
case 'update':
$id = (int)($input['id'] ?? 0);
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? '');
$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;
$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':
$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']);
}

39
containers.json Normal file
View File

@@ -0,0 +1,39 @@
{
"categories": [
{
"name": "Glass Jars",
"containers": [
{ "id": "glass-flip-large", "name": "Flip Top (Large)", "color": "#C4684A" },
{ "id": "glass-flip-med-tall", "name": "Flip Top (Medium Tall)", "color": "#D4856B" },
{ "id": "glass-flip-med-squat", "name": "Flip Top (Medium Squat)", "color": "#E07B5B" },
{ "id": "glass-flip-small", "name": "Flip Top (Small)", "color": "#E8A88B" },
{ "id": "glass-canning-quart", "name": "Canning Jar (Quart)", "color": "#6B8E9B" },
{ "id": "glass-canning-pint", "name": "Canning Jar (Pint)", "color": "#7FA3B0" },
{ "id": "glass-canning-small", "name": "Canning Jar (Small)", "color": "#93B8C5" }
]
},
{
"name": "Plastic Containers",
"containers": [
{ "id": "plastic-oxo-large", "name": "OXO Large", "color": "#2D4739" },
{ "id": "plastic-flip", "name": "Flip Top", "color": "#3D5A4B" },
{ "id": "plastic-clamp-large", "name": "Clamp Top (Large)", "color": "#4D6A5B" },
{ "id": "plastic-clamp-tall", "name": "Clamp Top (Tall)", "color": "#5D7A6B" }
]
},
{
"name": "Glass Spice Jars",
"containers": [
{ "id": "spice-hex-large", "name": "Hex (Large)", "color": "#8B7355" },
{ "id": "spice-hex-small", "name": "Hex (Small)", "color": "#A08668" },
{ "id": "spice-flip-tiny", "name": "Flip Top (Tiny)", "color": "#B5997B" }
]
},
{
"name": "Other",
"containers": [
{ "id": "original-package", "name": "Original Packaging", "color": "#9A9590" }
]
}
]
}

24
deploy Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
USER=admin
HOST=social
DIR=/var/www/my_webapp__2/www
# Deploy code files
rsync -avz --no-t --no-p --delete \
--exclude 'data/' \
index.html api.php containers.json og-image.png ${HOST}:${DIR}
# Handle data files
if [ "$1" = "--reset-data" ]; then
echo "Pushing local data to server..."
rsync -avz --no-t --no-p \
data/ ${HOST}:${DIR}/data/
else
echo "Pulling data from server..."
mkdir -p data
rsync -avz --no-t --no-p \
--exclude 'pin.txt' \
${HOST}:${DIR}/data/ data/
fi
exit 0

1291
index.html Normal file

File diff suppressed because it is too large Load Diff

BIN
og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

20
og-image.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FAF8F5"/>
<stop offset="100%" style="stop-color:#F5F0E8"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg)"/>
<!-- Jar emoji as text (large) -->
<text x="600" y="280" font-size="180" text-anchor="middle" dominant-baseline="middle">🫙</text>
<!-- Title -->
<text x="600" y="420" font-family="Georgia, serif" font-size="72" font-weight="600" fill="#2D5A3D" text-anchor="middle">Pantry</text>
<!-- Subtitle -->
<text x="600" y="490" font-family="Arial, sans-serif" font-size="32" fill="#8B7355" text-anchor="middle">Kitchen Inventory Tracker</text>
</svg>

After

Width:  |  Height:  |  Size: 814 B