commit 88e151f7921cee2ad57e9c3d81f8158392e451d0 Author: jonas Date: Tue Mar 10 07:06:52 2026 +0100 Initial commit: Fast Inventory app Monorepo with Express + SQLite backend and Vite + React frontend. Features: item CRUD, file uploads with thumbnails, soft delete, item duplication with file copying, autocomplete inputs, stats dashboard. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54e7887 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +server/data/ +server/uploads/ +data/ +*.db +*.db-journal +*.db-shm +*.db-wal +.env +.DS_Store +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f16667e --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Fast Inventory + +A quick-entry home inventory tool. Add items in seconds, track values, manage status, attach photos and receipts. + +## Quick Start + +```bash +npm install +npm run dev +``` + +Open http://localhost:5173 + +## Usage + +**Add an item:** Type a name in the top bar and press Enter. That's it — 1 second. + +**Enriched entry:** Name → Tab → Category (autocomplete) → Tab → Location (autocomplete) → Enter. About 3 seconds. + +**Edit details:** Click any item row to open the detail modal. Add purchase price, date, notes, photos, receipts, or change status. + +**Statuses:** Active, Lent Out, Sold, Lost, Trashed. Defaults to Active. + +**Stats:** Click the Stats tab for value breakdowns, item counts, and inflow/outflow charts. + +## Production + +```bash +npm run build +npm start +``` + +Runs on http://localhost:3001 (single server, no separate frontend process). + +Set a custom port with `PORT=8080 npm start`. + +## Tech Stack + +- **Backend:** Express + TypeScript + SQLite (better-sqlite3) +- **Frontend:** React + Vite + Tailwind CSS + Recharts +- **Storage:** SQLite database at `server/data/inventory.db`, uploads at `server/uploads/` + +## Data + +All data is local. The SQLite database and uploaded files live in the `server/` directory: + +- `server/data/inventory.db` — database +- `server/uploads/` — photos and receipts + +Back up these two paths to preserve your inventory. + +## Scripts + +| Command | Description | +|---|---| +| `npm run dev` | Start dev servers (frontend + backend) | +| `npm run build` | Build for production | +| `npm start` | Run production server | +| `npm run typecheck` | Type-check both packages | diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..b9d9ff6 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Fast Inventory + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..a43b657 --- /dev/null +++ b/client/package.json @@ -0,0 +1,25 @@ +{ + "name": "client", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "react-router-dom": "^7.1.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.0" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..9e91844 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,66 @@ +import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'; +import ItemsPage from './pages/ItemsPage'; +import StatsPage from './pages/StatsPage'; +import DeletedItemsPage from './pages/DeletedItemsPage'; +import { useTheme } from './useTheme'; + +export default function App() { + const { dark, toggle } = useTheme(); + + return ( + +
+ +
+ + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/client/src/api.ts b/client/src/api.ts new file mode 100644 index 0000000..f757ed2 --- /dev/null +++ b/client/src/api.ts @@ -0,0 +1,96 @@ +import type { Item, CreateItemBody, UpdateItemBody, StatsResponse, ItemFile } from './types'; + +const BASE = '/api'; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + if (res.status === 204) return undefined as T; + return res.json(); +} + +export const api = { + // Items + listItems(params?: Record): Promise { + const qs = params ? '?' + new URLSearchParams(params).toString() : ''; + return request(`/items${qs}`); + }, + getItem(id: string): Promise { + return request(`/items/${id}`); + }, + createItem(body: CreateItemBody): Promise { + return request('/items', { method: 'POST', body: JSON.stringify(body) }); + }, + updateItem(id: string, body: UpdateItemBody): Promise { + return request(`/items/${id}`, { method: 'PUT', body: JSON.stringify(body) }); + }, + deleteItem(id: string): Promise { + return request(`/items/${id}`, { method: 'DELETE' }); + }, + restoreItem(id: string): Promise { + return request(`/items/${id}/restore`, { method: 'POST' }); + }, + permanentDeleteItem(id: string): Promise { + return request(`/items/${id}/permanent`, { method: 'DELETE' }); + }, + duplicateItem(id: string): Promise { + return request(`/items/${id}/duplicate`, { method: 'POST' }); + }, + + bulkUpdateItems(ids: string[], updates: { category?: string; location?: string; status?: string; flagged?: number }): Promise<{ updated: number }> { + return request('/items/bulk', { method: 'PATCH', body: JSON.stringify({ ids, updates }) }); + }, + + // Suggestions + getCategories(): Promise { + return request('/suggestions/categories'); + }, + getLocations(): Promise { + return request('/suggestions/locations'); + }, + + // Files + async uploadFiles(itemId: string, files: File[], type: 'photo' | 'receipt'): Promise { + const form = new FormData(); + form.append('type', type); + for (const f of files) form.append('files', f); + const res = await fetch(`${BASE}/items/${itemId}/files`, { method: 'POST', body: form }); + if (!res.ok) throw new Error('Upload failed'); + return res.json(); + }, + deleteFile(fileId: string): Promise { + return request(`/files/${fileId}`, { method: 'DELETE' }); + }, + setFeaturedPhoto(itemId: string, fileId: string): Promise { + return request(`/items/${itemId}/files/${fileId}/featured`, { method: 'PUT' }); + }, + + // Stats + getStats(): Promise { + return request('/stats'); + }, +}; + +// Image variant URL helpers +function variantUrl(storedName: string, suffix: string): string { + const dot = storedName.lastIndexOf('.'); + return `/api/uploads/${storedName.slice(0, dot)}${suffix}${storedName.slice(dot)}`; +} + +export function thumbUrl(storedName: string): string { + return variantUrl(storedName, '_thumb'); +} + +export function largeUrl(storedName: string): string { + return variantUrl(storedName, '_large'); +} + +export function uploadUrl(storedName: string): string { + return `/api/uploads/${storedName}`; +} diff --git a/client/src/components/AutocompleteInput.tsx b/client/src/components/AutocompleteInput.tsx new file mode 100644 index 0000000..03313eb --- /dev/null +++ b/client/src/components/AutocompleteInput.tsx @@ -0,0 +1,106 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +interface Props { + value: string; + onChange: (value: string) => void; + suggestions: string[]; + placeholder?: string; + className?: string; + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +export default function AutocompleteInput({ value, onChange, suggestions, placeholder, className, onKeyDown }: Props) { + const [open, setOpen] = useState(false); + const [highlightIdx, setHighlightIdx] = useState(-1); + const inputRef = useRef(null); + const listRef = useRef(null); + + const filtered = value + ? suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase())) + : suggestions; + + const handleSelect = useCallback((val: string) => { + onChange(val); + setOpen(false); + setHighlightIdx(-1); + }, [onChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open || filtered.length === 0) { + // Tab with no dropdown: accept what's typed, let focus move naturally + onKeyDown?.(e); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightIdx(i => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightIdx(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && highlightIdx >= 0) { + e.preventDefault(); + handleSelect(filtered[highlightIdx]); + } else if (e.key === 'Tab' && highlightIdx >= 0) { + // Tab accepts highlighted suggestion + handleSelect(filtered[highlightIdx]); + // Don't prevent default — let focus move to next field + } else if (e.key === 'Tab' && filtered.length > 0 && value) { + // Tab with text typed: accept first match + handleSelect(filtered[0]); + } else if (e.key === 'Escape') { + setOpen(false); + setHighlightIdx(-1); + } else { + onKeyDown?.(e); + } + }; + + useEffect(() => { + if (highlightIdx >= 0 && listRef.current) { + const el = listRef.current.children[highlightIdx] as HTMLElement; + el?.scrollIntoView({ block: 'nearest' }); + } + }, [highlightIdx]); + + return ( +
+ { + onChange(e.target.value); + setOpen(true); + setHighlightIdx(-1); + }} + onFocus={() => setOpen(true)} + onBlur={() => { + // Delay to allow click on dropdown items + setTimeout(() => setOpen(false), 150); + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className={className} + autoComplete="off" + /> + {open && filtered.length > 0 && ( +
    + {filtered.slice(0, 10).map((item, idx) => ( +
  • handleSelect(item)} + onMouseEnter={() => setHighlightIdx(idx)} + > + {item} +
  • + ))} +
+ )} +
+ ); +} diff --git a/client/src/components/ItemDetail.tsx b/client/src/components/ItemDetail.tsx new file mode 100644 index 0000000..4b7c55c --- /dev/null +++ b/client/src/components/ItemDetail.tsx @@ -0,0 +1,375 @@ +import { useEffect, useState, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { api, thumbUrl, uploadUrl } from '../api'; +import type { Item, ItemFile, ItemStatus, UpdateItemBody } from '../types'; +import { STATUS_LABELS } from '../types'; +import AutocompleteInput from './AutocompleteInput'; +import Lightbox from './Lightbox'; + +interface Props { + itemId: string; + onClose: () => void; + onUpdated: () => void; +} + +const STATUSES: ItemStatus[] = ['active', 'stored', 'lent_out', 'sold', 'lost', 'trashed']; + +export default function ItemDetail({ itemId, onClose, onUpdated }: Props) { + const [item, setItem] = useState<(Item & { files: ItemFile[] }) | null>(null); + const [categories, setCategories] = useState([]); + const [locations, setLocations] = useState([]); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [duplicating, setDuplicating] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [uploading, setUploading] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); + const photoInputRef = useRef(null); + const receiptInputRef = useRef(null); + + const { register, handleSubmit, reset, setValue, watch } = useForm(); + const categoryVal = watch('category') || ''; + const locationVal = watch('location') || ''; + + useEffect(() => { + api.getItem(itemId).then(data => { + setItem(data); + reset({ + name: data.name, + category: data.category, + location: data.location, + purchase_date: data.purchase_date, + purchase_price: data.purchase_price, + status: data.status, + notes: data.notes, + flagged: data.flagged, + }); + }); + api.getCategories().then(setCategories); + api.getLocations().then(setLocations); + }, [itemId, reset]); + + const onSave = async (data: UpdateItemBody) => { + setSaving(true); + try { + await api.updateItem(itemId, data); + onUpdated(); + onClose(); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setDeleting(true); + try { + await api.deleteItem(itemId); + onUpdated(); + onClose(); + } finally { + setDeleting(false); + } + }; + + const handleDuplicate = async () => { + if (!item) return; + setDuplicating(true); + try { + await api.duplicateItem(itemId); + onUpdated(); + onClose(); + } finally { + setDuplicating(false); + } + }; + + const handleFileUpload = async (files: FileList | null, type: 'photo' | 'receipt') => { + if (!files?.length) return; + setUploading(true); + try { + await api.uploadFiles(itemId, Array.from(files), type); + const updated = await api.getItem(itemId); + setItem(updated); + onUpdated(); + } finally { + setUploading(false); + } + }; + + const handleFileDelete = async (fileId: string) => { + await api.deleteFile(fileId); + const updated = await api.getItem(itemId); + setItem(updated); + onUpdated(); + }; + + const handleSetFeatured = async (fileId: string) => { + await api.setFeaturedPhoto(itemId, fileId); + const updated = await api.getItem(itemId); + setItem(updated); + onUpdated(); + }; + + if (!item) return null; + + const photos = item.files.filter(f => f.file_type === 'photo'); + const receipts = item.files.filter(f => f.file_type === 'receipt'); + + return ( + <> +
+
+
+
+

+ Edit Item #{item.label_id} +

+ +
+ +
+ {/* Name */} +
+ + +
+ + {/* Category + Location */} +
+
+ + setValue('category', v)} + suggestions={categories} + placeholder="Category" + className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100" + /> +
+
+ + setValue('location', v)} + suggestions={locations} + placeholder="Location" + className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100" + /> +
+
+ + {/* Price + Date */} +
+
+ + +
+
+ + +
+
+ + {/* Status */} +
+ + +
+ + {/* Flagged */} + + + {/* Notes */} +
+ +