feat: Add custom bag and archetype editors and compact line view mode
This commit is contained in:
1 parent
805f3fe97a
commit
d275808711
8 files changed
+878
-141
No files matched your search
@@ -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);
|
||||
});
|
||||
|
||||
+86
-14
@@ -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<AppSettings>(StorageClient.getSettings());
|
||||
const [compactView, setCompactView] = useState<boolean>(false);
|
||||
|
||||
// UI Filter states
|
||||
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('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 */}
|
||||
<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" />
|
||||
@@ -285,13 +311,14 @@ export function App() {
|
||||
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{settings.customBags.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.label} ({b.tagCode})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
@@ -303,7 +330,7 @@ export function App() {
|
||||
</form>
|
||||
</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">
|
||||
{/* Category Pills */}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full">
|
||||
@@ -325,8 +352,39 @@ export function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search & State Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search, State Filter & Compact View Toggle */}
|
||||
<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">
|
||||
<Search
|
||||
size={14}
|
||||
@@ -337,10 +395,11 @@ export function App() {
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* State Filter (All / Unpacked / Stowed) */}
|
||||
<div className="flex border border-ink bg-white font-mono text-xs">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -382,6 +441,11 @@ export function App() {
|
||||
(FILTERED TO {selectedBag.toUpperCase()})
|
||||
</span>
|
||||
)}
|
||||
{compactView && (
|
||||
<span className="text-blue-700 font-bold ml-1">
|
||||
[COMPACT MODE]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -405,7 +469,7 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of Perforated Stubs */}
|
||||
{/* List of Items (Compact Lines or 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">
|
||||
@@ -421,6 +485,8 @@ export function App() {
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
compact={compactView}
|
||||
availableBags={settings.customBags}
|
||||
onTogglePacked={handleTogglePacked}
|
||||
onUpdateQuantity={handleUpdateQuantity}
|
||||
onUpdateBag={handleUpdateBag}
|
||||
@@ -470,6 +536,11 @@ export function App() {
|
||||
onClose={() => 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}
|
||||
|
||||
@@ -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 <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> = ({
|
||||
@@ -15,64 +47,39 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||
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: <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,
|
||||
},
|
||||
];
|
||||
// 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 (
|
||||
<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">
|
||||
<span className="font-mono text-xs uppercase font-bold text-ink tracking-wider">
|
||||
LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG"
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onSelectBag('all');
|
||||
@@ -89,22 +96,21 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{bags
|
||||
.filter((b) => b.visible)
|
||||
.map((b) => {
|
||||
const bagItems = items.filter((i) => i.bag === b.type);
|
||||
{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.type;
|
||||
const isSelected = selectedBag === b.id;
|
||||
const isFull = bagTotal > 0 && bagPacked === bagTotal;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={b.type}
|
||||
type="button"
|
||||
key={b.id}
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onSelectBag(isSelected ? 'all' : b.type);
|
||||
onSelectBag(isSelected ? 'all' : b.id);
|
||||
}}
|
||||
className={`relative text-left p-3.5 border-2 border-ink transition-all flex flex-col justify-between ${
|
||||
isSelected
|
||||
@@ -122,16 +128,16 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||
<span className="bg-ink text-ticket-100 px-1 py-0.5 font-bold uppercase">
|
||||
{b.tagCode}
|
||||
</span>
|
||||
<span className="text-ink-muted tracking-tight">
|
||||
<span className="text-ink-muted tracking-tight truncate max-w-[130px]">
|
||||
{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">
|
||||
<span className="text-ink">{b.icon}</span>
|
||||
<span className="font-display text-xl tracking-wide uppercase leading-tight">
|
||||
<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>
|
||||
</div>
|
||||
@@ -140,10 +146,12 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TSA Advisory Tip */}
|
||||
<p className="font-sans text-[11px] text-ink-muted leading-tight mb-3">
|
||||
{/* TSA / Bag Advisory Tip */}
|
||||
{b.tsaTip && (
|
||||
<p className="font-sans text-[11px] text-ink-muted leading-tight mb-3 line-clamp-2">
|
||||
{b.tsaTip}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meter and Stowed Count */}
|
||||
|
||||
+115
-13
@@ -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: <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 }> = {
|
||||
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<Category, { bg: string; text: string; label: strin
|
||||
|
||||
export const ItemRow: React.FC<ItemRowProps> = ({
|
||||
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 (
|
||||
<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 (
|
||||
<div
|
||||
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 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playTear();
|
||||
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">
|
||||
<span className="text-[10px] text-ink-muted uppercase mr-1">QTY:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onUpdateQuantity(item.id, -1);
|
||||
@@ -160,6 +260,7 @@ export const ItemRow: React.FC<ItemRowProps> = ({
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onUpdateQuantity(item.id, 1);
|
||||
@@ -180,19 +281,20 @@ export const ItemRow: React.FC<ItemRowProps> = ({
|
||||
value={item.bag}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
>
|
||||
{BAG_OPTIONS.map((opt) => (
|
||||
<option key={opt.type} value={opt.type}>
|
||||
{opt.label} ({opt.short})
|
||||
{availableBags.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.label} ({opt.tagCode})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Delete Item */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onDelete(item.id);
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AppSettings, Trip, PackMemory } from '../types';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppSettings, Trip, PackMemory, CustomBag, CustomArchetype } from '../types';
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
@@ -11,6 +27,7 @@ interface SettingsModalProps {
|
||||
trips: Trip[];
|
||||
memory: PackMemory;
|
||||
onImportData: (data: { trips: Trip[]; memory: PackMemory }) => void;
|
||||
initialTab?: 'general' | 'bags' | 'archetypes';
|
||||
}
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
@@ -21,20 +38,113 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
trips,
|
||||
memory,
|
||||
onImportData,
|
||||
initialTab = 'general',
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'bags' | 'archetypes'>(initialTab);
|
||||
const [current, setCurrent] = useState<AppSettings>({ ...settings });
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// New Bag Form State
|
||||
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.enabled = current.soundEnabled;
|
||||
onSaveSettings(current);
|
||||
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 = () => {
|
||||
sound.playClick();
|
||||
const exportBundle = {
|
||||
@@ -69,6 +179,10 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
trips: parsed.trips,
|
||||
memory: parsed.memory || { exclusions: [], inclusions: [], totalTripsDebriefed: 0 },
|
||||
});
|
||||
if (parsed.settings) {
|
||||
setCurrent({ ...current, ...parsed.settings });
|
||||
onSaveSettings({ ...current, ...parsed.settings });
|
||||
}
|
||||
setImportStatus('Backup successfully restored!');
|
||||
setTimeout(() => setImportStatus(null), 3000);
|
||||
} else {
|
||||
@@ -83,16 +197,17 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
|
||||
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="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 */}
|
||||
<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">
|
||||
<SettingsIcon size={20} className="text-canary" />
|
||||
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
|
||||
DISPATCH TERMINAL SETTINGS
|
||||
DISPATCH TERMINAL & REGISTRY CONFIGURATION
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onClose();
|
||||
@@ -103,7 +218,58 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-6">
|
||||
{/* Navigation Tabs */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setActiveTab('general');
|
||||
}}
|
||||
className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all ${
|
||||
activeTab === 'general'
|
||||
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
|
||||
: 'bg-white/60 text-ink-muted hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
GENERAL & AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setActiveTab('bags');
|
||||
}}
|
||||
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 === 'bags'
|
||||
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
|
||||
: 'bg-white/60 text-ink-muted hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<Luggage size={13} />
|
||||
<span>LUGGAGE CONTAINERS ({current.customBags.length})</span>
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
|
||||
<div className="p-4 sm:p-6 max-h-[72vh] overflow-y-auto space-y-5">
|
||||
{/* TAB 1: GENERAL & AI */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-5">
|
||||
{/* Audio Synthesizer */}
|
||||
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -137,6 +303,37 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
</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">
|
||||
@@ -243,13 +440,13 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
<span>EXPORT MANIFEST BACKUP (.JSON)</span>
|
||||
</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} />
|
||||
<span>IMPORT BACKUP</span>
|
||||
<input
|
||||
@@ -266,8 +463,261 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -280,13 +730,14 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
CANCEL
|
||||
</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"
|
||||
>
|
||||
SAVE SETTINGS
|
||||
SAVE CONFIGURATION
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<TripModalProps> = ({
|
||||
onClose,
|
||||
onSaveTrip,
|
||||
memory,
|
||||
customArchetypes,
|
||||
onEditArchetypes,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [destination, setDestination] = 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 [climate, setClimate] = useState<Climate>('mild');
|
||||
const [luggageConfig, setLuggageConfig] = useState<LuggageConfig>('carry_on_only');
|
||||
@@ -179,11 +173,25 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
|
||||
{/* Trip Type */}
|
||||
<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">
|
||||
<label className="font-mono text-xs font-bold uppercase text-ink">
|
||||
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">
|
||||
{TRIP_TYPES.map((t) => (
|
||||
{customArchetypes.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
|
||||
+105
-21
@@ -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 TripType =
|
||||
| 'weekend_getaway'
|
||||
| 'business'
|
||||
| 'beach'
|
||||
| 'alpine_cold'
|
||||
| 'hiking_camping'
|
||||
| 'international_urban'
|
||||
| 'road_trip';
|
||||
export type TripType = string;
|
||||
|
||||
export type Climate = 'hot' | 'mild' | 'cold' | 'rainy';
|
||||
|
||||
@@ -79,24 +72,115 @@ export interface PackMemory {
|
||||
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 {
|
||||
soundEnabled: boolean;
|
||||
compactView: boolean;
|
||||
aiEnabled: boolean;
|
||||
aiProvider: 'ollama' | 'gemini' | 'openai';
|
||||
ollamaUrl: string;
|
||||
ollamaModel: string;
|
||||
apiKey: string;
|
||||
customBags: CustomBag[];
|
||||
customArchetypes: CustomArchetype[];
|
||||
}
|
||||
|
||||
export interface BagMeta {
|
||||
type: BagType;
|
||||
label: string;
|
||||
code: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
tsaRule?: string;
|
||||
color: string;
|
||||
borderClass: string;
|
||||
bgClass: string;
|
||||
badgeBg: string;
|
||||
}
|
||||
export const DEFAULT_BAGS: CustomBag[] = [
|
||||
{
|
||||
id: 'personal_item',
|
||||
label: 'PERSONAL ITEM',
|
||||
sub: 'Under-Seat / Daypack',
|
||||
tagCode: 'CABIN-01',
|
||||
tsaTip: 'Cabin only: Passports, power banks, essential meds, laptop',
|
||||
iconKey: 'backpack',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
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
@@ -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 = {
|
||||
TRIPS: 'loadout_trips_v1',
|
||||
@@ -48,11 +48,14 @@ const DEFAULT_MEMORY: PackMemory = {
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
soundEnabled: true,
|
||||
compactView: false,
|
||||
aiEnabled: false,
|
||||
aiProvider: 'ollama',
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
ollamaModel: 'llama3.2',
|
||||
apiKey: '',
|
||||
customBags: DEFAULT_BAGS,
|
||||
customArchetypes: DEFAULT_ARCHETYPES,
|
||||
};
|
||||
|
||||
// Seed sample trip if user has none yet
|
||||
@@ -253,7 +256,13 @@ export const StorageClient = {
|
||||
const local = localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
if (local) {
|
||||
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 {}
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
|
||||
Reference in new issue
Block a user