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
+1087
-350
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user