From d275808711909a0cd694aff49343e73df1653ac3 Mon Sep 17 00:00:00 2001 From: airent106 Date: Thu, 15 Oct 2026 11:15:19 -0600 Subject: [PATCH] feat: Add custom bag and archetype editors and compact line view mode --- server/index.js | 3 + src/App.tsx | 100 +++- src/components/BagOverview.tsx | 246 +++++----- src/components/ItemRow.tsx | 128 ++++- src/components/SettingsModal.tsx | 779 ++++++++++++++++++++++++------- src/components/TripModal.tsx | 42 +- src/types/index.ts | 126 ++++- src/utils/storage.ts | 13 +- 8 files changed, 1087 insertions(+), 350 deletions(-) diff --git a/server/index.js b/server/index.js index cf440f6..5fe34ee 100644 --- a/server/index.js +++ b/server/index.js @@ -200,11 +200,14 @@ app.post('/api/memory/debrief', (req, res) => { app.get('/api/settings', (_req, res) => { const settings = readJson(SETTINGS_FILE, { soundEnabled: true, + compactView: false, aiEnabled: false, aiProvider: 'ollama', ollamaUrl: 'http://localhost:11434', ollamaModel: 'llama3.2', apiKey: '', + customBags: [], + customArchetypes: [], }); res.json(settings); }); diff --git a/src/App.tsx b/src/App.tsx index a21559c..a614647 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,9 @@ import { Category, PackMemory, AppSettings, - TripDebrief + TripDebrief, + CustomBag, + CustomArchetype } from './types'; import { StorageClient } from './utils/storage'; import { sound } from './utils/audio'; @@ -28,7 +30,9 @@ import { Printer, Share2, RotateCcw, - Plane + Plane, + List, + LayoutList } from 'lucide-react'; const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [ @@ -50,6 +54,7 @@ export function App() { totalTripsDebriefed: 0, }); const [settings, setSettings] = useState(StorageClient.getSettings()); + const [compactView, setCompactView] = useState(false); // UI Filter states const [selectedBag, setSelectedBag] = useState('all'); @@ -67,6 +72,7 @@ export function App() { const [isDebriefModalOpen, setIsDebriefModalOpen] = useState(false); const [isMemoryOpen, setIsMemoryOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [settingsTab, setSettingsTab] = useState<'general' | 'bags' | 'archetypes'>('general'); // Load initial data useEffect(() => { @@ -78,8 +84,13 @@ export function App() { setTrips(loadedTrips); setMemory(loadedMemory); setSettings(loadedSettings); + setCompactView(loadedSettings.compactView || false); sound.enabled = loadedSettings.soundEnabled; + if (loadedSettings.customBags && loadedSettings.customBags.length > 0) { + setNewItemBag(loadedSettings.customBags[0].id); + } + const savedActiveId = StorageClient.getActiveTripId(); if (savedActiveId && loadedTrips.some((t) => t.id === savedActiveId)) { setActiveTripId(savedActiveId); @@ -207,6 +218,13 @@ export function App() { StorageClient.saveSettings(newSettings); }; + const toggleCompactView = () => { + sound.playClick(); + const next = !compactView; + setCompactView(next); + handleSaveSettings({ ...settings, compactView: next }); + }; + // Filtered items const filteredItems = (activeTrip?.items || []).filter((item) => { if (selectedBag !== 'all' && item.bag !== selectedBag) return false; @@ -233,7 +251,10 @@ export function App() { onNewTrip={() => setIsTripModalOpen(true)} onOpenDebrief={() => setIsDebriefModalOpen(true)} onOpenMemory={() => setIsMemoryOpen(true)} - onOpenSettings={() => setIsSettingsOpen(true)} + onOpenSettings={() => { + setSettingsTab('general'); + setIsSettingsOpen(true); + }} soundEnabled={settings.soundEnabled} onToggleSound={() => { const next = !settings.soundEnabled; @@ -252,9 +273,14 @@ export function App() { selectedBag={selectedBag} onSelectBag={setSelectedBag} luggageConfig={activeTrip.luggageConfig} + customBags={settings.customBags} + onEditBags={() => { + setSettingsTab('bags'); + setIsSettingsOpen(true); + }} /> - {/* Quick Add Perforated Coupon Strip */} + {/* Quick Add Strip */}
@@ -285,13 +311,14 @@ export function App() {
- {/* Filter & Search Bar */} + {/* Filter, View Toggle & Search Bar */}
{/* Category Pills */}
@@ -325,8 +352,39 @@ export function App() { ))}
- {/* Search & State Filter */} -
+ {/* Search, State Filter & Compact View Toggle */} +
+ {/* Compact vs Detailed Toggle */} +
+ + +
+ + {/* Search Input */}
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" + className="pl-8 pr-3 py-1 bg-white border border-ink font-mono text-xs w-32 sm:w-44 focus:outline-none focus:ring-1 focus:ring-ink" />
+ {/* State Filter (All / Unpacked / Stowed) */}
- {/* List of Perforated Stubs */} + {/* List of Items (Compact Lines or Perforated Stubs) */} {filteredItems.length === 0 ? (

@@ -421,6 +485,8 @@ export function App() { setIsTripModalOpen(false)} onSaveTrip={handleSaveTrip} memory={memory} + customArchetypes={settings.customArchetypes} + onEditArchetypes={() => { + setSettingsTab('archetypes'); + setIsSettingsOpen(true); + }} /> {activeTrip && ( @@ -492,6 +563,7 @@ export function App() { isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} settings={settings} + initialTab={settingsTab} onSaveSettings={handleSaveSettings} trips={trips} memory={memory} diff --git a/src/components/BagOverview.tsx b/src/components/BagOverview.tsx index 4f6a5b3..d5e46a3 100644 --- a/src/components/BagOverview.tsx +++ b/src/components/BagOverview.tsx @@ -1,13 +1,45 @@ import React from 'react'; -import { BagType, PackingItem, LuggageConfig } from '../types'; +import { BagType, PackingItem, LuggageConfig, CustomBag } from '../types'; import { sound } from '../utils/audio'; -import { Backpack, Briefcase, Package, UserCheck, Layers, CheckCircle2 } from 'lucide-react'; +import { + Backpack, + Briefcase, + Package, + UserCheck, + Layers, + CheckCircle2, + Settings2, + Camera, + Compass, + Tag +} from 'lucide-react'; interface BagOverviewProps { items: PackingItem[]; selectedBag: BagType | 'all'; onSelectBag: (bag: BagType | 'all') => void; luggageConfig: LuggageConfig; + customBags: CustomBag[]; + onEditBags: () => void; +} + +function getBagIcon(iconKey?: string) { + switch (iconKey) { + case 'backpack': + return ; + case 'briefcase': + return ; + case 'package': + return ; + case 'user': + return ; + case 'camera': + return ; + case 'compass': + return ; + default: + return ; + } } export const BagOverview: React.FC = ({ @@ -15,64 +47,39 @@ export const BagOverview: React.FC = ({ selectedBag, onSelectBag, luggageConfig, + customBags, + onEditBags, }) => { - const bags: { - type: BagType; - label: string; - sub: string; - icon: React.ReactNode; - tsaTip: string; - tagCode: string; - visible: boolean; - }[] = [ - { - type: 'personal_item', - label: 'PERSONAL ITEM', - sub: 'Under-Seat / Daypack', - icon: , - tsaTip: 'Cabin only: Passports, power banks, essential meds, laptop', - tagCode: 'CABIN-01', - visible: true, - }, - { - type: 'carry_on', - label: 'CARRY-ON ROLLER', - sub: 'Overhead Compartment', - icon: , - tsaTip: 'Max 3.4oz / 100ml liquids pouch. Clothes cubes & toiletries', - tagCode: 'CABIN-02', - visible: luggageConfig !== 'backpack_only', - }, - { - type: 'checked', - label: 'CHECKED LUGGAGE', - sub: 'Cargo Hold', - icon: , - tsaTip: 'Liquids > 100ml, trekking poles, pocket knives, heavy layers', - tagCode: 'HOLD-03', - visible: luggageConfig === 'checked_bag', - }, - { - type: 'worn', - label: 'WORN ON PERSON', - sub: 'Travel Day Outfit', - icon: , - tsaTip: 'Heaviest boots & airport jacket to minimize luggage bulk', - tagCode: 'PAX-WORN', - visible: true, - }, - ]; + // Filter bags based on luggage mode if it's a default bag + const visibleBags = customBags.filter((b) => { + if (b.id === 'carry_on' && luggageConfig === 'backpack_only') return false; + if (b.id === 'checked' && luggageConfig !== 'checked_bag') return false; + return true; + }); return (

-
+
LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG" +
- {bags - .filter((b) => b.visible) - .map((b) => { - const bagItems = items.filter((i) => i.bag === b.type); - const bagPacked = bagItems.filter((i) => i.packed).length; - const bagTotal = bagItems.length; - const pct = bagTotal > 0 ? Math.round((bagPacked / bagTotal) * 100) : 0; - const isSelected = selectedBag === b.type; - const isFull = bagTotal > 0 && bagPacked === bagTotal; + {visibleBags.map((b) => { + const bagItems = items.filter((i) => i.bag === b.id); + const bagPacked = bagItems.filter((i) => i.packed).length; + const bagTotal = bagItems.length; + const pct = bagTotal > 0 ? Math.round((bagPacked / bagTotal) * 100) : 0; + const isSelected = selectedBag === b.id; + const isFull = bagTotal > 0 && bagPacked === bagTotal; - return ( - - ); - })} +
+ + ); + })}
); diff --git a/src/components/ItemRow.tsx b/src/components/ItemRow.tsx index 9f6c2e2..b9c2b55 100644 --- a/src/components/ItemRow.tsx +++ b/src/components/ItemRow.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { PackingItem, BagType, Category } from '../types'; +import { PackingItem, BagType, Category, CustomBag } from '../types'; import { sound } from '../utils/audio'; import { Check, @@ -16,19 +16,14 @@ import { interface ItemRowProps { item: PackingItem; + compact?: boolean; + availableBags: CustomBag[]; onTogglePacked: (id: string) => void; onUpdateQuantity: (id: string, delta: number) => void; onUpdateBag: (id: string, bag: BagType) => void; onDelete: (id: string) => void; } -const BAG_OPTIONS: { type: BagType; label: string; icon: React.ReactNode; short: string }[] = [ - { type: 'personal_item', label: 'Personal Item', short: 'PERS', icon: }, - { type: 'carry_on', label: 'Carry-On Roller', short: 'CARRY', icon: }, - { type: 'checked', label: 'Checked Bag', short: 'CHKD', icon: }, - { type: 'worn', label: 'Worn on Person', short: 'WORN', icon: }, -]; - const CATEGORY_COLORS: Record = { clothing: { bg: 'bg-[#E3EAF7]', text: 'text-[#1D3E74]', label: 'CLOTHING' }, toiletries: { bg: 'bg-[#E3F5EC]', text: 'text-[#15663F]', label: 'TOILETRIES' }, @@ -40,14 +35,117 @@ const CATEGORY_COLORS: Record = ({ item, + compact = false, + availableBags, onTogglePacked, onUpdateQuantity, onUpdateBag, onDelete, }) => { const catStyle = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.gear; - const currentBag = BAG_OPTIONS.find((b) => b.type === item.bag) || BAG_OPTIONS[0]; + const currentBag = availableBags.find((b) => b.id === item.bag) || availableBags[0] || { + id: item.bag, + label: item.bag.toUpperCase(), + tagCode: 'BAG', + }; + // COMPACT LINE VIEW (Single horizontal line per item) + if (compact) { + return ( +
+ {/* Left: Checkbox & Name */} +
+ + + {/* Qty indicator */} + + {item.quantity}× + + + {/* Name */} + { + sound.playTear(); + onTogglePacked(item.id); + }} + className={`font-grotesk font-bold text-xs sm:text-sm truncate cursor-pointer select-none ${ + item.packed ? 'line-through text-ink/50' : 'text-ink' + }`} + > + {item.name} + + + {item.essential && ( + + ESSENTIAL + + )} +
+ + {/* Right: Bag Selector & Category Pill & Actions */} +
+ {/* Bag Selector Dropdown Pill */} + + + {/* Category Pill */} + + + {/* Delete Button */} + +
+
+ ); + } + + // DETAILED TICKET STUB VIEW (Perforated coupon layout) return (
= ({ > {/* Left Tear-Off Coupon Stub: Checkbox Stamp */}
-
- {/* Audio Synthesizer */} -
-
-
- -
- - TACTILE AUDIO SYNTHESIZER - - - Paper tear, luggage stamp, and flight terminal sounds - -
-
- -
-
+ {/* Navigation Tabs */} +
+ + + +
- {/* AI Configuration */} -
-
-
- -
- - OPTIONAL AI FLIGHT DISPATCHER - - - Supports local Ollama on vloser or cloud API - -
-
- -
- - {current.aiEnabled && ( -
-
- - -
- - {current.aiProvider === 'ollama' ? ( -
+
+ {/* TAB 1: GENERAL & AI */} + {activeTab === 'general' && ( +
+ {/* Audio Synthesizer */} +
+
+
+
- - setCurrent({ ...current, ollamaUrl: e.target.value })} - placeholder="http://localhost:11434" - className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" - /> -
-
- - setCurrent({ ...current, ollamaModel: e.target.value })} - placeholder="llama3.2 or mistral" - className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" - /> + + TACTILE AUDIO SYNTHESIZER + + + Paper tear, luggage stamp, and flight terminal sounds +
- ) : ( -
- - setCurrent({ ...current, apiKey: e.target.value })} - placeholder="sk-..." - className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" - /> + +
+
+ + {/* Default List View Mode */} +
+
+
+ +
+ + DEFAULT MANIFEST DENSITY + + + Choose between single-line compact items or full perforated ticket stubs + +
+
+ +
+
+ + {/* AI Configuration */} +
+
+
+ +
+ + OPTIONAL AI FLIGHT DISPATCHER + + + Supports local Ollama on vloser or cloud API + +
+
+ +
+ + {current.aiEnabled && ( +
+
+ + +
+ + {current.aiProvider === 'ollama' ? ( +
+
+ + setCurrent({ ...current, ollamaUrl: e.target.value })} + placeholder="http://localhost:11434" + className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" + /> +
+
+ + setCurrent({ ...current, ollamaModel: e.target.value })} + placeholder="llama3.2 or mistral" + className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" + /> +
+
+ ) : ( +
+ + setCurrent({ ...current, apiKey: e.target.value })} + placeholder="sk-..." + className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" + /> +
+ )}
)}
- )} -
- {/* Backup & Restore */} -
- - DATA ARCHIVE & RESTORE - -
- + {/* Backup & Restore */} +
+ + DATA ARCHIVE & RESTORE + +
+ - + +
+ {importStatus && ( +

+ {importStatus} +

+ )} +
- {importStatus && ( -

- {importStatus} -

- )} -
+ )} - {/* Actions */} + {/* TAB 2: LUGGAGE CONTAINERS / BAGS */} + {activeTab === 'bags' && ( +
+
+
+

+ Configured Luggage Bags & Containers +

+

+ Customize your bags, modify TSA packing advisories, or add custom bags like a camera pack or bike box. +

+
+
+ + {/* List of current bags */} +
+ {current.customBags.map((bag, idx) => ( +
+
+
+ + {bag.tagCode} + + { + const updated = [...current.customBags]; + updated[idx].label = e.target.value.toUpperCase(); + setCurrent({ ...current, customBags: updated }); + }} + className="font-grotesk font-bold text-sm text-ink border-b border-dashed border-ink/40 focus:outline-none focus:border-ink px-1" + /> + • + { + const updated = [...current.customBags]; + updated[idx].sub = e.target.value; + setCurrent({ ...current, customBags: updated }); + }} + className="font-sans text-xs text-ink-muted border-b border-dashed border-ink/40 focus:outline-none focus:border-ink px-1 flex-1" + /> +
+ + { + const updated = [...current.customBags]; + updated[idx].tsaTip = e.target.value; + setCurrent({ ...current, customBags: updated }); + }} + placeholder="TSA or packing advisory note..." + className="w-full font-sans text-[11px] text-ink-muted border-b border-dashed border-ink/30 focus:outline-none px-1 mt-0.5" + /> +
+ + +
+ ))} +
+ + {/* Add New Bag Container Form */} + + + + ADD NEW LUGGAGE CONTAINER + + +
+
+ + setNewBagLabel(e.target.value)} + className="w-full bg-white border border-ink font-grotesk font-bold text-xs p-1.5" + /> +
+
+ + setNewBagSub(e.target.value)} + className="w-full bg-white border border-ink font-sans text-xs p-1.5" + /> +
+
+ + setNewBagCode(e.target.value)} + className="w-full bg-white border border-ink font-mono text-xs p-1.5" + /> +
+
+ +
+
+ + setNewBagTip(e.target.value)} + className="w-full bg-white border border-ink font-sans text-xs p-1.5" + /> +
+
+ + +
+
+ +
+ +
+ +
+ )} + + {/* TAB 3: TRIP ARCHETYPES */} + {activeTab === 'archetypes' && ( +
+
+

+ Trip Archetypes & Expedition Profiles +

+

+ Customize the archetypes available when creating voyages (e.g. add Ocean Cruise, Bikepacking, Wedding, or Desert Trek). +

+
+ + {/* List of current archetypes */} +
+ {current.customArchetypes.map((arch, idx) => ( +
+
+ { + const updated = [...current.customArchetypes]; + updated[idx].label = e.target.value; + setCurrent({ ...current, customArchetypes: updated }); + }} + className="font-grotesk font-bold text-sm text-ink border-b border-dashed border-ink/40 focus:outline-none focus:border-ink px-1 w-full sm:w-64" + /> + { + const updated = [...current.customArchetypes]; + updated[idx].desc = e.target.value; + setCurrent({ ...current, customArchetypes: updated }); + }} + placeholder="Description of activities, packing needs..." + className="w-full font-sans text-xs text-ink-muted border-b border-dashed border-ink/30 focus:outline-none px-1 mt-1" + /> +
+ + +
+ ))} +
+ + {/* Add New Archetype Form */} +
+ + + ADD NEW TRIP ARCHETYPE + + +
+
+ + setNewArchLabel(e.target.value)} + className="w-full bg-white border border-ink font-grotesk font-bold text-xs p-1.5" + /> +
+
+ + setNewArchDesc(e.target.value)} + className="w-full bg-white border border-ink font-sans text-xs p-1.5" + /> +
+
+ +
+ +
+
+
+ )} + + {/* Footer Actions */}
- +
); diff --git a/src/components/TripModal.tsx b/src/components/TripModal.tsx index fb9d544..1be320c 100644 --- a/src/components/TripModal.tsx +++ b/src/components/TripModal.tsx @@ -1,26 +1,18 @@ import React, { useState } from 'react'; -import { TripType, Climate, LuggageConfig, Trip, PackMemory } from '../types'; +import { TripType, Climate, LuggageConfig, Trip, PackMemory, CustomArchetype } from '../types'; import { generateAirportCode, generatePackingList } from '../utils/generator'; import { sound } from '../utils/audio'; -import { X, Plane, Calendar, Cloud, Luggage, Sparkles, Check } from 'lucide-react'; +import { X, Plane, Calendar, Cloud, Luggage, Sparkles, Check, Settings2 } from 'lucide-react'; interface TripModalProps { isOpen: boolean; onClose: () => void; onSaveTrip: (trip: Trip) => void; memory: PackMemory; + customArchetypes: CustomArchetype[]; + onEditArchetypes: () => void; } -const TRIP_TYPES: { id: TripType; label: string; desc: string }[] = [ - { id: 'international_urban', label: 'International City', desc: 'Urban walking, adapters, transit cards' }, - { id: 'beach', label: 'Beach & Tropical', desc: 'Swimwear, reef sunscreen, UV protection' }, - { id: 'alpine_cold', label: 'Cold / Alpine Ski', desc: 'Merino base layers, down coat, thermal gear' }, - { id: 'hiking_camping', label: 'Hiking & Outdoor', desc: 'Trail boots, daypack, headlamp, blister kit' }, - { id: 'business', label: 'Business / Conference', desc: 'Blazer, dress shoes, laptop, power cords' }, - { id: 'weekend_getaway', label: 'Weekend Break', desc: 'Minimalist 2-3 day capsule wardrobe' }, - { id: 'road_trip', label: 'Road Trip / Overland', desc: 'Car gear, casual flex, comfort layers' }, -]; - const CLIMATES: { id: Climate; label: string; icon: string }[] = [ { id: 'hot', label: 'Hot / Sunny', icon: '☀️' }, { id: 'mild', label: 'Mild / Temperate', icon: '⛅' }, @@ -48,12 +40,14 @@ export const TripModal: React.FC = ({ onClose, onSaveTrip, memory, + customArchetypes, + onEditArchetypes, }) => { if (!isOpen) return null; const [destination, setDestination] = useState(''); const [title, setTitle] = useState(''); - const [tripType, setTripType] = useState('international_urban'); + const [tripType, setTripType] = useState(customArchetypes[0]?.id || 'international_urban'); const [durationDays, setDurationDays] = useState(7); const [climate, setClimate] = useState('mild'); const [luggageConfig, setLuggageConfig] = useState('carry_on_only'); @@ -179,11 +173,25 @@ export const TripModal: React.FC = ({ {/* Trip Type */}
- +
+ + +
- {TRIP_TYPES.map((t) => ( + {customArchetypes.map((t) => (