feat: Add custom bag and archetype editors and compact line view mode

This commit is contained in:
airent106 committed 2026-10-15 11:15:19 -06:00
1 parent 805f3fe97a
commit d275808711
8 files changed
+1087 -350

No files matched your search

+3
View File
@@ -200,11 +200,14 @@ app.post('/api/memory/debrief', (req, res) => {
app.get('/api/settings', (_req, res) => { app.get('/api/settings', (_req, res) => {
const settings = readJson(SETTINGS_FILE, { const settings = readJson(SETTINGS_FILE, {
soundEnabled: true, soundEnabled: true,
compactView: false,
aiEnabled: false, aiEnabled: false,
aiProvider: 'ollama', aiProvider: 'ollama',
ollamaUrl: 'http://localhost:11434', ollamaUrl: 'http://localhost:11434',
ollamaModel: 'llama3.2', ollamaModel: 'llama3.2',
apiKey: '', apiKey: '',
customBags: [],
customArchetypes: [],
}); });
res.json(settings); res.json(settings);
}); });
+86 -14
View File
@@ -6,7 +6,9 @@ import {
Category, Category,
PackMemory, PackMemory,
AppSettings, AppSettings,
TripDebrief TripDebrief,
CustomBag,
CustomArchetype
} from './types'; } from './types';
import { StorageClient } from './utils/storage'; import { StorageClient } from './utils/storage';
import { sound } from './utils/audio'; import { sound } from './utils/audio';
@@ -28,7 +30,9 @@ import {
Printer, Printer,
Share2, Share2,
RotateCcw, RotateCcw,
Plane Plane,
List,
LayoutList
} from 'lucide-react'; } from 'lucide-react';
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [ const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
@@ -50,6 +54,7 @@ export function App() {
totalTripsDebriefed: 0, totalTripsDebriefed: 0,
}); });
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings()); const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
const [compactView, setCompactView] = useState<boolean>(false);
// UI Filter states // UI Filter states
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all'); const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
@@ -67,6 +72,7 @@ export function App() {
const [isDebriefModalOpen, setIsDebriefModalOpen] = useState(false); const [isDebriefModalOpen, setIsDebriefModalOpen] = useState(false);
const [isMemoryOpen, setIsMemoryOpen] = useState(false); const [isMemoryOpen, setIsMemoryOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState<'general' | 'bags' | 'archetypes'>('general');
// Load initial data // Load initial data
useEffect(() => { useEffect(() => {
@@ -78,8 +84,13 @@ export function App() {
setTrips(loadedTrips); setTrips(loadedTrips);
setMemory(loadedMemory); setMemory(loadedMemory);
setSettings(loadedSettings); setSettings(loadedSettings);
setCompactView(loadedSettings.compactView || false);
sound.enabled = loadedSettings.soundEnabled; sound.enabled = loadedSettings.soundEnabled;
if (loadedSettings.customBags && loadedSettings.customBags.length > 0) {
setNewItemBag(loadedSettings.customBags[0].id);
}
const savedActiveId = StorageClient.getActiveTripId(); const savedActiveId = StorageClient.getActiveTripId();
if (savedActiveId && loadedTrips.some((t) => t.id === savedActiveId)) { if (savedActiveId && loadedTrips.some((t) => t.id === savedActiveId)) {
setActiveTripId(savedActiveId); setActiveTripId(savedActiveId);
@@ -207,6 +218,13 @@ export function App() {
StorageClient.saveSettings(newSettings); StorageClient.saveSettings(newSettings);
}; };
const toggleCompactView = () => {
sound.playClick();
const next = !compactView;
setCompactView(next);
handleSaveSettings({ ...settings, compactView: next });
};
// Filtered items // Filtered items
const filteredItems = (activeTrip?.items || []).filter((item) => { const filteredItems = (activeTrip?.items || []).filter((item) => {
if (selectedBag !== 'all' && item.bag !== selectedBag) return false; if (selectedBag !== 'all' && item.bag !== selectedBag) return false;
@@ -233,7 +251,10 @@ export function App() {
onNewTrip={() => setIsTripModalOpen(true)} onNewTrip={() => setIsTripModalOpen(true)}
onOpenDebrief={() => setIsDebriefModalOpen(true)} onOpenDebrief={() => setIsDebriefModalOpen(true)}
onOpenMemory={() => setIsMemoryOpen(true)} onOpenMemory={() => setIsMemoryOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)} onOpenSettings={() => {
setSettingsTab('general');
setIsSettingsOpen(true);
}}
soundEnabled={settings.soundEnabled} soundEnabled={settings.soundEnabled}
onToggleSound={() => { onToggleSound={() => {
const next = !settings.soundEnabled; const next = !settings.soundEnabled;
@@ -252,9 +273,14 @@ export function App() {
selectedBag={selectedBag} selectedBag={selectedBag}
onSelectBag={setSelectedBag} onSelectBag={setSelectedBag}
luggageConfig={activeTrip.luggageConfig} luggageConfig={activeTrip.luggageConfig}
customBags={settings.customBags}
onEditBags={() => {
setSettingsTab('bags');
setIsSettingsOpen(true);
}}
/> />
{/* Quick Add Perforated Coupon Strip */} {/* Quick Add Strip */}
<div className="mb-6 bg-ticket-100 border-2 border-ink p-3 shadow-card-hard-sm"> <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"> <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" /> <Plus size={14} className="text-gate-orange" />
@@ -285,13 +311,14 @@ export function App() {
<select <select
value={newItemBag} value={newItemBag}
onChange={(e) => setNewItemBag(e.target.value as BagType)} onChange={(e) => setNewItemBag(e.target.value)}
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer" 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> {settings.customBags.map((b) => (
<option value="carry_on">Carry-On Roller</option> <option key={b.id} value={b.id}>
<option value="checked">Checked Bag</option> {b.label} ({b.tagCode})
<option value="worn">Worn on Person</option> </option>
))}
</select> </select>
<button <button
@@ -303,7 +330,7 @@ export function App() {
</form> </form>
</div> </div>
{/* Filter & Search Bar */} {/* Filter, View Toggle & 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"> <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 */} {/* Category Pills */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full"> <div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full">
@@ -325,8 +352,39 @@ export function App() {
))} ))}
</div> </div>
{/* Search & State Filter */} {/* Search, State Filter & Compact View Toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{/* Compact vs Detailed Toggle */}
<div className="flex border-2 border-ink bg-white font-mono text-xs shadow-xs">
<button
type="button"
onClick={() => {
if (compactView) toggleCompactView();
}}
className={`px-2 py-1 flex items-center gap-1 font-bold ${
!compactView ? 'bg-ink text-ticket-100' : 'text-ink hover:bg-ticket-100'
}`}
title="Show full ticket stubs with notes and metadata"
>
<LayoutList size={13} />
<span className="hidden sm:inline">DETAILED</span>
</button>
<button
type="button"
onClick={() => {
if (!compactView) toggleCompactView();
}}
className={`px-2 py-1 border-l-2 border-ink flex items-center gap-1 font-bold ${
compactView ? 'bg-ink text-ticket-100' : 'text-ink hover:bg-ticket-100'
}`}
title="Hide extra details and show each item on a clean single line"
>
<List size={13} />
<span>COMPACT LINES</span>
</button>
</div>
{/* Search Input */}
<div className="relative"> <div className="relative">
<Search <Search
size={14} size={14}
@@ -337,10 +395,11 @@ export function App() {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search manifest..." 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"
/> />
</div> </div>
{/* State Filter (All / Unpacked / Stowed) */}
<div className="flex border border-ink bg-white font-mono text-xs"> <div className="flex border border-ink bg-white font-mono text-xs">
<button <button
onClick={() => { onClick={() => {
@@ -382,6 +441,11 @@ export function App() {
(FILTERED TO {selectedBag.toUpperCase()}) (FILTERED TO {selectedBag.toUpperCase()})
</span> </span>
)} )}
{compactView && (
<span className="text-blue-700 font-bold ml-1">
[COMPACT MODE]
</span>
)}
</span> </span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -405,7 +469,7 @@ export function App() {
</div> </div>
</div> </div>
{/* List of Perforated Stubs */} {/* List of Items (Compact Lines or Perforated Stubs) */}
{filteredItems.length === 0 ? ( {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"> <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"> <p className="text-sm font-bold uppercase text-ink mb-1">
@@ -421,6 +485,8 @@ export function App() {
<ItemRow <ItemRow
key={item.id} key={item.id}
item={item} item={item}
compact={compactView}
availableBags={settings.customBags}
onTogglePacked={handleTogglePacked} onTogglePacked={handleTogglePacked}
onUpdateQuantity={handleUpdateQuantity} onUpdateQuantity={handleUpdateQuantity}
onUpdateBag={handleUpdateBag} onUpdateBag={handleUpdateBag}
@@ -470,6 +536,11 @@ export function App() {
onClose={() => setIsTripModalOpen(false)} onClose={() => setIsTripModalOpen(false)}
onSaveTrip={handleSaveTrip} onSaveTrip={handleSaveTrip}
memory={memory} memory={memory}
customArchetypes={settings.customArchetypes}
onEditArchetypes={() => {
setSettingsTab('archetypes');
setIsSettingsOpen(true);
}}
/> />
{activeTrip && ( {activeTrip && (
@@ -492,6 +563,7 @@ export function App() {
isOpen={isSettingsOpen} isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)} onClose={() => setIsSettingsOpen(false)}
settings={settings} settings={settings}
initialTab={settingsTab}
onSaveSettings={handleSaveSettings} onSaveSettings={handleSaveSettings}
trips={trips} trips={trips}
memory={memory} memory={memory}
+127 -119
View File
@@ -1,13 +1,45 @@
import React from 'react'; import React from 'react';
import { BagType, PackingItem, LuggageConfig } from '../types'; import { BagType, PackingItem, LuggageConfig, CustomBag } from '../types';
import { sound } from '../utils/audio'; 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 { interface BagOverviewProps {
items: PackingItem[]; items: PackingItem[];
selectedBag: BagType | 'all'; selectedBag: BagType | 'all';
onSelectBag: (bag: BagType | 'all') => void; onSelectBag: (bag: BagType | 'all') => void;
luggageConfig: LuggageConfig; luggageConfig: LuggageConfig;
customBags: CustomBag[];
onEditBags: () => void;
}
function getBagIcon(iconKey?: string) {
switch (iconKey) {
case 'backpack':
return <Backpack size={20} strokeWidth={2.2} />;
case 'briefcase':
return <Briefcase size={20} strokeWidth={2.2} />;
case 'package':
return <Package size={20} strokeWidth={2.2} />;
case 'user':
return <UserCheck size={20} strokeWidth={2.2} />;
case 'camera':
return <Camera size={20} strokeWidth={2.2} />;
case 'compass':
return <Compass size={20} strokeWidth={2.2} />;
default:
return <Tag size={20} strokeWidth={2.2} />;
}
} }
export const BagOverview: React.FC<BagOverviewProps> = ({ export const BagOverview: React.FC<BagOverviewProps> = ({
@@ -15,64 +47,39 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
selectedBag, selectedBag,
onSelectBag, onSelectBag,
luggageConfig, luggageConfig,
customBags,
onEditBags,
}) => { }) => {
const bags: { // Filter bags based on luggage mode if it's a default bag
type: BagType; const visibleBags = customBags.filter((b) => {
label: string; if (b.id === 'carry_on' && luggageConfig === 'backpack_only') return false;
sub: string; if (b.id === 'checked' && luggageConfig !== 'checked_bag') return false;
icon: React.ReactNode; return true;
tsaTip: string; });
tagCode: string;
visible: boolean;
}[] = [
{
type: 'personal_item',
label: 'PERSONAL ITEM',
sub: 'Under-Seat / Daypack',
icon: <Backpack size={20} strokeWidth={2.2} />,
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: <Briefcase size={20} strokeWidth={2.2} />,
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: <Package size={20} strokeWidth={2.2} />,
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: <UserCheck size={20} strokeWidth={2.2} />,
tsaTip: 'Heaviest boots & airport jacket to minimize luggage bulk',
tagCode: 'PAX-WORN',
visible: true,
},
];
return ( return (
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2 flex-wrap gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-xs uppercase font-bold text-ink tracking-wider"> <span className="font-mono text-xs uppercase font-bold text-ink tracking-wider">
LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG" LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG"
</span> </span>
<button
type="button"
onClick={() => {
sound.playClick();
onEditBags();
}}
className="font-mono text-[11px] font-bold px-2 py-0.5 border border-ink bg-white hover:bg-canary text-ink transition-colors flex items-center gap-1 shadow-xs"
title="Add, rename, or reconfigure bags"
>
<Settings2 size={12} />
<span>EDIT BAGS</span>
</button>
</div> </div>
<button <button
type="button"
onClick={() => { onClick={() => {
sound.playClick(); sound.playClick();
onSelectBag('all'); onSelectBag('all');
@@ -89,86 +96,87 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{bags {visibleBags.map((b) => {
.filter((b) => b.visible) const bagItems = items.filter((i) => i.bag === b.id);
.map((b) => { const bagPacked = bagItems.filter((i) => i.packed).length;
const bagItems = items.filter((i) => i.bag === b.type); const bagTotal = bagItems.length;
const bagPacked = bagItems.filter((i) => i.packed).length; const pct = bagTotal > 0 ? Math.round((bagPacked / bagTotal) * 100) : 0;
const bagTotal = bagItems.length; const isSelected = selectedBag === b.id;
const pct = bagTotal > 0 ? Math.round((bagPacked / bagTotal) * 100) : 0; const isFull = bagTotal > 0 && bagPacked === bagTotal;
const isSelected = selectedBag === b.type;
const isFull = bagTotal > 0 && bagPacked === bagTotal;
return ( return (
<button <button
key={b.type} type="button"
onClick={() => { key={b.id}
sound.playClick(); onClick={() => {
onSelectBag(isSelected ? 'all' : b.type); sound.playClick();
}} onSelectBag(isSelected ? 'all' : b.id);
className={`relative text-left p-3.5 border-2 border-ink transition-all flex flex-col justify-between ${ }}
isSelected className={`relative text-left p-3.5 border-2 border-ink transition-all flex flex-col justify-between ${
? 'bg-canary-light shadow-card-hard border-ink translate-x-0.5 translate-y-0.5' isSelected
: 'bg-ticket-50 hover:bg-ticket-100 shadow-card-hard-sm' ? 'bg-canary-light shadow-card-hard border-ink translate-x-0.5 translate-y-0.5'
}`} : 'bg-ticket-50 hover:bg-ticket-100 shadow-card-hard-sm'
> }`}
{/* Perforation holes on card edges */} >
<div className="ticket-notch-left" /> {/* Perforation holes on card edges */}
<div className="ticket-notch-right" /> <div className="ticket-notch-left" />
<div className="ticket-notch-right" />
<div> <div>
{/* Top Barcode Header */} {/* Top Barcode Header */}
<div className="flex items-center justify-between border-b border-ink/20 pb-2 mb-2 font-mono text-[10px]"> <div className="flex items-center justify-between border-b border-ink/20 pb-2 mb-2 font-mono text-[10px]">
<span className="bg-ink text-ticket-100 px-1 py-0.5 font-bold uppercase"> <span className="bg-ink text-ticket-100 px-1 py-0.5 font-bold uppercase">
{b.tagCode} {b.tagCode}
</span> </span>
<span className="text-ink-muted tracking-tight"> <span className="text-ink-muted tracking-tight truncate max-w-[130px]">
{b.sub} {b.sub}
</span>
</div>
{/* Title & Icon */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 text-ink min-w-0">
<span className="text-ink shrink-0">{getBagIcon(b.iconKey)}</span>
<span className="font-display text-xl tracking-wide uppercase leading-tight truncate">
{b.label}
</span> </span>
</div> </div>
{isFull && (
<CheckCircle2 size={18} className="text-stamp-green shrink-0" />
)}
</div>
{/* Title & Icon */} {/* TSA / Bag Advisory Tip */}
<div className="flex items-center justify-between gap-2 mb-1"> {b.tsaTip && (
<div className="flex items-center gap-2 text-ink"> <p className="font-sans text-[11px] text-ink-muted leading-tight mb-3 line-clamp-2">
<span className="text-ink">{b.icon}</span>
<span className="font-display text-xl tracking-wide uppercase leading-tight">
{b.label}
</span>
</div>
{isFull && (
<CheckCircle2 size={18} className="text-stamp-green shrink-0" />
)}
</div>
{/* TSA Advisory Tip */}
<p className="font-sans text-[11px] text-ink-muted leading-tight mb-3">
{b.tsaTip} {b.tsaTip}
</p> </p>
)}
</div>
{/* Meter and Stowed Count */}
<div>
<div className="flex items-center justify-between font-mono text-xs font-bold mb-1">
<span className="text-ink">
{bagPacked}/{bagTotal} ITEMS
</span>
<span className={isFull ? 'text-stamp-green' : 'text-ink-muted'}>
{pct}%
</span>
</div> </div>
{/* Meter and Stowed Count */} <div className="w-full bg-ticket-200 border border-ink h-2.5 overflow-hidden">
<div> <div
<div className="flex items-center justify-between font-mono text-xs font-bold mb-1"> className={`h-full transition-all duration-200 ${
<span className="text-ink"> isFull ? 'bg-stamp-green' : 'bg-gate-orange'
{bagPacked}/{bagTotal} ITEMS }`}
</span> style={{ width: `${pct}%` }}
<span className={isFull ? 'text-stamp-green' : 'text-ink-muted'}> />
{pct}%
</span>
</div>
<div className="w-full bg-ticket-200 border border-ink h-2.5 overflow-hidden">
<div
className={`h-full transition-all duration-200 ${
isFull ? 'bg-stamp-green' : 'bg-gate-orange'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div> </div>
</button> </div>
); </button>
})} );
})}
</div> </div>
</div> </div>
); );
+115 -13
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { PackingItem, BagType, Category } from '../types'; import { PackingItem, BagType, Category, CustomBag } from '../types';
import { sound } from '../utils/audio'; import { sound } from '../utils/audio';
import { import {
Check, Check,
@@ -16,19 +16,14 @@ import {
interface ItemRowProps { interface ItemRowProps {
item: PackingItem; item: PackingItem;
compact?: boolean;
availableBags: CustomBag[];
onTogglePacked: (id: string) => void; onTogglePacked: (id: string) => void;
onUpdateQuantity: (id: string, delta: number) => void; onUpdateQuantity: (id: string, delta: number) => void;
onUpdateBag: (id: string, bag: BagType) => void; onUpdateBag: (id: string, bag: BagType) => void;
onDelete: (id: string) => 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: <Backpack size={12} /> },
{ type: 'carry_on', label: 'Carry-On Roller', short: 'CARRY', icon: <Briefcase size={12} /> },
{ type: 'checked', label: 'Checked Bag', short: 'CHKD', icon: <Package size={12} /> },
{ type: 'worn', label: 'Worn on Person', short: 'WORN', icon: <UserCheck size={12} /> },
];
const CATEGORY_COLORS: Record<Category, { bg: string; text: string; label: string }> = { const CATEGORY_COLORS: Record<Category, { bg: string; text: string; label: string }> = {
clothing: { bg: 'bg-[#E3EAF7]', text: 'text-[#1D3E74]', label: 'CLOTHING' }, clothing: { bg: 'bg-[#E3EAF7]', text: 'text-[#1D3E74]', label: 'CLOTHING' },
toiletries: { bg: 'bg-[#E3F5EC]', text: 'text-[#15663F]', label: 'TOILETRIES' }, toiletries: { bg: 'bg-[#E3F5EC]', text: 'text-[#15663F]', label: 'TOILETRIES' },
@@ -40,14 +35,117 @@ const CATEGORY_COLORS: Record<Category, { bg: string; text: string; label: strin
export const ItemRow: React.FC<ItemRowProps> = ({ export const ItemRow: React.FC<ItemRowProps> = ({
item, item,
compact = false,
availableBags,
onTogglePacked, onTogglePacked,
onUpdateQuantity, onUpdateQuantity,
onUpdateBag, onUpdateBag,
onDelete, onDelete,
}) => { }) => {
const catStyle = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.gear; 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 (
<div
className={`group relative flex items-center justify-between border-2 py-1.5 px-3 mb-1.5 transition-all shadow-xs ${
item.packed
? 'bg-[#ECE8DC]/70 border-ink/40 text-ink/60'
: 'bg-ticket-50 hover:bg-ticket-100 border-ink'
}`}
>
{/* Left: Checkbox & Name */}
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<button
type="button"
onClick={() => {
sound.playTear();
onTogglePacked(item.id);
}}
className={`w-5 h-5 rounded border-2 flex items-center justify-center font-mono font-bold shrink-0 transition-transform ${
item.packed
? 'border-stamp-green bg-stamp-green text-white rotate-[-3deg]'
: 'border-ink/60 bg-white hover:border-ink'
}`}
title={item.packed ? 'Mark as unpacked' : 'Check off'}
>
{item.packed ? <Check size={12} strokeWidth={3} /> : null}
</button>
{/* Qty indicator */}
<span className="font-mono text-xs font-bold text-ink shrink-0 bg-ticket-200 border border-ink/20 px-1.5 py-0.2 rounded-xs">
{item.quantity}×
</span>
{/* Name */}
<span
onClick={() => {
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}
</span>
{item.essential && (
<span className="font-mono text-[9px] font-bold bg-gate-orange text-white px-1 py-0.2 uppercase shrink-0 hidden sm:inline">
ESSENTIAL
</span>
)}
</div>
{/* Right: Bag Selector & Category Pill & Actions */}
<div className="flex items-center gap-2 shrink-0 ml-2">
{/* Bag Selector Dropdown Pill */}
<select
value={item.bag}
onChange={(e) => {
sound.playClick();
onUpdateBag(item.id, e.target.value);
}}
className="bg-white border border-ink font-mono text-[10px] font-bold px-1.5 py-0.5 cursor-pointer hover:bg-canary-light"
title="Assigned Bag"
>
{availableBags.map((b) => (
<option key={b.id} value={b.id}>
{b.tagCode} ({b.label})
</option>
))}
</select>
{/* Category Pill */}
<span
className={`font-mono text-[9px] font-bold px-1.5 py-0.2 border border-ink/20 hidden md:inline ${catStyle.bg} ${catStyle.text}`}
>
{catStyle.label}
</span>
{/* Delete Button */}
<button
type="button"
onClick={() => {
sound.playClick();
onDelete(item.id);
}}
className="p-1 text-ink/30 hover:text-stamp-red transition-colors"
title="Delete item"
>
<Trash2 size={13} />
</button>
</div>
</div>
);
}
// DETAILED TICKET STUB VIEW (Perforated coupon layout)
return ( return (
<div <div
className={`group relative flex flex-col sm:flex-row items-stretch border-2 border-ink transition-all mb-2 shadow-card-hard-sm ${ className={`group relative flex flex-col sm:flex-row items-stretch border-2 border-ink transition-all mb-2 shadow-card-hard-sm ${
@@ -58,6 +156,7 @@ export const ItemRow: React.FC<ItemRowProps> = ({
> >
{/* Left Tear-Off Coupon Stub: Checkbox Stamp */} {/* Left Tear-Off Coupon Stub: Checkbox Stamp */}
<button <button
type="button"
onClick={() => { onClick={() => {
sound.playTear(); sound.playTear();
onTogglePacked(item.id); onTogglePacked(item.id);
@@ -147,6 +246,7 @@ export const ItemRow: React.FC<ItemRowProps> = ({
<div className="flex items-center gap-1.5 bg-ticket-200 border border-ink/30 px-1.5 py-0.5"> <div className="flex items-center gap-1.5 bg-ticket-200 border border-ink/30 px-1.5 py-0.5">
<span className="text-[10px] text-ink-muted uppercase mr-1">QTY:</span> <span className="text-[10px] text-ink-muted uppercase mr-1">QTY:</span>
<button <button
type="button"
onClick={() => { onClick={() => {
sound.playClick(); sound.playClick();
onUpdateQuantity(item.id, -1); onUpdateQuantity(item.id, -1);
@@ -160,6 +260,7 @@ export const ItemRow: React.FC<ItemRowProps> = ({
{item.quantity} {item.quantity}
</span> </span>
<button <button
type="button"
onClick={() => { onClick={() => {
sound.playClick(); sound.playClick();
onUpdateQuantity(item.id, 1); onUpdateQuantity(item.id, 1);
@@ -180,19 +281,20 @@ export const ItemRow: React.FC<ItemRowProps> = ({
value={item.bag} value={item.bag}
onChange={(e) => { onChange={(e) => {
sound.playClick(); sound.playClick();
onUpdateBag(item.id, e.target.value as BagType); onUpdateBag(item.id, e.target.value);
}} }}
className="bg-white border border-ink font-mono text-[11px] font-bold px-2 py-1 shadow-card-hard-sm cursor-pointer hover:bg-canary-light transition-colors" className="bg-white border border-ink font-mono text-[11px] font-bold px-2 py-1 shadow-card-hard-sm cursor-pointer hover:bg-canary-light transition-colors"
> >
{BAG_OPTIONS.map((opt) => ( {availableBags.map((opt) => (
<option key={opt.type} value={opt.type}> <option key={opt.id} value={opt.id}>
{opt.label} ({opt.short}) {opt.label} ({opt.tagCode})
</option> </option>
))} ))}
</select> </select>
{/* Delete Item */} {/* Delete Item */}
<button <button
type="button"
onClick={() => { onClick={() => {
sound.playClick(); sound.playClick();
onDelete(item.id); onDelete(item.id);
+615 -164
View File
@@ -1,7 +1,23 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { AppSettings, Trip, PackMemory } from '../types'; import { AppSettings, Trip, PackMemory, CustomBag, CustomArchetype } from '../types';
import { sound } from '../utils/audio'; import { sound } from '../utils/audio';
import { X, Settings as SettingsIcon, Volume2, Bot, Download, Upload, Check } from 'lucide-react'; import {
X,
Settings as SettingsIcon,
Volume2,
Bot,
Download,
Upload,
Check,
Luggage,
Compass,
Plus,
Trash2,
Edit3,
Layers,
List,
LayoutGrid
} from 'lucide-react';
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -11,6 +27,7 @@ interface SettingsModalProps {
trips: Trip[]; trips: Trip[];
memory: PackMemory; memory: PackMemory;
onImportData: (data: { trips: Trip[]; memory: PackMemory }) => void; onImportData: (data: { trips: Trip[]; memory: PackMemory }) => void;
initialTab?: 'general' | 'bags' | 'archetypes';
} }
export const SettingsModal: React.FC<SettingsModalProps> = ({ export const SettingsModal: React.FC<SettingsModalProps> = ({
@@ -21,20 +38,113 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
trips, trips,
memory, memory,
onImportData, onImportData,
initialTab = 'general',
}) => { }) => {
if (!isOpen) return null; if (!isOpen) return null;
const [activeTab, setActiveTab] = useState<'general' | 'bags' | 'archetypes'>(initialTab);
const [current, setCurrent] = useState<AppSettings>({ ...settings }); const [current, setCurrent] = useState<AppSettings>({ ...settings });
const [importStatus, setImportStatus] = useState<string | null>(null); const [importStatus, setImportStatus] = useState<string | null>(null);
const handleSave = (e: React.FormEvent) => { // New Bag Form State
e.preventDefault(); const [newBagLabel, setNewBagLabel] = useState('');
const [newBagSub, setNewBagSub] = useState('');
const [newBagCode, setNewBagCode] = useState('');
const [newBagTip, setNewBagTip] = useState('');
const [newBagIcon, setNewBagIcon] = useState<'backpack' | 'briefcase' | 'package' | 'user' | 'camera' | 'compass' | 'tag'>('tag');
// New Archetype Form State
const [newArchLabel, setNewArchLabel] = useState('');
const [newArchDesc, setNewArchDesc] = useState('');
// Sync initial tab when reopened
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab, isOpen]);
const handleSave = (e?: React.FormEvent) => {
if (e) e.preventDefault();
sound.playStamp(); sound.playStamp();
sound.enabled = current.soundEnabled; sound.enabled = current.soundEnabled;
onSaveSettings(current); onSaveSettings(current);
onClose(); onClose();
}; };
const handleAddBag = (e: React.FormEvent) => {
e.preventDefault();
const cleanLabel = newBagLabel.trim();
if (!cleanLabel) return;
sound.playClick();
const id = `bag_${cleanLabel.toLowerCase().replace(/[^a-z0-9]/g, '_')}_${Date.now()}`;
const code = newBagCode.trim().toUpperCase() || `BAG-${current.customBags.length + 1}`;
const newBag: CustomBag = {
id,
label: cleanLabel.toUpperCase(),
sub: newBagSub.trim() || 'Custom Luggage Container',
tagCode: code,
tsaTip: newBagTip.trim() || 'Custom container for organized gear',
iconKey: newBagIcon,
};
setCurrent({
...current,
customBags: [...current.customBags, newBag],
});
setNewBagLabel('');
setNewBagSub('');
setNewBagCode('');
setNewBagTip('');
};
const handleDeleteBag = (bagId: string) => {
if (current.customBags.length <= 1) {
alert('You must have at least one luggage container.');
return;
}
sound.playClick();
setCurrent({
...current,
customBags: current.customBags.filter((b) => b.id !== bagId),
});
};
const handleAddArchetype = (e: React.FormEvent) => {
e.preventDefault();
const cleanLabel = newArchLabel.trim();
if (!cleanLabel) return;
sound.playClick();
const id = cleanLabel.toLowerCase().replace(/[^a-z0-9]/g, '_');
const newArch: CustomArchetype = {
id,
label: cleanLabel,
desc: newArchDesc.trim() || 'Custom trip packing archetype',
};
setCurrent({
...current,
customArchetypes: [...current.customArchetypes, newArch],
});
setNewArchLabel('');
setNewArchDesc('');
};
const handleDeleteArchetype = (archId: string) => {
if (current.customArchetypes.length <= 1) {
alert('You must have at least one trip archetype.');
return;
}
sound.playClick();
setCurrent({
...current,
customArchetypes: current.customArchetypes.filter((a) => a.id !== archId),
});
};
const handleExport = () => { const handleExport = () => {
sound.playClick(); sound.playClick();
const exportBundle = { const exportBundle = {
@@ -69,6 +179,10 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
trips: parsed.trips, trips: parsed.trips,
memory: parsed.memory || { exclusions: [], inclusions: [], totalTripsDebriefed: 0 }, memory: parsed.memory || { exclusions: [], inclusions: [], totalTripsDebriefed: 0 },
}); });
if (parsed.settings) {
setCurrent({ ...current, ...parsed.settings });
onSaveSettings({ ...current, ...parsed.settings });
}
setImportStatus('Backup successfully restored!'); setImportStatus('Backup successfully restored!');
setTimeout(() => setImportStatus(null), 3000); setTimeout(() => setImportStatus(null), 3000);
} else { } else {
@@ -83,16 +197,17 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
return ( return (
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto"> <div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
<div className="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150"> <div className="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
{/* Header */} {/* Header */}
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary"> <div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SettingsIcon size={20} className="text-canary" /> <SettingsIcon size={20} className="text-canary" />
<h2 className="font-display text-2xl tracking-wide uppercase text-canary"> <h2 className="font-display text-2xl tracking-wide uppercase text-canary">
DISPATCH TERMINAL SETTINGS DISPATCH TERMINAL & REGISTRY CONFIGURATION
</h2> </h2>
</div> </div>
<button <button
type="button"
onClick={() => { onClick={() => {
sound.playClick(); sound.playClick();
onClose(); onClose();
@@ -103,171 +218,506 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</button> </button>
</div> </div>
<form onSubmit={handleSave} className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-6"> {/* Navigation Tabs */}
{/* Audio Synthesizer */} <div className="bg-ticket-200 border-b-2 border-ink px-4 pt-2 flex gap-1 font-mono text-xs font-bold overflow-x-auto">
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm"> <button
<div className="flex items-center justify-between"> type="button"
<div className="flex items-center gap-2"> onClick={() => {
<Volume2 size={18} className="text-gate-orange" /> sound.playClick();
<div> setActiveTab('general');
<span className="font-mono text-xs font-bold uppercase text-ink block"> }}
TACTILE AUDIO SYNTHESIZER className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all ${
</span> activeTab === 'general'
<span className="font-sans text-[11px] text-ink-muted"> ? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
Paper tear, luggage stamp, and flight terminal sounds : 'bg-white/60 text-ink-muted hover:bg-white'
</span> }`}
</div> >
</div> GENERAL & AI
<button </button>
type="button" <button
onClick={() => { type="button"
const next = !current.soundEnabled; onClick={() => {
setCurrent({ ...current, soundEnabled: next }); sound.playClick();
sound.enabled = next; setActiveTab('bags');
if (next) sound.playStamp(); }}
}} className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all flex items-center gap-1.5 ${
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${ activeTab === 'bags'
current.soundEnabled ? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
? 'bg-stamp-green-light text-stamp-green border-stamp-green' : 'bg-white/60 text-ink-muted hover:bg-white'
: 'bg-ticket-200 text-ink-muted' }`}
}`} >
> <Luggage size={13} />
{current.soundEnabled ? 'ENABLED' : 'MUTED'} <span>LUGGAGE CONTAINERS ({current.customBags.length})</span>
</button> </button>
</div> <button
</div> type="button"
onClick={() => {
sound.playClick();
setActiveTab('archetypes');
}}
className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all flex items-center gap-1.5 ${
activeTab === 'archetypes'
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
: 'bg-white/60 text-ink-muted hover:bg-white'
}`}
>
<Compass size={13} />
<span>TRIP ARCHETYPES ({current.customArchetypes.length})</span>
</button>
</div>
{/* AI Configuration */} <div className="p-4 sm:p-6 max-h-[72vh] overflow-y-auto space-y-5">
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm space-y-3"> {/* TAB 1: GENERAL & AI */}
<div className="flex items-center justify-between border-b border-ink/20 pb-2"> {activeTab === 'general' && (
<div className="flex items-center gap-2"> <div className="space-y-5">
<Bot size={18} className="text-purple-700" /> {/* Audio Synthesizer */}
<div> <div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm">
<span className="font-mono text-xs font-bold uppercase text-ink block"> <div className="flex items-center justify-between">
OPTIONAL AI FLIGHT DISPATCHER <div className="flex items-center gap-2">
</span> <Volume2 size={18} className="text-gate-orange" />
<span className="font-sans text-[11px] text-ink-muted">
Supports local Ollama on vloser or cloud API
</span>
</div>
</div>
<button
type="button"
onClick={() => {
sound.playClick();
setCurrent({ ...current, aiEnabled: !current.aiEnabled });
}}
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
current.aiEnabled
? 'bg-purple-100 text-purple-900 border-purple-400'
: 'bg-ticket-200 text-ink-muted'
}`}
>
{current.aiEnabled ? 'AI ACTIVE' : 'OFFLINE ONLY'}
</button>
</div>
{current.aiEnabled && (
<div className="space-y-3 pt-2">
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Provider
</label>
<select
value={current.aiProvider}
onChange={(e) =>
setCurrent({
...current,
aiProvider: e.target.value as 'ollama' | 'gemini' | 'openai',
})
}
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2.5 py-1.5"
>
<option value="ollama">Local Ollama (Self-Hosted on vloser)</option>
<option value="gemini">Google Gemini API</option>
<option value="openai">OpenAI API</option>
</select>
</div>
{current.aiProvider === 'ollama' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div> <div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1"> <span className="font-mono text-xs font-bold uppercase text-ink block">
Ollama Endpoint URL TACTILE AUDIO SYNTHESIZER
</label> </span>
<input <span className="font-sans text-[11px] text-ink-muted">
type="text" Paper tear, luggage stamp, and flight terminal sounds
value={current.ollamaUrl} </span>
onChange={(e) => 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"
/>
</div>
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Model Tag
</label>
<input
type="text"
value={current.ollamaModel}
onChange={(e) => 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"
/>
</div> </div>
</div> </div>
) : ( <button
<div> type="button"
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1"> onClick={() => {
API Key const next = !current.soundEnabled;
</label> setCurrent({ ...current, soundEnabled: next });
<input sound.enabled = next;
type="password" if (next) sound.playStamp();
value={current.apiKey} }}
onChange={(e) => setCurrent({ ...current, apiKey: e.target.value })} className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
placeholder="sk-..." current.soundEnabled
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5" ? 'bg-stamp-green-light text-stamp-green border-stamp-green'
/> : 'bg-ticket-200 text-ink-muted'
}`}
>
{current.soundEnabled ? 'ENABLED' : 'MUTED'}
</button>
</div>
</div>
{/* Default List View Mode */}
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<List size={18} className="text-blue-600" />
<div>
<span className="font-mono text-xs font-bold uppercase text-ink block">
DEFAULT MANIFEST DENSITY
</span>
<span className="font-sans text-[11px] text-ink-muted">
Choose between single-line compact items or full perforated ticket stubs
</span>
</div>
</div>
<button
type="button"
onClick={() => {
sound.playClick();
setCurrent({ ...current, compactView: !current.compactView });
}}
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
current.compactView
? 'bg-blue-100 text-blue-900 border-blue-400'
: 'bg-ticket-200 text-ink'
}`}
>
{current.compactView ? 'COMPACT LINES' : 'DETAILED STUBS'}
</button>
</div>
</div>
{/* AI Configuration */}
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm space-y-3">
<div className="flex items-center justify-between border-b border-ink/20 pb-2">
<div className="flex items-center gap-2">
<Bot size={18} className="text-purple-700" />
<div>
<span className="font-mono text-xs font-bold uppercase text-ink block">
OPTIONAL AI FLIGHT DISPATCHER
</span>
<span className="font-sans text-[11px] text-ink-muted">
Supports local Ollama on vloser or cloud API
</span>
</div>
</div>
<button
type="button"
onClick={() => {
sound.playClick();
setCurrent({ ...current, aiEnabled: !current.aiEnabled });
}}
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
current.aiEnabled
? 'bg-purple-100 text-purple-900 border-purple-400'
: 'bg-ticket-200 text-ink-muted'
}`}
>
{current.aiEnabled ? 'AI ACTIVE' : 'OFFLINE ONLY'}
</button>
</div>
{current.aiEnabled && (
<div className="space-y-3 pt-2">
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Provider
</label>
<select
value={current.aiProvider}
onChange={(e) =>
setCurrent({
...current,
aiProvider: e.target.value as 'ollama' | 'gemini' | 'openai',
})
}
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2.5 py-1.5"
>
<option value="ollama">Local Ollama (Self-Hosted on vloser)</option>
<option value="gemini">Google Gemini API</option>
<option value="openai">OpenAI API</option>
</select>
</div>
{current.aiProvider === 'ollama' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Ollama Endpoint URL
</label>
<input
type="text"
value={current.ollamaUrl}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Model Tag
</label>
<input
type="text"
value={current.ollamaModel}
onChange={(e) => 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"
/>
</div>
</div>
) : (
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
API Key
</label>
<input
type="password"
value={current.apiKey}
onChange={(e) => 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"
/>
</div>
)}
</div> </div>
)} )}
</div> </div>
)}
</div>
{/* Backup & Restore */} {/* Backup & Restore */}
<div className="bg-ticket-200/60 border-2 border-ink p-3.5 space-y-2"> <div className="bg-ticket-200/60 border-2 border-ink p-3.5 space-y-2">
<span className="font-mono text-xs font-bold uppercase text-ink block"> <span className="font-mono text-xs font-bold uppercase text-ink block">
DATA ARCHIVE & RESTORE DATA ARCHIVE & RESTORE
</span> </span>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<button <button
type="button" type="button"
onClick={handleExport} onClick={handleExport}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50" className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50 shadow-xs"
> >
<Download size={13} /> <Download size={13} />
<span>EXPORT MANIFEST BACKUP (.JSON)</span> <span>EXPORT MANIFEST BACKUP (.JSON)</span>
</button> </button>
<label className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50 cursor-pointer"> <label className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50 cursor-pointer shadow-xs">
<Upload size={13} /> <Upload size={13} />
<span>IMPORT BACKUP</span> <span>IMPORT BACKUP</span>
<input <input
type="file" type="file"
accept=".json" accept=".json"
onChange={handleImport} onChange={handleImport}
className="hidden" className="hidden"
/> />
</label> </label>
</div>
{importStatus && (
<p className="font-mono text-xs font-bold text-stamp-green mt-1">
{importStatus}
</p>
)}
</div>
</div> </div>
{importStatus && ( )}
<p className="font-mono text-xs font-bold text-stamp-green mt-1">
{importStatus}
</p>
)}
</div>
{/* Actions */} {/* TAB 2: LUGGAGE CONTAINERS / BAGS */}
{activeTab === 'bags' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-grotesk font-bold text-sm text-ink uppercase">
Configured Luggage Bags & Containers
</h3>
<p className="font-sans text-xs text-ink-muted">
Customize your bags, modify TSA packing advisories, or add custom bags like a camera pack or bike box.
</p>
</div>
</div>
{/* List of current bags */}
<div className="space-y-2">
{current.customBags.map((bag, idx) => (
<div
key={bag.id}
className="p-3 bg-white border-2 border-ink shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs bg-ink text-ticket-100 px-1.5 py-0.5 font-bold uppercase">
{bag.tagCode}
</span>
<input
type="text"
value={bag.label}
onChange={(e) => {
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"
/>
<span className="text-xs text-ink-muted">•</span>
<input
type="text"
value={bag.sub}
onChange={(e) => {
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"
/>
</div>
<input
type="text"
value={bag.tsaTip}
onChange={(e) => {
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"
/>
</div>
<button
type="button"
onClick={() => handleDeleteBag(bag.id)}
className="p-1.5 text-ink/40 hover:text-stamp-red self-end sm:self-center"
title="Remove bag container"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
{/* Add New Bag Container Form */}
<form onSubmit={handleAddBag} className="bg-ticket-200 border-2 border-dashed border-ink p-3.5 space-y-3">
<span className="font-mono text-xs font-bold uppercase text-ink block">
+ ADD NEW LUGGAGE CONTAINER
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block font-mono text-[10px] text-ink-muted uppercase">Bag Name</label>
<input
type="text"
required
placeholder="e.g. CAMERA SLING"
value={newBagLabel}
onChange={(e) => setNewBagLabel(e.target.value)}
className="w-full bg-white border border-ink font-grotesk font-bold text-xs p-1.5"
/>
</div>
<div>
<label className="block font-mono text-[10px] text-ink-muted uppercase">Sub-Label</label>
<input
type="text"
placeholder="e.g. Daypack / Tech"
value={newBagSub}
onChange={(e) => setNewBagSub(e.target.value)}
className="w-full bg-white border border-ink font-sans text-xs p-1.5"
/>
</div>
<div>
<label className="block font-mono text-[10px] text-ink-muted uppercase">Tag Code</label>
<input
type="text"
placeholder="e.g. CAM-01"
value={newBagCode}
onChange={(e) => setNewBagCode(e.target.value)}
className="w-full bg-white border border-ink font-mono text-xs p-1.5"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
<div className="sm:col-span-3">
<label className="block font-mono text-[10px] text-ink-muted uppercase">Packing / Security Advisory</label>
<input
type="text"
placeholder="e.g. Extra batteries, lenses, and filters"
value={newBagTip}
onChange={(e) => setNewBagTip(e.target.value)}
className="w-full bg-white border border-ink font-sans text-xs p-1.5"
/>
</div>
<div>
<label className="block font-mono text-[10px] text-ink-muted uppercase">Icon</label>
<select
value={newBagIcon}
onChange={(e) => setNewBagIcon(e.target.value as any)}
className="w-full bg-white border border-ink font-mono text-xs p-1.5"
>
<option value="backpack">Backpack</option>
<option value="briefcase">Roller / Case</option>
<option value="package">Box / Hold</option>
<option value="camera">Camera</option>
<option value="compass">Compass / Outdoor</option>
<option value="tag">Generic Tag</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="px-4 py-1.5 bg-canary hover:bg-canary-hover border border-ink font-grotesk font-bold text-xs uppercase shadow-card-hard-sm"
>
ADD CONTAINER
</button>
</div>
</form>
</div>
)}
{/* TAB 3: TRIP ARCHETYPES */}
{activeTab === 'archetypes' && (
<div className="space-y-4">
<div>
<h3 className="font-grotesk font-bold text-sm text-ink uppercase">
Trip Archetypes & Expedition Profiles
</h3>
<p className="font-sans text-xs text-ink-muted">
Customize the archetypes available when creating voyages (e.g. add Ocean Cruise, Bikepacking, Wedding, or Desert Trek).
</p>
</div>
{/* List of current archetypes */}
<div className="space-y-2">
{current.customArchetypes.map((arch, idx) => (
<div
key={arch.id}
className="p-3 bg-white border-2 border-ink shadow-xs flex items-center justify-between gap-3"
>
<div className="flex-1">
<input
type="text"
value={arch.label}
onChange={(e) => {
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"
/>
<input
type="text"
value={arch.desc}
onChange={(e) => {
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"
/>
</div>
<button
type="button"
onClick={() => handleDeleteArchetype(arch.id)}
className="p-1.5 text-ink/40 hover:text-stamp-red shrink-0"
title="Remove archetype"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
{/* Add New Archetype Form */}
<form onSubmit={handleAddArchetype} className="bg-ticket-200 border-2 border-dashed border-ink p-3.5 space-y-3">
<span className="font-mono text-xs font-bold uppercase text-ink block">
+ ADD NEW TRIP ARCHETYPE
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block font-mono text-[10px] text-ink-muted uppercase">Archetype Name</label>
<input
type="text"
required
placeholder="e.g. Ocean Cruise"
value={newArchLabel}
onChange={(e) => setNewArchLabel(e.target.value)}
className="w-full bg-white border border-ink font-grotesk font-bold text-xs p-1.5"
/>
</div>
<div className="sm:col-span-2">
<label className="block font-mono text-[10px] text-ink-muted uppercase">Description</label>
<input
type="text"
placeholder="e.g. Formal dinners, port walking, swimming & pool deck"
value={newArchDesc}
onChange={(e) => setNewArchDesc(e.target.value)}
className="w-full bg-white border border-ink font-sans text-xs p-1.5"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="px-4 py-1.5 bg-canary hover:bg-canary-hover border border-ink font-grotesk font-bold text-xs uppercase shadow-card-hard-sm"
>
ADD ARCHETYPE
</button>
</div>
</form>
</div>
)}
{/* Footer Actions */}
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3"> <div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
<button <button
type="button" type="button"
@@ -280,13 +730,14 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
CANCEL CANCEL
</button> </button>
<button <button
type="submit" type="button"
onClick={() => handleSave()}
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-ink text-ticket-100 shadow-card-hard active:translate-x-0.5 active:translate-y-0.5" className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-ink text-ticket-100 shadow-card-hard active:translate-x-0.5 active:translate-y-0.5"
> >
SAVE SETTINGS SAVE CONFIGURATION
</button> </button>
</div> </div>
</form> </div>
</div> </div>
</div> </div>
); );
+25 -17
View File
@@ -1,26 +1,18 @@
import React, { useState } from 'react'; 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 { generateAirportCode, generatePackingList } from '../utils/generator';
import { sound } from '../utils/audio'; 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 { interface TripModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSaveTrip: (trip: Trip) => void; onSaveTrip: (trip: Trip) => void;
memory: PackMemory; 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 }[] = [ const CLIMATES: { id: Climate; label: string; icon: string }[] = [
{ id: 'hot', label: 'Hot / Sunny', icon: '☀️' }, { id: 'hot', label: 'Hot / Sunny', icon: '☀️' },
{ id: 'mild', label: 'Mild / Temperate', icon: '⛅' }, { id: 'mild', label: 'Mild / Temperate', icon: '⛅' },
@@ -48,12 +40,14 @@ export const TripModal: React.FC<TripModalProps> = ({
onClose, onClose,
onSaveTrip, onSaveTrip,
memory, memory,
customArchetypes,
onEditArchetypes,
}) => { }) => {
if (!isOpen) return null; if (!isOpen) return null;
const [destination, setDestination] = useState(''); const [destination, setDestination] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [tripType, setTripType] = useState<TripType>('international_urban'); const [tripType, setTripType] = useState<TripType>(customArchetypes[0]?.id || 'international_urban');
const [durationDays, setDurationDays] = useState(7); const [durationDays, setDurationDays] = useState(7);
const [climate, setClimate] = useState<Climate>('mild'); const [climate, setClimate] = useState<Climate>('mild');
const [luggageConfig, setLuggageConfig] = useState<LuggageConfig>('carry_on_only'); const [luggageConfig, setLuggageConfig] = useState<LuggageConfig>('carry_on_only');
@@ -179,11 +173,25 @@ export const TripModal: React.FC<TripModalProps> = ({
{/* Trip Type */} {/* Trip Type */}
<div> <div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5"> <div className="flex items-center justify-between mb-1.5">
Trip Archetype <label className="font-mono text-xs font-bold uppercase text-ink">
</label> Trip Archetype
</label>
<button
type="button"
onClick={() => {
sound.playClick();
onEditArchetypes();
}}
className="font-mono text-[11px] font-bold px-2 py-0.5 border border-ink bg-white hover:bg-canary text-ink flex items-center gap-1 shadow-xs"
title="Add, rename, or edit trip archetypes"
>
<Settings2 size={12} />
<span>EDIT ARCHETYPES</span>
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{TRIP_TYPES.map((t) => ( {customArchetypes.map((t) => (
<button <button
type="button" type="button"
key={t.id} key={t.id}
+105 -21
View File
@@ -1,15 +1,8 @@
export type BagType = 'personal_item' | 'carry_on' | 'checked' | 'worn'; export type BagType = string;
export type Category = 'clothing' | 'toiletries' | 'tech' | 'documents' | 'gear' | 'wellness'; export type Category = 'clothing' | 'toiletries' | 'tech' | 'documents' | 'gear' | 'wellness';
export type TripType = export type TripType = string;
| 'weekend_getaway'
| 'business'
| 'beach'
| 'alpine_cold'
| 'hiking_camping'
| 'international_urban'
| 'road_trip';
export type Climate = 'hot' | 'mild' | 'cold' | 'rainy'; export type Climate = 'hot' | 'mild' | 'cold' | 'rainy';
@@ -79,24 +72,115 @@ export interface PackMemory {
totalTripsDebriefed: number; totalTripsDebriefed: number;
} }
export interface CustomBag {
id: string;
label: string;
sub: string;
tagCode: string;
tsaTip: string;
iconKey?: 'backpack' | 'briefcase' | 'package' | 'user' | 'camera' | 'tag' | 'compass';
isDefault?: boolean;
}
export interface CustomArchetype {
id: string;
label: string;
desc: string;
isDefault?: boolean;
}
export interface AppSettings { export interface AppSettings {
soundEnabled: boolean; soundEnabled: boolean;
compactView: boolean;
aiEnabled: boolean; aiEnabled: boolean;
aiProvider: 'ollama' | 'gemini' | 'openai'; aiProvider: 'ollama' | 'gemini' | 'openai';
ollamaUrl: string; ollamaUrl: string;
ollamaModel: string; ollamaModel: string;
apiKey: string; apiKey: string;
customBags: CustomBag[];
customArchetypes: CustomArchetype[];
} }
export interface BagMeta { export const DEFAULT_BAGS: CustomBag[] = [
type: BagType; {
label: string; id: 'personal_item',
code: string; label: 'PERSONAL ITEM',
icon: string; sub: 'Under-Seat / Daypack',
description: string; tagCode: 'CABIN-01',
tsaRule?: string; tsaTip: 'Cabin only: Passports, power banks, essential meds, laptop',
color: string; iconKey: 'backpack',
borderClass: string; isDefault: true,
bgClass: string; },
badgeBg: string; {
} id: 'carry_on',
label: 'CARRY-ON ROLLER',
sub: 'Overhead Compartment',
tagCode: 'CABIN-02',
tsaTip: 'Max 3.4oz / 100ml liquids pouch. Clothes cubes & toiletries',
iconKey: 'briefcase',
isDefault: true,
},
{
id: 'checked',
label: 'CHECKED LUGGAGE',
sub: 'Cargo Hold',
tagCode: 'HOLD-03',
tsaTip: 'Liquids > 100ml, trekking poles, pocket knives, heavy layers',
iconKey: 'package',
isDefault: true,
},
{
id: 'worn',
label: 'WORN ON PERSON',
sub: 'Travel Day Outfit',
tagCode: 'PAX-WORN',
tsaTip: 'Heaviest boots & airport jacket to minimize luggage bulk',
iconKey: 'user',
isDefault: true,
},
];
export const DEFAULT_ARCHETYPES: CustomArchetype[] = [
{
id: 'international_urban',
label: 'International City',
desc: 'Urban walking, adapters, transit cards',
isDefault: true,
},
{
id: 'beach',
label: 'Beach & Tropical',
desc: 'Swimwear, reef sunscreen, UV protection',
isDefault: true,
},
{
id: 'alpine_cold',
label: 'Cold / Alpine Ski',
desc: 'Merino base layers, down coat, thermal gear',
isDefault: true,
},
{
id: 'hiking_camping',
label: 'Hiking & Outdoor',
desc: 'Trail boots, daypack, headlamp, blister kit',
isDefault: true,
},
{
id: 'business',
label: 'Business / Conference',
desc: 'Blazer, dress shoes, laptop, power cords',
isDefault: true,
},
{
id: 'weekend_getaway',
label: 'Weekend Break',
desc: 'Minimalist 2-3 day capsule wardrobe',
isDefault: true,
},
{
id: 'road_trip',
label: 'Road Trip / Overland',
desc: 'Car gear, casual flex, comfort layers',
isDefault: true,
},
];
+11 -2
View File
@@ -1,4 +1,4 @@
import { Trip, PackMemory, AppSettings, TripDebrief, TripType } from '../types'; import { Trip, PackMemory, AppSettings, TripDebrief, TripType, DEFAULT_BAGS, DEFAULT_ARCHETYPES } from '../types';
const STORAGE_KEYS = { const STORAGE_KEYS = {
TRIPS: 'loadout_trips_v1', TRIPS: 'loadout_trips_v1',
@@ -48,11 +48,14 @@ const DEFAULT_MEMORY: PackMemory = {
const DEFAULT_SETTINGS: AppSettings = { const DEFAULT_SETTINGS: AppSettings = {
soundEnabled: true, soundEnabled: true,
compactView: false,
aiEnabled: false, aiEnabled: false,
aiProvider: 'ollama', aiProvider: 'ollama',
ollamaUrl: 'http://localhost:11434', ollamaUrl: 'http://localhost:11434',
ollamaModel: 'llama3.2', ollamaModel: 'llama3.2',
apiKey: '', apiKey: '',
customBags: DEFAULT_BAGS,
customArchetypes: DEFAULT_ARCHETYPES,
}; };
// Seed sample trip if user has none yet // Seed sample trip if user has none yet
@@ -253,7 +256,13 @@ export const StorageClient = {
const local = localStorage.getItem(STORAGE_KEYS.SETTINGS); const local = localStorage.getItem(STORAGE_KEYS.SETTINGS);
if (local) { if (local) {
try { try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(local) }; const parsed = JSON.parse(local);
return {
...DEFAULT_SETTINGS,
...parsed,
customBags: parsed.customBags && parsed.customBags.length > 0 ? parsed.customBags : DEFAULT_BAGS,
customArchetypes: parsed.customArchetypes && parsed.customArchetypes.length > 0 ? parsed.customArchetypes : DEFAULT_ARCHETYPES,
};
} catch {} } catch {}
} }
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;