Files
loadout/src/App.tsx
T

511 lines
20 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Trip,
PackingItem,
BagType,
Category,
PackMemory,
AppSettings,
TripDebrief
} from './types';
import { StorageClient } from './utils/storage';
import { sound } from './utils/audio';
import { BoardingHeader } from './components/BoardingHeader';
import { BagOverview } from './components/BagOverview';
import { ItemRow } from './components/ItemRow';
import { TripModal } from './components/TripModal';
import { DebriefModal } from './components/DebriefModal';
import { MemoryDrawer } from './components/MemoryDrawer';
import { SettingsModal } from './components/SettingsModal';
import {
Plus,
Search,
Filter,
CheckCircle2,
Luggage,
Sparkles,
Trash2,
Printer,
Share2,
RotateCcw,
Plane
} from 'lucide-react';
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
{ id: 'all', label: 'ALL GEAR' },
{ id: 'clothing', label: 'CLOTHING' },
{ id: 'toiletries', label: 'TOILETRIES' },
{ id: 'tech', label: 'TECH & POWER' },
{ id: 'documents', label: 'DOCUMENTS' },
{ id: 'gear', label: 'EXPEDITION GEAR' },
{ id: 'wellness', label: 'WELLNESS / MED' },
];
export function App() {
const [trips, setTrips] = useState<Trip[]>([]);
const [activeTripId, setActiveTripId] = useState<string | null>(null);
const [memory, setMemory] = useState<PackMemory>({
exclusions: [],
inclusions: [],
totalTripsDebriefed: 0,
});
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
// UI Filter states
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'unpacked' | 'packed'>('all');
// Quick item entry
const [newItemName, setNewItemName] = useState('');
const [newItemCategory, setNewItemCategory] = useState<Category>('clothing');
const [newItemBag, setNewItemBag] = useState<BagType>('carry_on');
// Modals
const [isTripModalOpen, setIsTripModalOpen] = useState(false);
const [isDebriefModalOpen, setIsDebriefModalOpen] = useState(false);
const [isMemoryOpen, setIsMemoryOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
// Load initial data
useEffect(() => {
async function loadData() {
const loadedTrips = await StorageClient.getTrips();
const loadedMemory = await StorageClient.getMemory();
const loadedSettings = StorageClient.getSettings();
setTrips(loadedTrips);
setMemory(loadedMemory);
setSettings(loadedSettings);
sound.enabled = loadedSettings.soundEnabled;
const savedActiveId = StorageClient.getActiveTripId();
if (savedActiveId && loadedTrips.some((t) => t.id === savedActiveId)) {
setActiveTripId(savedActiveId);
} else if (loadedTrips.length > 0) {
setActiveTripId(loadedTrips[0].id);
}
}
loadData();
}, []);
const activeTrip = trips.find((t) => t.id === activeTripId) || null;
const handleSelectTrip = (id: string) => {
setActiveTripId(id);
StorageClient.setActiveTripId(id);
setSelectedBag('all');
};
const handleSaveTrip = async (trip: Trip) => {
await StorageClient.saveTrip(trip);
const updatedTrips = await StorageClient.getTrips();
setTrips(updatedTrips);
setActiveTripId(trip.id);
StorageClient.setActiveTripId(trip.id);
};
const handleDeleteTrip = async (tripId: string) => {
if (!window.confirm('Are you sure you want to delete this trip manifest?')) return;
sound.playClick();
await StorageClient.deleteTrip(tripId);
const updatedTrips = await StorageClient.getTrips();
setTrips(updatedTrips);
if (activeTripId === tripId) {
setActiveTripId(updatedTrips[0]?.id || null);
}
};
// Item actions
const handleTogglePacked = async (itemId: string) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.map((i) =>
i.id === itemId ? { ...i, packed: !i.packed } : i
);
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleUpdateQuantity = async (itemId: string, delta: number) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.map((i) => {
if (i.id === itemId) {
const nextQty = Math.max(1, i.quantity + delta);
return { ...i, quantity: nextQty };
}
return i;
});
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleUpdateBag = async (itemId: string, newBag: BagType) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.map((i) =>
i.id === itemId ? { ...i, bag: newBag } : i
);
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleDeleteItem = async (itemId: string) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.filter((i) => i.id !== itemId);
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeTrip || !newItemName.trim()) return;
sound.playStamp();
const newItem: PackingItem = {
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
name: newItemName.trim(),
category: newItemCategory,
quantity: 1,
bag: newItemBag,
packed: false,
source: 'user',
};
const updatedTrip = { ...activeTrip, items: [newItem, ...activeTrip.items] };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
setNewItemName('');
};
const handleSaveDebrief = async (debrief: TripDebrief) => {
if (!activeTrip) return;
const updated = await StorageClient.recordDebrief(activeTrip.id, debrief);
if (updated) {
setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
const refreshedMemory = await StorageClient.getMemory();
setMemory(refreshedMemory);
}
};
const handleClearMemoryItem = async (type: 'exclusion' | 'inclusion', itemName: string) => {
const updated = { ...memory };
if (type === 'exclusion') {
updated.exclusions = updated.exclusions.filter((e) => e.itemName !== itemName);
} else {
updated.inclusions = updated.inclusions.filter((i) => i.itemName !== itemName);
}
setMemory(updated);
localStorage.setItem('loadout_memory_v1', JSON.stringify(updated));
};
const handleSaveSettings = (newSettings: AppSettings) => {
setSettings(newSettings);
StorageClient.saveSettings(newSettings);
};
// Filtered items
const filteredItems = (activeTrip?.items || []).filter((item) => {
if (selectedBag !== 'all' && item.bag !== selectedBag) return false;
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
if (filterMode === 'unpacked' && item.packed) return false;
if (filterMode === 'packed' && !item.packed) return false;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
return (
item.name.toLowerCase().includes(q) ||
(item.notes && item.notes.toLowerCase().includes(q))
);
}
return true;
});
return (
<div className="min-h-screen bg-[#F4F1EA] text-[#141518] flex flex-col font-sans">
{/* Boarding Pass Telemetry Header */}
<BoardingHeader
activeTrip={activeTrip}
trips={trips}
onSelectTrip={handleSelectTrip}
onNewTrip={() => setIsTripModalOpen(true)}
onOpenDebrief={() => setIsDebriefModalOpen(true)}
onOpenMemory={() => setIsMemoryOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)}
soundEnabled={settings.soundEnabled}
onToggleSound={() => {
const next = !settings.soundEnabled;
sound.enabled = next;
handleSaveSettings({ ...settings, soundEnabled: next });
}}
/>
{/* Main Content Area */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-6 py-6">
{activeTrip ? (
<>
{/* Bag Allocation Gauges ("What Goes in What Bag") */}
<BagOverview
items={activeTrip.items}
selectedBag={selectedBag}
onSelectBag={setSelectedBag}
luggageConfig={activeTrip.luggageConfig}
/>
{/* Quick Add Perforated Coupon Strip */}
<div className="mb-6 bg-ticket-100 border-2 border-ink p-3 shadow-card-hard-sm">
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold uppercase text-ink">
<Plus size={14} className="text-gate-orange" />
<span>EXPEDITION ADDITION // FAST ITEM STOWAGE</span>
</div>
<form onSubmit={handleAddItem} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
placeholder="Enter item name (e.g. Passport pouch, extra camera lens, dry bag)..."
className="flex-1 bg-white border-2 border-ink px-3 py-2 text-xs font-grotesk font-bold text-ink shadow-xs focus:outline-none focus:ring-2 focus:ring-canary"
/>
<select
value={newItemCategory}
onChange={(e) => setNewItemCategory(e.target.value as Category)}
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
>
<option value="clothing">Clothing</option>
<option value="toiletries">Toiletries</option>
<option value="tech">Tech & Power</option>
<option value="documents">Documents</option>
<option value="gear">Expedition Gear</option>
<option value="wellness">Wellness / Med</option>
</select>
<select
value={newItemBag}
onChange={(e) => setNewItemBag(e.target.value as BagType)}
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
>
<option value="personal_item">Personal Item (Cabin)</option>
<option value="carry_on">Carry-On Roller</option>
<option value="checked">Checked Bag</option>
<option value="worn">Worn on Person</option>
</select>
<button
type="submit"
className="bg-canary hover:bg-canary-hover border-2 border-ink px-4 py-2 font-grotesk font-bold text-xs uppercase shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5"
>
STOW ITEM
</button>
</form>
</div>
{/* Filter & Search Bar */}
<div className="mb-4 flex flex-col md:flex-row md:items-center justify-between gap-3 border-b-2 border-ink pb-3">
{/* Category Pills */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full">
{CATEGORY_TABS.map((cat) => (
<button
key={cat.id}
onClick={() => {
sound.playClick();
setSelectedCategory(cat.id);
}}
className={`font-mono text-xs font-bold px-2.5 py-1 border border-ink whitespace-nowrap transition-all ${
selectedCategory === cat.id
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
: 'bg-white text-ink hover:bg-ticket-100'
}`}
>
{cat.label}
</button>
))}
</div>
{/* Search & State Filter */}
<div className="flex items-center gap-2">
<div className="relative">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-muted"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search manifest..."
className="pl-8 pr-3 py-1 bg-white border border-ink font-mono text-xs w-36 sm:w-48 focus:outline-none focus:ring-1 focus:ring-ink"
/>
</div>
<div className="flex border border-ink bg-white font-mono text-xs">
<button
onClick={() => {
sound.playClick();
setFilterMode('all');
}}
className={`px-2 py-1 ${filterMode === 'all' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
>
ALL
</button>
<button
onClick={() => {
sound.playClick();
setFilterMode('unpacked');
}}
className={`px-2 py-1 border-l border-ink ${filterMode === 'unpacked' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
>
UNPACKED
</button>
<button
onClick={() => {
sound.playClick();
setFilterMode('packed');
}}
className={`px-2 py-1 border-l border-ink ${filterMode === 'packed' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
>
STOWED
</button>
</div>
</div>
</div>
{/* Manifest Item Count & Bag Filter Notice */}
<div className="flex items-center justify-between text-xs font-mono text-ink-muted mb-3">
<span>
DISPLAYING {filteredItems.length} ITEM(S)
{selectedBag !== 'all' && (
<span className="text-gate-orange font-bold ml-1">
(FILTERED TO {selectedBag.toUpperCase()})
</span>
)}
</span>
<div className="flex items-center gap-3">
<button
onClick={() => window.print()}
className="hover:text-ink flex items-center gap-1 font-bold"
title="Print paper boarding manifest"
>
<Printer size={13} />
<span>PRINT MANIFEST</span>
</button>
<span>•</span>
<button
onClick={() => handleDeleteTrip(activeTrip.id)}
className="hover:text-stamp-red flex items-center gap-1 font-bold text-stamp-red/70"
title="Delete voyage"
>
<Trash2 size={13} />
<span>DELETE VOYAGE</span>
</button>
</div>
</div>
{/* List of Perforated Stubs */}
{filteredItems.length === 0 ? (
<div className="p-12 border-2 border-dashed border-ink/40 text-center font-mono text-ink-muted bg-ticket-50/50">
<p className="text-sm font-bold uppercase text-ink mb-1">
NO ITEMS MATCHING THIS FILTER
</p>
<p className="text-xs">
Adjust your bag or category selection above, or add a new custom item.
</p>
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => (
<ItemRow
key={item.id}
item={item}
onTogglePacked={handleTogglePacked}
onUpdateQuantity={handleUpdateQuantity}
onUpdateBag={handleUpdateBag}
onDelete={handleDeleteItem}
/>
))}
</div>
)}
</>
) : (
/* Empty State: Terminal Welcome */
<div className="max-w-xl mx-auto my-12 p-8 bg-ticket-100 border-2 border-ink shadow-card-hard text-center">
<div className="w-16 h-16 bg-canary border-2 border-ink mx-auto flex items-center justify-center shadow-card-hard-sm mb-4">
<Plane className="text-ink -rotate-45" size={32} />
</div>
<h2 className="font-display text-3xl tracking-wide uppercase text-ink mb-2">
DISPATCH TERMINAL READY
</h2>
<p className="font-sans text-sm text-ink-muted mb-6">
You currently have no active packing manifests. Create a new trip to generate an intelligent list, assign items to your bags, and begin your loadout.
</p>
<button
onClick={() => {
sound.playClick();
setIsTripModalOpen(true);
}}
className="bg-canary hover:bg-canary-hover text-ink font-grotesk font-bold text-sm uppercase px-6 py-3 border-2 border-ink shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 inline-flex items-center gap-2"
>
<Plus size={18} strokeWidth={3} />
<span>DISPATCH FIRST VOYAGE</span>
</button>
</div>
)}
</main>
{/* Footer */}
<footer className="border-t-2 border-ink bg-ticket-200 py-3 px-4 sm:px-6 text-center font-mono text-xs text-ink-muted">
<div className="flex flex-col sm:flex-row items-center justify-between max-w-6xl mx-auto gap-2">
<span>LOADOUT // TRAVEL MANIFEST & BAG ALLOCATION SYSTEM</span>
<span>SELF-HOSTED AT VLOSER.US • DEBRIEF-DRIVEN INTELLIGENCE</span>
</div>
</footer>
{/* Modals */}
<TripModal
isOpen={isTripModalOpen}
onClose={() => setIsTripModalOpen(false)}
onSaveTrip={handleSaveTrip}
memory={memory}
/>
{activeTrip && (
<DebriefModal
isOpen={isDebriefModalOpen}
onClose={() => setIsDebriefModalOpen(false)}
trip={activeTrip}
onSaveDebrief={handleSaveDebrief}
/>
)}
<MemoryDrawer
isOpen={isMemoryOpen}
onClose={() => setIsMemoryOpen(false)}
memory={memory}
onClearMemoryItem={handleClearMemoryItem}
/>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
settings={settings}
onSaveSettings={handleSaveSettings}
trips={trips}
memory={memory}
onImportData={({ trips: impTrips, memory: impMemory }) => {
setTrips(impTrips);
setMemory(impMemory);
localStorage.setItem('loadout_trips_v1', JSON.stringify(impTrips));
localStorage.setItem('loadout_memory_v1', JSON.stringify(impMemory));
if (impTrips.length > 0) setActiveTripId(impTrips[0].id);
}}
/>
</div>
);
}
export default App;