feat: Initial release of LOADOUT - Travel Manifest & Bag Allocation System
This commit is contained in:
commit
805f3fe97a
26 files changed
+7618
No files matched your search
+510
@@ -0,0 +1,510 @@
|
||||
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;
|
||||
Reference in new issue
Block a user