diff --git a/index.html b/index.html index d5cd2df..5af0c38 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,14 @@ - + + + + + + + LOADOUT // Travel Manifest & Bag Allocation diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..cfb8239 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..ce207ed --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "LOADOUT // Travel Manifest & Bag Allocation", + "short_name": "Loadout", + "start_url": "/", + "display": "standalone", + "background_color": "#F4F1EA", + "theme_color": "#141518", + "description": "Tactile travel packing manifest and luggage allocation system.", + "icons": [ + { + "src": "/icon.svg", + "sizes": "64x64", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/src/App.tsx b/src/App.tsx index a614647..7ac9dec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,9 @@ import { RotateCcw, Plane, List, - LayoutList + LayoutList, + Copy, + CheckCheck } from 'lucide-react'; const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [ @@ -55,6 +57,8 @@ export function App() { }); const [settings, setSettings] = useState(StorageClient.getSettings()); const [compactView, setCompactView] = useState(false); + const [cloneSourceTripId, setCloneSourceTripId] = useState(null); + const [toastMessage, setToastMessage] = useState(null); // UI Filter states const [selectedBag, setSelectedBag] = useState('all'); @@ -74,6 +78,11 @@ export function App() { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settingsTab, setSettingsTab] = useState<'general' | 'bags' | 'archetypes'>('general'); + const showToast = (msg: string) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(null), 2800); + }; + // Load initial data useEffect(() => { async function loadData() { @@ -115,6 +124,7 @@ export function App() { setTrips(updatedTrips); setActiveTripId(trip.id); StorageClient.setActiveTripId(trip.id); + showToast(`Voyage "${trip.title}" manifest issued!`); }; const handleDeleteTrip = async (tripId: string) => { @@ -126,6 +136,14 @@ export function App() { if (activeTripId === tripId) { setActiveTripId(updatedTrips[0]?.id || null); } + showToast('Voyage manifest deleted'); + }; + + const handleCloneCurrentTrip = () => { + if (!activeTrip) return; + sound.playClick(); + setCloneSourceTripId(activeTrip.id); + setIsTripModalOpen(true); }; // Item actions @@ -192,6 +210,55 @@ export function App() { setNewItemName(''); }; + const handleBatchTogglePacked = async (packedState: boolean) => { + if (!activeTrip || filteredItems.length === 0) return; + sound.playStamp(); + const targetItemIds = new Set(filteredItems.map((i) => i.id)); + const updatedItems = activeTrip.items.map((item) => + targetItemIds.has(item.id) ? { ...item, packed: packedState } : item + ); + const updatedTrip = { ...activeTrip, items: updatedItems }; + await StorageClient.saveTrip(updatedTrip); + setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t))); + showToast( + packedState + ? `Marked ${targetItemIds.size} item(s) as stowed!` + : `Reset ${targetItemIds.size} item(s) to unpacked.` + ); + }; + + const handleCopyTextManifest = () => { + if (!activeTrip) return; + sound.playClick(); + + let text = `LOADOUT MANIFEST // ${activeTrip.routeCode}: ${activeTrip.title}\n`; + text += `Destination: ${activeTrip.destination} • ${activeTrip.durationDays}D / ${activeTrip.durationNights}N • Archetype: ${activeTrip.tripType.toUpperCase()}\n`; + text += `==========================================================\n\n`; + + for (const bag of settings.customBags) { + const bagItems = activeTrip.items.filter((i) => i.bag === bag.id); + if (bagItems.length > 0) { + text += `[${bag.tagCode}] ${bag.label} (${bag.sub}):\n`; + for (const item of bagItems) { + const check = item.packed ? '[✓]' : '[ ]'; + text += ` ${check} ${item.name} (${item.quantity}x)${item.essential ? ' [ESSENTIAL]' : ''}${ + item.notes ? ` — Note: ${item.notes}` : '' + }\n`; + } + text += `\n`; + } + } + + navigator.clipboard + .writeText(text) + .then(() => { + showToast('Text manifest copied to clipboard!'); + }) + .catch(() => { + showToast('Could not copy to clipboard.'); + }); + }; + const handleSaveDebrief = async (debrief: TripDebrief) => { if (!activeTrip) return; const updated = await StorageClient.recordDebrief(activeTrip.id, debrief); @@ -199,6 +266,7 @@ export function App() { setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); const refreshedMemory = await StorageClient.getMemory(); setMemory(refreshedMemory); + showToast('Debrief saved & engine memory updated!'); } }; @@ -243,12 +311,23 @@ export function App() { return (
+ {/* Toast Feedback Notification */} + {toastMessage && ( +
+ + {toastMessage} +
+ )} + {/* Boarding Pass Telemetry Header */} setIsTripModalOpen(true)} + onNewTrip={() => { + setCloneSourceTripId(null); + setIsTripModalOpen(true); + }} onOpenDebrief={() => setIsDebriefModalOpen(true)} onOpenMemory={() => setIsMemoryOpen(true)} onOpenSettings={() => { @@ -432,39 +511,87 @@ export function App() {
- {/* Manifest Item Count & Bag Filter Notice */} -
- - DISPLAYING {filteredItems.length} ITEM(S) - {selectedBag !== 'all' && ( - - (FILTERED TO {selectedBag.toUpperCase()}) - - )} - {compactView && ( - - [COMPACT MODE] - - )} - + {/* Manifest Item Count, Batch Actions & Toolbar */} +
+
+ + DISPLAYING {filteredItems.length} ITEM(S) + {selectedBag !== 'all' && ( + + (FILTERED TO {selectedBag.toUpperCase()}) + + )} + {compactView && ( + + [COMPACT MODE] + + )} + -
+ {/* Batch Stow / Unpack Buttons */} + {filteredItems.length > 0 && ( +
+ + +
+ )} +
+ +
• + • + + • +
@@ -511,6 +638,7 @@ export function App() {
+ {/* Creation Mode Tabs */} +
+ + {trips.length > 0 && ( + + )} +
+ {/* Route Code Banner */}
ROUTING TELEMETRY: @@ -140,7 +277,57 @@ export const TripModal: React.FC = ({
-
+ + {/* CLONE SELECTION SECTION (When cloning from a past trip) */} + {creationMode === 'clone' && ( +
+
+ + + SELECT BASE VOYAGE TO RE-FLY + + + {selectedCloneTrip?.items.length || 0} ITEMS IN BASE + +
+ + + + {/* Debrief Learning Filter Toggle */} + {selectedCloneTrip?.debrief && ( +
+ +
+ )} +
+ )} + {/* Destination & Title */}
@@ -171,7 +358,7 @@ export const TripModal: React.FC = ({
- {/* Trip Type */} + {/* Trip Type (Archetypes) */}
- {/* Learning Memory Signal */} - {(relevantInclusions > 0 || relevantExclusions > 0) && ( -
- -
- CONTINUOUS LEARNING ACTIVE: -

- Your past debriefs have taught the engine for {tripType.replace('_', ' ')}: - {relevantInclusions > 0 && ` ${relevantInclusions} wishlist item(s) will be automatically injected.`} - {relevantExclusions > 0 && ` ${relevantExclusions} frequently unused deadweight item(s) will be omitted or reduced.`} -

-
-
- )} - {/* Form Actions */}