Spaces:
Running
feat: conversation minimize, HF sync UX, inventory editing/sort/drag/tags
Browse filesConversation:
- Minimize/expand the transcript panel (chevron + tap-header-to-expand;
minimized header shows the message count).
HF sync:
- Save/Load side by side; each asks for confirmation and states what will be
overwritten (item count, plan y/n, shopping count) before replacing.
- Auto-sync toggle: debounced upload ~4s after each change (reuses
hfDatasetSync); shows 'last synced' relative time.
Inventory:
- Edit any item in a styled modal (name/qty/unit/expiration/location);
new inv.normalizeItemInput replaces the item in place.
- Restyled 'Use' modal (was window.prompt) with an amount field + 'use all'.
- Delete asks for confirmation.
- Expiration date now shown on every card (clock icon + date, red if overdue);
sort control: Expiration / Name / Custom.
- Custom mode: pointer-based drag-to-reorder (works on touch), persisted.
- Storage location shown as a chip; tapping it filters the list to that
location; add/edit location field autocompletes from existing locations.
- New line SVG action icons (edit/use/trash/drag/clock/pin) matching the
hand-drawn style; reusable modal + confirm helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- assets/embed-CtskQx_e.js +0 -0
- assets/{embed-Nu2ZlthJ.css β embed-DdITxSHM.css} +1 -1
- assets/embed-tHhpGeu0.js +0 -0
- assets/{index-CppylzQx.js β index-BkQO3aLW.js} +2 -2
- index.html +1 -1
- web/src/domain/inventory.ts +21 -0
- web/src/i18n/index.ts +52 -4
- web/src/storage/store.ts +17 -1
- web/src/ui/app.ts +445 -91
- web/src/ui/styles.css +359 -0
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -1 +1 @@
|
|
| 1 |
-
:root{--cream: #fff4e6;--cream-strong: #ffe8c9;--peach: #f8b48a;--peach-deep: #e5885c;--teal: #2c8b88;--teal-dark: #0f5453;--ink: #1c2428;--muted: #4e5c61;--white: #ffffff;--card: #fffaf3;--danger: #b3402a;--danger-bg: #ffe1d6;--warn: #8a6210;--warn-bg: #ffedc2;--shadow-sm: 0 8px 18px rgba(24, 38, 38, .08);--shadow-lg: 0 18px 40px rgba(24, 38, 38, .15)}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html,body{margin:0;padding:0;height:100%}body{font-family:Nunito,-apple-system,BlinkMacSystemFont,sans-serif;background:linear-gradient(180deg,#fff7ed,#fef0e1,#f5ece6);color:var(--ink);-webkit-font-smoothing:antialiased;overscroll-behavior:none}#root{display:flex;flex-direction:column;height:100dvh}.app-main{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:1rem 1rem calc(7.5rem + env(safe-area-inset-bottom,0px));max-width:640px;width:100%;margin:0 auto}.app-tabs{display:flex;background:var(--white);border-top:1px solid rgba(44,139,136,.12);box-shadow:0 -6px 18px #1826260f;padding-bottom:env(safe-area-inset-bottom,0px)}.app-tabs button{flex:1;min-height:58px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:.72rem;font-weight:700;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.2rem;cursor:pointer;padding:.4rem .2rem;touch-action:manipulation;transition:color .2s ease,transform .15s ease}.app-tabs button:active{transform:scale(.94)}.app-tabs button .tab-icon{line-height:1;display:flex}.app-tabs button .tab-icon svg{width:23px;height:23px}.app-tabs button.active{color:var(--teal)}h1{font-family:Fraunces,serif;font-size:1.55rem;margin:.3rem 0 1rem;color:var(--ink)}h2{font-family:inherit;font-size:.82rem;font-weight:800;margin:1.5rem 0 .6rem;color:var(--teal-dark);text-transform:uppercase;letter-spacing:.06em}.card{background:var(--white);border:1px solid rgba(0,0,0,.04);border-radius:20px;padding:.95rem 1.05rem;margin-bottom:.7rem;box-shadow:var(--shadow-sm);transition:transform .2s ease,box-shadow .2s ease}.row{display:flex;align-items:center;gap:.6rem}.row .grow{flex:1;min-width:0}.item-title{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.item-sub{color:var(--muted);font-size:.85rem;margin-top:.15rem}.chip{display:inline-block;font-size:.72rem;font-weight:700;border-radius:999px;padding:.16rem .6rem;margin-left:.4rem;vertical-align:middle}.chip.warn{background:var(--warn-bg);color:var(--warn)}.chip.danger{background:var(--danger-bg);color:var(--danger)}button.btn{min-height:46px;min-width:46px;border-radius:14px;border:1px solid rgba(44,139,136,.2);background:var(--card);color:var(--teal-dark);font-family:inherit;font-size:.95rem;font-weight:700;padding:0 1rem;cursor:pointer;touch-action:manipulation;transition:transform .15s ease,box-shadow .2s ease,background .2s ease}button.btn:active{transform:scale(.97)}button.btn.primary{background:var(--teal);border-color:var(--teal);color:var(--white);box-shadow:0 10px 24px #2c8b8840}button.btn.small{min-height:44px;font-size:.85rem;padding:0 .75rem}button.btn.block{width:100%;margin:.45rem 0}input,select{min-height:46px;width:100%;min-width:0;max-width:100%;border-radius:14px;border:1.5px solid var(--cream-strong);background:var(--white);color:var(--ink);font-family:inherit;font-size:1rem;padding:0 .8rem;transition:border-color .2s ease,box-shadow .2s ease}input[type=date]{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:block;min-width:0}.form-grid>div{min-width:0}input:focus,select:focus{outline:none;border-color:var(--teal);box-shadow:0 0 0 3px #2c8b8826}label{display:block;font-size:.8rem;font-weight:700;color:var(--muted);margin:.65rem 0 .28rem}.form-grid{display:grid;grid-template-columns:2fr 1fr;gap:0 .6rem}.empty{color:var(--muted);text-align:center;padding:2rem 1rem}.day-card .day-name{font-family:Fraunces,serif;font-weight:700;font-size:1.05rem;margin-bottom:.4rem}.meal{border-top:1px solid var(--cream-strong);padding:.55rem 0}.meal:first-of-type{border-top:none}.meal .meal-kind{font-size:.72rem;font-weight:800;color:var(--teal);text-transform:uppercase;letter-spacing:.06em}.meal .meal-name{font-weight:800;margin:.12rem 0}.meal .meal-ing{color:var(--muted);font-size:.85rem}.toast{position:fixed;left:50%;bottom:calc(84px + env(safe-area-inset-bottom,0px));transform:translate(-50%);background:var(--teal-dark);color:var(--white);border-radius:14px;padding:.65rem 1.05rem;font-size:.9rem;font-weight:600;box-shadow:var(--shadow-lg);z-index:10;max-width:90vw;animation:toast-in .35s cubic-bezier(.175,.885,.32,1.275)}@keyframes toast-in{0%{opacity:0;transform:translate(-50%,12px)}to{opacity:1;transform:translate(-50%)}}.hint{font-size:.82rem;color:var(--muted);line-height:1.45}.lang-toggle{display:flex;gap:.5rem}.lang-toggle button{flex:1}.lang-toggle button.active{background:var(--teal);color:var(--white);border-color:var(--teal)}.voice-fab{position:fixed;left:50%;transform:translate(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0px));z-index:9;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:50px;padding:0 1.4rem;border-radius:999px;border:none;background:var(--teal);color:var(--white);font-family:inherit;font-size:.98rem;font-weight:800;box-shadow:0 12px 30px #2c8b8859;cursor:pointer;max-width:90vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;touch-action:manipulation;transition:transform .15s ease,background .25s ease,box-shadow .25s ease}.voice-fab:active{transform:translate(-50%) scale(.96)}.voice-fab.active{background:var(--peach-deep);box-shadow:0 12px 30px #e5885c66}.voice-fab.speaking{background:var(--peach-deep);animation:voice-pulse 1.2s ease-in-out infinite}@keyframes voice-pulse{0%,to{box-shadow:0 12px 30px #e5885c59}50%{box-shadow:0 12px 40px #e5885ca6}}.voice-fab svg{width:19px;height:19px;flex:none}.voice-fab .spin-path{transform-origin:12px 12px;animation:fab-spin .9s linear infinite}@keyframes fab-spin{to{transform:rotate(360deg)}}.voice-transcript{position:fixed;left:50%;transform:translate(-50%);bottom:calc(136px + env(safe-area-inset-bottom,0px));width:min(92vw,560px);max-height:38vh;display:none;background:#f8f9fa;border:1px solid #e9ecef;border-radius:24px;box-shadow:var(--shadow-lg);z-index:8;overflow:hidden}.voice-transcript .vt-head{display:flex;align-items:center;justify-content:space-between;padding:.5rem 1rem;border-bottom:1px solid #e9ecef;font-size:.75rem;font-weight:800;color:var(--teal-dark);text-transform:uppercase;letter-spacing:.06em;background:var(--white)}.voice-transcript .vt-clear{border:none;background:none;color:var(--peach-deep);font-family:inherit;font-size:.78rem;font-weight:700;cursor:pointer;min-height:32px;padding:0 .4rem}.voice-transcript .vt-body{overflow-y:auto;-webkit-overflow-scrolling:touch;max-height:calc(38vh - 44px);padding:.8rem .9rem .9rem;display:flex;flex-direction:column;gap:.7rem}.voice-transcript .vt-msg{display:flex;align-items:flex-end;gap:8px;animation:slide-in .4s cubic-bezier(.175,.885,.32,1.275)}.voice-transcript .vt-msg.user{flex-direction:row-reverse}@keyframes slide-in{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.voice-transcript .avatar{width:34px;height:34px;flex:none}.voice-transcript .bubble{padding:9px 13px;border-radius:16px;max-width:80%;font-size:.9rem;line-height:1.45;color:var(--white)}.voice-transcript .vt-msg.assistant .bubble{background:var(--peach-deep);border-bottom-left-radius:4px;box-shadow:0 4px 15px #2c8b8826}.voice-transcript .vt-msg.user .bubble{background:var(--teal);border-bottom-right-radius:4px;box-shadow:0 4px 15px #2c8b8833}.voice-transcript .vt-empty{color:var(--muted);font-size:.85rem;text-align:center;padding:.6rem 0}.voice-transcript .bubble.photo-bubble{padding:5px;background:var(--white);box-shadow:var(--shadow-sm)}.voice-transcript .bubble .photo{display:block;max-width:100%;max-height:180px;border-radius:12px}
|
|
|
|
| 1 |
+
:root{--cream: #fff4e6;--cream-strong: #ffe8c9;--peach: #f8b48a;--peach-deep: #e5885c;--teal: #2c8b88;--teal-dark: #0f5453;--ink: #1c2428;--muted: #4e5c61;--white: #ffffff;--card: #fffaf3;--danger: #b3402a;--danger-bg: #ffe1d6;--warn: #8a6210;--warn-bg: #ffedc2;--shadow-sm: 0 8px 18px rgba(24, 38, 38, .08);--shadow-lg: 0 18px 40px rgba(24, 38, 38, .15)}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html,body{margin:0;padding:0;height:100%}body{font-family:Nunito,-apple-system,BlinkMacSystemFont,sans-serif;background:linear-gradient(180deg,#fff7ed,#fef0e1,#f5ece6);color:var(--ink);-webkit-font-smoothing:antialiased;overscroll-behavior:none}#root{display:flex;flex-direction:column;height:100dvh}.app-main{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:1rem 1rem calc(7.5rem + env(safe-area-inset-bottom,0px));max-width:640px;width:100%;margin:0 auto}.app-tabs{display:flex;background:var(--white);border-top:1px solid rgba(44,139,136,.12);box-shadow:0 -6px 18px #1826260f;padding-bottom:env(safe-area-inset-bottom,0px)}.app-tabs button{flex:1;min-height:58px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:.72rem;font-weight:700;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.2rem;cursor:pointer;padding:.4rem .2rem;touch-action:manipulation;transition:color .2s ease,transform .15s ease}.app-tabs button:active{transform:scale(.94)}.app-tabs button .tab-icon{line-height:1;display:flex}.app-tabs button .tab-icon svg{width:23px;height:23px}.app-tabs button.active{color:var(--teal)}h1{font-family:Fraunces,serif;font-size:1.55rem;margin:.3rem 0 1rem;color:var(--ink)}h2{font-family:inherit;font-size:.82rem;font-weight:800;margin:1.5rem 0 .6rem;color:var(--teal-dark);text-transform:uppercase;letter-spacing:.06em}.card{background:var(--white);border:1px solid rgba(0,0,0,.04);border-radius:20px;padding:.95rem 1.05rem;margin-bottom:.7rem;box-shadow:var(--shadow-sm);transition:transform .2s ease,box-shadow .2s ease}.row{display:flex;align-items:center;gap:.6rem}.row .grow{flex:1;min-width:0}.item-title{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.item-sub{color:var(--muted);font-size:.85rem;margin-top:.15rem}.chip{display:inline-block;font-size:.72rem;font-weight:700;border-radius:999px;padding:.16rem .6rem;margin-left:.4rem;vertical-align:middle}.chip.warn{background:var(--warn-bg);color:var(--warn)}.chip.danger{background:var(--danger-bg);color:var(--danger)}button.btn{min-height:46px;min-width:46px;border-radius:14px;border:1px solid rgba(44,139,136,.2);background:var(--card);color:var(--teal-dark);font-family:inherit;font-size:.95rem;font-weight:700;padding:0 1rem;cursor:pointer;touch-action:manipulation;transition:transform .15s ease,box-shadow .2s ease,background .2s ease}button.btn:active{transform:scale(.97)}button.btn.primary{background:var(--teal);border-color:var(--teal);color:var(--white);box-shadow:0 10px 24px #2c8b8840}button.btn.small{min-height:44px;font-size:.85rem;padding:0 .75rem}button.btn.block{width:100%;margin:.45rem 0}input,select{min-height:46px;width:100%;min-width:0;max-width:100%;border-radius:14px;border:1.5px solid var(--cream-strong);background:var(--white);color:var(--ink);font-family:inherit;font-size:1rem;padding:0 .8rem;transition:border-color .2s ease,box-shadow .2s ease}input[type=date]{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:block;min-width:0}.form-grid>div{min-width:0}input:focus,select:focus{outline:none;border-color:var(--teal);box-shadow:0 0 0 3px #2c8b8826}label{display:block;font-size:.8rem;font-weight:700;color:var(--muted);margin:.65rem 0 .28rem}.form-grid{display:grid;grid-template-columns:2fr 1fr;gap:0 .6rem}.empty{color:var(--muted);text-align:center;padding:2rem 1rem}.day-card .day-name{font-family:Fraunces,serif;font-weight:700;font-size:1.05rem;margin-bottom:.4rem}.meal{border-top:1px solid var(--cream-strong);padding:.55rem 0}.meal:first-of-type{border-top:none}.meal .meal-kind{font-size:.72rem;font-weight:800;color:var(--teal);text-transform:uppercase;letter-spacing:.06em}.meal .meal-name{font-weight:800;margin:.12rem 0}.meal .meal-ing{color:var(--muted);font-size:.85rem}.toast{position:fixed;left:50%;bottom:calc(84px + env(safe-area-inset-bottom,0px));transform:translate(-50%);background:var(--teal-dark);color:var(--white);border-radius:14px;padding:.65rem 1.05rem;font-size:.9rem;font-weight:600;box-shadow:var(--shadow-lg);z-index:10;max-width:90vw;animation:toast-in .35s cubic-bezier(.175,.885,.32,1.275)}@keyframes toast-in{0%{opacity:0;transform:translate(-50%,12px)}to{opacity:1;transform:translate(-50%)}}.hint{font-size:.82rem;color:var(--muted);line-height:1.45}.lang-toggle{display:flex;gap:.5rem}.lang-toggle button{flex:1}.lang-toggle button.active{background:var(--teal);color:var(--white);border-color:var(--teal)}.voice-fab{position:fixed;left:50%;transform:translate(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0px));z-index:9;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:50px;padding:0 1.4rem;border-radius:999px;border:none;background:var(--teal);color:var(--white);font-family:inherit;font-size:.98rem;font-weight:800;box-shadow:0 12px 30px #2c8b8859;cursor:pointer;max-width:90vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;touch-action:manipulation;transition:transform .15s ease,background .25s ease,box-shadow .25s ease}.voice-fab:active{transform:translate(-50%) scale(.96)}.voice-fab.active{background:var(--peach-deep);box-shadow:0 12px 30px #e5885c66}.voice-fab.speaking{background:var(--peach-deep);animation:voice-pulse 1.2s ease-in-out infinite}@keyframes voice-pulse{0%,to{box-shadow:0 12px 30px #e5885c59}50%{box-shadow:0 12px 40px #e5885ca6}}.voice-fab svg{width:19px;height:19px;flex:none}.voice-fab .spin-path{transform-origin:12px 12px;animation:fab-spin .9s linear infinite}@keyframes fab-spin{to{transform:rotate(360deg)}}.voice-transcript{position:fixed;left:50%;transform:translate(-50%);bottom:calc(136px + env(safe-area-inset-bottom,0px));width:min(92vw,560px);max-height:38vh;display:none;background:#f8f9fa;border:1px solid #e9ecef;border-radius:24px;box-shadow:var(--shadow-lg);z-index:8;overflow:hidden}.voice-transcript .vt-head{display:flex;align-items:center;justify-content:space-between;padding:.5rem 1rem;border-bottom:1px solid #e9ecef;font-size:.75rem;font-weight:800;color:var(--teal-dark);text-transform:uppercase;letter-spacing:.06em;background:var(--white)}.voice-transcript .vt-clear{border:none;background:none;color:var(--peach-deep);font-family:inherit;font-size:.78rem;font-weight:700;cursor:pointer;min-height:32px;padding:0 .4rem}.voice-transcript .vt-body{overflow-y:auto;-webkit-overflow-scrolling:touch;max-height:calc(38vh - 44px);padding:.8rem .9rem .9rem;display:flex;flex-direction:column;gap:.7rem}.voice-transcript .vt-msg{display:flex;align-items:flex-end;gap:8px;animation:slide-in .4s cubic-bezier(.175,.885,.32,1.275)}.voice-transcript .vt-msg.user{flex-direction:row-reverse}@keyframes slide-in{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.voice-transcript .avatar{width:34px;height:34px;flex:none}.voice-transcript .bubble{padding:9px 13px;border-radius:16px;max-width:80%;font-size:.9rem;line-height:1.45;color:var(--white)}.voice-transcript .vt-msg.assistant .bubble{background:var(--peach-deep);border-bottom-left-radius:4px;box-shadow:0 4px 15px #2c8b8826}.voice-transcript .vt-msg.user .bubble{background:var(--teal);border-bottom-right-radius:4px;box-shadow:0 4px 15px #2c8b8833}.voice-transcript .vt-empty{color:var(--muted);font-size:.85rem;text-align:center;padding:.6rem 0}.voice-transcript .bubble.photo-bubble{padding:5px;background:var(--white);box-shadow:var(--shadow-sm)}.voice-transcript .bubble .photo{display:block;max-width:100%;max-height:180px;border-radius:12px}.sort-bar{display:flex;align-items:center;gap:.6rem;margin:.2rem 0 .8rem}.sort-bar .sort-label{font-size:.8rem;font-weight:700;color:var(--muted)}.segmented{display:inline-flex;background:var(--cream-strong);border-radius:12px;padding:3px;gap:2px}.segmented button{border:none;background:none;font-family:inherit;font-size:.82rem;font-weight:700;color:var(--teal-dark);padding:.35rem .7rem;border-radius:9px;cursor:pointer;min-height:36px;touch-action:manipulation}.segmented button.active{background:var(--white);color:var(--teal);box-shadow:var(--shadow-sm)}.drag-hint{display:flex;align-items:center;gap:.35rem;margin:0 0 .5rem}.drag-hint svg{width:16px;height:16px}.filter-banner{display:flex;align-items:center;gap:.4rem;width:100%;border:none;background:var(--cream-strong);color:var(--teal-dark);font-family:inherit;font-weight:700;font-size:.85rem;border-radius:12px;padding:.5rem .8rem;margin-bottom:.6rem;cursor:pointer}.filter-banner svg{width:16px;height:16px;flex:none}.inv-list{display:flex;flex-direction:column;gap:.6rem}.inv-card{display:flex;align-items:center;gap:.5rem;margin-bottom:0;touch-action:pan-y}.inv-card.dragging{opacity:.85;box-shadow:var(--shadow-lg);transform:scale(1.02)}.drag-handle{display:flex;align-items:center;color:var(--muted);cursor:grab;padding:.2rem;margin:-.2rem 0 -.2rem -.3rem}.drag-handle svg{width:18px;height:18px}.item-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .7rem;margin-top:.25rem;font-size:.82rem;color:var(--muted)}.item-meta .qty{font-weight:700;color:var(--ink)}.item-meta .exp{display:inline-flex;align-items:center;gap:.2rem}.item-meta .exp.over{color:var(--danger);font-weight:700}.item-meta .exp svg{width:14px;height:14px}.item-meta .loc-chip{display:inline-flex;align-items:center;gap:.15rem;border:none;background:var(--cream-strong);color:var(--teal-dark);font-family:inherit;font-size:.78rem;font-weight:700;border-radius:999px;padding:.12rem .5rem .12rem .35rem;cursor:pointer;touch-action:manipulation}.item-meta .loc-chip svg{width:13px;height:13px}.inv-actions{display:flex;gap:.15rem;flex:none}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border:none;background:none;color:var(--teal-dark);border-radius:10px;cursor:pointer;touch-action:manipulation;transition:background .15s ease,transform .15s ease}.icon-btn:active{transform:scale(.9)}.icon-btn:hover{background:var(--cream)}.icon-btn svg{width:20px;height:20px}.icon-btn.danger{color:var(--danger)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:30;background:#18262673;display:flex;align-items:flex-end;justify-content:center;padding:1rem;padding-bottom:calc(1rem + env(safe-area-inset-bottom,0px));animation:fade-in .2s ease}@media (min-width: 540px){.modal-backdrop{align-items:center}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.modal-card{background:var(--white);border-radius:22px;width:100%;max-width:440px;box-shadow:var(--shadow-lg);padding:1.1rem 1.1rem 1rem;animation:modal-up .28s cubic-bezier(.175,.885,.32,1.1)}@keyframes modal-up{0%{transform:translateY(20px);opacity:.6}to{transform:translateY(0);opacity:1}}.modal-title{font-family:Fraunces,serif;font-size:1.2rem;font-weight:700;margin-bottom:.6rem}.modal-body.confirm-msg{color:var(--muted);line-height:1.5;padding:.2rem 0 .4rem}.modal-actions{display:flex;gap:.6rem;margin-top:1rem}.modal-actions .btn{flex:1}.sync-row{display:flex;gap:.6rem;margin-top:.6rem}.sync-row .btn{flex:1}.switch-row{display:flex;align-items:center;gap:.7rem;margin-top:.9rem}.switch-row>span:first-child{flex:1}.switch{-moz-appearance:none;appearance:none;-webkit-appearance:none;width:46px;height:28px;border-radius:999px;background:var(--cream-strong);position:relative;flex:none;cursor:pointer;transition:background .2s ease}.switch:after{content:"";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:var(--white);box-shadow:0 1px 3px #00000040;transition:transform .2s ease}.switch:checked{background:var(--teal)}.switch:checked:after{transform:translate(18px)}.sync-status{margin-top:.7rem;text-align:center}.vt-head-actions{display:flex;align-items:center;gap:.2rem}.vt-min{width:32px;height:32px;color:var(--teal-dark)}.vt-min svg{width:20px;height:20px}.voice-transcript.minimized{max-height:none}.voice-transcript.minimized .vt-body{display:none}.voice-transcript.minimized .vt-head{cursor:pointer}
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/auto-CaTD3xKq.js","assets/sub-paths-F6TP7MGR-8fYyfeHC.js","assets/embed-
|
| 2 |
var G=Object.defineProperty;var q=(o,a,e)=>a in o?G(o,a,{enumerable:!0,configurable:!0,writable:!0,value:e}):o[a]=e;var d=(o,a,e)=>q(o,typeof a!="symbol"?a+"":a,e);(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))t(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const n of s.addedNodes)n.tagName==="LINK"&&n.rel==="modulepreload"&&t(n)}).observe(document,{childList:!0,subtree:!0});function e(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function t(i){if(i.ep)return;i.ep=!0;const s=e(i);fetch(i.href,s)}})();const W="modulepreload",z=function(o){return"/"+o},O={},U=function(a,e,t){let i=Promise.resolve();if(e&&e.length>0){document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),r=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));i=Promise.allSettled(e.map(c=>{if(c=z(c),c in O)return;O[c]=!0;const l=c.endsWith(".css"),h=l?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${c}"]${h}`))return;const u=document.createElement("link");if(u.rel=l?"stylesheet":W,l||(u.as="script"),u.crossOrigin="",u.href=c,r&&u.setAttribute("nonce",r),document.head.appendChild(u),l)return new Promise((p,g)=>{u.addEventListener("load",p),u.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${c}`)))})}))}function s(n){const r=new Event("vite:preloadError",{cancelable:!0});if(r.payload=n,window.dispatchEvent(r),!r.defaultPrevented)throw n}return i.then(n=>{for(const r of n||[])r.status==="rejected"&&s(r.reason);return a().catch(s)})};var J=Object.defineProperty,K=(o,a,e)=>a in o?J(o,a,{enumerable:!0,configurable:!0,writable:!0,value:e}):o[a]=e,k=(o,a,e)=>(K(o,typeof a!="symbol"?a+"":a,e),e),B="https://huggingface.co";async function T(o,a){var e,t;const i=new X(o.url,o.status,(e=o.headers.get("X-Request-Id"))!=null?e:void 0);i.message=`Api error with status ${i.statusCode}`;const s=[`URL: ${i.url}`,i.requestId?`Request ID: ${i.requestId}`:void 0].filter(Boolean).join(". ");if((t=o.headers.get("Content-Type"))!=null&&t.startsWith("application/json")){const n=await o.json();i.message=n.error||n.message||i.message,i.data=n}else i.data={message:await o.text()};throw i.message+=`. ${s}`,i}var X=class extends Error{constructor(o,a,e,t){super(t),k(this,"statusCode"),k(this,"url"),k(this,"requestId"),k(this,"data"),this.statusCode=a,this.requestId=e,this.url=o}};new Promise(o=>{});function Y(o){if(globalThis.Buffer)return globalThis.Buffer.from(o).toString("base64");{const a=[];return o.forEach(e=>{a.push(String.fromCharCode(e))}),globalThis.btoa(a.join(""))}}async function $(o){var a,e;if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const t=new URLSearchParams(window.location.search),[i,s]=[t.get("error"),t.get("error_description")];if(i)throw new Error(`${i}: ${s}`);const n=t.get("code"),r=localStorage.getItem("huggingface.co:oauth:nonce");if(!n)throw new Error("Missing oauth code from query parameters in redirected URL");if(!r)throw new Error("Missing oauth nonce from localStorage");const c=localStorage.getItem("huggingface.co:oauth:code_verifier");if(!c)throw new Error("Missing oauth code_verifier from localStorage");const l=t.get("state");if(!l)throw new Error("Missing oauth state from query parameters in redirected URL");let h;try{h=JSON.parse(l)}catch{throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: "+l)}if(h.nonce!==r)throw new Error("Invalid oauth state in redirected URL");const u=B,p=`${new URL(u).origin}/.well-known/openid-configuration`,g=await fetch(p,{headers:{Accept:"application/json"}});if(!g.ok)throw await T(g);const w=await g.json(),m=await fetch(w.token_endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:n,redirect_uri:h.redirectUri,code_verifier:c}).toString()});if(localStorage.removeItem("huggingface.co:oauth:code_verifier"),localStorage.removeItem("huggingface.co:oauth:nonce"),!m.ok)throw await T(m);const f=await m.json(),y=new Date(Date.now()+f.expires_in*1e3),v=await fetch(w.userinfo_endpoint,{headers:{Authorization:`Bearer ${f.access_token}`}});if(!v.ok)throw await T(v);const b=await v.json();return{accessToken:f.access_token,accessTokenExpiresAt:y,userInfo:{id:b.sub,name:b.name,fullname:b.preferred_username,email:b.email,emailVerified:b.email_verified,avatarUrl:b.picture,websiteUrl:b.website,isPro:b.isPro,orgs:(e=(a=b.orgs)==null?void 0:a.map(R=>({id:R.sub,name:R.name,fullname:R.name,isEnterprise:R.isEnterprise,canPay:R.canPay,avatarUrl:R.picture,roleInOrg:R.roleInOrg})))!=null?e:[]},state:h.state,scope:f.scope}}async function Q(o){if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const a=new URLSearchParams(window.location.search);return a.has("error")?$():a.has("code")?localStorage.getItem("huggingface.co:oauth:nonce")?$():(console.warn("Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."),!1):!1}async function Z(o){var a,e;if(typeof window>"u")throw new Error("oauthLogin is only available in the browser");const t=(o==null?void 0:o.hubUrl)||B,i=`${new URL(t).origin}/.well-known/openid-configuration`,s=await fetch(i,{headers:{Accept:"application/json"}});if(!s.ok)throw await T(s);const n=await s.json(),r=globalThis.crypto.randomUUID(),c=globalThis.crypto.randomUUID()+globalThis.crypto.randomUUID();localStorage.setItem("huggingface.co:oauth:nonce",r),localStorage.setItem("huggingface.co:oauth:code_verifier",c);const l=(o==null?void 0:o.redirectUrl)||window.location.href,h=JSON.stringify({nonce:r,redirectUri:l,state:o==null?void 0:o.state}),u=(e=(a=window==null?void 0:window.huggingface)==null?void 0:a.variables)!=null?e:null,p=(o==null?void 0:o.clientId)||(u==null?void 0:u.OAUTH_CLIENT_ID);if(!p)throw u?new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space"):new Error("Missing clientId");const g=Y(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256",new TextEncoder().encode(c)))).replace(/[+]/g,"-").replace(/[/]/g,"_").replace(/=/g,"");return`${n.authorization_endpoint}?${new URLSearchParams({client_id:p,scope:(o==null?void 0:o.scopes)||(u==null?void 0:u.OAUTH_SCOPES)||"openid profile",response_type:"code",redirect_uri:l,state:h,code_challenge:g,code_challenge_method:"S256"}).toString()}`}function C(o){return o*Math.PI/180}function _e(o){return o*180/Math.PI}function ee(o,a,e){const t=C(o),i=C(a),s=C(e),n=Math.cos(s),r=Math.sin(s),c=Math.cos(i),l=Math.sin(i),h=Math.cos(t),u=Math.sin(t);return[[n*c,n*l*u-r*h,n*l*h+r*u,0],[r*c,r*l*u+n*h,r*l*h-n*u,0],[-l,c*u,c*h,0],[0,0,0,1]]}function te(){if(typeof window>"u"||!window.location.hash)return;const o=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;let a;try{a=new URLSearchParams(o)}catch{return}const e=a.get("hf_token");if(!e)return;const t=a.get("hf_username")||"user",i=a.get("hf_token_expires"),s=i&&!Number.isNaN(new Date(i).getTime())?i:new Date(Date.now()+365*24*60*60*1e3).toISOString();try{sessionStorage.setItem("hf_token",e),sessionStorage.setItem("hf_username",t),sessionStorage.setItem("hf_token_expires",s)}catch(c){console.warn("[reachy-mini] could not persist pre-seeded HF credentials:",c)}a.delete("hf_token"),a.delete("hf_username"),a.delete("hf_token_expires");const n=a.toString(),r=window.location.pathname+window.location.search+(n?"#"+n:"");try{window.history.replaceState(null,"",r)}catch{}}function ie(){if(typeof window>"u")return null;if(window.location.hash){const o=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;try{const e=new URLSearchParams(o).get("robot_peer_id");if(e)return e}catch{}}if(window.location.search)try{const a=new URLSearchParams(window.location.search).get("robot_peer_id");if(a)return a}catch{}return null}function se(o){const a=o.split(`\r
|
| 3 |
`);let e=!1;for(const t of a)if(t.startsWith("m=audio")?e=!0:t.startsWith("m=")&&(e=!1),e&&t==="a=sendrecv")return!0;return!1}const S=12*1024,L=1*1024*1024,ne=512*1024;function oe(){return typeof CompressionStream<"u"}function x(){return"u"+Math.random().toString(36).slice(2,11)+Date.now().toString(36)}function P(o){let a="";for(let t=0;t<o.length;t+=32768)a+=String.fromCharCode.apply(null,Array.from(o.subarray(t,t+32768)));return btoa(a)}async function re(o){const a=new TextEncoder().encode(o),e=await new Response(new Blob([a]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer();return P(new Uint8Array(e))}function N(o){const a=Math.round(Number(o)||0);return Math.max(0,Math.min(100,a))}const H=3e3,V=1e3,ae=6e4;class le extends EventTarget{constructor(e={}){super();d(this,"_signalingUrl");d(this,"_clientId");d(this,"_appName");d(this,"_videoJitterBufferTargetMs");d(this,"_autoStartFromUrl");d(this,"_autoStartAttempted");d(this,"_state","disconnected");d(this,"_robots",[]);d(this,"_robotState",{});d(this,"_preselectedRobotId");d(this,"_token",null);d(this,"_username",null);d(this,"_tokenExpires",null);d(this,"_sseAbortController",null);d(this,"_pc",null);d(this,"_dc",null);d(this,"_sessionId",null);d(this,"_selectedRobotId",null);d(this,"_pendingRemoteIce",[]);d(this,"_micStream",null);d(this,"_micMuted",!0);d(this,"_audioMuted",!0);d(this,"_micSupported",!1);d(this,"_latencyMonitorId",null);d(this,"_stateRefreshInterval",null);d(this,"_versionResolve",null);d(this,"_hardwareIdResolve",null);d(this,"_volumeResolve",null);d(this,"_micVolumeResolve",null);d(this,"_applyAudioConfigResolve",null);d(this,"_readAudioParameterResolve",null);d(this,"_logSubscribers",new Set);d(this,"_broadcastWaiters",[]);d(this,"_activeMoveUploadId",null);d(this,"_activeAudioUploadId",null);d(this,"_sessionResolve",null);d(this,"_sessionReject",null);d(this,"_iceConnected",!1);d(this,"_dcOpen",!1);d(this,"_iceGraceTimer",null);d(this,"_iceGraceReason",null);d(this,"_pendingVisibilityHandler",null);d(this,"_onlineHandler",null);d(this,"_offlineHandler",null);d(this,"_connectionChangeHandler",null);d(this,"_pendingMotionCompletions",{wake_up:[],goto_sleep:[]});d(this,"_videoElement",null);this._signalingUrl=e.signalingUrl||"https://pollen-robotics-reachy-mini-central.hf.space",this._clientId=e.clientId||null,this._appName=e.appName||"unknown",this._videoJitterBufferTargetMs=e.videoJitterBufferTargetMs??0,this._autoStartFromUrl=e.autoStartFromUrl===!0,this._autoStartAttempted=!1,this._preselectedRobotId=ie()}get state(){return this._state}get robots(){return this._robots}get robotState(){return this._robotState}get username(){return this._username}get isAuthenticated(){return!!this._token}get micSupported(){return this._micSupported}get micMuted(){return this._micMuted}get audioMuted(){return this._audioMuted}get preselectedRobotId(){return this._preselectedRobotId}get isEmbedded(){return this._preselectedRobotId!==null}_maybeAutoStart(){if(!this._autoStartFromUrl||this._autoStartAttempted||!this._preselectedRobotId||this._state!=="connected"||!this._robots.find(i=>i.id===this._preselectedRobotId))return;this._autoStartAttempted=!0;const t=this._preselectedRobotId;setTimeout(()=>{this._state==="connected"&&this.startSession(t).catch(i=>{console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:",i)})},0)}async authenticate(){try{te();const e=await Q();if(e)return this._username=e.userInfo.preferred_username||e.userInfo.name||null,this._token=e.accessToken,this._tokenExpires=e.accessTokenExpiresAt,sessionStorage.setItem("hf_token",this._token),sessionStorage.setItem("hf_username",this._username??""),sessionStorage.setItem("hf_token_expires",typeof this._tokenExpires=="string"?this._tokenExpires:this._tokenExpires.toISOString()),!0;const t=sessionStorage.getItem("hf_token"),i=sessionStorage.getItem("hf_username"),s=sessionStorage.getItem("hf_token_expires");return t&&i&&s&&new Date(s)>new Date?(this._token=t,this._username=i,this._tokenExpires=s,!0):!1}catch(e){return console.error("Auth error:",e),!1}}async login(){const e={};this._clientId&&(e.clientId=this._clientId),window.location.href=await Z(e)}logout(){sessionStorage.removeItem("hf_token"),sessionStorage.removeItem("hf_username"),sessionStorage.removeItem("hf_token_expires"),this._username=null,this._tokenExpires=null,this.disconnect()}async connect(e){if(this._state!=="disconnected")throw new Error("Already connected");if(e&&(this._token=e),!this._token)throw new Error("No token β call authenticate() first or pass a token");this._sseAbortController=new AbortController;let t;try{t=await fetch(`${this._signalingUrl}/events`,{signal:this._sseAbortController.signal,headers:{Authorization:`Bearer ${this._token}`}})}catch(i){throw this._sseAbortController=null,i}if(!t.ok)throw this._sseAbortController=null,new Error(`HTTP ${t.status}`);return new Promise((i,s)=>{let n=!1;const r=t.body.getReader(),c=new TextDecoder;let l="";(async()=>{try{for(;;){const{done:u,value:p}=await r.read();if(u)break;l+=c.decode(p,{stream:!0});const g=l.split(`
|
| 4 |
-
`);l=g.pop()??"";for(const w of g)if(w.startsWith("data:"))try{const m=JSON.parse(w.slice(5).trim());!n&&m.type==="welcome"&&m.peerId&&(n=!0,this._state="connected",await this._sendToServer({type:"setPeerStatus",roles:["listener"],meta:{name:this._appName}}),this._emit("connected",{peerId:m.peerId}),i()),this._handleSignalingMessage(m)}catch{}}}catch(u){if(u.name!=="AbortError"&&this._emit("error",{source:"signaling",error:u}),!n){s(u);return}}this._state!=="disconnected"&&(this._state="disconnected",this._emit("disconnected",{reason:"SSE closed"})),n||s(new Error("Connection closed before welcome"))})()})}async autoConnect(e={}){var l,h,u,p,g;const{token:t,pickRobot:i,autoPickIfSingle:s=!0,filterBusy:n=!0,wakeOnConnect:r=!0}=e;if(this._state==="streaming"){const w=(l=this._robots)==null?void 0:l.find(m=>m.id===this._selectedRobotId);return{robotId:this._selectedRobotId,robotName:((h=w==null?void 0:w.meta)==null?void 0:h.name)??null,isEmbedded:this.isEmbedded,alreadyStreaming:!0}}const c=this._autoStartFromUrl;this._autoStartFromUrl=!1;try{if(t)this._token=t;else if(!this._token&&!await this.authenticate())throw new Error("Not authenticated β call login() or pass a token");this._state==="disconnected"&&await this.connect();let w,m=null;if(this.isEmbedded){w=this._preselectedRobotId;try{await this._waitForRobotInList(w,5e3)}catch{}const f=(u=this._robots)==null?void 0:u.find(y=>y.id===w);m=((p=f==null?void 0:f.meta)==null?void 0:p.name)??null}else{const f=await this._fetchOwnedRobots({filterBusy:n});if(f.length===0)throw new Error("No reachable robots");if(s&&f.length===1&&!f[0].busy)w=f[0].id,m=f[0].name;else if(i){const y=await i(f);if(!y)throw new Error("Robot selection cancelled");w=y,m=((g=f.find(v=>v.id===y))==null?void 0:g.name)??null}else throw new Error("Multiple robots available β pass a pickRobot callback to autoConnect()")}if(await this.startSession(w),r&&typeof this.ensureAwake=="function")try{await this.ensureAwake()}catch(f){console.warn("[reachy-mini] autoConnect: ensureAwake failed:",f)}return{robotId:w,robotName:m,isEmbedded:this.isEmbedded}}finally{this._autoStartFromUrl=c}}async _fetchOwnedRobots({filterBusy:e=!0}={}){var t,i,s;try{const n=await fetch(`${this._signalingUrl}/api/robot-status`,{headers:{Authorization:`Bearer ${this._token}`}});if(!n.ok)throw new Error(`HTTP ${n.status}`);const r=await n.json(),c=new Map;for(const l of r.robots||[]){if(e&&l.busy)continue;const h=((t=l.meta)==null?void 0:t.install_id)??((i=l.meta)==null?void 0:i.hardware_id)??l.peerId;c.set(h,{id:l.peerId,name:l.robotName??((s=l.meta)==null?void 0:s.name)??null,busy:!!l.busy,activeApp:l.activeApp??null,meta:l.meta??{},lastSeenAgeSeconds:l.last_seen_age_seconds??null})}return Array.from(c.values()).sort((l,h)=>(l.lastSeenAgeSeconds??1/0)-(h.lastSeenAgeSeconds??1/0))}catch(n){return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:",n),(this._robots||[]).map(r=>{var c;return{id:r.id,name:((c=r.meta)==null?void 0:c.name)??null,busy:!1,activeApp:null,meta:r.meta??{},lastSeenAgeSeconds:null}})}}_waitForRobotInList(e,t){var i;return(i=this._robots)!=null&&i.find(s=>s.id===e)?Promise.resolve():new Promise((s,n)=>{const r=()=>{var l;(l=this._robots)!=null&&l.find(h=>h.id===e)&&(this.removeEventListener("robotsChanged",r),clearTimeout(c),s())},c=setTimeout(()=>{this.removeEventListener("robotsChanged",r),n(new Error(`Timeout waiting for robot ${e} in list`))},t);this.addEventListener("robotsChanged",r)})}async startSession(e){if(this._state!=="connected")throw new Error("Not connected");this._selectedRobotId=e,this._iceConnected=!1,this._dcOpen=!1,this._micSupported=!1,this._pendingRemoteIce=[];try{const t=window,i=t.AudioContext??t.webkitAudioContext;if(!i)throw new Error("AudioContext not supported");const s=new i,n=s.createMediaStreamDestination(),r=s.createOscillator(),c=s.createGain();c.gain.value=0,r.connect(c).connect(n),r.start();const l=n.stream;l.getAudioTracks().forEach(h=>{h.enabled=!1}),this._micStream=l,this._micMuted=!0}catch(t){console.warn("Audio sender placeholder setup failed:",t),this._micStream=null}return this._pc=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),this._installNetworkListeners(),new Promise((t,i)=>{this._sessionResolve=t,this._sessionReject=i,this._pc.ontrack=s=>{if(s.track.kind==="video"){const n=this._videoJitterBufferTargetMs;try{s.receiver.jitterBufferTarget=n}catch{}try{s.receiver.playoutDelayHint=n/1e3}catch{}this._emit("videoTrack",{track:s.track,stream:s.streams[0]})}},this._pc.onicecandidate=async s=>{s.candidate&&this._sessionId&&await this._sendToServer({type:"peer",sessionId:this._sessionId,ice:{candidate:s.candidate.candidate,sdpMLineIndex:s.candidate.sdpMLineIndex,sdpMid:s.candidate.sdpMid}})},this._pc.oniceconnectionstatechange=()=>{var n;const s=(n=this._pc)==null?void 0:n.iceConnectionState;if(s){if(this._emit("iceStateChange",{state:s}),s==="connected"||s==="completed"){this._clearIceGrace(),this._iceConnected=!0,this._checkSessionReady();return}if(s==="disconnected"){typeof document<"u"&&document.hidden?this._armIceGraceOnVisibility():this._scheduleIceGrace(H,"disconnected");return}if(s==="failed"){this._scheduleIceGrace(V,"failed");return}}},this._pc.ondatachannel=s=>{this._dc=s.channel,this._dc.onopen=()=>{this._dcOpen=!0,this._checkSessionReady()},this._dc.onmessage=n=>this._handleRobotMessage(JSON.parse(n.data))},this._sendToServer({type:"startSession",peerId:e}).then(s=>{if((s==null?void 0:s.type)==="sessionRejected"){this._failSessionRejected(s);return}s!=null&&s.sessionId&&(this._sessionId=s.sessionId)})})}_failSessionRejected(e){const t=new Error(e.reason==="robot_busy"?`Robot is busy: "${e.activeApp||"another app"}" is already connected`:`Session rejected: ${e.reason||"unknown reason"}`);if(t.reason=e.reason??null,t.activeApp=e.activeApp??null,this._clearIceGrace(),this._uninstallNetworkListeners(),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(i=>i.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1,this._emit("sessionRejected",{reason:e.reason,activeApp:e.activeApp}),this._sessionReject){const i=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,i(t)}}async stopSession(){this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Session stopped")),this._clearIceGrace(),this._uninstallNetworkListeners(),this._sessionReject&&(this._sessionReject(new Error("Session stopped")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&await this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(t=>t.stop()),this._micStream=null),this._micMuted=!0,this._micSupported=!1,this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._iceConnected=!1,this._dcOpen=!1,this._state==="streaming"&&(this._state="connected",this._emit("sessionStopped",{reason:"user"}))}disconnect(){this._sseAbortController&&(this._sseAbortController.abort(),this._sseAbortController=null),this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Disconnected")),this._clearIceGrace(),this._uninstallNetworkListeners(),this._sessionReject&&(this._sessionReject(new Error("Disconnected")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&this._token&&this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._micMuted=!0,this._micSupported=!1,this._iceConnected=!1,this._dcOpen=!1,this._robots=[],this._state="disconnected",this._emit("disconnected",{reason:"user"})}_clearIceGrace(){this._iceGraceTimer!==null&&(clearTimeout(this._iceGraceTimer),this._iceGraceTimer=null),this._iceGraceReason=null,this._pendingVisibilityHandler&&typeof document<"u"&&document.removeEventListener("visibilitychange",this._pendingVisibilityHandler),this._pendingVisibilityHandler=null}_scheduleIceGrace(e,t){if(this._iceGraceTimer!==null){if(this._iceGraceReason===t)return;clearTimeout(this._iceGraceTimer)}this._iceGraceReason=t,this._iceGraceTimer=setTimeout(()=>{var n;this._iceGraceTimer=null;const i=this._iceGraceReason;this._iceGraceReason=null;const s=(n=this._pc)==null?void 0:n.iceConnectionState;if(!(s==="connected"||s==="completed")){if(i==="disconnected"&&s==="disconnected"){this._emit("error",{source:"webrtc",error:new Error(`ICE stuck in 'disconnected' for > ${e}ms`)});return}if(i==="failed"||s==="failed"){const r=new Error("ICE connection failed");this._sessionReject&&(this._sessionReject(r),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:r})}}},e)}_armIceGraceOnVisibility(){if(this._pendingVisibilityHandler)return;const e=Date.now(),t=()=>{if(typeof document<"u"&&document.hidden||(document.removeEventListener("visibilitychange",t),this._pendingVisibilityHandler=null,!this._pc))return;const i=this._pc.iceConnectionState;if(!(i==="connected"||i==="completed")){if(Date.now()-e>ae){const s=new Error("Session expired while tab was backgrounded");this._sessionReject&&(this._sessionReject(s),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:s});return}if(i==="failed"){this._scheduleIceGrace(V,"failed");return}this._scheduleIceGrace(H,"disconnected")}};document.addEventListener("visibilitychange",t),this._pendingVisibilityHandler=t}_installNetworkListeners(){if(this._onlineHandler||typeof window>"u")return;const e=()=>this._emit("networkOnline",{}),t=()=>this._emit("networkOffline",{});window.addEventListener("online",e),window.addEventListener("offline",t),this._onlineHandler=e,this._offlineHandler=t;const i=navigator.connection;if(i&&typeof i.addEventListener=="function"){const s=()=>this._emit("networkChange",{effectiveType:i.effectiveType,downlink:i.downlink,rtt:i.rtt,saveData:i.saveData});i.addEventListener("change",s),this._connectionChangeHandler=s}}_uninstallNetworkListeners(){typeof window<"u"&&(this._onlineHandler&&window.removeEventListener("online",this._onlineHandler),this._offlineHandler&&window.removeEventListener("offline",this._offlineHandler));const e=navigator.connection;e&&this._connectionChangeHandler&&typeof e.removeEventListener=="function"&&e.removeEventListener("change",this._connectionChangeHandler),this._onlineHandler=null,this._offlineHandler=null,this._connectionChangeHandler=null}setTarget({head:e,antennas:t,body_yaw:i}={}){const s={type:"set_full_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(n=>Number.isFinite(n)))throw new TypeError(`setTarget: head must be a 16-element flat row-major 4Γ4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);s.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(n=>Number.isFinite(n)))throw new TypeError(`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);s.antennas=t}if(i!==void 0){if(!Number.isFinite(i))throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${i}`);s.body_yaw=i}return this._sendCommand(s)}gotoTarget({head:e,antennas:t,body_yaw:i,duration:s}){const n={type:"goto_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: head must be a 16-element flat row-major 4Γ4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);n.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);n.antennas=t}if(i!==void 0){if(!Number.isFinite(i))throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${i}`);n.body_yaw=i}if(!Number.isFinite(s)||s<=0)throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${s}`);return n.duration=s,this._sendCommand(n)}setHeadRpyDeg(e,t,i){return this.setTarget({head:ee(e,t,i).flat()})}setAntennasDeg(e,t){return this.setTarget({antennas:[C(e),C(t)]})}setBodyYawDeg(e){return this.setTarget({body_yaw:C(e)})}playSound(e){return this._sendCommand({type:"play_sound",file:e})}clearIncomingAudio(){return this._sendCommand({type:"clear_incoming_audio"})}setMotorMode(e){return this._sendCommand({type:"set_motor_mode",mode:e})}setMotorTorque(e,t=null){return this._sendCommand({type:"set_torque",on:e,ids:t})}wakeUp({timeoutMs:e=8e3}={}){return this._sendCommand({type:"set_motor_mode",mode:"enabled"}),this._sendCommandAwaitCompletion("wake_up",e)}gotoSleep({timeoutMs:e=8e3}={}){return this._sendCommandAwaitCompletion("goto_sleep",e)}_sendCommandAwaitCompletion(e,t){return this._sendCommand({type:e})?new Promise((i,s)=>{const n={resolve:i,reject:s,timer:setTimeout(()=>{const r=this._pendingMotionCompletions[e],c=r.indexOf(n);c!==-1&&r.splice(c,1),s(new Error(`${e} timed out after ${t}ms`))},t)};this._pendingMotionCompletions[e].push(n)}):Promise.reject(new Error(`${e}: data channel not open`))}_rejectPendingMotionCompletions(e){for(const t of Object.keys(this._pendingMotionCompletions)){const i=this._pendingMotionCompletions[t];for(;i.length;){const s=i.shift();clearTimeout(s.timer),s.reject(e)}}}isAwake(){var t;const e=(t=this._robotState)==null?void 0:t.motor_mode;return e==="enabled"||e==="gravity_compensation"}async ensureAwake(e=1e3){var t;return((t=this._robotState)==null?void 0:t.motor_mode)===void 0&&await new Promise(i=>{const s=()=>{this.removeEventListener("state",s),clearTimeout(n),i()},n=setTimeout(s,e);this.addEventListener("state",s),this.requestState()}),this.isAwake()||this.wakeUp().catch(()=>{}),!0}getVersion(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._versionResolve&&this._versionResolve(null),this._versionResolve=e,this._sendCommand({type:"get_version"})})}getHardwareId(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._hardwareIdResolve&&this._hardwareIdResolve(null),this._hardwareIdResolve=e,this._sendCommand({type:"get_hardware_id"})})}getVolume(){return this._slotRoundtrip(()=>this._volumeResolve,e=>{this._volumeResolve=e},{type:"get_volume"})}setVolume(e){return this._slotRoundtrip(()=>this._volumeResolve,t=>{this._volumeResolve=t},{type:"set_volume",volume:N(e)})}getMicrophoneVolume(){return this._slotRoundtrip(()=>this._micVolumeResolve,e=>{this._micVolumeResolve=e},{type:"get_microphone_volume"})}setMicrophoneVolume(e){return this._slotRoundtrip(()=>this._micVolumeResolve,t=>{this._micVolumeResolve=t},{type:"set_microphone_volume",volume:N(e)})}applyAudioConfig(e,{verify:t=!0}={}){return this._slotRoundtrip(()=>this._applyAudioConfigResolve,i=>{this._applyAudioConfigResolve=i},{type:"apply_audio_config",config:e,verify:t}).then(i=>i===!0)}readAudioParameter(e){return this._slotRoundtrip(()=>this._readAudioParameterResolve,t=>{this._readAudioParameterResolve=t},{type:"read_audio_parameter",name:e})}_slotRoundtrip(e,t,i){return new Promise((s,n)=>{if(!this._dc||this._dc.readyState!=="open"){n(new Error("Data channel not open"));return}const r=e();r&&r(null),t(s),this._sendCommand(i)})}sendRaw(e){return this._sendCommand(e)}subscribeLogs({onLine:e,onError:t}){if(typeof e!="function")throw new TypeError("subscribeLogs: onLine callback is required");const i={onLine:e,onError:t},s=this._logSubscribers.size===0;this._logSubscribers.add(i),s&&this._sendCommand({type:"subscribe_logs"});let n=!1;return()=>{n||(n=!0,this._logSubscribers.delete(i),this._logSubscribers.size===0&&this._sendCommand({type:"unsubscribe_logs"}))}}requestState(){return this._sendCommand({type:"get_state"})}setAudioMuted(e){this._audioMuted=e,this._videoElement&&(this._videoElement.muted=e)}setMicMuted(e){this._micMuted=e,this._micStream&&this._micStream.getAudioTracks().forEach(t=>{t.enabled=!e})}attachVideo(e){this._videoElement=e,e.muted=this._audioMuted;const t=s=>{const n=s;e.srcObject=n.detail.stream,e.playsInline=!0,"requestVideoFrameCallback"in e&&this._startLatencyMonitor(e)},i=()=>{e.srcObject=null};return this.addEventListener("videoTrack",t),this.addEventListener("sessionStopped",i),()=>{this.removeEventListener("videoTrack",t),this.removeEventListener("sessionStopped",i),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),e.srcObject=null,this._videoElement=null}}async playMove(e,{audioBlob:t=null,audioLeadMs:i=-100,description:s="move",encoding:n="gzip+base64",playFrequency:r=100,initialGotoDuration:c=0,startTimeoutMs:l=8e3,onProgress:h=()=>{},onStarted:u=()=>{}}={}){var R,j;if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!((R=e==null?void 0:e.time)!=null&&R.length)||!((j=e==null?void 0:e.set_target_data)!=null&&j.length))throw new Error("playMove: motion must have time + set_target_data");const p=x();this._activeMoveUploadId=p;const g={description:s,time:e.time,set_target_data:e.set_target_data},w=JSON.stringify(g);let m,f;n==="gzip+base64"&&oe()?(m=await re(w),f="gzip+base64"):(m=w,f="json");const y=Math.ceil(m.length/S)||1;h({phase:"starting",sent:0,total:y,bytes:m.length,encoding:f}),this._sendCommand({type:"upload_move_start",upload_id:p,total_chunks:y,description:s,encoding:f});for(let _=0;_<y;_++){this._dc.bufferedAmount>L&&await this._awaitDataChannelDrain();const I=_*S;this._sendCommand({type:"upload_move_chunk",upload_id:p,chunk_index:_,chunk:m.slice(I,I+S)}),h({phase:"upload",sent:_+1,total:y})}if(this._sendCommand({type:"upload_move_finish",upload_id:p}),h({phase:"uploaded",sent:y,total:y}),t){const _=new Uint8Array(await t.arrayBuffer()),I=P(_),E=Math.ceil(I.length/S)||1;h({phase:"audio-starting",sent:0,total:E,bytes:I.length}),this._sendCommand({type:"upload_audio_start",upload_id:p,total_chunks:E,encoding:"wav-base64",description:s});for(let A=0;A<E;A++){this._dc.bufferedAmount>L&&await this._awaitDataChannelDrain();const D=A*S;this._sendCommand({type:"upload_audio_chunk",upload_id:p,chunk_index:A,chunk:I.slice(D,D+S)}),h({phase:"audio-upload",sent:A+1,total:E})}this._sendCommand({type:"upload_audio_finish",upload_id:p}),h({phase:"audio-uploaded",sent:E,total:E})}this._sendCommand({type:"play_uploaded_move",upload_id:p,play_frequency:r,initial_goto_duration:c,audio_lead_ms:i});let v;try{v=await this._waitForBroadcast(_=>(_==null?void 0:_.type)==="play_uploaded_move"&&(_==null?void 0:_.upload_id)===p&&(_.started===!0||typeof _.error=="string"),{timeoutMs:l,debugLabel:"play_uploaded_move started"})}catch(_){throw new Error(`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${_.message}`)}if(typeof v.error=="string")throw new Error(`play_uploaded_move: ${v.error}`);try{u({duration_s:v.duration_s,has_audio:v.has_audio===!0})}catch(_){console.warn("playMove.onStarted threw:",_)}h({phase:"playing",duration_s:v.duration_s});const b=await this._waitForBroadcast(_=>(_==null?void 0:_.type)==="play_uploaded_move"&&(_==null?void 0:_.upload_id)===p&&(_.finished===!0||_.cancelled===!0||typeof _.error=="string"),{timeoutMs:(v.duration_s+30)*1e3,debugLabel:"play_uploaded_move final"});return this._activeMoveUploadId===p&&(this._activeMoveUploadId=null),b}cancelMove(e=null){const t=e??this._activeMoveUploadId;return t?this._sendCommand({type:"cancel_move",upload_id:t}):!1}async uploadAudio(e,{description:t="audio",onProgress:i=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!(e instanceof Blob))throw new TypeError("uploadAudio: expected a Blob");const s=x(),n=new Uint8Array(await e.arrayBuffer()),r=P(n),c=Math.ceil(r.length/S)||1;i({phase:"audio-starting",sent:0,total:c,bytes:r.length}),this._sendCommand({type:"upload_audio_start",upload_id:s,total_chunks:c,encoding:"wav-base64",description:t});for(let l=0;l<c;l++){this._dc.bufferedAmount>L&&await this._awaitDataChannelDrain();const h=l*S;this._sendCommand({type:"upload_audio_chunk",upload_id:s,chunk_index:l,chunk:r.slice(h,h+S)}),i({phase:"audio-upload",sent:l+1,total:c})}return this._sendCommand({type:"upload_audio_finish",upload_id:s}),i({phase:"audio-uploaded",sent:c,total:c}),s}async playUploadedAudio(e,{timeoutMs:t=8e3}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");const i=this._waitForBroadcast(n=>(n==null?void 0:n.type)==="play_uploaded_audio"&&(n==null?void 0:n.upload_id)===e&&(n.started===!0||typeof n.error=="string"),{timeoutMs:t,debugLabel:"play_uploaded_audio started"});this._sendCommand({type:"play_uploaded_audio",upload_id:e});const s=await i;if(typeof s.error=="string")throw new Error(s.error);return this._activeAudioUploadId=e,s}cancelAudio(e=null){const t=e??this._activeAudioUploadId;return t?(this._activeAudioUploadId===t&&(this._activeAudioUploadId=null),this._sendCommand({type:"cancel_audio",upload_id:t})):!1}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}_waitForBroadcast(e,{timeoutMs:t=5e3,debugLabel:i=""}={}){return new Promise((s,n)=>{const r={predicate:e,resolve:s,timer:setTimeout(()=>{const c=this._broadcastWaiters.indexOf(r);c!==-1&&this._broadcastWaiters.splice(c,1),n(new Error(`broadcast timeout (${t} ms): ${i}`))},t)};this._broadcastWaiters.push(r)})}async _awaitDataChannelDrain(){for(;this._dc&&this._dc.bufferedAmount>ne;)if(await new Promise(e=>setTimeout(e,30)),!this._dc||this._dc.readyState!=="open")throw new Error("data channel closed mid-upload")}async _sendToServer(e){if(!this._token)throw new Error("No token β authenticate() first");try{const t=await fetch(`${this._signalingUrl}/send`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this._token}`},body:JSON.stringify(e)});if(!t.ok){let i="";try{i=await t.text()}catch{}return console.warn(`[reachy-mini] /send rejected (${t.status}) for type=${e==null?void 0:e.type}; body=${i||"<empty>"}`),null}return await t.json()}catch(t){return console.error("Send error:",t),null}}_sendCommand(e){return!this._dc||this._dc.readyState!=="open"?!1:(this._dc.send(JSON.stringify(e)),!0)}_checkSessionReady(){this._iceConnected&&this._dcOpen&&this._sessionResolve&&(this._state="streaming",this.requestState(),this._stateRefreshInterval=setInterval(()=>this.requestState(),500),this._emit("streaming",{sessionId:this._sessionId,robotId:this._selectedRobotId}),this._sessionResolve(),this._sessionResolve=null,this._sessionReject=null)}async _handleSignalingMessage(e){switch(e.type){case"welcome":break;case"list":this._robots=e.producers||[],this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart();break;case"peerStatusChanged":{const t=await this._sendToServer({type:"list"});t!=null&&t.producers&&(this._robots=t.producers,this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart());break}case"sessionStarted":this._sessionId=e.sessionId??null;break;case"sessionRejected":this._failSessionRejected(e);break;case"endSession":this._handleEndSession(e);break;case"peer":this._handlePeerMessage(e);break}}_handleEndSession(e){const t=e.reason,i=t==="robot_busy_local_app"?"Robot is busy: a local Python app is running":t==="local_app_started"?"Disconnected: a local Python app started on the robot":t==="robot_busy_local"?"Robot is busy: another session is already active":null;if(this._sessionReject){const s=new Error(i||`Session ended before it could start: ${t||"unknown reason"}`);s.reason=t??null,this._emit("sessionRejected",{reason:t,activeApp:null}),this._clearIceGrace(),this._uninstallNetworkListeners(),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(r=>r.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1;const n=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,n(s);return}this._state==="streaming"&&(this._emit("sessionStopped",{reason:t||"remote_end",message:i}),this.stopSession().catch(()=>{}))}async _handlePeerMessage(e){if(this._pc)try{if(e.sdp){const t=e.sdp;if(t.type==="offer"){const s=se(t.sdp);if(this._micSupported=s,this._emit("micSupported",{supported:s}),s&&this._micStream)for(const r of this._micStream.getAudioTracks())this._pc.addTrack(r,this._micStream);await this._pc.setRemoteDescription(new RTCSessionDescription(t));const n=await this._pc.createAnswer();await this._pc.setLocalDescription(n),await this._sendToServer({type:"peer",sessionId:this._sessionId,sdp:{type:"answer",sdp:n.sdp}})}else await this._pc.setRemoteDescription(new RTCSessionDescription(t));const i=this._pendingRemoteIce;if(i&&i.length){this._pendingRemoteIce=[];for(const s of i)try{await this._pc.addIceCandidate(new RTCIceCandidate(s))}catch(n){console.warn("[reachy-mini] buffered ICE candidate rejected:",n)}}}if(e.ice){if(!e.ice.candidate)return;this._pc.remoteDescription?await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)):(this._pendingRemoteIce||(this._pendingRemoteIce=[]),this._pendingRemoteIce.push(e.ice))}}catch(t){console.error("WebRTC error:",t),this._emit("error",{source:"webrtc",error:t})}}_handleRobotMessage(e){if("version"in e&&this._versionResolve){this._versionResolve(e.version),this._versionResolve=null;return}if("hardware_id"in e&&this._hardwareIdResolve){this._hardwareIdResolve(e.hardware_id),this._hardwareIdResolve=null;return}if(e.command==="get_volume"||e.command==="set_volume"){this._volumeResolve&&(this._volumeResolve(e.status==="error"?null:e.volume),this._volumeResolve=null);return}if(e.command==="get_microphone_volume"||e.command==="set_microphone_volume"){this._micVolumeResolve&&(this._micVolumeResolve(e.status==="error"?null:e.volume),this._micVolumeResolve=null);return}if(e.command==="apply_audio_config"){this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(e.error?!1:!!e.applied),this._applyAudioConfigResolve=null);return}if(e.command==="read_audio_parameter"){this._readAudioParameterResolve&&(this._readAudioParameterResolve(e.error?null:e.values??null),this._readAudioParameterResolve=null);return}if((e.command==="wake_up"||e.command==="goto_sleep")&&this._pendingMotionCompletions&&this._pendingMotionCompletions[e.command]){const t=this._pendingMotionCompletions[e.command];if(e.completed===!0&&t.length>0){const i=t.shift();clearTimeout(i.timer),i.resolve();return}if(e.error&&t.length>0){const i=t.shift();clearTimeout(i.timer),i.reject(new Error(`${e.command}: ${e.error}`));return}}if(e.type==="log_line"){for(const t of this._logSubscribers)try{t.onLine({timestamp:e.timestamp,line:e.line})}catch(i){console.error("subscribeLogs onLine threw:",i)}return}if(e.type==="log_stream_error"){for(const t of this._logSubscribers)if(typeof t.onError=="function")try{t.onError(e.error)}catch(i){console.error("subscribeLogs onError threw:",i)}return}if(e.state){const t=e.state;t.head_pose&&(this._robotState.head=t.head_pose.flat()),t.antennas&&(this._robotState.antennas=[t.antennas[0],t.antennas[1]]),typeof t.body_yaw=="number"&&(this._robotState.body_yaw=t.body_yaw),t.motor_mode&&(this._robotState.motor_mode=t.motor_mode),typeof t.is_move_running=="boolean"&&(this._robotState.is_move_running=t.is_move_running),this._emit("state",{...this._robotState})}if(e.error&&this._emit("error",{source:"robot",error:e.error}),this._broadcastWaiters.length>0)for(let t=this._broadcastWaiters.length-1;t>=0;t--){const i=this._broadcastWaiters[t];if(i.predicate(e)){this._broadcastWaiters.splice(t,1),clearTimeout(i.timer),i.resolve(e);return}}}_startLatencyMonitor(e){this._latencyMonitorId&&clearInterval(this._latencyMonitorId),this._latencyMonitorId=setInterval(()=>{if(!e.srcObject||e.paused)return;const t=e.buffered;if(t.length>0){const i=t.end(t.length-1),s=i-e.currentTime;s>.5&&(console.log(`Latency correction: was ${s.toFixed(2)}s behind`),e.currentTime=i-.1)}},2e3)}}window.ReachyMini=le;window.dispatchEvent(new Event("reachymini:ready"));const M=new URLSearchParams(window.location.search),ce=M.get("embedded")==="1"||M.get("embed")==="1",de=M.has("code")||M.has("error"),he=sessionStorage.getItem("cookaiware:launched")==="1";function F(){sessionStorage.setItem("cookaiware:launched","1");const o=document.getElementById("root");return o&&(o.innerHTML=""),U(async()=>{const{mountHost:a}=await import("./auto-CaTD3xKq.js");return{mountHost:a}},__vite__mapDeps([0,1])).then(({mountHost:a})=>{a({appName:"CookAIware",appIconUrl:"/icon.png",appEmoji:"π²",enableMicrophone:!0,devToken:void 0,clientId:void 0})})}ce?U(()=>import("./embed-tHhpGeu0.js"),__vite__mapDeps([2,1,3])):de||he?F():U(async()=>{const{renderLanding:o}=await import("./landing-DkIk3-oJ.js");return{renderLanding:o}},__vite__mapDeps([4,5])).then(({renderLanding:o})=>o(()=>void F()));export{U as _,_e as r};
|
|
|
|
| 1 |
+
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/auto-CaTD3xKq.js","assets/sub-paths-F6TP7MGR-8fYyfeHC.js","assets/embed-CtskQx_e.js","assets/embed-DdITxSHM.css","assets/landing-DkIk3-oJ.js","assets/landing-BJE7fNCb.css"])))=>i.map(i=>d[i]);
|
| 2 |
var G=Object.defineProperty;var q=(o,a,e)=>a in o?G(o,a,{enumerable:!0,configurable:!0,writable:!0,value:e}):o[a]=e;var d=(o,a,e)=>q(o,typeof a!="symbol"?a+"":a,e);(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))t(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const n of s.addedNodes)n.tagName==="LINK"&&n.rel==="modulepreload"&&t(n)}).observe(document,{childList:!0,subtree:!0});function e(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function t(i){if(i.ep)return;i.ep=!0;const s=e(i);fetch(i.href,s)}})();const W="modulepreload",z=function(o){return"/"+o},O={},U=function(a,e,t){let i=Promise.resolve();if(e&&e.length>0){document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),r=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));i=Promise.allSettled(e.map(c=>{if(c=z(c),c in O)return;O[c]=!0;const l=c.endsWith(".css"),h=l?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${c}"]${h}`))return;const u=document.createElement("link");if(u.rel=l?"stylesheet":W,l||(u.as="script"),u.crossOrigin="",u.href=c,r&&u.setAttribute("nonce",r),document.head.appendChild(u),l)return new Promise((p,g)=>{u.addEventListener("load",p),u.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${c}`)))})}))}function s(n){const r=new Event("vite:preloadError",{cancelable:!0});if(r.payload=n,window.dispatchEvent(r),!r.defaultPrevented)throw n}return i.then(n=>{for(const r of n||[])r.status==="rejected"&&s(r.reason);return a().catch(s)})};var J=Object.defineProperty,K=(o,a,e)=>a in o?J(o,a,{enumerable:!0,configurable:!0,writable:!0,value:e}):o[a]=e,k=(o,a,e)=>(K(o,typeof a!="symbol"?a+"":a,e),e),B="https://huggingface.co";async function T(o,a){var e,t;const i=new X(o.url,o.status,(e=o.headers.get("X-Request-Id"))!=null?e:void 0);i.message=`Api error with status ${i.statusCode}`;const s=[`URL: ${i.url}`,i.requestId?`Request ID: ${i.requestId}`:void 0].filter(Boolean).join(". ");if((t=o.headers.get("Content-Type"))!=null&&t.startsWith("application/json")){const n=await o.json();i.message=n.error||n.message||i.message,i.data=n}else i.data={message:await o.text()};throw i.message+=`. ${s}`,i}var X=class extends Error{constructor(o,a,e,t){super(t),k(this,"statusCode"),k(this,"url"),k(this,"requestId"),k(this,"data"),this.statusCode=a,this.requestId=e,this.url=o}};new Promise(o=>{});function Y(o){if(globalThis.Buffer)return globalThis.Buffer.from(o).toString("base64");{const a=[];return o.forEach(e=>{a.push(String.fromCharCode(e))}),globalThis.btoa(a.join(""))}}async function $(o){var a,e;if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const t=new URLSearchParams(window.location.search),[i,s]=[t.get("error"),t.get("error_description")];if(i)throw new Error(`${i}: ${s}`);const n=t.get("code"),r=localStorage.getItem("huggingface.co:oauth:nonce");if(!n)throw new Error("Missing oauth code from query parameters in redirected URL");if(!r)throw new Error("Missing oauth nonce from localStorage");const c=localStorage.getItem("huggingface.co:oauth:code_verifier");if(!c)throw new Error("Missing oauth code_verifier from localStorage");const l=t.get("state");if(!l)throw new Error("Missing oauth state from query parameters in redirected URL");let h;try{h=JSON.parse(l)}catch{throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: "+l)}if(h.nonce!==r)throw new Error("Invalid oauth state in redirected URL");const u=B,p=`${new URL(u).origin}/.well-known/openid-configuration`,g=await fetch(p,{headers:{Accept:"application/json"}});if(!g.ok)throw await T(g);const w=await g.json(),m=await fetch(w.token_endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:n,redirect_uri:h.redirectUri,code_verifier:c}).toString()});if(localStorage.removeItem("huggingface.co:oauth:code_verifier"),localStorage.removeItem("huggingface.co:oauth:nonce"),!m.ok)throw await T(m);const f=await m.json(),y=new Date(Date.now()+f.expires_in*1e3),v=await fetch(w.userinfo_endpoint,{headers:{Authorization:`Bearer ${f.access_token}`}});if(!v.ok)throw await T(v);const b=await v.json();return{accessToken:f.access_token,accessTokenExpiresAt:y,userInfo:{id:b.sub,name:b.name,fullname:b.preferred_username,email:b.email,emailVerified:b.email_verified,avatarUrl:b.picture,websiteUrl:b.website,isPro:b.isPro,orgs:(e=(a=b.orgs)==null?void 0:a.map(R=>({id:R.sub,name:R.name,fullname:R.name,isEnterprise:R.isEnterprise,canPay:R.canPay,avatarUrl:R.picture,roleInOrg:R.roleInOrg})))!=null?e:[]},state:h.state,scope:f.scope}}async function Q(o){if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const a=new URLSearchParams(window.location.search);return a.has("error")?$():a.has("code")?localStorage.getItem("huggingface.co:oauth:nonce")?$():(console.warn("Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."),!1):!1}async function Z(o){var a,e;if(typeof window>"u")throw new Error("oauthLogin is only available in the browser");const t=(o==null?void 0:o.hubUrl)||B,i=`${new URL(t).origin}/.well-known/openid-configuration`,s=await fetch(i,{headers:{Accept:"application/json"}});if(!s.ok)throw await T(s);const n=await s.json(),r=globalThis.crypto.randomUUID(),c=globalThis.crypto.randomUUID()+globalThis.crypto.randomUUID();localStorage.setItem("huggingface.co:oauth:nonce",r),localStorage.setItem("huggingface.co:oauth:code_verifier",c);const l=(o==null?void 0:o.redirectUrl)||window.location.href,h=JSON.stringify({nonce:r,redirectUri:l,state:o==null?void 0:o.state}),u=(e=(a=window==null?void 0:window.huggingface)==null?void 0:a.variables)!=null?e:null,p=(o==null?void 0:o.clientId)||(u==null?void 0:u.OAUTH_CLIENT_ID);if(!p)throw u?new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space"):new Error("Missing clientId");const g=Y(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256",new TextEncoder().encode(c)))).replace(/[+]/g,"-").replace(/[/]/g,"_").replace(/=/g,"");return`${n.authorization_endpoint}?${new URLSearchParams({client_id:p,scope:(o==null?void 0:o.scopes)||(u==null?void 0:u.OAUTH_SCOPES)||"openid profile",response_type:"code",redirect_uri:l,state:h,code_challenge:g,code_challenge_method:"S256"}).toString()}`}function C(o){return o*Math.PI/180}function _e(o){return o*180/Math.PI}function ee(o,a,e){const t=C(o),i=C(a),s=C(e),n=Math.cos(s),r=Math.sin(s),c=Math.cos(i),l=Math.sin(i),h=Math.cos(t),u=Math.sin(t);return[[n*c,n*l*u-r*h,n*l*h+r*u,0],[r*c,r*l*u+n*h,r*l*h-n*u,0],[-l,c*u,c*h,0],[0,0,0,1]]}function te(){if(typeof window>"u"||!window.location.hash)return;const o=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;let a;try{a=new URLSearchParams(o)}catch{return}const e=a.get("hf_token");if(!e)return;const t=a.get("hf_username")||"user",i=a.get("hf_token_expires"),s=i&&!Number.isNaN(new Date(i).getTime())?i:new Date(Date.now()+365*24*60*60*1e3).toISOString();try{sessionStorage.setItem("hf_token",e),sessionStorage.setItem("hf_username",t),sessionStorage.setItem("hf_token_expires",s)}catch(c){console.warn("[reachy-mini] could not persist pre-seeded HF credentials:",c)}a.delete("hf_token"),a.delete("hf_username"),a.delete("hf_token_expires");const n=a.toString(),r=window.location.pathname+window.location.search+(n?"#"+n:"");try{window.history.replaceState(null,"",r)}catch{}}function ie(){if(typeof window>"u")return null;if(window.location.hash){const o=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;try{const e=new URLSearchParams(o).get("robot_peer_id");if(e)return e}catch{}}if(window.location.search)try{const a=new URLSearchParams(window.location.search).get("robot_peer_id");if(a)return a}catch{}return null}function se(o){const a=o.split(`\r
|
| 3 |
`);let e=!1;for(const t of a)if(t.startsWith("m=audio")?e=!0:t.startsWith("m=")&&(e=!1),e&&t==="a=sendrecv")return!0;return!1}const S=12*1024,L=1*1024*1024,ne=512*1024;function oe(){return typeof CompressionStream<"u"}function x(){return"u"+Math.random().toString(36).slice(2,11)+Date.now().toString(36)}function P(o){let a="";for(let t=0;t<o.length;t+=32768)a+=String.fromCharCode.apply(null,Array.from(o.subarray(t,t+32768)));return btoa(a)}async function re(o){const a=new TextEncoder().encode(o),e=await new Response(new Blob([a]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer();return P(new Uint8Array(e))}function N(o){const a=Math.round(Number(o)||0);return Math.max(0,Math.min(100,a))}const H=3e3,V=1e3,ae=6e4;class le extends EventTarget{constructor(e={}){super();d(this,"_signalingUrl");d(this,"_clientId");d(this,"_appName");d(this,"_videoJitterBufferTargetMs");d(this,"_autoStartFromUrl");d(this,"_autoStartAttempted");d(this,"_state","disconnected");d(this,"_robots",[]);d(this,"_robotState",{});d(this,"_preselectedRobotId");d(this,"_token",null);d(this,"_username",null);d(this,"_tokenExpires",null);d(this,"_sseAbortController",null);d(this,"_pc",null);d(this,"_dc",null);d(this,"_sessionId",null);d(this,"_selectedRobotId",null);d(this,"_pendingRemoteIce",[]);d(this,"_micStream",null);d(this,"_micMuted",!0);d(this,"_audioMuted",!0);d(this,"_micSupported",!1);d(this,"_latencyMonitorId",null);d(this,"_stateRefreshInterval",null);d(this,"_versionResolve",null);d(this,"_hardwareIdResolve",null);d(this,"_volumeResolve",null);d(this,"_micVolumeResolve",null);d(this,"_applyAudioConfigResolve",null);d(this,"_readAudioParameterResolve",null);d(this,"_logSubscribers",new Set);d(this,"_broadcastWaiters",[]);d(this,"_activeMoveUploadId",null);d(this,"_activeAudioUploadId",null);d(this,"_sessionResolve",null);d(this,"_sessionReject",null);d(this,"_iceConnected",!1);d(this,"_dcOpen",!1);d(this,"_iceGraceTimer",null);d(this,"_iceGraceReason",null);d(this,"_pendingVisibilityHandler",null);d(this,"_onlineHandler",null);d(this,"_offlineHandler",null);d(this,"_connectionChangeHandler",null);d(this,"_pendingMotionCompletions",{wake_up:[],goto_sleep:[]});d(this,"_videoElement",null);this._signalingUrl=e.signalingUrl||"https://pollen-robotics-reachy-mini-central.hf.space",this._clientId=e.clientId||null,this._appName=e.appName||"unknown",this._videoJitterBufferTargetMs=e.videoJitterBufferTargetMs??0,this._autoStartFromUrl=e.autoStartFromUrl===!0,this._autoStartAttempted=!1,this._preselectedRobotId=ie()}get state(){return this._state}get robots(){return this._robots}get robotState(){return this._robotState}get username(){return this._username}get isAuthenticated(){return!!this._token}get micSupported(){return this._micSupported}get micMuted(){return this._micMuted}get audioMuted(){return this._audioMuted}get preselectedRobotId(){return this._preselectedRobotId}get isEmbedded(){return this._preselectedRobotId!==null}_maybeAutoStart(){if(!this._autoStartFromUrl||this._autoStartAttempted||!this._preselectedRobotId||this._state!=="connected"||!this._robots.find(i=>i.id===this._preselectedRobotId))return;this._autoStartAttempted=!0;const t=this._preselectedRobotId;setTimeout(()=>{this._state==="connected"&&this.startSession(t).catch(i=>{console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:",i)})},0)}async authenticate(){try{te();const e=await Q();if(e)return this._username=e.userInfo.preferred_username||e.userInfo.name||null,this._token=e.accessToken,this._tokenExpires=e.accessTokenExpiresAt,sessionStorage.setItem("hf_token",this._token),sessionStorage.setItem("hf_username",this._username??""),sessionStorage.setItem("hf_token_expires",typeof this._tokenExpires=="string"?this._tokenExpires:this._tokenExpires.toISOString()),!0;const t=sessionStorage.getItem("hf_token"),i=sessionStorage.getItem("hf_username"),s=sessionStorage.getItem("hf_token_expires");return t&&i&&s&&new Date(s)>new Date?(this._token=t,this._username=i,this._tokenExpires=s,!0):!1}catch(e){return console.error("Auth error:",e),!1}}async login(){const e={};this._clientId&&(e.clientId=this._clientId),window.location.href=await Z(e)}logout(){sessionStorage.removeItem("hf_token"),sessionStorage.removeItem("hf_username"),sessionStorage.removeItem("hf_token_expires"),this._username=null,this._tokenExpires=null,this.disconnect()}async connect(e){if(this._state!=="disconnected")throw new Error("Already connected");if(e&&(this._token=e),!this._token)throw new Error("No token β call authenticate() first or pass a token");this._sseAbortController=new AbortController;let t;try{t=await fetch(`${this._signalingUrl}/events`,{signal:this._sseAbortController.signal,headers:{Authorization:`Bearer ${this._token}`}})}catch(i){throw this._sseAbortController=null,i}if(!t.ok)throw this._sseAbortController=null,new Error(`HTTP ${t.status}`);return new Promise((i,s)=>{let n=!1;const r=t.body.getReader(),c=new TextDecoder;let l="";(async()=>{try{for(;;){const{done:u,value:p}=await r.read();if(u)break;l+=c.decode(p,{stream:!0});const g=l.split(`
|
| 4 |
+
`);l=g.pop()??"";for(const w of g)if(w.startsWith("data:"))try{const m=JSON.parse(w.slice(5).trim());!n&&m.type==="welcome"&&m.peerId&&(n=!0,this._state="connected",await this._sendToServer({type:"setPeerStatus",roles:["listener"],meta:{name:this._appName}}),this._emit("connected",{peerId:m.peerId}),i()),this._handleSignalingMessage(m)}catch{}}}catch(u){if(u.name!=="AbortError"&&this._emit("error",{source:"signaling",error:u}),!n){s(u);return}}this._state!=="disconnected"&&(this._state="disconnected",this._emit("disconnected",{reason:"SSE closed"})),n||s(new Error("Connection closed before welcome"))})()})}async autoConnect(e={}){var l,h,u,p,g;const{token:t,pickRobot:i,autoPickIfSingle:s=!0,filterBusy:n=!0,wakeOnConnect:r=!0}=e;if(this._state==="streaming"){const w=(l=this._robots)==null?void 0:l.find(m=>m.id===this._selectedRobotId);return{robotId:this._selectedRobotId,robotName:((h=w==null?void 0:w.meta)==null?void 0:h.name)??null,isEmbedded:this.isEmbedded,alreadyStreaming:!0}}const c=this._autoStartFromUrl;this._autoStartFromUrl=!1;try{if(t)this._token=t;else if(!this._token&&!await this.authenticate())throw new Error("Not authenticated β call login() or pass a token");this._state==="disconnected"&&await this.connect();let w,m=null;if(this.isEmbedded){w=this._preselectedRobotId;try{await this._waitForRobotInList(w,5e3)}catch{}const f=(u=this._robots)==null?void 0:u.find(y=>y.id===w);m=((p=f==null?void 0:f.meta)==null?void 0:p.name)??null}else{const f=await this._fetchOwnedRobots({filterBusy:n});if(f.length===0)throw new Error("No reachable robots");if(s&&f.length===1&&!f[0].busy)w=f[0].id,m=f[0].name;else if(i){const y=await i(f);if(!y)throw new Error("Robot selection cancelled");w=y,m=((g=f.find(v=>v.id===y))==null?void 0:g.name)??null}else throw new Error("Multiple robots available β pass a pickRobot callback to autoConnect()")}if(await this.startSession(w),r&&typeof this.ensureAwake=="function")try{await this.ensureAwake()}catch(f){console.warn("[reachy-mini] autoConnect: ensureAwake failed:",f)}return{robotId:w,robotName:m,isEmbedded:this.isEmbedded}}finally{this._autoStartFromUrl=c}}async _fetchOwnedRobots({filterBusy:e=!0}={}){var t,i,s;try{const n=await fetch(`${this._signalingUrl}/api/robot-status`,{headers:{Authorization:`Bearer ${this._token}`}});if(!n.ok)throw new Error(`HTTP ${n.status}`);const r=await n.json(),c=new Map;for(const l of r.robots||[]){if(e&&l.busy)continue;const h=((t=l.meta)==null?void 0:t.install_id)??((i=l.meta)==null?void 0:i.hardware_id)??l.peerId;c.set(h,{id:l.peerId,name:l.robotName??((s=l.meta)==null?void 0:s.name)??null,busy:!!l.busy,activeApp:l.activeApp??null,meta:l.meta??{},lastSeenAgeSeconds:l.last_seen_age_seconds??null})}return Array.from(c.values()).sort((l,h)=>(l.lastSeenAgeSeconds??1/0)-(h.lastSeenAgeSeconds??1/0))}catch(n){return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:",n),(this._robots||[]).map(r=>{var c;return{id:r.id,name:((c=r.meta)==null?void 0:c.name)??null,busy:!1,activeApp:null,meta:r.meta??{},lastSeenAgeSeconds:null}})}}_waitForRobotInList(e,t){var i;return(i=this._robots)!=null&&i.find(s=>s.id===e)?Promise.resolve():new Promise((s,n)=>{const r=()=>{var l;(l=this._robots)!=null&&l.find(h=>h.id===e)&&(this.removeEventListener("robotsChanged",r),clearTimeout(c),s())},c=setTimeout(()=>{this.removeEventListener("robotsChanged",r),n(new Error(`Timeout waiting for robot ${e} in list`))},t);this.addEventListener("robotsChanged",r)})}async startSession(e){if(this._state!=="connected")throw new Error("Not connected");this._selectedRobotId=e,this._iceConnected=!1,this._dcOpen=!1,this._micSupported=!1,this._pendingRemoteIce=[];try{const t=window,i=t.AudioContext??t.webkitAudioContext;if(!i)throw new Error("AudioContext not supported");const s=new i,n=s.createMediaStreamDestination(),r=s.createOscillator(),c=s.createGain();c.gain.value=0,r.connect(c).connect(n),r.start();const l=n.stream;l.getAudioTracks().forEach(h=>{h.enabled=!1}),this._micStream=l,this._micMuted=!0}catch(t){console.warn("Audio sender placeholder setup failed:",t),this._micStream=null}return this._pc=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),this._installNetworkListeners(),new Promise((t,i)=>{this._sessionResolve=t,this._sessionReject=i,this._pc.ontrack=s=>{if(s.track.kind==="video"){const n=this._videoJitterBufferTargetMs;try{s.receiver.jitterBufferTarget=n}catch{}try{s.receiver.playoutDelayHint=n/1e3}catch{}this._emit("videoTrack",{track:s.track,stream:s.streams[0]})}},this._pc.onicecandidate=async s=>{s.candidate&&this._sessionId&&await this._sendToServer({type:"peer",sessionId:this._sessionId,ice:{candidate:s.candidate.candidate,sdpMLineIndex:s.candidate.sdpMLineIndex,sdpMid:s.candidate.sdpMid}})},this._pc.oniceconnectionstatechange=()=>{var n;const s=(n=this._pc)==null?void 0:n.iceConnectionState;if(s){if(this._emit("iceStateChange",{state:s}),s==="connected"||s==="completed"){this._clearIceGrace(),this._iceConnected=!0,this._checkSessionReady();return}if(s==="disconnected"){typeof document<"u"&&document.hidden?this._armIceGraceOnVisibility():this._scheduleIceGrace(H,"disconnected");return}if(s==="failed"){this._scheduleIceGrace(V,"failed");return}}},this._pc.ondatachannel=s=>{this._dc=s.channel,this._dc.onopen=()=>{this._dcOpen=!0,this._checkSessionReady()},this._dc.onmessage=n=>this._handleRobotMessage(JSON.parse(n.data))},this._sendToServer({type:"startSession",peerId:e}).then(s=>{if((s==null?void 0:s.type)==="sessionRejected"){this._failSessionRejected(s);return}s!=null&&s.sessionId&&(this._sessionId=s.sessionId)})})}_failSessionRejected(e){const t=new Error(e.reason==="robot_busy"?`Robot is busy: "${e.activeApp||"another app"}" is already connected`:`Session rejected: ${e.reason||"unknown reason"}`);if(t.reason=e.reason??null,t.activeApp=e.activeApp??null,this._clearIceGrace(),this._uninstallNetworkListeners(),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(i=>i.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1,this._emit("sessionRejected",{reason:e.reason,activeApp:e.activeApp}),this._sessionReject){const i=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,i(t)}}async stopSession(){this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Session stopped")),this._clearIceGrace(),this._uninstallNetworkListeners(),this._sessionReject&&(this._sessionReject(new Error("Session stopped")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&await this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(t=>t.stop()),this._micStream=null),this._micMuted=!0,this._micSupported=!1,this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._iceConnected=!1,this._dcOpen=!1,this._state==="streaming"&&(this._state="connected",this._emit("sessionStopped",{reason:"user"}))}disconnect(){this._sseAbortController&&(this._sseAbortController.abort(),this._sseAbortController=null),this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Disconnected")),this._clearIceGrace(),this._uninstallNetworkListeners(),this._sessionReject&&(this._sessionReject(new Error("Disconnected")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&this._token&&this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._micMuted=!0,this._micSupported=!1,this._iceConnected=!1,this._dcOpen=!1,this._robots=[],this._state="disconnected",this._emit("disconnected",{reason:"user"})}_clearIceGrace(){this._iceGraceTimer!==null&&(clearTimeout(this._iceGraceTimer),this._iceGraceTimer=null),this._iceGraceReason=null,this._pendingVisibilityHandler&&typeof document<"u"&&document.removeEventListener("visibilitychange",this._pendingVisibilityHandler),this._pendingVisibilityHandler=null}_scheduleIceGrace(e,t){if(this._iceGraceTimer!==null){if(this._iceGraceReason===t)return;clearTimeout(this._iceGraceTimer)}this._iceGraceReason=t,this._iceGraceTimer=setTimeout(()=>{var n;this._iceGraceTimer=null;const i=this._iceGraceReason;this._iceGraceReason=null;const s=(n=this._pc)==null?void 0:n.iceConnectionState;if(!(s==="connected"||s==="completed")){if(i==="disconnected"&&s==="disconnected"){this._emit("error",{source:"webrtc",error:new Error(`ICE stuck in 'disconnected' for > ${e}ms`)});return}if(i==="failed"||s==="failed"){const r=new Error("ICE connection failed");this._sessionReject&&(this._sessionReject(r),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:r})}}},e)}_armIceGraceOnVisibility(){if(this._pendingVisibilityHandler)return;const e=Date.now(),t=()=>{if(typeof document<"u"&&document.hidden||(document.removeEventListener("visibilitychange",t),this._pendingVisibilityHandler=null,!this._pc))return;const i=this._pc.iceConnectionState;if(!(i==="connected"||i==="completed")){if(Date.now()-e>ae){const s=new Error("Session expired while tab was backgrounded");this._sessionReject&&(this._sessionReject(s),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:s});return}if(i==="failed"){this._scheduleIceGrace(V,"failed");return}this._scheduleIceGrace(H,"disconnected")}};document.addEventListener("visibilitychange",t),this._pendingVisibilityHandler=t}_installNetworkListeners(){if(this._onlineHandler||typeof window>"u")return;const e=()=>this._emit("networkOnline",{}),t=()=>this._emit("networkOffline",{});window.addEventListener("online",e),window.addEventListener("offline",t),this._onlineHandler=e,this._offlineHandler=t;const i=navigator.connection;if(i&&typeof i.addEventListener=="function"){const s=()=>this._emit("networkChange",{effectiveType:i.effectiveType,downlink:i.downlink,rtt:i.rtt,saveData:i.saveData});i.addEventListener("change",s),this._connectionChangeHandler=s}}_uninstallNetworkListeners(){typeof window<"u"&&(this._onlineHandler&&window.removeEventListener("online",this._onlineHandler),this._offlineHandler&&window.removeEventListener("offline",this._offlineHandler));const e=navigator.connection;e&&this._connectionChangeHandler&&typeof e.removeEventListener=="function"&&e.removeEventListener("change",this._connectionChangeHandler),this._onlineHandler=null,this._offlineHandler=null,this._connectionChangeHandler=null}setTarget({head:e,antennas:t,body_yaw:i}={}){const s={type:"set_full_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(n=>Number.isFinite(n)))throw new TypeError(`setTarget: head must be a 16-element flat row-major 4Γ4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);s.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(n=>Number.isFinite(n)))throw new TypeError(`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);s.antennas=t}if(i!==void 0){if(!Number.isFinite(i))throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${i}`);s.body_yaw=i}return this._sendCommand(s)}gotoTarget({head:e,antennas:t,body_yaw:i,duration:s}){const n={type:"goto_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: head must be a 16-element flat row-major 4Γ4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);n.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);n.antennas=t}if(i!==void 0){if(!Number.isFinite(i))throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${i}`);n.body_yaw=i}if(!Number.isFinite(s)||s<=0)throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${s}`);return n.duration=s,this._sendCommand(n)}setHeadRpyDeg(e,t,i){return this.setTarget({head:ee(e,t,i).flat()})}setAntennasDeg(e,t){return this.setTarget({antennas:[C(e),C(t)]})}setBodyYawDeg(e){return this.setTarget({body_yaw:C(e)})}playSound(e){return this._sendCommand({type:"play_sound",file:e})}clearIncomingAudio(){return this._sendCommand({type:"clear_incoming_audio"})}setMotorMode(e){return this._sendCommand({type:"set_motor_mode",mode:e})}setMotorTorque(e,t=null){return this._sendCommand({type:"set_torque",on:e,ids:t})}wakeUp({timeoutMs:e=8e3}={}){return this._sendCommand({type:"set_motor_mode",mode:"enabled"}),this._sendCommandAwaitCompletion("wake_up",e)}gotoSleep({timeoutMs:e=8e3}={}){return this._sendCommandAwaitCompletion("goto_sleep",e)}_sendCommandAwaitCompletion(e,t){return this._sendCommand({type:e})?new Promise((i,s)=>{const n={resolve:i,reject:s,timer:setTimeout(()=>{const r=this._pendingMotionCompletions[e],c=r.indexOf(n);c!==-1&&r.splice(c,1),s(new Error(`${e} timed out after ${t}ms`))},t)};this._pendingMotionCompletions[e].push(n)}):Promise.reject(new Error(`${e}: data channel not open`))}_rejectPendingMotionCompletions(e){for(const t of Object.keys(this._pendingMotionCompletions)){const i=this._pendingMotionCompletions[t];for(;i.length;){const s=i.shift();clearTimeout(s.timer),s.reject(e)}}}isAwake(){var t;const e=(t=this._robotState)==null?void 0:t.motor_mode;return e==="enabled"||e==="gravity_compensation"}async ensureAwake(e=1e3){var t;return((t=this._robotState)==null?void 0:t.motor_mode)===void 0&&await new Promise(i=>{const s=()=>{this.removeEventListener("state",s),clearTimeout(n),i()},n=setTimeout(s,e);this.addEventListener("state",s),this.requestState()}),this.isAwake()||this.wakeUp().catch(()=>{}),!0}getVersion(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._versionResolve&&this._versionResolve(null),this._versionResolve=e,this._sendCommand({type:"get_version"})})}getHardwareId(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._hardwareIdResolve&&this._hardwareIdResolve(null),this._hardwareIdResolve=e,this._sendCommand({type:"get_hardware_id"})})}getVolume(){return this._slotRoundtrip(()=>this._volumeResolve,e=>{this._volumeResolve=e},{type:"get_volume"})}setVolume(e){return this._slotRoundtrip(()=>this._volumeResolve,t=>{this._volumeResolve=t},{type:"set_volume",volume:N(e)})}getMicrophoneVolume(){return this._slotRoundtrip(()=>this._micVolumeResolve,e=>{this._micVolumeResolve=e},{type:"get_microphone_volume"})}setMicrophoneVolume(e){return this._slotRoundtrip(()=>this._micVolumeResolve,t=>{this._micVolumeResolve=t},{type:"set_microphone_volume",volume:N(e)})}applyAudioConfig(e,{verify:t=!0}={}){return this._slotRoundtrip(()=>this._applyAudioConfigResolve,i=>{this._applyAudioConfigResolve=i},{type:"apply_audio_config",config:e,verify:t}).then(i=>i===!0)}readAudioParameter(e){return this._slotRoundtrip(()=>this._readAudioParameterResolve,t=>{this._readAudioParameterResolve=t},{type:"read_audio_parameter",name:e})}_slotRoundtrip(e,t,i){return new Promise((s,n)=>{if(!this._dc||this._dc.readyState!=="open"){n(new Error("Data channel not open"));return}const r=e();r&&r(null),t(s),this._sendCommand(i)})}sendRaw(e){return this._sendCommand(e)}subscribeLogs({onLine:e,onError:t}){if(typeof e!="function")throw new TypeError("subscribeLogs: onLine callback is required");const i={onLine:e,onError:t},s=this._logSubscribers.size===0;this._logSubscribers.add(i),s&&this._sendCommand({type:"subscribe_logs"});let n=!1;return()=>{n||(n=!0,this._logSubscribers.delete(i),this._logSubscribers.size===0&&this._sendCommand({type:"unsubscribe_logs"}))}}requestState(){return this._sendCommand({type:"get_state"})}setAudioMuted(e){this._audioMuted=e,this._videoElement&&(this._videoElement.muted=e)}setMicMuted(e){this._micMuted=e,this._micStream&&this._micStream.getAudioTracks().forEach(t=>{t.enabled=!e})}attachVideo(e){this._videoElement=e,e.muted=this._audioMuted;const t=s=>{const n=s;e.srcObject=n.detail.stream,e.playsInline=!0,"requestVideoFrameCallback"in e&&this._startLatencyMonitor(e)},i=()=>{e.srcObject=null};return this.addEventListener("videoTrack",t),this.addEventListener("sessionStopped",i),()=>{this.removeEventListener("videoTrack",t),this.removeEventListener("sessionStopped",i),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),e.srcObject=null,this._videoElement=null}}async playMove(e,{audioBlob:t=null,audioLeadMs:i=-100,description:s="move",encoding:n="gzip+base64",playFrequency:r=100,initialGotoDuration:c=0,startTimeoutMs:l=8e3,onProgress:h=()=>{},onStarted:u=()=>{}}={}){var R,j;if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!((R=e==null?void 0:e.time)!=null&&R.length)||!((j=e==null?void 0:e.set_target_data)!=null&&j.length))throw new Error("playMove: motion must have time + set_target_data");const p=x();this._activeMoveUploadId=p;const g={description:s,time:e.time,set_target_data:e.set_target_data},w=JSON.stringify(g);let m,f;n==="gzip+base64"&&oe()?(m=await re(w),f="gzip+base64"):(m=w,f="json");const y=Math.ceil(m.length/S)||1;h({phase:"starting",sent:0,total:y,bytes:m.length,encoding:f}),this._sendCommand({type:"upload_move_start",upload_id:p,total_chunks:y,description:s,encoding:f});for(let _=0;_<y;_++){this._dc.bufferedAmount>L&&await this._awaitDataChannelDrain();const I=_*S;this._sendCommand({type:"upload_move_chunk",upload_id:p,chunk_index:_,chunk:m.slice(I,I+S)}),h({phase:"upload",sent:_+1,total:y})}if(this._sendCommand({type:"upload_move_finish",upload_id:p}),h({phase:"uploaded",sent:y,total:y}),t){const _=new Uint8Array(await t.arrayBuffer()),I=P(_),E=Math.ceil(I.length/S)||1;h({phase:"audio-starting",sent:0,total:E,bytes:I.length}),this._sendCommand({type:"upload_audio_start",upload_id:p,total_chunks:E,encoding:"wav-base64",description:s});for(let A=0;A<E;A++){this._dc.bufferedAmount>L&&await this._awaitDataChannelDrain();const D=A*S;this._sendCommand({type:"upload_audio_chunk",upload_id:p,chunk_index:A,chunk:I.slice(D,D+S)}),h({phase:"audio-upload",sent:A+1,total:E})}this._sendCommand({type:"upload_audio_finish",upload_id:p}),h({phase:"audio-uploaded",sent:E,total:E})}this._sendCommand({type:"play_uploaded_move",upload_id:p,play_frequency:r,initial_goto_duration:c,audio_lead_ms:i});let v;try{v=await this._waitForBroadcast(_=>(_==null?void 0:_.type)==="play_uploaded_move"&&(_==null?void 0:_.upload_id)===p&&(_.started===!0||typeof _.error=="string"),{timeoutMs:l,debugLabel:"play_uploaded_move started"})}catch(_){throw new Error(`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${_.message}`)}if(typeof v.error=="string")throw new Error(`play_uploaded_move: ${v.error}`);try{u({duration_s:v.duration_s,has_audio:v.has_audio===!0})}catch(_){console.warn("playMove.onStarted threw:",_)}h({phase:"playing",duration_s:v.duration_s});const b=await this._waitForBroadcast(_=>(_==null?void 0:_.type)==="play_uploaded_move"&&(_==null?void 0:_.upload_id)===p&&(_.finished===!0||_.cancelled===!0||typeof _.error=="string"),{timeoutMs:(v.duration_s+30)*1e3,debugLabel:"play_uploaded_move final"});return this._activeMoveUploadId===p&&(this._activeMoveUploadId=null),b}cancelMove(e=null){const t=e??this._activeMoveUploadId;return t?this._sendCommand({type:"cancel_move",upload_id:t}):!1}async uploadAudio(e,{description:t="audio",onProgress:i=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!(e instanceof Blob))throw new TypeError("uploadAudio: expected a Blob");const s=x(),n=new Uint8Array(await e.arrayBuffer()),r=P(n),c=Math.ceil(r.length/S)||1;i({phase:"audio-starting",sent:0,total:c,bytes:r.length}),this._sendCommand({type:"upload_audio_start",upload_id:s,total_chunks:c,encoding:"wav-base64",description:t});for(let l=0;l<c;l++){this._dc.bufferedAmount>L&&await this._awaitDataChannelDrain();const h=l*S;this._sendCommand({type:"upload_audio_chunk",upload_id:s,chunk_index:l,chunk:r.slice(h,h+S)}),i({phase:"audio-upload",sent:l+1,total:c})}return this._sendCommand({type:"upload_audio_finish",upload_id:s}),i({phase:"audio-uploaded",sent:c,total:c}),s}async playUploadedAudio(e,{timeoutMs:t=8e3}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");const i=this._waitForBroadcast(n=>(n==null?void 0:n.type)==="play_uploaded_audio"&&(n==null?void 0:n.upload_id)===e&&(n.started===!0||typeof n.error=="string"),{timeoutMs:t,debugLabel:"play_uploaded_audio started"});this._sendCommand({type:"play_uploaded_audio",upload_id:e});const s=await i;if(typeof s.error=="string")throw new Error(s.error);return this._activeAudioUploadId=e,s}cancelAudio(e=null){const t=e??this._activeAudioUploadId;return t?(this._activeAudioUploadId===t&&(this._activeAudioUploadId=null),this._sendCommand({type:"cancel_audio",upload_id:t})):!1}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}_waitForBroadcast(e,{timeoutMs:t=5e3,debugLabel:i=""}={}){return new Promise((s,n)=>{const r={predicate:e,resolve:s,timer:setTimeout(()=>{const c=this._broadcastWaiters.indexOf(r);c!==-1&&this._broadcastWaiters.splice(c,1),n(new Error(`broadcast timeout (${t} ms): ${i}`))},t)};this._broadcastWaiters.push(r)})}async _awaitDataChannelDrain(){for(;this._dc&&this._dc.bufferedAmount>ne;)if(await new Promise(e=>setTimeout(e,30)),!this._dc||this._dc.readyState!=="open")throw new Error("data channel closed mid-upload")}async _sendToServer(e){if(!this._token)throw new Error("No token β authenticate() first");try{const t=await fetch(`${this._signalingUrl}/send`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this._token}`},body:JSON.stringify(e)});if(!t.ok){let i="";try{i=await t.text()}catch{}return console.warn(`[reachy-mini] /send rejected (${t.status}) for type=${e==null?void 0:e.type}; body=${i||"<empty>"}`),null}return await t.json()}catch(t){return console.error("Send error:",t),null}}_sendCommand(e){return!this._dc||this._dc.readyState!=="open"?!1:(this._dc.send(JSON.stringify(e)),!0)}_checkSessionReady(){this._iceConnected&&this._dcOpen&&this._sessionResolve&&(this._state="streaming",this.requestState(),this._stateRefreshInterval=setInterval(()=>this.requestState(),500),this._emit("streaming",{sessionId:this._sessionId,robotId:this._selectedRobotId}),this._sessionResolve(),this._sessionResolve=null,this._sessionReject=null)}async _handleSignalingMessage(e){switch(e.type){case"welcome":break;case"list":this._robots=e.producers||[],this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart();break;case"peerStatusChanged":{const t=await this._sendToServer({type:"list"});t!=null&&t.producers&&(this._robots=t.producers,this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart());break}case"sessionStarted":this._sessionId=e.sessionId??null;break;case"sessionRejected":this._failSessionRejected(e);break;case"endSession":this._handleEndSession(e);break;case"peer":this._handlePeerMessage(e);break}}_handleEndSession(e){const t=e.reason,i=t==="robot_busy_local_app"?"Robot is busy: a local Python app is running":t==="local_app_started"?"Disconnected: a local Python app started on the robot":t==="robot_busy_local"?"Robot is busy: another session is already active":null;if(this._sessionReject){const s=new Error(i||`Session ended before it could start: ${t||"unknown reason"}`);s.reason=t??null,this._emit("sessionRejected",{reason:t,activeApp:null}),this._clearIceGrace(),this._uninstallNetworkListeners(),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(r=>r.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1;const n=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,n(s);return}this._state==="streaming"&&(this._emit("sessionStopped",{reason:t||"remote_end",message:i}),this.stopSession().catch(()=>{}))}async _handlePeerMessage(e){if(this._pc)try{if(e.sdp){const t=e.sdp;if(t.type==="offer"){const s=se(t.sdp);if(this._micSupported=s,this._emit("micSupported",{supported:s}),s&&this._micStream)for(const r of this._micStream.getAudioTracks())this._pc.addTrack(r,this._micStream);await this._pc.setRemoteDescription(new RTCSessionDescription(t));const n=await this._pc.createAnswer();await this._pc.setLocalDescription(n),await this._sendToServer({type:"peer",sessionId:this._sessionId,sdp:{type:"answer",sdp:n.sdp}})}else await this._pc.setRemoteDescription(new RTCSessionDescription(t));const i=this._pendingRemoteIce;if(i&&i.length){this._pendingRemoteIce=[];for(const s of i)try{await this._pc.addIceCandidate(new RTCIceCandidate(s))}catch(n){console.warn("[reachy-mini] buffered ICE candidate rejected:",n)}}}if(e.ice){if(!e.ice.candidate)return;this._pc.remoteDescription?await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)):(this._pendingRemoteIce||(this._pendingRemoteIce=[]),this._pendingRemoteIce.push(e.ice))}}catch(t){console.error("WebRTC error:",t),this._emit("error",{source:"webrtc",error:t})}}_handleRobotMessage(e){if("version"in e&&this._versionResolve){this._versionResolve(e.version),this._versionResolve=null;return}if("hardware_id"in e&&this._hardwareIdResolve){this._hardwareIdResolve(e.hardware_id),this._hardwareIdResolve=null;return}if(e.command==="get_volume"||e.command==="set_volume"){this._volumeResolve&&(this._volumeResolve(e.status==="error"?null:e.volume),this._volumeResolve=null);return}if(e.command==="get_microphone_volume"||e.command==="set_microphone_volume"){this._micVolumeResolve&&(this._micVolumeResolve(e.status==="error"?null:e.volume),this._micVolumeResolve=null);return}if(e.command==="apply_audio_config"){this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(e.error?!1:!!e.applied),this._applyAudioConfigResolve=null);return}if(e.command==="read_audio_parameter"){this._readAudioParameterResolve&&(this._readAudioParameterResolve(e.error?null:e.values??null),this._readAudioParameterResolve=null);return}if((e.command==="wake_up"||e.command==="goto_sleep")&&this._pendingMotionCompletions&&this._pendingMotionCompletions[e.command]){const t=this._pendingMotionCompletions[e.command];if(e.completed===!0&&t.length>0){const i=t.shift();clearTimeout(i.timer),i.resolve();return}if(e.error&&t.length>0){const i=t.shift();clearTimeout(i.timer),i.reject(new Error(`${e.command}: ${e.error}`));return}}if(e.type==="log_line"){for(const t of this._logSubscribers)try{t.onLine({timestamp:e.timestamp,line:e.line})}catch(i){console.error("subscribeLogs onLine threw:",i)}return}if(e.type==="log_stream_error"){for(const t of this._logSubscribers)if(typeof t.onError=="function")try{t.onError(e.error)}catch(i){console.error("subscribeLogs onError threw:",i)}return}if(e.state){const t=e.state;t.head_pose&&(this._robotState.head=t.head_pose.flat()),t.antennas&&(this._robotState.antennas=[t.antennas[0],t.antennas[1]]),typeof t.body_yaw=="number"&&(this._robotState.body_yaw=t.body_yaw),t.motor_mode&&(this._robotState.motor_mode=t.motor_mode),typeof t.is_move_running=="boolean"&&(this._robotState.is_move_running=t.is_move_running),this._emit("state",{...this._robotState})}if(e.error&&this._emit("error",{source:"robot",error:e.error}),this._broadcastWaiters.length>0)for(let t=this._broadcastWaiters.length-1;t>=0;t--){const i=this._broadcastWaiters[t];if(i.predicate(e)){this._broadcastWaiters.splice(t,1),clearTimeout(i.timer),i.resolve(e);return}}}_startLatencyMonitor(e){this._latencyMonitorId&&clearInterval(this._latencyMonitorId),this._latencyMonitorId=setInterval(()=>{if(!e.srcObject||e.paused)return;const t=e.buffered;if(t.length>0){const i=t.end(t.length-1),s=i-e.currentTime;s>.5&&(console.log(`Latency correction: was ${s.toFixed(2)}s behind`),e.currentTime=i-.1)}},2e3)}}window.ReachyMini=le;window.dispatchEvent(new Event("reachymini:ready"));const M=new URLSearchParams(window.location.search),ce=M.get("embedded")==="1"||M.get("embed")==="1",de=M.has("code")||M.has("error"),he=sessionStorage.getItem("cookaiware:launched")==="1";function F(){sessionStorage.setItem("cookaiware:launched","1");const o=document.getElementById("root");return o&&(o.innerHTML=""),U(async()=>{const{mountHost:a}=await import("./auto-CaTD3xKq.js");return{mountHost:a}},__vite__mapDeps([0,1])).then(({mountHost:a})=>{a({appName:"CookAIware",appIconUrl:"/icon.png",appEmoji:"π²",enableMicrophone:!0,devToken:void 0,clientId:void 0})})}ce?U(()=>import("./embed-CtskQx_e.js"),__vite__mapDeps([2,1,3])):de||he?F():U(async()=>{const{renderLanding:o}=await import("./landing-DkIk3-oJ.js");return{renderLanding:o}},__vite__mapDeps([4,5])).then(({renderLanding:o})=>o(()=>void F()));export{U as _,_e as r};
|
|
@@ -59,7 +59,7 @@
|
|
| 59 |
}
|
| 60 |
})();
|
| 61 |
</script>
|
| 62 |
-
<script type="module" crossorigin src="/assets/index-
|
| 63 |
</head>
|
| 64 |
<body>
|
| 65 |
<!-- Capture the robot's inbound audio track BEFORE the Reachy SDK module
|
|
|
|
| 59 |
}
|
| 60 |
})();
|
| 61 |
</script>
|
| 62 |
+
<script type="module" crossorigin src="/assets/index-BkQO3aLW.js"></script>
|
| 63 |
</head>
|
| 64 |
<body>
|
| 65 |
<!-- Capture the robot's inbound audio track BEFORE the Reachy SDK module
|
|
@@ -155,3 +155,24 @@ export function daysToExpiration(item: InventoryItem, today = new Date()): numbe
|
|
| 155 |
export function displayQuantity(item: InventoryItem): string {
|
| 156 |
return formatQuantity(item.quantity, item.unit);
|
| 157 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
export function displayQuantity(item: InventoryItem): string {
|
| 156 |
return formatQuantity(item.quantity, item.unit);
|
| 157 |
}
|
| 158 |
+
|
| 159 |
+
/** Normalize a single raw item input into a stored InventoryItem (base unit,
|
| 160 |
+
* parsed date). Returns null if the quantity/unit is invalid. Used by the edit
|
| 161 |
+
* form, which replaces an item in place rather than merging. */
|
| 162 |
+
export function normalizeItemInput(input: NewItemInput): InventoryItem | null {
|
| 163 |
+
const rawName = String(input.name ?? "").trim();
|
| 164 |
+
const name = normalizeName(rawName);
|
| 165 |
+
const normalized = normalizeQuantity(input.quantity, input.unit);
|
| 166 |
+
if (!name || !normalized) return null;
|
| 167 |
+
const [quantity, unit] = normalized;
|
| 168 |
+
let storageLocation = input.storage_location ?? null;
|
| 169 |
+
if (storageLocation !== null) storageLocation = String(storageLocation).trim() || null;
|
| 170 |
+
return {
|
| 171 |
+
name,
|
| 172 |
+
display_name: rawName || name,
|
| 173 |
+
quantity,
|
| 174 |
+
unit,
|
| 175 |
+
expiration_date: parseDate(input.expiration_date),
|
| 176 |
+
storage_location: storageLocation,
|
| 177 |
+
};
|
| 178 |
+
}
|
|
@@ -42,6 +42,19 @@ const STRINGS = {
|
|
| 42 |
location_placeholder: "fridge, pantryβ¦",
|
| 43 |
cook_some: "Use",
|
| 44 |
delete_item: "Remove",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
expires_in_days: (d: number) => (d === 0 ? "expires today" : d === 1 ? "expires tomorrow" : `expires in ${d} days`),
|
| 46 |
expired: "expired",
|
| 47 |
plan_title: "Weekly meal plan",
|
|
@@ -74,10 +87,21 @@ const STRINGS = {
|
|
| 74 |
hf_sync_hint:
|
| 75 |
"Optional: store your data in a private dataset in your HF account. Paste a fine-grained token with write access to repos. It is kept only in this browser.",
|
| 76 |
hf_token_label: "HF token",
|
| 77 |
-
hf_save_to_cloud: "Save
|
| 78 |
-
hf_load_from_cloud: "Load
|
| 79 |
hf_sync_ok: (repo: string) => `Synced with ${repo}`,
|
| 80 |
hf_sync_err: "Sync failed. Check the token and try again.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
voice_title: "Talk to Reachy",
|
| 82 |
voice_key_label: "OpenAI API key",
|
| 83 |
voice_key_hint: "Used for voice (gpt-realtime) and for Reachy's camera vision (gpt-4o-mini). The key needs permissions for BOTH the Realtime API and Model capabilities β a key with 'All' permissions is simplest. Stored only on this device, sent only to OpenAI, uses your own OpenAI credits.",
|
|
@@ -146,6 +170,19 @@ const STRINGS = {
|
|
| 146 |
location_placeholder: "nevera, armarioβ¦",
|
| 147 |
cook_some: "Usar",
|
| 148 |
delete_item: "Quitar",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
expires_in_days: (d: number) =>
|
| 150 |
d === 0 ? "caduca hoy" : d === 1 ? "caduca maΓ±ana" : `caduca en ${d} dΓas`,
|
| 151 |
expired: "caducado",
|
|
@@ -179,10 +216,21 @@ const STRINGS = {
|
|
| 179 |
hf_sync_hint:
|
| 180 |
"Opcional: guarda tus datos en un dataset privado de tu cuenta de HF. Pega un token fine-grained con permiso de escritura en repos. Solo se guarda en este navegador.",
|
| 181 |
hf_token_label: "Token de HF",
|
| 182 |
-
hf_save_to_cloud: "Guardar
|
| 183 |
-
hf_load_from_cloud: "Cargar
|
| 184 |
hf_sync_ok: (repo: string) => `Sincronizado con ${repo}`,
|
| 185 |
hf_sync_err: "Error de sincronizaciΓ³n. Revisa el token e intΓ©ntalo de nuevo.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
voice_title: "Habla con Reachy",
|
| 187 |
voice_key_label: "Clave de API de OpenAI",
|
| 188 |
voice_key_hint: "Se usa para la voz (gpt-realtime) y para la visiΓ³n de la cΓ‘mara de Reachy (gpt-4o-mini). La clave necesita permisos para la API Realtime Y para Model capabilities β lo mΓ‘s sencillo es una clave con permisos 'All'. Se guarda solo en este dispositivo, se envΓa solo a OpenAI y consume tus crΓ©ditos de OpenAI.",
|
|
|
|
| 42 |
location_placeholder: "fridge, pantryβ¦",
|
| 43 |
cook_some: "Use",
|
| 44 |
delete_item: "Remove",
|
| 45 |
+
edit_item: "Edit item",
|
| 46 |
+
delete_confirm: (name: string) => `Remove "${name}" from the inventory?`,
|
| 47 |
+
use_title: (name: string) => `Use ${name}`,
|
| 48 |
+
use_amount: "Amount used",
|
| 49 |
+
use_all: "Use all",
|
| 50 |
+
sort_label: "Sort",
|
| 51 |
+
sort_expiration: "Expiration",
|
| 52 |
+
sort_name: "Name",
|
| 53 |
+
sort_custom: "Custom",
|
| 54 |
+
drag_hint: "Drag items to reorder",
|
| 55 |
+
filter_location: (loc: string) => `Showing β${loc}β β tap to clear`,
|
| 56 |
+
no_expiration: "no expiry",
|
| 57 |
+
expires_on: (d: string) => `expires ${d}`,
|
| 58 |
expires_in_days: (d: number) => (d === 0 ? "expires today" : d === 1 ? "expires tomorrow" : `expires in ${d} days`),
|
| 59 |
expired: "expired",
|
| 60 |
plan_title: "Weekly meal plan",
|
|
|
|
| 87 |
hf_sync_hint:
|
| 88 |
"Optional: store your data in a private dataset in your HF account. Paste a fine-grained token with write access to repos. It is kept only in this browser.",
|
| 89 |
hf_token_label: "HF token",
|
| 90 |
+
hf_save_to_cloud: "Save",
|
| 91 |
+
hf_load_from_cloud: "Load",
|
| 92 |
hf_sync_ok: (repo: string) => `Synced with ${repo}`,
|
| 93 |
hf_sync_err: "Sync failed. Check the token and try again.",
|
| 94 |
+
hf_autosync: "Auto-sync",
|
| 95 |
+
hf_autosync_hint: "Save to your HF account automatically a few seconds after each change.",
|
| 96 |
+
hf_last_synced: (when: string) => `Last synced ${when}`,
|
| 97 |
+
hf_never_synced: "Not synced yet",
|
| 98 |
+
hf_save_confirm: (summary: string) =>
|
| 99 |
+
`This will overwrite the cloud copy in your HF account with what's on this device (${summary}). Continue?`,
|
| 100 |
+
hf_load_confirm: (summary: string) =>
|
| 101 |
+
`This will replace the data on this device with the cloud copy (${summary}). Continue?`,
|
| 102 |
+
data_summary: (inv: number, plan: boolean, shop: number) =>
|
| 103 |
+
`${inv} item${inv === 1 ? "" : "s"}, ${plan ? "a meal plan" : "no meal plan"}, ${shop} to buy`,
|
| 104 |
+
confirm_yes: "Continue",
|
| 105 |
voice_title: "Talk to Reachy",
|
| 106 |
voice_key_label: "OpenAI API key",
|
| 107 |
voice_key_hint: "Used for voice (gpt-realtime) and for Reachy's camera vision (gpt-4o-mini). The key needs permissions for BOTH the Realtime API and Model capabilities β a key with 'All' permissions is simplest. Stored only on this device, sent only to OpenAI, uses your own OpenAI credits.",
|
|
|
|
| 170 |
location_placeholder: "nevera, armarioβ¦",
|
| 171 |
cook_some: "Usar",
|
| 172 |
delete_item: "Quitar",
|
| 173 |
+
edit_item: "Editar alimento",
|
| 174 |
+
delete_confirm: (name: string) => `ΒΏQuitar "${name}" del inventario?`,
|
| 175 |
+
use_title: (name: string) => `Usar ${name}`,
|
| 176 |
+
use_amount: "Cantidad usada",
|
| 177 |
+
use_all: "Usar todo",
|
| 178 |
+
sort_label: "Ordenar",
|
| 179 |
+
sort_expiration: "Caducidad",
|
| 180 |
+
sort_name: "Nombre",
|
| 181 |
+
sort_custom: "Manual",
|
| 182 |
+
drag_hint: "Arrastra para reordenar",
|
| 183 |
+
filter_location: (loc: string) => `Mostrando β${loc}β β toca para quitar`,
|
| 184 |
+
no_expiration: "sin caducidad",
|
| 185 |
+
expires_on: (d: string) => `caduca ${d}`,
|
| 186 |
expires_in_days: (d: number) =>
|
| 187 |
d === 0 ? "caduca hoy" : d === 1 ? "caduca maΓ±ana" : `caduca en ${d} dΓas`,
|
| 188 |
expired: "caducado",
|
|
|
|
| 216 |
hf_sync_hint:
|
| 217 |
"Opcional: guarda tus datos en un dataset privado de tu cuenta de HF. Pega un token fine-grained con permiso de escritura en repos. Solo se guarda en este navegador.",
|
| 218 |
hf_token_label: "Token de HF",
|
| 219 |
+
hf_save_to_cloud: "Guardar",
|
| 220 |
+
hf_load_from_cloud: "Cargar",
|
| 221 |
hf_sync_ok: (repo: string) => `Sincronizado con ${repo}`,
|
| 222 |
hf_sync_err: "Error de sincronizaciΓ³n. Revisa el token e intΓ©ntalo de nuevo.",
|
| 223 |
+
hf_autosync: "SincronizaciΓ³n automΓ‘tica",
|
| 224 |
+
hf_autosync_hint: "Guarda en tu cuenta de HF automΓ‘ticamente unos segundos despuΓ©s de cada cambio.",
|
| 225 |
+
hf_last_synced: (when: string) => `Γltima sincronizaciΓ³n ${when}`,
|
| 226 |
+
hf_never_synced: "AΓΊn sin sincronizar",
|
| 227 |
+
hf_save_confirm: (summary: string) =>
|
| 228 |
+
`Esto sobrescribirΓ‘ la copia en tu cuenta de HF con lo que hay en este dispositivo (${summary}). ΒΏContinuar?`,
|
| 229 |
+
hf_load_confirm: (summary: string) =>
|
| 230 |
+
`Esto reemplazarΓ‘ los datos de este dispositivo con la copia de la nube (${summary}). ΒΏContinuar?`,
|
| 231 |
+
data_summary: (inv: number, plan: boolean, shop: number) =>
|
| 232 |
+
`${inv} alimento${inv === 1 ? "" : "s"}, ${plan ? "un menΓΊ" : "sin menΓΊ"}, ${shop} por comprar`,
|
| 233 |
+
confirm_yes: "Continuar",
|
| 234 |
voice_title: "Habla con Reachy",
|
| 235 |
voice_key_label: "Clave de API de OpenAI",
|
| 236 |
voice_key_hint: "Se usa para la voz (gpt-realtime) y para la visiΓ³n de la cΓ‘mara de Reachy (gpt-4o-mini). La clave necesita permisos para la API Realtime Y para Model capabilities β lo mΓ‘s sencillo es una clave con permisos 'All'. Se guarda solo en este dispositivo, se envΓa solo a OpenAI y consume tus crΓ©ditos de OpenAI.",
|
|
@@ -13,8 +13,10 @@ export interface AppData {
|
|
| 13 |
export interface AppSettings {
|
| 14 |
language: "es" | "en";
|
| 15 |
hfToken: string | null;
|
| 16 |
-
hfDatasetSync: boolean;
|
|
|
|
| 17 |
openaiKey: string | null;
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const DATA_KEY = "cookaiware:data";
|
|
@@ -48,7 +50,9 @@ export function loadSettings(): AppSettings {
|
|
| 48 |
language: navigator.language?.toLowerCase().startsWith("es") ? "es" : "en",
|
| 49 |
hfToken: null,
|
| 50 |
hfDatasetSync: false,
|
|
|
|
| 51 |
openaiKey: null,
|
|
|
|
| 52 |
};
|
| 53 |
try {
|
| 54 |
const raw = localStorage.getItem(SETTINGS_KEY);
|
|
@@ -58,13 +62,25 @@ export function loadSettings(): AppSettings {
|
|
| 58 |
language: parsed.language === "es" || parsed.language === "en" ? parsed.language : fallback.language,
|
| 59 |
hfToken: typeof parsed.hfToken === "string" && parsed.hfToken ? parsed.hfToken : null,
|
| 60 |
hfDatasetSync: parsed.hfDatasetSync === true,
|
|
|
|
| 61 |
openaiKey: typeof parsed.openaiKey === "string" && parsed.openaiKey ? parsed.openaiKey : null,
|
|
|
|
|
|
|
| 62 |
};
|
| 63 |
} catch {
|
| 64 |
return fallback;
|
| 65 |
}
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
export function saveSettings(settings: AppSettings): void {
|
| 69 |
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
| 70 |
}
|
|
|
|
| 13 |
export interface AppSettings {
|
| 14 |
language: "es" | "en";
|
| 15 |
hfToken: string | null;
|
| 16 |
+
hfDatasetSync: boolean; // auto-sync to HF after each change
|
| 17 |
+
lastSyncedAt: string | null; // ISO timestamp of last successful upload
|
| 18 |
openaiKey: string | null;
|
| 19 |
+
inventorySort: "expiration" | "name" | "custom";
|
| 20 |
}
|
| 21 |
|
| 22 |
const DATA_KEY = "cookaiware:data";
|
|
|
|
| 50 |
language: navigator.language?.toLowerCase().startsWith("es") ? "es" : "en",
|
| 51 |
hfToken: null,
|
| 52 |
hfDatasetSync: false,
|
| 53 |
+
lastSyncedAt: null,
|
| 54 |
openaiKey: null,
|
| 55 |
+
inventorySort: "expiration",
|
| 56 |
};
|
| 57 |
try {
|
| 58 |
const raw = localStorage.getItem(SETTINGS_KEY);
|
|
|
|
| 62 |
language: parsed.language === "es" || parsed.language === "en" ? parsed.language : fallback.language,
|
| 63 |
hfToken: typeof parsed.hfToken === "string" && parsed.hfToken ? parsed.hfToken : null,
|
| 64 |
hfDatasetSync: parsed.hfDatasetSync === true,
|
| 65 |
+
lastSyncedAt: typeof parsed.lastSyncedAt === "string" ? parsed.lastSyncedAt : null,
|
| 66 |
openaiKey: typeof parsed.openaiKey === "string" && parsed.openaiKey ? parsed.openaiKey : null,
|
| 67 |
+
inventorySort:
|
| 68 |
+
parsed.inventorySort === "name" || parsed.inventorySort === "custom" ? parsed.inventorySort : "expiration",
|
| 69 |
};
|
| 70 |
} catch {
|
| 71 |
return fallback;
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
| 75 |
+
/** Short summary of a dataset's contents for confirmation dialogs. */
|
| 76 |
+
export function summarizeData(data: AppData): { inventory: number; hasPlan: boolean; shopping: number } {
|
| 77 |
+
return {
|
| 78 |
+
inventory: data.inventory.filter((i) => i.quantity > 0).length,
|
| 79 |
+
hasPlan: !!data.meal_plan && data.meal_plan.days.some((d) => d.meals.length > 0),
|
| 80 |
+
shopping: data.shopping_list.length,
|
| 81 |
+
};
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
export function saveSettings(settings: AppSettings): void {
|
| 85 |
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
| 86 |
}
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import type { ReachyMiniInstance } from "@pollen-robotics/reachy-mini-sdk";
|
| 2 |
import type { RobotGestures } from "../robot/gestures";
|
|
|
|
| 3 |
import type { AppData, AppSettings } from "../storage/store";
|
| 4 |
import {
|
| 5 |
loadData,
|
|
@@ -8,6 +9,7 @@ import {
|
|
| 8 |
saveSettings,
|
| 9 |
exportToFile,
|
| 10 |
importFromFile,
|
|
|
|
| 11 |
} from "../storage/store";
|
| 12 |
import { uploadData, downloadData } from "../storage/hfDataset";
|
| 13 |
import { VoiceController, type VoiceStatus } from "../voice/controller";
|
|
@@ -49,6 +51,12 @@ const ICONS = {
|
|
| 49 |
'<path d="M4.5 9.5v5h3l4 3.5v-12l-4 3.5h-3z"/><path d="M15 9a4.5 4.5 0 0 1 0 6M17.5 6.8a8 8 0 0 1 0 10.4"/>',
|
| 50 |
),
|
| 51 |
spinner: svg('<path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5" class="spin-path"/>'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
};
|
| 53 |
|
| 54 |
export class App {
|
|
@@ -58,6 +66,9 @@ export class App {
|
|
| 58 |
private settings: AppSettings;
|
| 59 |
private tab: Tab = "inventory";
|
| 60 |
private warnedExpiry = false;
|
|
|
|
|
|
|
|
|
|
| 61 |
private voice: VoiceController;
|
| 62 |
private voiceStatus: VoiceStatus = "idle";
|
| 63 |
private transcript: { role: "user" | "assistant"; text?: string; image?: string }[] = [];
|
|
@@ -131,6 +142,7 @@ export class App {
|
|
| 131 |
|
| 132 |
private commit(): void {
|
| 133 |
saveData(this.data);
|
|
|
|
| 134 |
this.render();
|
| 135 |
}
|
| 136 |
|
|
@@ -140,7 +152,111 @@ export class App {
|
|
| 140 |
el.className = "toast";
|
| 141 |
el.textContent = message;
|
| 142 |
document.body.appendChild(el);
|
| 143 |
-
setTimeout(() => el.remove(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
private render(): void {
|
|
@@ -219,6 +335,7 @@ export class App {
|
|
| 219 |
return;
|
| 220 |
}
|
| 221 |
panel.style.display = "block";
|
|
|
|
| 222 |
const rows =
|
| 223 |
this.transcript.length === 0
|
| 224 |
? `<div class="vt-empty">${s.voice_transcript_empty}</div>`
|
|
@@ -232,13 +349,30 @@ export class App {
|
|
| 232 |
}" alt="${m.role}" /><div class="bubble${m.image ? " photo-bubble" : ""}">${inner}</div></div>`;
|
| 233 |
})
|
| 234 |
.join("");
|
|
|
|
|
|
|
|
|
|
| 235 |
panel.innerHTML = `
|
| 236 |
-
<div class="vt-head">
|
| 237 |
-
<span>${s.voice_transcript_title}</span>
|
| 238 |
-
<
|
|
|
|
|
|
|
|
|
|
| 239 |
</div>
|
| 240 |
<div class="vt-body" id="vt-body">${rows}</div>`;
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
this.transcript = [];
|
| 243 |
this.updateTranscript();
|
| 244 |
};
|
|
@@ -312,79 +446,70 @@ export class App {
|
|
| 312 |
|
| 313 |
main.insertAdjacentHTML("beforeend", `<h1>${s.inventory_title}</h1>`);
|
| 314 |
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
if (items.length === 0) {
|
| 317 |
main.insertAdjacentHTML("beforeend", `<div class="empty">${s.inventory_empty}</div>`);
|
| 318 |
}
|
|
|
|
|
|
|
|
|
|
| 319 |
|
|
|
|
|
|
|
|
|
|
| 320 |
let hasExpiring = false;
|
| 321 |
-
|
| 322 |
-
const card = document.createElement("div");
|
| 323 |
-
card.className = "card row";
|
| 324 |
const days = inv.daysToExpiration(item);
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
chip = `<span class="chip warn">${s.expires_in_days(days)}</span>`;
|
| 331 |
-
hasExpiring = true;
|
| 332 |
-
}
|
| 333 |
-
const sub = [inv.displayQuantity(item), item.storage_location ?? ""].filter(Boolean).join(" Β· ");
|
| 334 |
-
card.innerHTML = `
|
| 335 |
-
<div class="grow">
|
| 336 |
-
<div class="item-title">${escapeHtml(item.display_name)}${chip}</div>
|
| 337 |
-
<div class="item-sub">${escapeHtml(sub)}</div>
|
| 338 |
-
</div>`;
|
| 339 |
-
|
| 340 |
-
const useBtn = document.createElement("button");
|
| 341 |
-
useBtn.className = "btn small";
|
| 342 |
-
useBtn.textContent = s.cook_some;
|
| 343 |
-
useBtn.onclick = () => this.promptUse(item.name, item.unit);
|
| 344 |
-
const delBtn = document.createElement("button");
|
| 345 |
-
delBtn.className = "btn small";
|
| 346 |
-
delBtn.textContent = "β";
|
| 347 |
-
delBtn.setAttribute("aria-label", s.delete_item);
|
| 348 |
-
delBtn.onclick = () => {
|
| 349 |
-
this.data.inventory = this.data.inventory.filter((entry) => entry !== item);
|
| 350 |
-
this.commit();
|
| 351 |
-
};
|
| 352 |
-
card.append(useBtn, delBtn);
|
| 353 |
-
main.appendChild(card);
|
| 354 |
-
}
|
| 355 |
|
| 356 |
if (hasExpiring && !this.warnedExpiry) {
|
| 357 |
this.warnedExpiry = true;
|
| 358 |
void this.gestures.concernedTilt();
|
| 359 |
}
|
| 360 |
|
|
|
|
| 361 |
main.insertAdjacentHTML("beforeend", `<h2>${s.add_item}</h2>`);
|
| 362 |
-
const form =
|
| 363 |
-
form.className = "card";
|
| 364 |
-
form.innerHTML = `
|
| 365 |
-
<label>${s.item_name}</label>
|
| 366 |
-
<input name="name" required autocomplete="off" />
|
| 367 |
-
<div class="form-grid">
|
| 368 |
-
<div>
|
| 369 |
-
<label>${s.item_quantity}</label>
|
| 370 |
-
<input name="quantity" type="number" inputmode="decimal" min="0" step="any" required />
|
| 371 |
-
</div>
|
| 372 |
-
<div>
|
| 373 |
-
<label>${s.item_unit}</label>
|
| 374 |
-
<select name="unit">
|
| 375 |
-
<option value="g">g</option>
|
| 376 |
-
<option value="kg">kg</option>
|
| 377 |
-
<option value="ml">ml</option>
|
| 378 |
-
<option value="l">l</option>
|
| 379 |
-
<option value="pcs">pcs</option>
|
| 380 |
-
</select>
|
| 381 |
-
</div>
|
| 382 |
-
</div>
|
| 383 |
-
<label>${s.item_expiration}</label>
|
| 384 |
-
<input name="expiration" type="date" />
|
| 385 |
-
<label>${s.item_location}</label>
|
| 386 |
-
<input name="location" placeholder="${s.location_placeholder}" autocomplete="off" />
|
| 387 |
-
<button class="btn primary block" type="submit">${s.add_item}</button>`;
|
| 388 |
form.onsubmit = (event) => {
|
| 389 |
event.preventDefault();
|
| 390 |
const fd = new FormData(form);
|
|
@@ -408,13 +533,198 @@ export class App {
|
|
| 408 |
main.appendChild(form);
|
| 409 |
}
|
| 410 |
|
| 411 |
-
private
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
if (
|
| 416 |
-
|
| 417 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
// ---------- Meal plan ----------
|
|
@@ -627,44 +937,67 @@ export class App {
|
|
| 627 |
this.settings.hfToken = tokenInput.value.trim() || null;
|
| 628 |
saveSettings(this.settings);
|
| 629 |
};
|
|
|
|
|
|
|
|
|
|
| 630 |
const saveCloud = document.createElement("button");
|
| 631 |
-
saveCloud.className = "btn primary
|
| 632 |
saveCloud.textContent = s.hf_save_to_cloud;
|
| 633 |
saveCloud.onclick = async () => {
|
| 634 |
const token = this.settings.hfToken;
|
| 635 |
if (!token) return this.toast(s.hf_sync_err);
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
console.error("[cookaiware] HF upload failed", err);
|
| 642 |
-
const msg = err instanceof Error ? err.message : String(err);
|
| 643 |
-
this.toast(`${s.hf_sync_err} β ${msg.slice(0, 140)}`);
|
| 644 |
-
}
|
| 645 |
};
|
| 646 |
const loadCloud = document.createElement("button");
|
| 647 |
-
loadCloud.className = "btn
|
| 648 |
loadCloud.textContent = s.hf_load_from_cloud;
|
| 649 |
loadCloud.onclick = async () => {
|
| 650 |
const token = this.settings.hfToken;
|
| 651 |
if (!token) return this.toast(s.hf_sync_err);
|
| 652 |
try {
|
| 653 |
const remote = await downloadData(token);
|
| 654 |
-
if (remote)
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
} catch (err) {
|
| 662 |
console.error("[cookaiware] HF download failed", err);
|
| 663 |
const msg = err instanceof Error ? err.message : String(err);
|
| 664 |
this.toast(`${s.hf_sync_err} β ${msg.slice(0, 140)}`);
|
| 665 |
}
|
| 666 |
};
|
| 667 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
main.appendChild(hfCard);
|
| 669 |
|
| 670 |
// Voice
|
|
@@ -697,3 +1030,24 @@ function escapeHtml(value: string): string {
|
|
| 697 |
.replaceAll(">", ">")
|
| 698 |
.replaceAll('"', """);
|
| 699 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import type { ReachyMiniInstance } from "@pollen-robotics/reachy-mini-sdk";
|
| 2 |
import type { RobotGestures } from "../robot/gestures";
|
| 3 |
+
import type { InventoryItem } from "../domain/types";
|
| 4 |
import type { AppData, AppSettings } from "../storage/store";
|
| 5 |
import {
|
| 6 |
loadData,
|
|
|
|
| 9 |
saveSettings,
|
| 10 |
exportToFile,
|
| 11 |
importFromFile,
|
| 12 |
+
summarizeData,
|
| 13 |
} from "../storage/store";
|
| 14 |
import { uploadData, downloadData } from "../storage/hfDataset";
|
| 15 |
import { VoiceController, type VoiceStatus } from "../voice/controller";
|
|
|
|
| 51 |
'<path d="M4.5 9.5v5h3l4 3.5v-12l-4 3.5h-3z"/><path d="M15 9a4.5 4.5 0 0 1 0 6M17.5 6.8a8 8 0 0 1 0 10.4"/>',
|
| 52 |
),
|
| 53 |
spinner: svg('<path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5" class="spin-path"/>'),
|
| 54 |
+
edit: svg('<path d="M14.5 5.5l4 4M4 20l4.5-1 9-9-4-4-9 9L4 20z"/>'),
|
| 55 |
+
trash: svg('<path d="M5 7h14M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7M7 7l1 12.2a1.5 1.5 0 0 0 1.5 1.4h5a1.5 1.5 0 0 0 1.5-1.4L17.5 7"/>'),
|
| 56 |
+
use: svg('<circle cx="12" cy="12" r="8.5"/><path d="M8 12h8"/>'),
|
| 57 |
+
drag: svg('<circle cx="9" cy="6" r="1.3"/><circle cx="15" cy="6" r="1.3"/><circle cx="9" cy="12" r="1.3"/><circle cx="15" cy="12" r="1.3"/><circle cx="9" cy="18" r="1.3"/><circle cx="15" cy="18" r="1.3"/>'),
|
| 58 |
+
clock: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
|
| 59 |
+
pin: svg('<path d="M12 21s6-5.3 6-10a6 6 0 1 0-12 0c0 4.7 6 10 6 10z"/><circle cx="12" cy="11" r="2.2"/>'),
|
| 60 |
};
|
| 61 |
|
| 62 |
export class App {
|
|
|
|
| 66 |
private settings: AppSettings;
|
| 67 |
private tab: Tab = "inventory";
|
| 68 |
private warnedExpiry = false;
|
| 69 |
+
private transcriptMinimized = false;
|
| 70 |
+
private locationFilter: string | null = null;
|
| 71 |
+
private syncTimer: ReturnType<typeof setTimeout> | null = null;
|
| 72 |
private voice: VoiceController;
|
| 73 |
private voiceStatus: VoiceStatus = "idle";
|
| 74 |
private transcript: { role: "user" | "assistant"; text?: string; image?: string }[] = [];
|
|
|
|
| 142 |
|
| 143 |
private commit(): void {
|
| 144 |
saveData(this.data);
|
| 145 |
+
this.scheduleAutoSync();
|
| 146 |
this.render();
|
| 147 |
}
|
| 148 |
|
|
|
|
| 152 |
el.className = "toast";
|
| 153 |
el.textContent = message;
|
| 154 |
document.body.appendChild(el);
|
| 155 |
+
setTimeout(() => el.remove(), 3500);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// ββ Reusable modal + confirm ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
+
|
| 160 |
+
private showModal(opts: {
|
| 161 |
+
title: string;
|
| 162 |
+
body: HTMLElement;
|
| 163 |
+
primaryLabel: string;
|
| 164 |
+
onPrimary: () => void | Promise<void>;
|
| 165 |
+
}): void {
|
| 166 |
+
const s = this.s;
|
| 167 |
+
const backdrop = document.createElement("div");
|
| 168 |
+
backdrop.className = "modal-backdrop";
|
| 169 |
+
const card = document.createElement("div");
|
| 170 |
+
card.className = "modal-card";
|
| 171 |
+
card.innerHTML = `<div class="modal-title">${escapeHtml(opts.title)}</div>`;
|
| 172 |
+
const bodyWrap = document.createElement("div");
|
| 173 |
+
bodyWrap.className = "modal-body";
|
| 174 |
+
bodyWrap.appendChild(opts.body);
|
| 175 |
+
const actions = document.createElement("div");
|
| 176 |
+
actions.className = "modal-actions";
|
| 177 |
+
const cancel = document.createElement("button");
|
| 178 |
+
cancel.className = "btn";
|
| 179 |
+
cancel.textContent = s.cancel;
|
| 180 |
+
const primary = document.createElement("button");
|
| 181 |
+
primary.className = "btn primary";
|
| 182 |
+
primary.textContent = opts.primaryLabel;
|
| 183 |
+
actions.append(cancel, primary);
|
| 184 |
+
card.append(bodyWrap, actions);
|
| 185 |
+
backdrop.appendChild(card);
|
| 186 |
+
document.body.appendChild(backdrop);
|
| 187 |
+
|
| 188 |
+
const close = () => backdrop.remove();
|
| 189 |
+
cancel.onclick = close;
|
| 190 |
+
backdrop.onclick = (e) => {
|
| 191 |
+
if (e.target === backdrop) close();
|
| 192 |
+
};
|
| 193 |
+
primary.onclick = async () => {
|
| 194 |
+
await opts.onPrimary();
|
| 195 |
+
close();
|
| 196 |
+
};
|
| 197 |
+
// Focus the first input for quick keyboard entry.
|
| 198 |
+
bodyWrap.querySelector<HTMLElement>("input, select")?.focus();
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
private confirm(message: string): Promise<boolean> {
|
| 202 |
+
const s = this.s;
|
| 203 |
+
return new Promise((resolve) => {
|
| 204 |
+
const backdrop = document.createElement("div");
|
| 205 |
+
backdrop.className = "modal-backdrop";
|
| 206 |
+
backdrop.innerHTML = `
|
| 207 |
+
<div class="modal-card">
|
| 208 |
+
<div class="modal-body confirm-msg">${escapeHtml(message)}</div>
|
| 209 |
+
<div class="modal-actions">
|
| 210 |
+
<button class="btn" data-no>${s.cancel}</button>
|
| 211 |
+
<button class="btn primary" data-yes>${s.confirm_yes}</button>
|
| 212 |
+
</div>
|
| 213 |
+
</div>`;
|
| 214 |
+
document.body.appendChild(backdrop);
|
| 215 |
+
const done = (v: boolean) => {
|
| 216 |
+
backdrop.remove();
|
| 217 |
+
resolve(v);
|
| 218 |
+
};
|
| 219 |
+
backdrop.querySelector<HTMLButtonElement>("[data-no]")!.onclick = () => done(false);
|
| 220 |
+
backdrop.querySelector<HTMLButtonElement>("[data-yes]")!.onclick = () => done(true);
|
| 221 |
+
backdrop.onclick = (e) => {
|
| 222 |
+
if (e.target === backdrop) done(false);
|
| 223 |
+
};
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// ββ Auto-sync to HF (debounced) ββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββ
|
| 228 |
+
|
| 229 |
+
private scheduleAutoSync(): void {
|
| 230 |
+
if (!this.settings.hfDatasetSync || !this.settings.hfToken) return;
|
| 231 |
+
if (this.syncTimer) clearTimeout(this.syncTimer);
|
| 232 |
+
this.syncTimer = setTimeout(() => void this.pushToCloud(true), 4000);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
private async pushToCloud(silent: boolean): Promise<void> {
|
| 236 |
+
const token = this.settings.hfToken;
|
| 237 |
+
if (!token) return;
|
| 238 |
+
try {
|
| 239 |
+
const identity = await uploadData(token, this.data);
|
| 240 |
+
this.settings.lastSyncedAt = new Date().toISOString();
|
| 241 |
+
saveSettings(this.settings);
|
| 242 |
+
if (!silent) this.toast(this.s.hf_sync_ok(identity.repoId));
|
| 243 |
+
// Refresh the "last synced" label if Settings is open.
|
| 244 |
+
if (this.tab === "settings") this.updateSyncLabel();
|
| 245 |
+
} catch (err) {
|
| 246 |
+
console.error("[cookaiware] auto-sync failed", err);
|
| 247 |
+
if (!silent) {
|
| 248 |
+
const msg = err instanceof Error ? err.message : String(err);
|
| 249 |
+
this.toast(`${this.s.hf_sync_err} β ${msg.slice(0, 140)}`);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
private updateSyncLabel(): void {
|
| 255 |
+
const el = this.root.querySelector<HTMLElement>("#hf-last-synced");
|
| 256 |
+
if (!el) return;
|
| 257 |
+
el.textContent = this.settings.lastSyncedAt
|
| 258 |
+
? this.s.hf_last_synced(relativeTime(this.settings.lastSyncedAt, this.settings.language))
|
| 259 |
+
: this.s.hf_never_synced;
|
| 260 |
}
|
| 261 |
|
| 262 |
private render(): void {
|
|
|
|
| 335 |
return;
|
| 336 |
}
|
| 337 |
panel.style.display = "block";
|
| 338 |
+
panel.classList.toggle("minimized", this.transcriptMinimized);
|
| 339 |
const rows =
|
| 340 |
this.transcript.length === 0
|
| 341 |
? `<div class="vt-empty">${s.voice_transcript_empty}</div>`
|
|
|
|
| 349 |
}" alt="${m.role}" /><div class="bubble${m.image ? " photo-bubble" : ""}">${inner}</div></div>`;
|
| 350 |
})
|
| 351 |
.join("");
|
| 352 |
+
const toggleIcon = this.transcriptMinimized
|
| 353 |
+
? svg('<path d="M7 14l5-5 5 5"/>') // chevron up = expand
|
| 354 |
+
: svg('<path d="M7 10l5 5 5-5"/>'); // chevron down = minimize
|
| 355 |
panel.innerHTML = `
|
| 356 |
+
<div class="vt-head" id="vt-head">
|
| 357 |
+
<span>${s.voice_transcript_title}${this.transcriptMinimized && this.transcript.length ? ` Β· ${this.transcript.length}` : ""}</span>
|
| 358 |
+
<div class="vt-head-actions">
|
| 359 |
+
<button class="vt-clear" id="vt-clear">${s.voice_transcript_clear}</button>
|
| 360 |
+
<button class="vt-min icon-btn" id="vt-min" aria-label="minimize">${toggleIcon}</button>
|
| 361 |
+
</div>
|
| 362 |
</div>
|
| 363 |
<div class="vt-body" id="vt-body">${rows}</div>`;
|
| 364 |
+
const toggle = () => {
|
| 365 |
+
this.transcriptMinimized = !this.transcriptMinimized;
|
| 366 |
+
this.updateTranscript();
|
| 367 |
+
};
|
| 368 |
+
panel.querySelector<HTMLButtonElement>("#vt-min")!.onclick = toggle;
|
| 369 |
+
// Tapping the header (not the buttons) also toggles when minimized.
|
| 370 |
+
panel.querySelector<HTMLElement>("#vt-head")!.onclick = (e) => {
|
| 371 |
+
if ((e.target as HTMLElement).closest("button")) return;
|
| 372 |
+
if (this.transcriptMinimized) toggle();
|
| 373 |
+
};
|
| 374 |
+
panel.querySelector<HTMLButtonElement>("#vt-clear")!.onclick = (e) => {
|
| 375 |
+
e.stopPropagation();
|
| 376 |
this.transcript = [];
|
| 377 |
this.updateTranscript();
|
| 378 |
};
|
|
|
|
| 446 |
|
| 447 |
main.insertAdjacentHTML("beforeend", `<h1>${s.inventory_title}</h1>`);
|
| 448 |
|
| 449 |
+
// Sort control.
|
| 450 |
+
const sortBar = document.createElement("div");
|
| 451 |
+
sortBar.className = "sort-bar";
|
| 452 |
+
sortBar.innerHTML = `<span class="sort-label">${s.sort_label}</span>`;
|
| 453 |
+
const seg = document.createElement("div");
|
| 454 |
+
seg.className = "segmented";
|
| 455 |
+
for (const [mode, label] of [
|
| 456 |
+
["expiration", s.sort_expiration],
|
| 457 |
+
["name", s.sort_name],
|
| 458 |
+
["custom", s.sort_custom],
|
| 459 |
+
] as const) {
|
| 460 |
+
const b = document.createElement("button");
|
| 461 |
+
b.className = this.settings.inventorySort === mode ? "active" : "";
|
| 462 |
+
b.textContent = label;
|
| 463 |
+
b.onclick = () => {
|
| 464 |
+
this.settings.inventorySort = mode;
|
| 465 |
+
saveSettings(this.settings);
|
| 466 |
+
this.render();
|
| 467 |
+
};
|
| 468 |
+
seg.appendChild(b);
|
| 469 |
+
}
|
| 470 |
+
sortBar.appendChild(seg);
|
| 471 |
+
main.appendChild(sortBar);
|
| 472 |
+
|
| 473 |
+
// Active location filter banner.
|
| 474 |
+
if (this.locationFilter) {
|
| 475 |
+
const banner = document.createElement("button");
|
| 476 |
+
banner.className = "filter-banner";
|
| 477 |
+
banner.innerHTML = `${ICONS.pin}<span>${s.filter_location(this.locationFilter)}</span>`;
|
| 478 |
+
banner.onclick = () => {
|
| 479 |
+
this.locationFilter = null;
|
| 480 |
+
this.render();
|
| 481 |
+
};
|
| 482 |
+
main.appendChild(banner);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
const items = this.orderedInventory();
|
| 486 |
if (items.length === 0) {
|
| 487 |
main.insertAdjacentHTML("beforeend", `<div class="empty">${s.inventory_empty}</div>`);
|
| 488 |
}
|
| 489 |
+
if (this.settings.inventorySort === "custom" && items.length > 1 && !this.locationFilter) {
|
| 490 |
+
main.insertAdjacentHTML("beforeend", `<p class="hint drag-hint">${ICONS.drag} ${s.drag_hint}</p>`);
|
| 491 |
+
}
|
| 492 |
|
| 493 |
+
const list = document.createElement("div");
|
| 494 |
+
list.className = "inv-list";
|
| 495 |
+
const draggable = this.settings.inventorySort === "custom" && !this.locationFilter;
|
| 496 |
let hasExpiring = false;
|
| 497 |
+
items.forEach((item) => {
|
|
|
|
|
|
|
| 498 |
const days = inv.daysToExpiration(item);
|
| 499 |
+
if (days !== null && days <= EXPIRY_SOON_DAYS) hasExpiring = true;
|
| 500 |
+
const card = this.renderInventoryCard(item, draggable);
|
| 501 |
+
list.appendChild(card);
|
| 502 |
+
});
|
| 503 |
+
main.appendChild(list);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
if (hasExpiring && !this.warnedExpiry) {
|
| 506 |
this.warnedExpiry = true;
|
| 507 |
void this.gestures.concernedTilt();
|
| 508 |
}
|
| 509 |
|
| 510 |
+
// Add-item form.
|
| 511 |
main.insertAdjacentHTML("beforeend", `<h2>${s.add_item}</h2>`);
|
| 512 |
+
const form = this.itemForm(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
form.onsubmit = (event) => {
|
| 514 |
event.preventDefault();
|
| 515 |
const fd = new FormData(form);
|
|
|
|
| 533 |
main.appendChild(form);
|
| 534 |
}
|
| 535 |
|
| 536 |
+
private orderedInventory(): InventoryItem[] {
|
| 537 |
+
let items = this.data.inventory.filter((i) => i.quantity > 0);
|
| 538 |
+
const mode = this.settings.inventorySort;
|
| 539 |
+
if (mode === "name") items = [...items].sort((a, b) => a.display_name.localeCompare(b.display_name));
|
| 540 |
+
else if (mode === "expiration") items = inv.sortInventory(items);
|
| 541 |
+
// "custom" keeps the underlying array order.
|
| 542 |
+
if (this.locationFilter) items = items.filter((i) => (i.storage_location ?? "") === this.locationFilter);
|
| 543 |
+
return items;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
private existingLocations(): string[] {
|
| 547 |
+
return [...new Set(this.data.inventory.map((i) => i.storage_location).filter((l): l is string => !!l))].sort();
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
private renderInventoryCard(item: InventoryItem, draggable: boolean): HTMLElement {
|
| 551 |
+
const s = this.s;
|
| 552 |
+
const card = document.createElement("div");
|
| 553 |
+
card.className = "card inv-card";
|
| 554 |
+
const days = inv.daysToExpiration(item);
|
| 555 |
+
let chip = "";
|
| 556 |
+
if (days !== null && days < 0) chip = `<span class="chip danger">${s.expired}</span>`;
|
| 557 |
+
else if (days !== null && days <= EXPIRY_SOON_DAYS) chip = `<span class="chip warn">${s.expires_in_days(days)}</span>`;
|
| 558 |
+
|
| 559 |
+
const expText = item.expiration_date
|
| 560 |
+
? `${ICONS.clock}<span>${s.expires_on(formatDate(item.expiration_date, this.settings.language))}</span>`
|
| 561 |
+
: `${ICONS.clock}<span>${s.no_expiration}</span>`;
|
| 562 |
+
|
| 563 |
+
card.innerHTML = `
|
| 564 |
+
${draggable ? `<span class="drag-handle" aria-label="reorder">${ICONS.drag}</span>` : ""}
|
| 565 |
+
<div class="grow">
|
| 566 |
+
<div class="item-title">${escapeHtml(item.display_name)}${chip}</div>
|
| 567 |
+
<div class="item-meta">
|
| 568 |
+
<span class="qty">${escapeHtml(inv.displayQuantity(item))}</span>
|
| 569 |
+
<span class="exp ${days !== null && days < 0 ? "over" : ""}">${expText}</span>
|
| 570 |
+
${item.storage_location ? `<button class="loc-chip">${ICONS.pin}${escapeHtml(item.storage_location)}</button>` : ""}
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
<div class="inv-actions">
|
| 574 |
+
<button class="icon-btn use" aria-label="${s.cook_some}">${ICONS.use}</button>
|
| 575 |
+
<button class="icon-btn edit" aria-label="${s.edit_item}">${ICONS.edit}</button>
|
| 576 |
+
<button class="icon-btn danger del" aria-label="${s.delete_item}">${ICONS.trash}</button>
|
| 577 |
+
</div>`;
|
| 578 |
+
|
| 579 |
+
card.querySelector<HTMLButtonElement>(".use")!.onclick = () => this.openUse(item);
|
| 580 |
+
card.querySelector<HTMLButtonElement>(".edit")!.onclick = () => this.openEdit(item);
|
| 581 |
+
card.querySelector<HTMLButtonElement>(".del")!.onclick = async () => {
|
| 582 |
+
if (await this.confirm(s.delete_confirm(item.display_name))) {
|
| 583 |
+
this.data.inventory = this.data.inventory.filter((e) => e !== item);
|
| 584 |
+
this.commit();
|
| 585 |
+
}
|
| 586 |
+
};
|
| 587 |
+
card.querySelector<HTMLButtonElement>(".loc-chip")?.addEventListener("click", () => {
|
| 588 |
+
this.locationFilter = item.storage_location;
|
| 589 |
+
this.render();
|
| 590 |
+
});
|
| 591 |
+
if (draggable) this.wireDrag(card, item);
|
| 592 |
+
return card;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
/** Pointer-based drag reorder (works on touch, unlike HTML5 DnD). The dragged
|
| 596 |
+
* card carries its InventoryItem reference so the final order is rebuilt from
|
| 597 |
+
* the DOM directly. */
|
| 598 |
+
private wireDrag(card: HTMLElement, item: InventoryItem): void {
|
| 599 |
+
(card as HTMLElement & { _item?: InventoryItem })._item = item;
|
| 600 |
+
const handle = card.querySelector<HTMLElement>(".drag-handle");
|
| 601 |
+
if (!handle) return;
|
| 602 |
+
handle.style.touchAction = "none";
|
| 603 |
+
handle.addEventListener("pointerdown", (down: PointerEvent) => {
|
| 604 |
+
down.preventDefault();
|
| 605 |
+
const list = card.parentElement!;
|
| 606 |
+
card.classList.add("dragging");
|
| 607 |
+
const move = (e: PointerEvent) => {
|
| 608 |
+
const after = [...list.querySelectorAll<HTMLElement>(".inv-card:not(.dragging)")].find((sib) => {
|
| 609 |
+
const box = sib.getBoundingClientRect();
|
| 610 |
+
return e.clientY < box.top + box.height / 2;
|
| 611 |
+
});
|
| 612 |
+
if (after) list.insertBefore(card, after);
|
| 613 |
+
else list.appendChild(card);
|
| 614 |
+
};
|
| 615 |
+
const up = () => {
|
| 616 |
+
card.classList.remove("dragging");
|
| 617 |
+
window.removeEventListener("pointermove", move);
|
| 618 |
+
window.removeEventListener("pointerup", up);
|
| 619 |
+
const newVisible = [...list.querySelectorAll<HTMLElement & { _item?: InventoryItem }>(".inv-card")]
|
| 620 |
+
.map((el) => el._item)
|
| 621 |
+
.filter((it): it is InventoryItem => !!it);
|
| 622 |
+
const hidden = this.data.inventory.filter((i) => i.quantity <= 0);
|
| 623 |
+
this.data.inventory = [...newVisible, ...hidden];
|
| 624 |
+
this.commit();
|
| 625 |
+
};
|
| 626 |
+
window.addEventListener("pointermove", move);
|
| 627 |
+
window.addEventListener("pointerup", up);
|
| 628 |
+
});
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
/** Shared add/edit form. `existing` null β add mode. */
|
| 632 |
+
private itemForm(existing: InventoryItem | null): HTMLFormElement {
|
| 633 |
+
const s = this.s;
|
| 634 |
+
const form = document.createElement("form");
|
| 635 |
+
form.className = existing ? "" : "card";
|
| 636 |
+
const locOptions = this.existingLocations()
|
| 637 |
+
.map((l) => `<option value="${escapeHtml(l)}"></option>`)
|
| 638 |
+
.join("");
|
| 639 |
+
const units: [string, string][] = [
|
| 640 |
+
["g", "g"],
|
| 641 |
+
["kg", "kg"],
|
| 642 |
+
["ml", "ml"],
|
| 643 |
+
["l", "l"],
|
| 644 |
+
["pcs", "pcs"],
|
| 645 |
+
];
|
| 646 |
+
// Show the item's stored unit; for grams/ml the stored unit is the base.
|
| 647 |
+
const curUnit = existing ? existing.unit : "g";
|
| 648 |
+
form.innerHTML = `
|
| 649 |
+
<label>${s.item_name}</label>
|
| 650 |
+
<input name="name" required autocomplete="off" value="${existing ? escapeHtml(existing.display_name) : ""}" />
|
| 651 |
+
<div class="form-grid">
|
| 652 |
+
<div>
|
| 653 |
+
<label>${s.item_quantity}</label>
|
| 654 |
+
<input name="quantity" type="number" inputmode="decimal" min="0" step="any" required value="${existing ? existing.quantity : ""}" />
|
| 655 |
+
</div>
|
| 656 |
+
<div>
|
| 657 |
+
<label>${s.item_unit}</label>
|
| 658 |
+
<select name="unit">${units.map(([v, l]) => `<option value="${v}"${v === curUnit ? " selected" : ""}>${l}</option>`).join("")}</select>
|
| 659 |
+
</div>
|
| 660 |
+
</div>
|
| 661 |
+
<label>${s.item_expiration}</label>
|
| 662 |
+
<input name="expiration" type="date" value="${existing?.expiration_date ?? ""}" />
|
| 663 |
+
<label>${s.item_location}</label>
|
| 664 |
+
<input name="location" list="loc-list" placeholder="${s.location_placeholder}" autocomplete="off" value="${existing ? escapeHtml(existing.storage_location ?? "") : ""}" />
|
| 665 |
+
<datalist id="loc-list">${locOptions}</datalist>
|
| 666 |
+
${existing ? "" : `<button class="btn primary block" type="submit">${s.add_item}</button>`}`;
|
| 667 |
+
// In the edit modal there is no submit button; stop Enter from reloading.
|
| 668 |
+
if (existing) form.addEventListener("submit", (e) => e.preventDefault());
|
| 669 |
+
return form;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
private openUse(item: InventoryItem): void {
|
| 673 |
+
const s = this.s;
|
| 674 |
+
const body = document.createElement("div");
|
| 675 |
+
body.innerHTML = `
|
| 676 |
+
<label>${s.use_amount} (${item.unit})</label>
|
| 677 |
+
<input name="amount" type="number" inputmode="decimal" min="0" step="any" placeholder="0" />
|
| 678 |
+
<button class="btn block" type="button" data-all>${s.use_all}</button>`;
|
| 679 |
+
const input = body.querySelector<HTMLInputElement>("input")!;
|
| 680 |
+
body.querySelector<HTMLButtonElement>("[data-all]")!.onclick = () => {
|
| 681 |
+
input.value = String(item.quantity);
|
| 682 |
+
};
|
| 683 |
+
this.showModal({
|
| 684 |
+
title: s.use_title(item.display_name),
|
| 685 |
+
body,
|
| 686 |
+
primaryLabel: s.cook_some,
|
| 687 |
+
onPrimary: () => {
|
| 688 |
+
const raw = input.value.trim();
|
| 689 |
+
if (!raw) return;
|
| 690 |
+
const { inventory, errors } = inv.cookItems(this.data.inventory, [
|
| 691 |
+
{ name: item.name, quantity: raw, unit: item.unit },
|
| 692 |
+
]);
|
| 693 |
+
if (errors.length > 0) this.toast(errors.join(", "));
|
| 694 |
+
this.data.inventory = inventory;
|
| 695 |
+
this.commit();
|
| 696 |
+
},
|
| 697 |
+
});
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
private openEdit(item: InventoryItem): void {
|
| 701 |
+
const s = this.s;
|
| 702 |
+
const form = this.itemForm(item);
|
| 703 |
+
this.showModal({
|
| 704 |
+
title: s.edit_item,
|
| 705 |
+
body: form,
|
| 706 |
+
primaryLabel: s.save,
|
| 707 |
+
onPrimary: () => {
|
| 708 |
+
const fd = new FormData(form);
|
| 709 |
+
const normalized = inv.normalizeItemInput({
|
| 710 |
+
name: String(fd.get("name") ?? ""),
|
| 711 |
+
quantity: String(fd.get("quantity") ?? ""),
|
| 712 |
+
unit: String(fd.get("unit") ?? "pcs"),
|
| 713 |
+
expiration_date: String(fd.get("expiration") ?? "") || null,
|
| 714 |
+
storage_location: String(fd.get("location") ?? "") || null,
|
| 715 |
+
});
|
| 716 |
+
if (!normalized) {
|
| 717 |
+
this.toast(this.s.item_quantity);
|
| 718 |
+
return;
|
| 719 |
+
}
|
| 720 |
+
// Replace this item in place (preserve position).
|
| 721 |
+
const idx = this.data.inventory.indexOf(item);
|
| 722 |
+
if (idx >= 0) {
|
| 723 |
+
this.data.inventory = this.data.inventory.map((e, i) => (i === idx ? normalized : e));
|
| 724 |
+
this.commit();
|
| 725 |
+
}
|
| 726 |
+
},
|
| 727 |
+
});
|
| 728 |
}
|
| 729 |
|
| 730 |
// ---------- Meal plan ----------
|
|
|
|
| 937 |
this.settings.hfToken = tokenInput.value.trim() || null;
|
| 938 |
saveSettings(this.settings);
|
| 939 |
};
|
| 940 |
+
// Save / Load side by side.
|
| 941 |
+
const syncRow = document.createElement("div");
|
| 942 |
+
syncRow.className = "sync-row";
|
| 943 |
const saveCloud = document.createElement("button");
|
| 944 |
+
saveCloud.className = "btn primary";
|
| 945 |
saveCloud.textContent = s.hf_save_to_cloud;
|
| 946 |
saveCloud.onclick = async () => {
|
| 947 |
const token = this.settings.hfToken;
|
| 948 |
if (!token) return this.toast(s.hf_sync_err);
|
| 949 |
+
const sum = summarizeData(this.data);
|
| 950 |
+
const summary = s.data_summary(sum.inventory, sum.hasPlan, sum.shopping);
|
| 951 |
+
if (!(await this.confirm(s.hf_save_confirm(summary)))) return;
|
| 952 |
+
await this.pushToCloud(false);
|
| 953 |
+
void this.gestures.happyDance();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
};
|
| 955 |
const loadCloud = document.createElement("button");
|
| 956 |
+
loadCloud.className = "btn";
|
| 957 |
loadCloud.textContent = s.hf_load_from_cloud;
|
| 958 |
loadCloud.onclick = async () => {
|
| 959 |
const token = this.settings.hfToken;
|
| 960 |
if (!token) return this.toast(s.hf_sync_err);
|
| 961 |
try {
|
| 962 |
const remote = await downloadData(token);
|
| 963 |
+
if (!remote) return this.toast(s.hf_sync_err);
|
| 964 |
+
const sum = summarizeData(remote);
|
| 965 |
+
const summary = s.data_summary(sum.inventory, sum.hasPlan, sum.shopping);
|
| 966 |
+
if (!(await this.confirm(s.hf_load_confirm(summary)))) return;
|
| 967 |
+
this.data = remote;
|
| 968 |
+
this.commit();
|
| 969 |
+
this.toast(s.import_ok);
|
| 970 |
} catch (err) {
|
| 971 |
console.error("[cookaiware] HF download failed", err);
|
| 972 |
const msg = err instanceof Error ? err.message : String(err);
|
| 973 |
this.toast(`${s.hf_sync_err} β ${msg.slice(0, 140)}`);
|
| 974 |
}
|
| 975 |
};
|
| 976 |
+
syncRow.append(saveCloud, loadCloud);
|
| 977 |
+
|
| 978 |
+
// Auto-sync toggle.
|
| 979 |
+
const autoRow = document.createElement("label");
|
| 980 |
+
autoRow.className = "switch-row";
|
| 981 |
+
autoRow.innerHTML = `<span><strong>${s.hf_autosync}</strong><br><span class="hint">${s.hf_autosync_hint}</span></span>`;
|
| 982 |
+
const autoInput = document.createElement("input");
|
| 983 |
+
autoInput.type = "checkbox";
|
| 984 |
+
autoInput.className = "switch";
|
| 985 |
+
autoInput.checked = this.settings.hfDatasetSync;
|
| 986 |
+
autoInput.onchange = () => {
|
| 987 |
+
this.settings.hfDatasetSync = autoInput.checked;
|
| 988 |
+
saveSettings(this.settings);
|
| 989 |
+
if (autoInput.checked) void this.pushToCloud(false);
|
| 990 |
+
};
|
| 991 |
+
autoRow.appendChild(autoInput);
|
| 992 |
+
|
| 993 |
+
const lastSynced = document.createElement("p");
|
| 994 |
+
lastSynced.className = "hint sync-status";
|
| 995 |
+
lastSynced.id = "hf-last-synced";
|
| 996 |
+
lastSynced.textContent = this.settings.lastSyncedAt
|
| 997 |
+
? s.hf_last_synced(relativeTime(this.settings.lastSyncedAt, this.settings.language))
|
| 998 |
+
: s.hf_never_synced;
|
| 999 |
+
|
| 1000 |
+
hfCard.append(tokenLabel, tokenInput, syncRow, autoRow, lastSynced);
|
| 1001 |
main.appendChild(hfCard);
|
| 1002 |
|
| 1003 |
// Voice
|
|
|
|
| 1030 |
.replaceAll(">", ">")
|
| 1031 |
.replaceAll('"', """);
|
| 1032 |
}
|
| 1033 |
+
|
| 1034 |
+
/** "just now" / "5 min ago" / a local date for older timestamps. */
|
| 1035 |
+
function relativeTime(iso: string, lang: Lang): string {
|
| 1036 |
+
const then = new Date(iso).getTime();
|
| 1037 |
+
if (Number.isNaN(then)) return iso;
|
| 1038 |
+
const secs = Math.round((Date.now() - then) / 1000);
|
| 1039 |
+
const es = lang === "es";
|
| 1040 |
+
if (secs < 60) return es ? "hace un momento" : "just now";
|
| 1041 |
+
const mins = Math.round(secs / 60);
|
| 1042 |
+
if (mins < 60) return es ? `hace ${mins} min` : `${mins} min ago`;
|
| 1043 |
+
const hrs = Math.round(mins / 60);
|
| 1044 |
+
if (hrs < 24) return es ? `hace ${hrs} h` : `${hrs} h ago`;
|
| 1045 |
+
return new Date(iso).toLocaleDateString(es ? "es-ES" : "en-US");
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
/** A locale-formatted expiration date. */
|
| 1049 |
+
function formatDate(iso: string, lang: Lang): string {
|
| 1050 |
+
const d = new Date(iso + "T00:00:00");
|
| 1051 |
+
if (Number.isNaN(d.getTime())) return iso;
|
| 1052 |
+
return d.toLocaleDateString(lang === "es" ? "es-ES" : "en-US", { day: "numeric", month: "short" });
|
| 1053 |
+
}
|
|
@@ -539,3 +539,362 @@ label {
|
|
| 539 |
max-height: 180px;
|
| 540 |
border-radius: 12px;
|
| 541 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
max-height: 180px;
|
| 540 |
border-radius: 12px;
|
| 541 |
}
|
| 542 |
+
|
| 543 |
+
/* ββ Inventory: sort bar, cards, actions, drag, chips ββββββββββββββββββββββ */
|
| 544 |
+
|
| 545 |
+
.sort-bar {
|
| 546 |
+
display: flex;
|
| 547 |
+
align-items: center;
|
| 548 |
+
gap: 0.6rem;
|
| 549 |
+
margin: 0.2rem 0 0.8rem;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.sort-bar .sort-label {
|
| 553 |
+
font-size: 0.8rem;
|
| 554 |
+
font-weight: 700;
|
| 555 |
+
color: var(--muted);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.segmented {
|
| 559 |
+
display: inline-flex;
|
| 560 |
+
background: var(--cream-strong);
|
| 561 |
+
border-radius: 12px;
|
| 562 |
+
padding: 3px;
|
| 563 |
+
gap: 2px;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.segmented button {
|
| 567 |
+
border: none;
|
| 568 |
+
background: none;
|
| 569 |
+
font-family: inherit;
|
| 570 |
+
font-size: 0.82rem;
|
| 571 |
+
font-weight: 700;
|
| 572 |
+
color: var(--teal-dark);
|
| 573 |
+
padding: 0.35rem 0.7rem;
|
| 574 |
+
border-radius: 9px;
|
| 575 |
+
cursor: pointer;
|
| 576 |
+
min-height: 36px;
|
| 577 |
+
touch-action: manipulation;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.segmented button.active {
|
| 581 |
+
background: var(--white);
|
| 582 |
+
color: var(--teal);
|
| 583 |
+
box-shadow: var(--shadow-sm);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.drag-hint {
|
| 587 |
+
display: flex;
|
| 588 |
+
align-items: center;
|
| 589 |
+
gap: 0.35rem;
|
| 590 |
+
margin: 0 0 0.5rem;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.drag-hint svg {
|
| 594 |
+
width: 16px;
|
| 595 |
+
height: 16px;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.filter-banner {
|
| 599 |
+
display: flex;
|
| 600 |
+
align-items: center;
|
| 601 |
+
gap: 0.4rem;
|
| 602 |
+
width: 100%;
|
| 603 |
+
border: none;
|
| 604 |
+
background: var(--cream-strong);
|
| 605 |
+
color: var(--teal-dark);
|
| 606 |
+
font-family: inherit;
|
| 607 |
+
font-weight: 700;
|
| 608 |
+
font-size: 0.85rem;
|
| 609 |
+
border-radius: 12px;
|
| 610 |
+
padding: 0.5rem 0.8rem;
|
| 611 |
+
margin-bottom: 0.6rem;
|
| 612 |
+
cursor: pointer;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.filter-banner svg {
|
| 616 |
+
width: 16px;
|
| 617 |
+
height: 16px;
|
| 618 |
+
flex: none;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.inv-list {
|
| 622 |
+
display: flex;
|
| 623 |
+
flex-direction: column;
|
| 624 |
+
gap: 0.6rem;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.inv-card {
|
| 628 |
+
display: flex;
|
| 629 |
+
align-items: center;
|
| 630 |
+
gap: 0.5rem;
|
| 631 |
+
margin-bottom: 0;
|
| 632 |
+
touch-action: pan-y;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.inv-card.dragging {
|
| 636 |
+
opacity: 0.85;
|
| 637 |
+
box-shadow: var(--shadow-lg);
|
| 638 |
+
transform: scale(1.02);
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.drag-handle {
|
| 642 |
+
display: flex;
|
| 643 |
+
align-items: center;
|
| 644 |
+
color: var(--muted);
|
| 645 |
+
cursor: grab;
|
| 646 |
+
padding: 0.2rem;
|
| 647 |
+
margin: -0.2rem 0 -0.2rem -0.3rem;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.drag-handle svg {
|
| 651 |
+
width: 18px;
|
| 652 |
+
height: 18px;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.item-meta {
|
| 656 |
+
display: flex;
|
| 657 |
+
flex-wrap: wrap;
|
| 658 |
+
align-items: center;
|
| 659 |
+
gap: 0.35rem 0.7rem;
|
| 660 |
+
margin-top: 0.25rem;
|
| 661 |
+
font-size: 0.82rem;
|
| 662 |
+
color: var(--muted);
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.item-meta .qty {
|
| 666 |
+
font-weight: 700;
|
| 667 |
+
color: var(--ink);
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.item-meta .exp {
|
| 671 |
+
display: inline-flex;
|
| 672 |
+
align-items: center;
|
| 673 |
+
gap: 0.2rem;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.item-meta .exp.over {
|
| 677 |
+
color: var(--danger);
|
| 678 |
+
font-weight: 700;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.item-meta .exp svg {
|
| 682 |
+
width: 14px;
|
| 683 |
+
height: 14px;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.item-meta .loc-chip {
|
| 687 |
+
display: inline-flex;
|
| 688 |
+
align-items: center;
|
| 689 |
+
gap: 0.15rem;
|
| 690 |
+
border: none;
|
| 691 |
+
background: var(--cream-strong);
|
| 692 |
+
color: var(--teal-dark);
|
| 693 |
+
font-family: inherit;
|
| 694 |
+
font-size: 0.78rem;
|
| 695 |
+
font-weight: 700;
|
| 696 |
+
border-radius: 999px;
|
| 697 |
+
padding: 0.12rem 0.5rem 0.12rem 0.35rem;
|
| 698 |
+
cursor: pointer;
|
| 699 |
+
touch-action: manipulation;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.item-meta .loc-chip svg {
|
| 703 |
+
width: 13px;
|
| 704 |
+
height: 13px;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.inv-actions {
|
| 708 |
+
display: flex;
|
| 709 |
+
gap: 0.15rem;
|
| 710 |
+
flex: none;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.icon-btn {
|
| 714 |
+
display: inline-flex;
|
| 715 |
+
align-items: center;
|
| 716 |
+
justify-content: center;
|
| 717 |
+
width: 40px;
|
| 718 |
+
height: 40px;
|
| 719 |
+
border: none;
|
| 720 |
+
background: none;
|
| 721 |
+
color: var(--teal-dark);
|
| 722 |
+
border-radius: 10px;
|
| 723 |
+
cursor: pointer;
|
| 724 |
+
touch-action: manipulation;
|
| 725 |
+
transition: background 0.15s ease, transform 0.15s ease;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
.icon-btn:active {
|
| 729 |
+
transform: scale(0.9);
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
.icon-btn:hover {
|
| 733 |
+
background: var(--cream);
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.icon-btn svg {
|
| 737 |
+
width: 20px;
|
| 738 |
+
height: 20px;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.icon-btn.danger {
|
| 742 |
+
color: var(--danger);
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
/* ββ Modal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 746 |
+
|
| 747 |
+
.modal-backdrop {
|
| 748 |
+
position: fixed;
|
| 749 |
+
inset: 0;
|
| 750 |
+
z-index: 30;
|
| 751 |
+
background: rgba(24, 38, 38, 0.45);
|
| 752 |
+
display: flex;
|
| 753 |
+
align-items: flex-end;
|
| 754 |
+
justify-content: center;
|
| 755 |
+
padding: 1rem;
|
| 756 |
+
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
| 757 |
+
animation: fade-in 0.2s ease;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
@media (min-width: 540px) {
|
| 761 |
+
.modal-backdrop {
|
| 762 |
+
align-items: center;
|
| 763 |
+
}
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
@keyframes fade-in {
|
| 767 |
+
from { opacity: 0; }
|
| 768 |
+
to { opacity: 1; }
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
.modal-card {
|
| 772 |
+
background: var(--white);
|
| 773 |
+
border-radius: 22px;
|
| 774 |
+
width: 100%;
|
| 775 |
+
max-width: 440px;
|
| 776 |
+
box-shadow: var(--shadow-lg);
|
| 777 |
+
padding: 1.1rem 1.1rem 1rem;
|
| 778 |
+
animation: modal-up 0.28s cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
@keyframes modal-up {
|
| 782 |
+
from { transform: translateY(20px); opacity: 0.6; }
|
| 783 |
+
to { transform: translateY(0); opacity: 1; }
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
.modal-title {
|
| 787 |
+
font-family: "Fraunces", serif;
|
| 788 |
+
font-size: 1.2rem;
|
| 789 |
+
font-weight: 700;
|
| 790 |
+
margin-bottom: 0.6rem;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
.modal-body.confirm-msg {
|
| 794 |
+
color: var(--muted);
|
| 795 |
+
line-height: 1.5;
|
| 796 |
+
padding: 0.2rem 0 0.4rem;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.modal-actions {
|
| 800 |
+
display: flex;
|
| 801 |
+
gap: 0.6rem;
|
| 802 |
+
margin-top: 1rem;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.modal-actions .btn {
|
| 806 |
+
flex: 1;
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
/* ββ HF sync βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 810 |
+
|
| 811 |
+
.sync-row {
|
| 812 |
+
display: flex;
|
| 813 |
+
gap: 0.6rem;
|
| 814 |
+
margin-top: 0.6rem;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.sync-row .btn {
|
| 818 |
+
flex: 1;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
.switch-row {
|
| 822 |
+
display: flex;
|
| 823 |
+
align-items: center;
|
| 824 |
+
gap: 0.7rem;
|
| 825 |
+
margin-top: 0.9rem;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
.switch-row > span:first-child {
|
| 829 |
+
flex: 1;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.switch {
|
| 833 |
+
appearance: none;
|
| 834 |
+
-webkit-appearance: none;
|
| 835 |
+
width: 46px;
|
| 836 |
+
height: 28px;
|
| 837 |
+
border-radius: 999px;
|
| 838 |
+
background: var(--cream-strong);
|
| 839 |
+
position: relative;
|
| 840 |
+
flex: none;
|
| 841 |
+
cursor: pointer;
|
| 842 |
+
transition: background 0.2s ease;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
.switch::after {
|
| 846 |
+
content: "";
|
| 847 |
+
position: absolute;
|
| 848 |
+
top: 3px;
|
| 849 |
+
left: 3px;
|
| 850 |
+
width: 22px;
|
| 851 |
+
height: 22px;
|
| 852 |
+
border-radius: 50%;
|
| 853 |
+
background: var(--white);
|
| 854 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
| 855 |
+
transition: transform 0.2s ease;
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
.switch:checked {
|
| 859 |
+
background: var(--teal);
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
.switch:checked::after {
|
| 863 |
+
transform: translateX(18px);
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
.sync-status {
|
| 867 |
+
margin-top: 0.7rem;
|
| 868 |
+
text-align: center;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
/* ββ Conversation minimize βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 872 |
+
|
| 873 |
+
.vt-head-actions {
|
| 874 |
+
display: flex;
|
| 875 |
+
align-items: center;
|
| 876 |
+
gap: 0.2rem;
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.vt-min {
|
| 880 |
+
width: 32px;
|
| 881 |
+
height: 32px;
|
| 882 |
+
color: var(--teal-dark);
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
.vt-min svg {
|
| 886 |
+
width: 20px;
|
| 887 |
+
height: 20px;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.voice-transcript.minimized {
|
| 891 |
+
max-height: none;
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
.voice-transcript.minimized .vt-body {
|
| 895 |
+
display: none;
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
.voice-transcript.minimized .vt-head {
|
| 899 |
+
cursor: pointer;
|
| 900 |
+
}
|