511 lines
20 KiB
TypeScript
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;
|