| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>HomeStack | Digital Inventory Curator</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link |
| href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" |
| rel="stylesheet"> |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/fuse.js/dist/fuse.js"></script> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| colors: { |
| primary: '#6750A4', |
| 'on-primary': '#FFFFFF', |
| 'primary-container': '#EADDFF', |
| 'on-primary-container': '#21005D', |
| secondary: '#625B71', |
| 'on-secondary': '#FFFFFF', |
| 'secondary-container': '#E8DEF8', |
| 'on-secondary-container': '#1D192B', |
| tertiary: '#7D5260', |
| 'on-tertiary': '#FFFFFF', |
| 'tertiary-container': '#FFD8E4', |
| 'on-tertiary-container': '#31111D', |
| error: '#B3261E', |
| 'on-error': '#FFFFFF', |
| 'error-container': '#F9DEDC', |
| 'on-error-container': '#410E0B', |
| surface: '#FEF7FF', |
| 'on-surface': '#1D1B20', |
| 'surface-variant': '#E7E0EB', |
| 'on-surface-variant': '#49454F', |
| outline: '#79747E', |
| 'outline-variant': '#CAC4D0', |
| 'surface-container-lowest': '#FFFFFF', |
| 'surface-container-low': '#F7F2FA', |
| 'surface-container': '#F3EDF7', |
| 'surface-container-high': '#ECE6F0', |
| 'surface-container-highest': '#E6E0E9', |
| }, |
| fontFamily: { |
| sans: ['Inter', 'sans-serif'], |
| headline: ['Inter', 'sans-serif'], |
| } |
| } |
| } |
| } |
| </script> |
| <style> |
| .material-symbols-outlined { |
| font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; |
| } |
| |
| .material-symbols-outlined.fill { |
| font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; |
| } |
| |
| .no-scrollbar::-webkit-scrollbar { |
| display: none; |
| } |
| |
| .no-scrollbar { |
| -ms-overflow-style: none; |
| scrollbar-width: none; |
| } |
| |
| .editorial-shadow { |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); |
| } |
| |
| .screen { |
| display: none; |
| } |
| |
| .screen.active { |
| display: flex; |
| } |
| </style> |
| </head> |
|
|
| <body class="bg-surface text-on-surface min-h-screen font-sans"> |
|
|
| |
| <main id="login-screen" |
| class="screen active flex-col items-center justify-center px-6 py-12 relative overflow-hidden min-h-screen"> |
| <div class="absolute -top-24 -right-24 w-96 h-96 bg-primary-container/20 rounded-full blur-3xl"></div> |
| <div class="absolute -bottom-24 -left-24 w-80 h-80 bg-secondary-container/30 rounded-full blur-3xl"></div> |
|
|
| <div class="w-full max-w-md z-10"> |
| <div class="text-center mb-12"> |
| <div |
| class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-container text-primary mb-6 editorial-shadow"> |
| <span class="material-symbols-outlined text-3xl">inventory_2</span> |
| </div> |
| <h1 class="font-headline font-extrabold text-4xl tracking-tight text-primary mb-2">HomeStack</h1> |
| <p class="text-on-surface-variant font-medium text-lg">Digital Inventory Curator</p> |
| </div> |
|
|
| <div |
| class="bg-surface-container-lowest p-8 md:p-10 rounded-[2rem] editorial-shadow border border-outline-variant/10"> |
| <div class="mb-8"> |
| <h2 class="text-2xl font-headline font-bold text-on-surface mb-3 tracking-tight">Welcome Back</h2> |
| <p class="text-on-surface-variant leading-relaxed">Enter Password to access your digital inventory |
| </p> |
| </div> |
|
|
| <form id="login-form" class="space-y-8"> |
| <div class="space-y-2"> |
| <label class="block text-sm font-semibold text-on-surface ml-1">Access Key</label> |
| <div class="relative"> |
| <div |
| class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-outline"> |
| <span class="material-symbols-outlined text-xl">lock</span> |
| </div> |
| <input type="password" id="password-input" |
| class="block w-full min-h-[4rem] pl-12 pr-4 bg-surface-container-low border-0 rounded-xl focus:ring-2 focus:ring-primary/40 focus:bg-surface-container-lowest transition-all duration-300 text-lg placeholder:text-outline/50" |
| placeholder="••••••••••••" required> |
| </div> |
| <p id="login-error" class="text-error text-sm font-medium ml-1 hidden"></p> |
| </div> |
|
|
| <div class="flex items-center gap-3 ml-1"> |
| <label class="relative inline-flex items-center cursor-pointer group"> |
| <input type="checkbox" id="remember-me" class="sr-only peer"> |
| <div class="w-11 h-6 bg-surface-container-high peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary transition-colors duration-300"></div> |
| <span class="ms-3 text-sm font-bold text-on-surface-variant group-hover:text-primary transition-colors cursor-pointer">Remember me for 30 days</span> |
| </label> |
| </div> |
|
|
| <button type="submit" id="login-btn" |
| class="group w-full min-h-[4rem] bg-gradient-to-r from-primary to-primary text-on-primary font-bold text-lg rounded-xl flex items-center justify-center gap-2 hover:opacity-95 transition-all duration-300 active:scale-[0.98] editorial-shadow"> |
| <span id="login-btn-text">Unlock Vault</span> |
| <span |
| class="material-symbols-outlined group-hover:translate-x-1 transition-transform">arrow_forward</span> |
| </button> |
| </form> |
| </div> |
|
|
| <div class="mt-10 text-center space-y-4"> |
| <p class="text-sm text-on-surface-variant">Need help? Contact your administrator.</p> |
| <div class="flex items-center justify-center gap-6"> |
| <a href="#" |
| class="text-xs font-semibold text-outline uppercase tracking-widest hover:text-primary transition-colors">Privacy</a> |
| <div class="w-1 h-1 bg-outline-variant/40 rounded-full"></div> |
| <a href="#" |
| class="text-xs font-semibold text-outline uppercase tracking-widest hover:text-primary transition-colors">Security</a> |
| <div class="w-1 h-1 bg-outline-variant/40 rounded-full"></div> |
| <a href="#" |
| class="text-xs font-semibold text-outline uppercase tracking-widest hover:text-primary transition-colors">Terms</a> |
| </div> |
| </div> |
| </div> |
|
|
| <footer |
| class="absolute bottom-0 w-full py-6 px-8 flex justify-between items-center text-[11px] font-bold text-outline/50 uppercase tracking-widest"> |
| <div class="flex items-center gap-2"> |
| <div class="w-2 h-2 bg-primary rounded-full"></div> |
| <span>System Status: Operational</span> |
| </div> |
| <span>Version 3.0.0-Python</span> |
| </footer> |
| </main> |
|
|
| |
| <div id="dashboard-screen" class="screen flex-col pb-32 min-h-screen"> |
| <header |
| class="bg-surface/90 backdrop-blur-lg top-0 sticky z-50 flex justify-between items-center w-full px-6 py-4"> |
| <div class="flex items-center gap-4"> |
| <button |
| class="text-on-surface hover:bg-surface-container-high transition-colors p-3 rounded-full min-w-[48px] min-h-[48px] flex items-center justify-center active:scale-95"> |
| <span class="material-symbols-outlined">menu</span> |
| </button> |
| <h1 class="text-primary font-extrabold tracking-tighter text-xl">HomeStack</h1> |
| </div> |
| <button onclick="showScreen('settings')" |
| class="text-on-surface hover:bg-surface-container-high transition-colors p-3 rounded-full min-w-[48px] min-h-[48px] flex items-center justify-center active:scale-95"> |
| <span class="material-symbols-outlined">account_circle</span> |
| </button> |
| </header> |
|
|
| <main class="px-6 pt-4 max-w-2xl mx-auto space-y-8 w-full"> |
| <section class="space-y-4"> |
| <div> |
| <h2 class="text-3xl font-extrabold tracking-tight text-on-surface" id="greeting-text">Good Morning.</h2> |
| <p class="text-on-surface-variant font-medium mt-1">You have <span class="text-primary font-bold" |
| id="item-count">0 items</span> in inventory.</p> |
| </div> |
| <div class="relative group z-50"> |
| <div class="absolute inset-y-0 left-4 flex items-center pointer-events-none text-outline"> |
| <span class="material-symbols-outlined">search</span> |
| </div> |
| <input type="text" id="search-input" |
| class="w-full bg-surface-container-lowest border-2 border-surface-container-high focus:border-primary focus:ring-0 rounded-2xl py-5 pl-12 pr-4 text-lg font-medium placeholder:text-outline-variant transition-all shadow-sm group-focus-within:shadow-md" |
| placeholder="Search across items..." oninput="handleSearch(this.value)" |
| onfocus="document.getElementById('search-dropdown').classList.remove('hidden')" |
| onblur="setTimeout(() => document.getElementById('search-dropdown').classList.add('hidden'), 200)"> |
| <div class="absolute inset-y-0 right-4 flex items-center"> |
| <button |
| class="bg-surface-container-high p-2 rounded-lg text-on-surface-variant hover:text-primary transition-colors z-10"> |
| <span class="material-symbols-outlined text-[20px]">tune</span> |
| </button> |
| </div> |
| |
| <div id="search-dropdown" |
| class="absolute top-full left-0 right-0 mt-2 bg-surface-container-lowest rounded-2xl shadow-2xl border border-outline-variant/10 max-h-80 overflow-y-auto hidden editorial-shadow"> |
| </div> |
| </div> |
| </section> |
|
|
| <section class="flex gap-4"> |
| <button onclick="openQuickAdd()" |
| class="flex-grow bg-primary text-on-primary py-6 rounded-2xl font-bold text-lg flex items-center justify-center gap-3 shadow-lg shadow-primary/20 active:scale-[0.98] transition-all min-h-[72px]"> |
| <span class="material-symbols-outlined fill text-[28px]">add_circle</span> |
| <span>Quick Add</span> |
| </button> |
| <button id="mic-btn" |
| class="w-20 bg-secondary-container text-on-secondary-container rounded-2xl flex items-center justify-center shadow-lg active:scale-[0.98] transition-all relative overflow-hidden"> |
| <span |
| class="material-symbols-outlined text-[32px] z-10 transition-transform duration-300 pointer-events-none" |
| id="mic-icon">mic</span> |
| <div id="mic-pulse" |
| class="absolute inset-0 bg-primary/30 scale-0 rounded-2xl transition-transform duration-300 origin-center pointer-events-none"> |
| </div> |
| </button> |
| </section> |
|
|
| <section class="space-y-4"> |
| <div class="flex justify-between items-center"> |
| <h3 class="text-xl font-bold">Recently Added</h3> |
| <button onclick="showScreen('inventory', true)" |
| class="text-primary font-bold text-sm min-h-[44px] px-2 flex items-center">View All</button> |
| </div> |
| <div id="recent-items" class="flex gap-4 overflow-x-auto no-scrollbar pb-2 -mx-6 px-6"> |
| |
| </div> |
| </section> |
|
|
| <section class="space-y-6"> |
| <div class="flex justify-between items-end"> |
| <h3 class="text-xl font-bold">Locations</h3> |
| <button onclick="openAddLocationModal()" |
| class="text-primary font-bold text-sm min-h-[44px] flex items-center px-3 hover:bg-primary-container/10 rounded-xl transition-colors active:scale-95">Edit |
| Map</button> |
| </div> |
| <div id="locations-grid" class="grid grid-cols-2 gap-4"> |
| |
| </div> |
| </section> |
| </main> |
| </div> |
|
|
| |
| <div id="inventory-screen" class="screen flex-col pb-32 min-h-screen"> |
| <header |
| class="bg-surface/80 backdrop-blur-lg top-0 sticky z-50 flex justify-between items-center w-full px-6 py-4"> |
| <div class="flex items-center gap-4"> |
| <button onclick="showScreen('dashboard')" |
| class="material-symbols-outlined text-primary hover:bg-primary-container/30 transition-colors p-2 rounded-full active:scale-95">arrow_back</button> |
| <h1 class="text-primary font-headline font-bold text-lg tracking-tight" id="inventory-header-title">All Items</h1> |
| </div> |
| <div class="bg-primary-container text-on-primary-container px-3 py-1 rounded-full text-xs font-bold" |
| id="inv-count-badge">0 Items</div> |
| </header> |
|
|
| <main class="max-w-md mx-auto px-6 w-full"> |
| <section class="mt-4 mb-6"> |
| <h2 class="text-3xl font-extrabold text-on-surface tracking-tighter">Inventory List</h2> |
| <p class="text-on-surface-variant mt-1 text-sm">Efficiently manage your items.</p> |
| </section> |
|
|
| <div id="inventory-list" class="space-y-8"> |
| |
| </div> |
| </main> |
| </div> |
|
|
| |
| <div id="ai-screen" class="screen flex-col h-screen pb-32"> |
| <header class="bg-surface/90 backdrop-blur-lg top-0 sticky z-50 flex items-center w-full px-6 py-4 gap-4"> |
| <button onclick="showScreen('dashboard')" class="text-primary p-2 rounded-full active:scale-95"> |
| <span class="material-symbols-outlined">arrow_back</span> |
| </button> |
| <h1 class="text-primary font-extrabold tracking-tighter text-xl">AI Curator</h1> |
| </header> |
|
|
| <div id="chat-messages" class="flex-grow overflow-y-auto px-6 py-6 space-y-6 no-scrollbar"> |
| <div class="text-center py-20 space-y-6" id="chat-empty"> |
| <div |
| class="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-primary-container text-primary editorial-shadow"> |
| <span class="material-symbols-outlined text-4xl">auto_awesome</span> |
| </div> |
| <div class="space-y-2"> |
| <h3 class="text-2xl font-headline font-bold">How can I help?</h3> |
| <p class="text-on-surface-variant max-w-[240px] mx-auto">Ask me about your inventory, stock levels, |
| or storage optimization.</p> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="p-6 bg-surface/80 backdrop-blur-xl border-t border-outline-variant/10"> |
| <form id="chat-form" class="flex gap-3"> |
| <input type="text" id="chat-input" placeholder="Ask HomeStack..." |
| class="flex-grow min-h-[3.5rem] px-6 bg-surface-container border-none rounded-2xl focus:ring-2 focus:ring-primary/40 text-on-surface placeholder:text-outline transition-all"> |
| <button type="submit" |
| class="w-14 h-14 bg-primary text-on-primary rounded-2xl flex items-center justify-center shadow-lg shadow-primary/20 active:scale-90 transition-all"> |
| <span class="material-symbols-outlined text-xl">send</span> |
| </button> |
| </form> |
| </div> |
| </div> |
|
|
| |
| <div id="settings-screen" class="screen flex-col pb-32 min-h-screen"> |
| <header class="bg-surface/90 backdrop-blur-lg top-0 sticky z-50 flex items-center w-full px-6 py-4 gap-4"> |
| <button onclick="showScreen('dashboard')" class="text-primary p-2 rounded-full active:scale-95"> |
| <span class="material-symbols-outlined">arrow_back</span> |
| </button> |
| <h1 class="text-primary font-extrabold tracking-tighter text-xl">Settings</h1> |
| </header> |
|
|
| <main class="px-6 py-8 space-y-10 max-w-2xl mx-auto w-full"> |
| <div class="space-y-4"> |
| <h3 class="text-xs font-bold text-outline uppercase tracking-[0.2em] px-2">Vault Configuration</h3> |
| <div |
| class="bg-surface-container-lowest rounded-3xl overflow-hidden editorial-shadow border border-outline-variant/10"> |
| <button onclick="openProfileModal()" |
| class="w-full flex items-center gap-4 p-6 hover:bg-surface-container-low transition-colors text-on-surface"> |
| <span class="material-symbols-outlined text-primary">person</span> |
| <span class="flex-grow text-left font-bold">Profile Details</span> |
| <span class="material-symbols-outlined text-outline">chevron_right</span> |
| </button> |
| </div> |
| </div> |
|
|
| <button onclick="logout()" |
| class="w-full min-h-[4.5rem] bg-error/10 text-error font-bold rounded-2xl flex items-center justify-center gap-3 active:scale-95 transition-all border border-error/20"> |
| <span class="material-symbols-outlined">logout</span> |
| <span>Lock Vault Session</span> |
| </button> |
|
|
| <div class="text-center pt-4"> |
| <p class="text-[10px] font-bold text-outline uppercase tracking-widest">HomeStack v3.0.0-Python</p> |
| <p class="text-[10px] text-outline/60 mt-1">© 2026 Digital Inventory Curator</p> |
| </div> |
| </main> |
| </div> |
|
|
| |
| <nav id="bottom-nav" |
| class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-4 pb-8 pt-4 bg-white/95 backdrop-blur-xl shadow-[0_-8px_24px_rgba(45,51,53,0.1)] rounded-t-[2.5rem] hidden"> |
| <button onclick="showScreen('dashboard')" |
| class="nav-btn flex flex-col items-center justify-center rounded-2xl px-8 py-3 transition-all active:scale-90 text-outline" |
| data-screen="dashboard"> |
| <span class="material-symbols-outlined text-[28px]">home</span> |
| <span class="font-headline font-bold text-[12px] mt-1">Inventory</span> |
| </button> |
| <button onclick="showScreen('ai')" |
| class="nav-btn flex flex-col items-center justify-center rounded-2xl px-8 py-3 transition-all active:scale-90 text-outline" |
| data-screen="ai"> |
| <span class="material-symbols-outlined text-[28px]">auto_awesome</span> |
| <span class="font-headline font-bold text-[12px] mt-1">AI Assistant</span> |
| </button> |
| <button onclick="showScreen('settings')" |
| class="nav-btn flex flex-col items-center justify-center rounded-2xl px-8 py-3 transition-all active:scale-90 text-outline" |
| data-screen="settings"> |
| <span class="material-symbols-outlined text-[28px]">settings</span> |
| <span class="font-headline font-bold text-[12px] mt-1">Settings</span> |
| </button> |
| </nav> |
|
|
| |
| <div id="quick-add-modal" |
| class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm hidden flex-col justify-end transition-all duration-300"> |
| <div class="bg-surface rounded-t-[3rem] p-8 space-y-8 animate-slide-up max-h-[90vh] overflow-y-auto"> |
| <div class="flex justify-between items-center"> |
| <h3 class="text-2xl font-extrabold text-primary">Quick Add Item</h3> |
| <button onclick="closeQuickAdd()" class="bg-surface-container-high p-2 rounded-full"> |
| <span class="material-symbols-outlined">close</span> |
| </button> |
| </div> |
| <form id="quick-add-form" class="space-y-6"> |
| <div class="space-y-2"> |
| <label class="text-sm font-bold ml-1">Item Name</label> |
| <input type="text" id="add-name" required |
| class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40" |
| placeholder="e.g. Superglue"> |
| </div> |
| <div class="grid grid-cols-2 gap-4"> |
| <div class="space-y-2"> |
| <label class="text-sm font-bold ml-1">Quantity</label> |
| <input type="number" id="add-qty" required |
| class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40" |
| placeholder="1"> |
| </div> |
| <div class="space-y-2"> |
| <label class="text-sm font-bold ml-1">Unit</label> |
| <input type="text" id="add-unit" |
| class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40" |
| placeholder="UNIT"> |
| </div> |
| </div> |
| <button type="submit" |
| class="w-full bg-primary text-on-primary py-5 rounded-2xl font-extrabold text-lg shadow-lg active:scale-95 transition-all"> |
| Add to Inventory |
| </button> |
| </form> |
| </div> |
| </div> |
|
|
| |
| <div id="location-modal" |
| class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm hidden flex-col justify-end transition-all duration-300"> |
| <div class="bg-surface rounded-t-[3rem] p-8 space-y-8 animate-slide-up max-h-[90vh] overflow-y-auto"> |
| <div class="flex justify-between items-center"> |
| <h3 class="text-2xl font-extrabold text-primary tracking-tight">Map a Storage Place</h3> |
| <button onclick="closeAddLocationModal()" class="bg-surface-container-high p-2 rounded-full"> |
| <span class="material-symbols-outlined">close</span> |
| </button> |
| </div> |
| <form id="location-form" class="space-y-6"> |
| <div class="space-y-2"> |
| <label class="text-sm font-bold ml-1">Place Name</label> |
| <input type="text" id="loc-name" required |
| class="w-full bg-surface-container p-4 rounded-xl border-none focus:ring-2 focus:ring-primary/40" |
| placeholder="e.g. Master Bedroom Closet"> |
| </div> |
| <button type="submit" |
| class="w-full bg-primary text-on-primary py-5 rounded-2xl font-extrabold text-lg shadow-lg active:scale-95 transition-all"> |
| Establish Connection |
| </button> |
| </form> |
| </div> |
| </div> |
|
|
| |
| <div id="profile-modal" |
| class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm hidden flex-col items-center justify-center p-6 transition-all duration-300"> |
| <div class="bg-surface w-full max-w-sm rounded-[2.5rem] p-8 space-y-6 text-center animate-scale-up"> |
| <div |
| class="w-24 h-24 bg-primary-container text-primary rounded-full mx-auto flex items-center justify-center"> |
| <span class="material-symbols-outlined text-5xl">person_pin</span> |
| </div> |
| <div class="space-y-1"> |
| <h3 class="text-2xl font-extrabold tracking-tight">Paarth's HomeStack</h3> |
| <p class="text-on-surface-variant font-medium">Vault Admin</p> |
| </div> |
| <div class="grid grid-cols-2 gap-3 py-4"> |
| <div class="bg-surface-container-low p-4 rounded-2xl"> |
| <span class="block text-primary font-extrabold text-xl" id="profile-items">0</span> |
| <span class="text-[10px] font-bold text-outline uppercase">Total Items</span> |
| </div> |
| <div class="bg-surface-container-low p-4 rounded-2xl"> |
| <span class="block text-primary font-extrabold text-xl" id="profile-locs">0</span> |
| <span class="text-[10px] font-bold text-outline uppercase">Locations</span> |
| </div> |
| </div> |
| <button onclick="closeProfileModal()" |
| class="w-full bg-surface-container-high py-4 rounded-2xl font-bold active:scale-95 transition-all"> |
| Close Profile |
| </button> |
| </div> |
| </div> |
|
|
| <style> |
| @keyframes slide-up { |
| from { |
| transform: translateY(100%); |
| } |
| |
| to { |
| transform: translateY(0); |
| } |
| } |
| |
| @keyframes scale-up { |
| from { |
| transform: scale(0.9); |
| opacity: 0; |
| } |
| |
| to { |
| transform: scale(1); |
| opacity: 1; |
| } |
| } |
| |
| .animate-slide-up { |
| animation: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1); |
| } |
| |
| .animate-scale-up { |
| animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); |
| } |
| </style> |
|
|
| <script> |
| let inventory = []; |
| let chatMessages = []; |
| let locationsData = []; |
| let containersData = []; |
| let sessionToken = null; |
| let currentLocationFilter = null; |
| |
| |
| function setGreeting() { |
| const hour = new Date().getHours(); |
| const greetingEl = document.getElementById('greeting-text'); |
| if (!greetingEl) return; |
| if (hour < 12) greetingEl.textContent = 'Good Morning.'; |
| else if (hour < 17) greetingEl.textContent = 'Good Afternoon.'; |
| else greetingEl.textContent = 'Good Evening.'; |
| } |
| setGreeting(); |
| |
| |
| function checkPersistedSession() { |
| const storedToken = localStorage.getItem('vault_token'); |
| const expiry = localStorage.getItem('vault_token_expiry'); |
| |
| if (storedToken && expiry) { |
| if (Date.now() < parseInt(expiry)) { |
| sessionToken = storedToken; |
| showScreen('dashboard'); |
| return true; |
| } else { |
| |
| localStorage.removeItem('vault_token'); |
| localStorage.removeItem('vault_token_expiry'); |
| } |
| } |
| return false; |
| } |
| |
| |
| window.addEventListener('DOMContentLoaded', () => { |
| if (!checkPersistedSession()) { |
| showScreen('login'); |
| } |
| }); |
| |
| function getHeaders() { |
| const headers = { 'Content-Type': 'application/json' }; |
| if (sessionToken) { |
| headers['Authorization'] = `Bearer ${sessionToken}`; |
| } |
| return headers; |
| } |
| |
| function showScreen(screenId, resetLocationFilter = false) { |
| document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); |
| document.getElementById(screenId + '-screen').classList.add('active'); |
| |
| const nav = document.getElementById('bottom-nav'); |
| if (screenId === 'login') { |
| nav.classList.add('hidden'); |
| |
| document.getElementById('login-screen').classList.add('active'); |
| } else { |
| nav.classList.remove('hidden'); |
| updateNav(screenId); |
| if (resetLocationFilter) { |
| currentLocationFilter = null; |
| const headerTitle = document.getElementById('inventory-header-title'); |
| if (headerTitle) headerTitle.textContent = 'All Items'; |
| } |
| if (inventory.length === 0 || containersData.length === 0) { |
| fetchAll(); |
| } else if (screenId === 'inventory') { |
| renderInventory(); |
| } |
| } |
| } |
| |
| async function fetchAll() { |
| await Promise.all([fetchInventory(), fetchLocations(), fetchContainers()]); |
| } |
| |
| function updateNav(screenId) { |
| document.querySelectorAll('.nav-btn').forEach(btn => { |
| if (btn.dataset.screen === screenId) { |
| btn.classList.add('bg-primary-container', 'text-on-primary-container'); |
| btn.classList.remove('text-outline'); |
| btn.querySelector('.material-symbols-outlined').classList.add('fill'); |
| } else { |
| btn.classList.remove('bg-primary-container', 'text-on-primary-container'); |
| btn.classList.add('text-outline'); |
| btn.querySelector('.material-symbols-outlined').classList.remove('fill'); |
| } |
| }); |
| } |
| |
| async function fetchInventory() { |
| try { |
| const res = await fetch('/api/inventory', { |
| headers: getHeaders() |
| }); |
| inventory = await res.json(); |
| renderDashboard(); |
| renderInventory(); |
| renderLocations(); |
| } catch (err) { |
| console.error("Failed to fetch inventory", err); |
| } |
| } |
| |
| function renderDashboard() { |
| document.getElementById('item-count').innerText = `${inventory.length} items`; |
| const recentContainer = document.getElementById('recent-items'); |
| |
| |
| recentContainer.innerHTML = inventory.slice(0, 8).map(item => ` |
| <div class="flex-none bg-surface-container-low p-4 rounded-2xl min-w-[140px] border border-outline-variant/10"> |
| <p class="font-bold text-sm truncate">${item.name}</p> |
| <div class="mt-3 flex items-center justify-between"> |
| <span class="text-xs font-bold text-outline">${item.quantity} ${item.unit || 'UNIT'}</span> |
| <span class="material-symbols-outlined text-outline/30 text-sm">inventory_2</span> |
| </div> |
| </div> |
| `).join(''); |
| } |
| |
| async function fetchLocations() { |
| try { |
| const res = await fetch('/api/locations', { |
| headers: getHeaders() |
| }); |
| locationsData = await res.json(); |
| renderLocations(); |
| } catch (err) { |
| console.error("Failed to fetch locations", err); |
| } |
| } |
| |
| async function fetchContainers() { |
| try { |
| const res = await fetch('/api/containers', { |
| headers: getHeaders() |
| }); |
| const data = await res.json(); |
| containersData = Array.isArray(data) ? data : []; |
| } catch (err) { |
| console.error("Failed to fetch containers", err); |
| } |
| } |
| |
| function renderLocations() { |
| const container = document.getElementById('locations-grid'); |
| if (!container) return; |
| |
| if (!locationsData || locationsData.length === 0) { |
| container.innerHTML = `<p class="text-sm text-on-surface-variant col-span-2">No locations created yet.</p>`; |
| return; |
| } |
| |
| container.innerHTML = locationsData.map((loc, idx) => { |
| const locContainers = containersData.filter(c => c.location_id === loc.id); |
| const locContainerIds = locContainers.map(c => c.id); |
| const locItems = inventory.filter(i => i.location_id === loc.id || locContainerIds.includes(i.container_id)); |
| |
| |
| const gradients = [ |
| 'from-primary/20 to-primary/5', |
| 'from-secondary/20 to-secondary/5', |
| 'from-tertiary/20 to-tertiary/5', |
| 'from-error/10 to-error/5' |
| ]; |
| const gradient = gradients[idx % gradients.length]; |
| |
| return ` |
| <div onclick="showLocationInventory('${loc.name}', '${loc.id}')" class="col-span-2 relative p-6 rounded-3xl overflow-hidden group cursor-pointer bg-surface-container-low border border-outline-variant/10 active:scale-[0.98] transition-all"> |
| <div class="absolute inset-0 bg-gradient-to-br ${gradient} opacity-50"></div> |
| <div class="relative z-10 flex justify-between items-start"> |
| <div class="space-y-1"> |
| <h4 class="text-xl font-black text-on-surface tracking-tight">${loc.name}</h4> |
| <p class="text-xs font-bold text-outline uppercase tracking-widest">${locItems.length} items • ${locContainers.length} boxes</p> |
| </div> |
| <div class="w-12 h-12 rounded-2xl bg-white/50 backdrop-blur-sm flex items-center justify-center text-primary group-hover:scale-110 transition-transform"> |
| <span class="material-symbols-outlined text-2xl">home_storage</span> |
| </div> |
| </div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| function renderInventory() { |
| const listContainer = document.getElementById('inventory-list'); |
| if (!listContainer) return; |
| |
| |
| let filteredInventory; |
| if (currentLocationFilter) { |
| const locContainers = containersData.filter(c => c.location_id === currentLocationFilter.id); |
| const locContainerIds = locContainers.map(c => c.id); |
| filteredInventory = inventory.filter(i => |
| i.location_id === currentLocationFilter.id || |
| locContainerIds.includes(i.container_id) |
| ); |
| } else { |
| filteredInventory = inventory; |
| } |
| |
| document.getElementById('inv-count-badge').innerText = `${filteredInventory.length} Items`; |
| |
| if (filteredInventory.length === 0) { |
| listContainer.innerHTML = ` |
| <div class="py-20 text-center space-y-4"> |
| <span class="material-symbols-outlined text-outline/20 text-6xl">inventory_2</span> |
| <p class="text-on-surface-variant font-medium">No items in this location yet.</p> |
| </div>`; |
| return; |
| } |
| |
| |
| const uniqueContainerIds = [...new Set(filteredInventory.map(i => i.container_id))]; |
| |
| listContainer.innerHTML = uniqueContainerIds.map(contId => { |
| const container = containersData.find(c => c.id === contId); |
| const contItems = filteredInventory.filter(i => i.container_id === contId); |
| |
| |
| const containerLocName = container |
| ? (locationsData.find(l => l.id === container.location_id)?.name || '') |
| : (currentLocationFilter ? currentLocationFilter.name : ''); |
| |
| return ` |
| <section class="space-y-6 mb-10 pt-2 group"> |
| <!-- Container/Box Header --> |
| <div class="flex items-center gap-4 px-2 border-b border-outline-variant/30 pb-3 bg-surface sticky top-[84px] z-20"> |
| <div class="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center transition-transform group-hover:scale-105"> |
| <span class="material-symbols-outlined text-primary text-[24px]"> |
| ${contId ? 'package_2' : 'home_storage'} |
| </span> |
| </div> |
| <div class="flex-grow"> |
| <h3 class="text-lg font-black text-on-surface uppercase tracking-tight leading-none mb-1"> |
| ${contId ? (container ? container.name : 'Unknown Box') : 'General Storage'} |
| </h3> |
| <p class="text-[10px] font-black text-outline uppercase tracking-widest flex items-center gap-2"> |
| <span>${contItems.length} ITEMS</span> |
| ${containerLocName ? `<span class="w-1 h-1 rounded-full bg-outline/30"></span><span>${containerLocName}</span>` : ''} |
| </p> |
| </div> |
| </div> |
| |
| <div class="grid gap-3 pl-4 border-l-2 border-primary/5 ml-6"> |
| ${contItems.map(item => ` |
| <div class="p-5 rounded-3xl flex items-center justify-between bg-surface-container-lowest shadow-sm active:scale-[0.98] transition-all border border-outline-variant/10 hover:border-primary/20 hover:shadow-md"> |
| <div class="min-w-0 pr-4"> |
| <h4 class="font-extrabold text-on-surface text-lg leading-tight truncate">${item.name}</h4> |
| </div> |
| <div class="flex items-center gap-6"> |
| <div class="text-right flex-shrink-0"> |
| <span class="block text-2xl font-black text-primary tabular-nums leading-none"> |
| ${item.quantity} |
| </span> |
| <span class="text-[10px] uppercase font-black text-outline tracking-widest"> |
| ${item.unit || 'UNIT'} |
| </span> |
| </div> |
| <button onclick="deleteItem('${item.id}', event)" class="w-12 h-12 rounded-2xl flex items-center justify-center text-outline/20 hover:text-error hover:bg-error/10 transition-all"> |
| <span class="material-symbols-outlined text-xl">delete</span> |
| </button> |
| </div> |
| </div> |
| `).join('')} |
| </div> |
| </section> |
| `; |
| }).join(''); |
| } |
| |
| |
| document.getElementById('login-form').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const password = document.getElementById('password-input').value; |
| const btn = document.getElementById('login-btn'); |
| const btnText = document.getElementById('login-btn-text'); |
| const errorEl = document.getElementById('login-error'); |
| |
| btn.disabled = true; |
| btnText.innerText = 'Unlocking...'; |
| errorEl.classList.add('hidden'); |
| |
| try { |
| const res = await fetch('/api/auth/unlock', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ password }) |
| }); |
| const data = await res.json(); |
| if (data.success) { |
| sessionToken = data.token; |
| |
| |
| const rememberMe = document.getElementById('remember-me').checked; |
| if (rememberMe) { |
| const thirtyDays = 30 * 24 * 60 * 60 * 1000; |
| localStorage.setItem('vault_token', sessionToken); |
| localStorage.setItem('vault_token_expiry', (Date.now() + thirtyDays).toString()); |
| } |
| |
| showScreen('dashboard'); |
| } else { |
| errorEl.innerText = data.detail || "Invalid Access Key"; |
| errorEl.classList.remove('hidden'); |
| } |
| } catch (err) { |
| errorEl.innerText = "Connection error"; |
| errorEl.classList.remove('hidden'); |
| } finally { |
| btn.disabled = false; |
| btnText.innerText = 'Unlock Vault'; |
| } |
| }); |
| |
| |
| document.getElementById('chat-form').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const input = document.getElementById('chat-input'); |
| const content = input.value.trim(); |
| if (!content) return; |
| |
| addMessage('user', content); |
| input.value = ''; |
| |
| const typingId = addTypingIndicator(); |
| |
| try { |
| const res = await fetch('/api/ai/chat', { |
| method: 'POST', |
| headers: getHeaders(), |
| body: JSON.stringify({ messages: chatMessages }) |
| }); |
| const data = await res.json(); |
| console.log("AI Assistant Response:", data); |
| removeTypingIndicator(typingId); |
| if (data.choices && data.choices[0]) { |
| const content = data.choices[0].message.content; |
| if (!content) { |
| addMessage('assistant', 'I am processing your complex request. Please give me a moment or check the system logs.'); |
| } else { |
| try { |
| const parsed = JSON.parse(content); |
| addMessage('assistant', parsed.reply || content); |
| } catch (e) { |
| addMessage('assistant', content); |
| } |
| } |
| |
| fetchAll(); |
| } |
| } catch (err) { |
| removeTypingIndicator(typingId); |
| addMessage('assistant', "Sorry, I encountered an error."); |
| } |
| }); |
| |
| function addMessage(role, content) { |
| chatMessages.push({ role, content }); |
| const container = document.getElementById('chat-messages'); |
| const empty = document.getElementById('chat-empty'); |
| if (empty) empty.remove(); |
| |
| const msgDiv = document.createElement('div'); |
| msgDiv.className = `flex flex-col ${role === 'user' ? 'items-end' : 'items-start'}`; |
| |
| |
| const renderedContent = role === 'assistant' ? marked.parse(content) : `<p>${content}</p>`; |
| |
| msgDiv.innerHTML = ` |
| <div class="max-w-[85%] p-5 rounded-2xl shadow-sm ${role === 'user' ? 'bg-primary text-on-primary rounded-tr-none' : 'bg-surface-container-low text-on-surface rounded-tl-none border border-outline-variant/10'}"> |
| <div class="text-[15px] leading-relaxed prose prose-sm ${role === 'user' ? 'prose-invert' : ''}">${renderedContent}</div> |
| </div> |
| `; |
| container.appendChild(msgDiv); |
| container.scrollTop = container.scrollHeight; |
| } |
| |
| function addTypingIndicator() { |
| const container = document.getElementById('chat-messages'); |
| const id = 'typing-' + Date.now(); |
| const div = document.createElement('div'); |
| div.id = id; |
| div.className = "flex items-start"; |
| div.innerHTML = ` |
| <div class="bg-surface-container-low p-5 rounded-2xl rounded-tl-none border border-outline-variant/10"> |
| <div class="flex gap-1.5"> |
| <div class="w-2 h-2 bg-primary/40 rounded-full animate-bounce"></div> |
| <div class="w-2 h-2 bg-primary/40 rounded-full animate-bounce [animation-delay:0.2s]"></div> |
| <div class="w-2 h-2 bg-primary/40 rounded-full animate-bounce [animation-delay:0.4s]"></div> |
| </div> |
| </div> |
| `; |
| container.appendChild(div); |
| container.scrollTop = container.scrollHeight; |
| return id; |
| } |
| |
| function showLocationInventory(locName, locId) { |
| currentLocationFilter = { id: locId, name: locName }; |
| const headerTitle = document.getElementById('inventory-header-title'); |
| if (headerTitle) headerTitle.textContent = locName; |
| showScreen('inventory'); |
| renderInventory(); |
| } |
| |
| function removeTypingIndicator(id) { |
| const el = document.getElementById(id); |
| if (el) el.remove(); |
| } |
| |
| function logout() { |
| |
| localStorage.removeItem('vault_token'); |
| localStorage.removeItem('vault_token_expiry'); |
| |
| sessionToken = null; |
| showScreen('login'); |
| document.getElementById('password-input').value = ''; |
| chatMessages = []; |
| document.getElementById('chat-messages').innerHTML = ` |
| <div class="text-center py-20 space-y-6" id="chat-empty"> |
| <div class="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-primary-container text-primary editorial-shadow"> |
| <span class="material-symbols-outlined text-4xl">auto_awesome</span> |
| </div> |
| <div class="space-y-2"> |
| <h3 class="text-2xl font-headline font-bold">How can I help?</h3> |
| <p class="text-on-surface-variant max-w-[240px] mx-auto">Ask me about your inventory, stock levels, or storage optimization.</p> |
| </div> |
| </div> |
| `; |
| } |
| |
| function openQuickAdd() { |
| document.getElementById('quick-add-modal').classList.remove('hidden'); |
| document.getElementById('quick-add-modal').classList.add('flex'); |
| } |
| |
| function closeQuickAdd() { |
| document.getElementById('quick-add-modal').classList.add('hidden'); |
| document.getElementById('quick-add-modal').classList.remove('flex'); |
| } |
| |
| async function deleteItem(id, event) { |
| event.stopPropagation(); |
| if (!confirm("Delete this item?")) return; |
| try { |
| const res = await fetch(`/api/inventory/${id}`, { |
| method: 'DELETE', |
| headers: getHeaders() |
| }); |
| if (res.ok) { |
| inventory = inventory.filter(i => i.id !== id); |
| renderDashboard(); |
| renderInventory(); |
| } |
| } catch (err) { |
| console.error("Delete failed", err); |
| } |
| } |
| |
| function handleSearch(val) { |
| const dropdown = document.getElementById('search-dropdown'); |
| if (val.trim() === '') { |
| dropdown.innerHTML = ''; |
| dropdown.classList.add('hidden'); |
| return; |
| } |
| |
| dropdown.classList.remove('hidden'); |
| |
| const fuse = new Fuse(inventory, { |
| keys: [ |
| { name: 'name', weight: 1.0 } |
| ], |
| threshold: 0.35 |
| }); |
| const results = fuse.search(val).map(r => r.item).slice(0, 10); |
| |
| if (results.length === 0) { |
| dropdown.innerHTML = ` |
| <div class="p-6 text-center text-on-surface-variant"> |
| <span class="material-symbols-outlined block text-3xl opacity-50 mb-2">search_off</span> |
| <p class="text-sm font-medium">No items found</p> |
| </div> |
| `; |
| return; |
| } |
| |
| dropdown.innerHTML = results.map(item => { |
| const container = containersData.find(c => c.id === item.container_id); |
| const loc = locationsData.find(l => l.id === (container ? container.location_id : item.location_id)); |
| const locName = loc ? loc.name : 'Unknown Location'; |
| const contName = container ? container.name : 'Unboxed'; |
| |
| return ` |
| <div onclick="showLocationInventory('${locName}')" class="p-5 hover:bg-surface-container-low cursor-pointer border-b border-outline-variant/5 last:border-0 transition-colors flex items-center gap-5"> |
| <div class="w-12 h-12 rounded-2xl bg-primary/5 flex items-center justify-center text-primary/40 flex-shrink-0"> |
| <span class="material-symbols-outlined">inventory_2</span> |
| </div> |
| <div class="flex-grow min-w-0"> |
| <h4 class="font-bold text-on-surface truncate leading-tight mb-1">${item.name}</h4> |
| <div class="flex items-center gap-1.5 text-[10px] font-black uppercase text-outline tracking-widest"> |
| <span class="truncate">${locName}</span> |
| <span class="text-outline-variant/60">•</span> |
| <span class="truncate text-primary/70">${contName}</span> |
| </div> |
| </div> |
| <div class="text-right flex-shrink-0 pl-2"> |
| <span class="block font-black text-lg text-primary leading-none">${item.quantity}</span> |
| <span class="text-[9px] font-bold text-outline-variant uppercase tracking-widest">${item.unit || 'UNIT'}</span> |
| </div> |
| </div> |
| `; |
| }).join(''); |
| } |
| |
| document.getElementById('quick-add-form').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const item = { |
| name: document.getElementById('add-name').value, |
| quantity: parseFloat(document.getElementById('add-qty').value), |
| unit: document.getElementById('add-unit').value || "UNIT" |
| }; |
| |
| try { |
| const res = await fetch('/api/inventory', { |
| method: 'POST', |
| headers: getHeaders(), |
| body: JSON.stringify(item) |
| }); |
| if (res.ok) { |
| await fetchInventory(); |
| closeQuickAdd(); |
| document.getElementById('quick-add-form').reset(); |
| } |
| } catch (err) { |
| console.error("Add failed", err); |
| } |
| }); |
| |
| function openAddLocationModal() { |
| document.getElementById('location-modal').classList.remove('hidden'); |
| document.getElementById('location-modal').classList.add('flex'); |
| } |
| |
| function closeAddLocationModal() { |
| document.getElementById('location-modal').classList.add('hidden'); |
| document.getElementById('location-modal').classList.remove('flex'); |
| } |
| |
| function openProfileModal() { |
| document.getElementById('profile-items').innerText = inventory.length; |
| document.getElementById('profile-locs').innerText = locationsData.length; |
| document.getElementById('profile-modal').classList.remove('hidden'); |
| document.getElementById('profile-modal').classList.add('flex'); |
| } |
| |
| function closeProfileModal() { |
| document.getElementById('profile-modal').classList.add('hidden'); |
| document.getElementById('profile-modal').classList.remove('flex'); |
| } |
| |
| document.getElementById('location-form').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const location = { |
| name: document.getElementById('loc-name').value |
| }; |
| |
| try { |
| const res = await fetch('/api/locations', { |
| method: 'POST', |
| headers: getHeaders(), |
| body: JSON.stringify(location) |
| }); |
| if (res.ok) { |
| await fetchLocations(); |
| closeAddLocationModal(); |
| document.getElementById('location-form').reset(); |
| } |
| } catch (err) { |
| console.error("Add location failed", err); |
| } |
| }); |
| |
| |
| let mediaRecorder; |
| let audioChunks = []; |
| let isRecording = false; |
| |
| const micBtn = document.getElementById('mic-btn'); |
| const micIcon = document.getElementById('mic-icon'); |
| const micPulse = document.getElementById('mic-pulse'); |
| |
| micBtn.addEventListener('click', async () => { |
| console.log("Mic button clicked! isRecording:", isRecording); |
| |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
| alert("Microphone is blocked or not supported! Ensure you are using HTTPS or localhost."); |
| return; |
| } |
| |
| if (!isRecording) { |
| |
| micPulse.classList.replace('scale-0', 'scale-150'); |
| micPulse.classList.add('animate-pulse', 'bg-outline/20'); |
| micIcon.innerText = 'pending'; |
| |
| |
| try { |
| |
| const stream = await Promise.race([ |
| navigator.mediaDevices.getUserMedia({ audio: true }), |
| new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout_Permission")), 5000)) |
| ]); |
| |
| mediaRecorder = new MediaRecorder(stream); |
| audioChunks = []; |
| |
| mediaRecorder.ondataavailable = e => { |
| if (e.data.size > 0) audioChunks.push(e.data); |
| }; |
| |
| mediaRecorder.onstop = async () => { |
| |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
| const formData = new FormData(); |
| |
| |
| formData.append('file', audioBlob, 'voice_note.webm'); |
| |
| |
| micIcon.innerText = 'hourglass_empty'; |
| micIcon.classList.add('animate-spin'); |
| |
| micPulse.classList.replace('scale-150', 'scale-0'); |
| micPulse.classList.remove('animate-pulse', 'bg-error/20'); |
| |
| try { |
| const res = await fetch('/api/audio/transcribe', { |
| method: 'POST', |
| headers: sessionToken ? { 'Authorization': `Bearer ${sessionToken}` } : {}, |
| body: formData |
| }); |
| |
| if (res.ok) { |
| const data = await res.json(); |
| if (data.text) { |
| |
| showScreen('ai'); |
| document.getElementById('chat-input').value = data.text; |
| |
| |
| document.getElementById('chat-form').dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); |
| } else { |
| alert("Transcription returned no text."); |
| } |
| } else { |
| alert("Transcription API failed with status: " + res.status); |
| } |
| } catch (err) { |
| console.error("Transcription failed", err); |
| alert("Transcription failed: Failed to reach the server."); |
| } finally { |
| micIcon.classList.remove('animate-spin'); |
| micIcon.innerText = 'mic'; |
| micBtn.classList.replace('bg-error/20', 'bg-secondary-container'); |
| micBtn.classList.replace('text-error', 'text-on-secondary-container'); |
| } |
| }; |
| |
| mediaRecorder.start(); |
| isRecording = true; |
| |
| |
| micPulse.classList.remove('bg-outline/20'); |
| micPulse.classList.add('bg-error/20'); |
| micBtn.classList.replace('bg-secondary-container', 'bg-error/20'); |
| micBtn.classList.replace('text-on-secondary-container', 'text-error'); |
| micIcon.innerText = 'stop'; |
| } catch (err) { |
| console.error("Mic access denied or timed out", err); |
| if (err.message === "Timeout_Permission") { |
| alert("The microphone request timed out. Your browser is hiding the permission prompt in the address bar. Please click the lock/settings icon next to the URL and allow microphone access."); |
| } else { |
| alert("Please allow microphone access in your browser's address bar to use voice commands."); |
| } |
| |
| micPulse.classList.replace('scale-150', 'scale-0'); |
| micPulse.classList.remove('animate-pulse', 'bg-outline/20'); |
| micIcon.innerText = 'mic'; |
| } |
| } else { |
| |
| mediaRecorder.stop(); |
| mediaRecorder.stream.getTracks().forEach(track => track.stop()); |
| isRecording = false; |
| |
| |
| micBtn.classList.replace('bg-error/20', 'bg-secondary-container'); |
| micBtn.classList.replace('text-error', 'text-on-secondary-container'); |
| micPulse.classList.replace('scale-150', 'scale-0'); |
| micPulse.classList.remove('animate-pulse'); |
| } |
| }); |
| </script> |
| </body> |
|
|
| </html> |