diff --git a/run/cifs_archive/copy-music.sh b/run/cifs_archive/copy-music.sh index 03960f7e..22fc2729 100755 --- a/run/cifs_archive/copy-music.sh +++ b/run/cifs_archive/copy-music.sh @@ -56,7 +56,7 @@ function do_music_sync { if ! rsync -rum --no-human-readable --exclude=.fseventsd/*** --exclude=*.DS_Store --exclude=.metadata_never_index \ --exclude="System Volume Information/***" \ - --delete --modify-window=2 --info=stats2 "$SRC/" "$DST" &> "$LOG" + --delete --modify-window=2 --info=progress2,stats2 "$SRC/" "$DST" &> "$LOG" then log "rsync failed with error $?" fi diff --git a/setup/pi/configure-web.sh b/setup/pi/configure-web.sh index f4341f44..ec30ad4f 100644 --- a/setup/pi/configure-web.sh +++ b/setup/pi/configure-web.sh @@ -53,6 +53,9 @@ then ln -s /var/www/html/favicon.ico /var/www/html/new/favicon.ico fi +# install React UI at /react/ - alternative interface with real-time sync progress +mkdir -p /var/www/html/react +cp -r "$SOURCE_DIR/teslausb-www-react/dist/"* /var/www/html/react/ cat > /sbin/mount.ctts << EOF #!/bin/bash -eu diff --git a/teslausb-www-react/.gitignore b/teslausb-www-react/.gitignore new file mode 100644 index 00000000..b7ecdd84 --- /dev/null +++ b/teslausb-www-react/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ + +# Build output - included in repo for teslausb integration +# dist/ + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local +.env.*.local + +# Session notes (local development only) +SESSION-NOTES.md + +# Release artifacts +teslausb-react-ui.tgz diff --git a/teslausb-www-react/README.md b/teslausb-www-react/README.md new file mode 100644 index 00000000..3dcb4646 --- /dev/null +++ b/teslausb-www-react/README.md @@ -0,0 +1,106 @@ +# TeslaUSB React Web UI + +A modern, React-based web interface for TeslaUSB. Built with Preact for minimal bundle size and optimized for Raspberry Pi. + +## Features + +- **Dashboard** - System status, storage visualization, real-time sync progress +- **Video Viewer** - 6-camera synchronized playback with multiple layout options +- **File Browser** - Drag-and-drop file management for Music, LightShow, and Boombox drives +- **Log Viewer** - Real-time log tailing with download option +- **Music Sync Progress** - Live progress display (percentage, speed, ETA) during music sync +- **UI Switcher** - Easy switching between Standard, Vue, and React UIs + +## Installation + +### Option 1: TeslaUSB Integration (Recommended) + +If TeslaUSB includes this UI, it will be automatically installed at `/react/` during setup. Access it at: + +``` +http:///react/ +``` + +### Option 2: Manual Tarball Installation + +Download the latest release and extract to your TeslaUSB: + +```bash +# On your Raspberry Pi +sudo /root/bin/remountfs_rw +curl -L -o /tmp/reactui.tgz https://github.com/oaquique/teslausb-www-react/releases/latest/download/teslausb-react-ui.tgz +sudo tar -C /var/www/html -xf /tmp/reactui.tgz +``` + +Then access at `http:///react/` + +### Option 3: Full Replacement via Deploy Script + +Replace the entire web UI (serves from root `/`): + +```bash +# Build and deploy to your Raspberry Pi +./deploy.sh pi@192.168.1.100 +``` + +## Switching Between UIs + +TeslaUSB supports multiple web interfaces: + +| UI | Path | Description | +|----|------|-------------| +| Standard | `/` | Original TeslaUSB interface | +| Vue | `/new/` | Vue-based alternative | +| React | `/react/` | This interface | + +Use the "Switch UI" section in the sidebar to navigate between them. + +## Development + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build + +# Build release tarball (for /react/ path) +./build-release.sh +``` + +## Tech Stack + +- **Preact** - 3KB React alternative for minimal bundle size +- **Vite** - Fast build tool with excellent tree-shaking +- **Custom CSS** - No external UI framework for maximum performance + +## Project Structure + +``` +teslausb-www-react/ +├── src/ +│ ├── components/ # UI components +│ ├── hooks/ # Custom React hooks +│ ├── services/ # API layer +│ └── styles/ # CSS +├── cgi-bin/ # CGI scripts for this UI +├── public/ # Static assets +├── deploy.sh # Full deployment script +├── rollback.sh # Restore previous UI +└── build-release.sh # Create release tarball +``` + +## Rollback + +If deployed via `deploy.sh`, you can restore the previous UI: + +```bash +./rollback.sh pi@192.168.1.100 +``` + +## License + +Same as TeslaUSB project. diff --git a/teslausb-www-react/dist/assets/index-CNaAh2IE.js b/teslausb-www-react/dist/assets/index-CNaAh2IE.js new file mode 100644 index 00000000..6bb858ce --- /dev/null +++ b/teslausb-www-react/dist/assets/index-CNaAh2IE.js @@ -0,0 +1,5 @@ +(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const a of i.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&o(a)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function o(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();var oe,N,We,V,Ne,He,Ue,Pe,me,de,he,Z={},Oe=[],pn=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ae=Array.isArray;function P(e,n){for(var r in n)e[r]=n[r];return e}function ve(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function _n(e,n,r){var o,s,i,a={};for(i in n)i=="key"?o=n[i]:i=="ref"?s=n[i]:a[i]=n[i];if(arguments.length>2&&(a.children=arguments.length>3?oe.call(arguments,2):r),typeof e=="function"&&e.defaultProps!=null)for(i in e.defaultProps)a[i]===void 0&&(a[i]=e.defaultProps[i]);return ne(e,a,o,s,null)}function ne(e,n,r,o,s){var i={type:e,props:n,key:r,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s==null?++We:s,__i:-1,__u:0};return s==null&&N.vnode!=null&&N.vnode(i),i}function M(e){return e.children}function te(e,n){this.props=e,this.context=n}function Y(e,n){if(n==null)return e.__?Y(e.__,e.__i+1):null;for(var r;nl&&V.sort(Ue),e=V.shift(),l=V.length,e.__d&&(r=void 0,o=void 0,s=(o=(n=e).__v).__e,i=[],a=[],n.__P&&((r=P({},o)).__v=o.__v+1,N.vnode&&N.vnode(r),ge(n.__P,r,o,n.__n,n.__P.namespaceURI,32&o.__u?[s]:null,i,s==null?Y(o):s,!!(32&o.__u),a),r.__v=o.__v,r.__.__k[r.__i]=r,Ge(i,r,a),o.__e=o.__=null,r.__e!=s&&Ve(r)));ie.__r=0}function ze(e,n,r,o,s,i,a,l,h,c,f){var d,u,p,g,v,k,b,_=o&&o.__k||Oe,y=n.length;for(h=mn(r,n,_,h,y),d=0;d0?a=e.__k[i]=ne(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):e.__k[i]=a,h=i+u,a.__=e,a.__b=e.__b+1,(c=a.__i=vn(a,r,h,d))!=-1&&(d--,(l=r[c])&&(l.__u|=2)),l==null||l.__v==null?(c==-1&&(s>f?u--:sh?u--:u++,a.__u|=4))):e.__k[i]=null;if(d)for(i=0;i(f?1:0)){for(s=r-1,i=r+1;s>=0||i=0?s--:i++])!=null&&(2&c.__u)==0&&l==c.key&&h==c.type)return a}return-1}function Ce(e,n,r){n[0]=="-"?e.setProperty(n,r==null?"":r):e[n]=r==null?"":typeof r!="number"||pn.test(n)?r:r+"px"}function Q(e,n,r,o,s){var i,a;e:if(n=="style")if(typeof r=="string")e.style.cssText=r;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(n in o)r&&n in r||Ce(e.style,n,"");if(r)for(n in r)o&&r[n]==o[n]||Ce(e.style,n,r[n])}else if(n[0]=="o"&&n[1]=="n")i=n!=(n=n.replace(Pe,"$1")),a=n.toLowerCase(),n=a in e||n=="onFocusOut"||n=="onFocusIn"?a.slice(2):n.slice(2),e.l||(e.l={}),e.l[n+i]=r,r?o?r.u=o.u:(r.u=me,e.addEventListener(n,i?he:de,i)):e.removeEventListener(n,i?he:de,i);else{if(s=="http://www.w3.org/2000/svg")n=n.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(n!="width"&&n!="height"&&n!="href"&&n!="list"&&n!="form"&&n!="tabIndex"&&n!="download"&&n!="rowSpan"&&n!="colSpan"&&n!="role"&&n!="popover"&&n in e)try{e[n]=r==null?"":r;break e}catch(l){}typeof r=="function"||(r==null||r===!1&&n[4]!="-"?e.removeAttribute(n):e.setAttribute(n,n=="popover"&&r==1?"":r))}}function Se(e){return function(n){if(this.l){var r=this.l[n.type+e];if(n.t==null)n.t=me++;else if(n.t0?e:ae(e)?e.map(Ke):P({},e)}function gn(e,n,r,o,s,i,a,l,h){var c,f,d,u,p,g,v,k=r.props||Z,b=n.props,_=n.type;if(_=="svg"?s="http://www.w3.org/2000/svg":_=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),i!=null){for(c=0;c=r.__.length&&r.__.push({}),r.__[e]}function w(e){return X=1,wn(Xe,e)}function wn(e,n,r){var o=be(J++,2);if(o.t=e,!o.__c&&(o.__=[Xe(void 0,n),function(l){var h=o.__N?o.__N[0]:o.__[0],c=o.t(h,l);h!==c&&(o.__N=[c,o.__[1]],o.__c.setState({}))}],o.__c=L,!L.__f)){var s=function(l,h,c){if(!o.__c.__H)return!0;var f=o.__c.__H.__.filter(function(u){return!!u.__c});if(f.every(function(u){return!u.__N}))return!i||i.call(this,l,h,c);var d=o.__c.props!==l;return f.forEach(function(u){if(u.__N){var p=u.__[0];u.__=u.__N,u.__N=void 0,p!==u.__[0]&&(d=!0)}}),i&&i.call(this,l,h,c)||d};L.__f=!0;var i=L.shouldComponentUpdate,a=L.componentWillUpdate;L.componentWillUpdate=function(l,h,c){if(this.__e){var f=i;i=void 0,s(l,h,c),i=f}a&&a.call(this,l,h,c)},L.shouldComponentUpdate=s}return o.__N||o.__}function F(e,n){var r=be(J++,3);!T.__s&&Je(r.__H,n)&&(r.__=e,r.u=n,L.__H.__h.push(r))}function H(e){return X=5,se(function(){return{current:e}},[])}function se(e,n){var r=be(J++,7);return Je(r.__H,n)&&(r.__=e(),r.__H=n,r.__h=e),r.__}function B(e,n){return X=8,se(function(){return e},n)}function Nn(){for(var e;e=Ze.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(re),e.__H.__h.forEach(fe),e.__H.__h=[]}catch(n){e.__H.__h=[],T.__e(n,e.__v)}}T.__b=function(e){L=null,Te&&Te(e)},T.__=function(e,n){e&&n.__k&&n.__k.__m&&(e.__m=n.__k.__m),Fe&&Fe(e,n)},T.__r=function(e){$e&&$e(e),J=0;var n=(L=e.__c).__H;n&&(ce===L?(n.__h=[],L.__h=[],n.__.forEach(function(r){r.__N&&(r.__=r.__N),r.u=r.__N=void 0})):(n.__h.forEach(re),n.__h.forEach(fe),n.__h=[],J=0)),ce=L},T.diffed=function(e){Be&&Be(e);var n=e.__c;n&&n.__H&&(n.__H.__h.length&&(Ze.push(n)!==1&&Le===T.requestAnimationFrame||((Le=T.requestAnimationFrame)||xn)(Nn)),n.__H.__.forEach(function(r){r.u&&(r.__H=r.u),r.u=void 0})),ce=L=null},T.__c=function(e,n){n.some(function(r){try{r.__h.forEach(re),r.__h=r.__h.filter(function(o){return!o.__||fe(o)})}catch(o){n.some(function(s){s.__h&&(s.__h=[])}),n=[],T.__e(o,r.__v)}}),Ie&&Ie(e,n)},T.unmount=function(e){Me&&Me(e);var n,r=e.__c;r&&r.__H&&(r.__H.__.forEach(function(o){try{re(o)}catch(s){n=s}}),r.__H=void 0,n&&T.__e(n,r.__v))};var Ae=typeof requestAnimationFrame=="function";function xn(e){var n,r=function(){clearTimeout(o),Ae&&cancelAnimationFrame(n),setTimeout(e)},o=setTimeout(r,35);Ae&&(n=requestAnimationFrame(r))}function re(e){var n=L,r=e.__c;typeof r=="function"&&(e.__c=void 0,r()),L=n}function fe(e){var n=L;e.__c=e.__(),L=n}function Je(e,n){return!e||e.length!==n.length||n.some(function(r,o){return r!==e[o]})}function Xe(e,n){return typeof n=="function"?n(e):n}const D="/cgi-bin";async function Cn(){const e=await fetch(`${D}/status.sh`);if(!e.ok)throw new Error("Failed to fetch status");return e.json()}async function Sn(){const e=await fetch(`${D}/config.sh`);if(!e.ok)throw new Error("Failed to fetch config");return e.json()}async function Ln(){const e=await fetch(`${D}/storage.sh`);if(!e.ok)throw new Error("Failed to fetch storage");return e.json()}async function Tn(){const e=await fetch(`${D}/videolist.sh`);if(!e.ok)throw new Error("Failed to fetch video list");const n=await e.text();return $n(n)}function $n(e){const n=e.trim().split(` +`).filter(Boolean),r={RecentClips:{},SavedClips:{},SentryClips:{}};for(const o of n){const s=o.split("/");if(s.length>=2){const[i,...a]=s;if(r[i]){const l=a[0];r[i][l]||(r[i][l]=[]),a.length>1&&r[i][l].push(a.slice(1).join("/"))}}}return r}async function Bn(){if(!(await fetch(`${D}/trigger_sync.sh`)).ok)throw new Error("Failed to trigger sync")}async function In(){const e=await fetch(`${D}/music_sync_progress.sh`);if(!e.ok)throw new Error("Failed to fetch music sync progress");return e.json()}async function Mn(){const e=await fetch(`${D}/cam_sync_progress.sh`);if(!e.ok)throw new Error("Failed to fetch cam sync progress");return e.json()}async function Fn(){if(!(await fetch(`${D}/toggledrives.sh`)).ok)throw new Error("Failed to toggle drives")}async function An(){if(!(await fetch(`${D}/reboot.sh`)).ok)throw new Error("Failed to reboot")}async function En(){return(await fetch(`${D}/pairBLEkey.sh`)).status===202}async function Dn(){return(await(await fetch(`${D}/checkBLEstatus.sh`)).text()).includes("

paired

")}async function Rn(){const e=await fetch(`${D}/diagnose.sh`);if(await e.text(),!e.ok)throw new Error("Failed to generate diagnostics")}async function jn(){const e=await fetch("/diagnostics.txt");if(!e.ok)throw new Error("Failed to fetch diagnostics");return e.text()}async function Wn(e,n=0){if(n>0){const a=await fetch(`/${e}`,{method:"HEAD"});if(a.ok){const l=parseInt(a.headers.get("Content-Length")||"0",10);if(l<=n)return l0&&(r.Range=`bytes=${n}-`);const o=await fetch(`/${e}`,{headers:r});if(o.status===416)return{content:"",size:n,truncated:!1};if(o.status===404)throw new Error("Log file not found");if(!o.ok&&o.status!==206)throw new Error(`Failed to fetch log: ${o.status}`);const s=await o.text();let i=n;if(o.status===206){const a=o.headers.get("Content-Range");if(a){const l=a.match(/bytes \d+-(\d+)\/(\d+)/);l?i=parseInt(l[1],10)+1:i=n+s.length}else i=n+s.length}else i=s.length;return{content:s,size:i,truncated:!1}}async function Hn(e,n){const r=await fetch(`${D}/randomdata.sh`,{signal:n});if(!r.ok)throw new Error("Failed to start speed test");const o=r.body.getReader();let s=0;const i=performance.now();try{for(;;){const{done:l,value:h}=await o.read();if(l)break;s+=h.length;const c=(performance.now()-i)/1e3,f=s*8/(c*1e6);e&&e(f)}}catch(l){if(l.name!=="AbortError")throw l}const a=(performance.now()-i)/1e3;return s*8/(a*1e6)}function Un(e,n,r){return`/TeslaCam/${e}/${n}/${r}`}function Pn(e){return`/TeslaCam/SentryClips/${e}/event.json`}async function On(e){try{const n=await fetch(Pn(e));return n.ok?n.json():null}catch(n){return null}}function Vn(e=5e3){const[n,r]=w(null),[o,s]=w(null),[i,a]=w(null),[l,h]=w(!0),[c,f]=w(null),[d,u]=w(null),p=B(async()=>{try{const[v,k,b]=await Promise.all([Cn(),Sn(),Ln().catch(()=>null)]);r(v),s(k),a(b),u(new Date),f(null)}catch(v){f(v.message)}finally{h(!1)}},[]);F(()=>{p();const v=setInterval(p,e);return()=>clearInterval(v)},[p,e]);const g=n?{cpuTempC:n.cpu_temp?(parseInt(n.cpu_temp,10)/1e3).toFixed(1):null,uptimeFormatted:zn(parseInt(n.uptime||"0",10)),diskUsedPercent:n.total_space&&n.free_space?Math.round((parseInt(n.total_space,10)-parseInt(n.free_space,10))/parseInt(n.total_space,10)*100):0,diskUsedGB:n.total_space&&n.free_space?((parseInt(n.total_space,10)-parseInt(n.free_space,10))/(1024*1024*1024)).toFixed(1):"0",diskTotalGB:n.total_space?(parseInt(n.total_space,10)/(1024*1024*1024)).toFixed(1):"0",diskFreeGB:n.free_space?(parseInt(n.free_space,10)/(1024*1024*1024)).toFixed(1):"0",drivesActive:n.drives_active==="yes",wifiConnected:!!n.wifi_ssid&&n.wifi_ssid!=="",wifiSignalPercent:qn(n.wifi_strength),ethernetConnected:!!n.ether_ip&&n.ether_ip!=="",snapshotCount:parseInt(n.num_snapshots||"0",10)}:null;return{status:n,config:o,storage:i,computed:g,loading:l,error:c,lastUpdate:d,refresh:p}}function zn(e){if(!e||isNaN(e))return"0s";const n=Math.floor(e/86400),r=Math.floor(e%86400/3600),o=Math.floor(e%3600/60),s=e%60,i=[];return n>0&&i.push(`${n}d`),r>0&&i.push(`${r}h`),o>0&&i.push(`${o}m`),(s>0||i.length===0)&&i.push(`${s}s`),i.join(" ")}function qn(e){if(!e)return 0;const n=e.split("/");if(n.length!==2)return 0;const[r,o]=n.map(Number);return isNaN(r)||isNaN(o)||o===0?0:Math.round(r/o*100)}function Gn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H8L6 10l-2.5 1.1C2.7 11.3 2 12.1 2 13v3c0 .6.4 1 1 1h2"}),t("circle",{cx:"7",cy:"17",r:"2"}),t("circle",{cx:"17",cy:"17",r:"2"})]})}function Qe({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("rect",{x:"3",y:"3",width:"7",height:"7",rx:"1"}),t("rect",{x:"14",y:"3",width:"7",height:"7",rx:"1"}),t("rect",{x:"3",y:"14",width:"7",height:"7",rx:"1"}),t("rect",{x:"14",y:"14",width:"7",height:"7",rx:"1"})]})}function Kn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polygon",{points:"23 7 16 12 23 17 23 7"}),t("rect",{x:"1",y:"5",width:"15",height:"14",rx:"2",ry:"2"})]})}function Yn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:t("path",{d:"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"})})}function pe({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polyline",{points:"23 4 23 10 17 10"}),t("path",{d:"M20.49 15a9 9 0 1 1-2.12-9.36L23 10"})]})}function Zn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"currentColor",children:t("polygon",{points:"5 3 19 12 5 21 5 3"})})}function Jn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"currentColor",children:[t("rect",{x:"6",y:"4",width:"4",height:"16"}),t("rect",{x:"14",y:"4",width:"4",height:"16"})]})}function Xn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polygon",{points:"19 20 9 12 19 4 19 20"}),t("line",{x1:"5",y1:"19",x2:"5",y2:"5"})]})}function Qn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polygon",{points:"5 4 15 12 5 20 5 4"}),t("line",{x1:"19",y1:"5",x2:"19",y2:"19"})]})}function et({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}),t("polyline",{points:"7 10 12 15 17 10"}),t("line",{x1:"12",y1:"15",x2:"12",y2:"3"})]})}function nt({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polyline",{points:"3 6 5 6 21 6"}),t("path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"})]})}function tt({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M5 12.55a11 11 0 0 1 14.08 0"}),t("path",{d:"M1.42 9a16 16 0 0 1 21.16 0"}),t("path",{d:"M8.53 16.11a6 6 0 0 1 6.95 0"}),t("line",{x1:"12",y1:"20",x2:"12.01",y2:"20"})]})}function Ee({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("line",{x1:"22",y1:"12",x2:"2",y2:"12"}),t("path",{d:"M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"}),t("line",{x1:"6",y1:"16",x2:"6.01",y2:"16"}),t("line",{x1:"10",y1:"16",x2:"10.01",y2:"16"})]})}function rt({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("rect",{x:"4",y:"4",width:"16",height:"16",rx:"2",ry:"2"}),t("rect",{x:"9",y:"9",width:"6",height:"6"}),t("line",{x1:"9",y1:"1",x2:"9",y2:"4"}),t("line",{x1:"15",y1:"1",x2:"15",y2:"4"}),t("line",{x1:"9",y1:"20",x2:"9",y2:"23"}),t("line",{x1:"15",y1:"20",x2:"15",y2:"23"}),t("line",{x1:"20",y1:"9",x2:"23",y2:"9"}),t("line",{x1:"20",y1:"14",x2:"23",y2:"14"}),t("line",{x1:"1",y1:"9",x2:"4",y2:"9"}),t("line",{x1:"1",y1:"14",x2:"4",y2:"14"})]})}function it({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("circle",{cx:"4",cy:"20",r:"1"}),t("circle",{cx:"20",cy:"20",r:"1"}),t("circle",{cx:"12",cy:"10",r:"1"}),t("path",{d:"M12 3v7"}),t("path",{d:"M4 20v-5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5"}),t("path",{d:"M12 10v9"}),t("path",{d:"M7 10l5-7 5 7"})]})}function st({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M21 2v6h-6"}),t("path",{d:"M3 12a9 9 0 0 1 15-6.7L21 8"}),t("path",{d:"M3 22v-6h6"}),t("path",{d:"M21 12a9 9 0 0 1-15 6.7L3 16"})]})}function ot({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M18.36 6.64a9 9 0 1 1-12.73 0"}),t("line",{x1:"12",y1:"2",x2:"12",y2:"12"})]})}function en({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:t("polyline",{points:"6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5"})})}function nn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polyline",{points:"4 17 10 11 4 5"}),t("line",{x1:"12",y1:"19",x2:"20",y2:"19"})]})}function at({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("circle",{cx:"12",cy:"12",r:"10"}),t("line",{x1:"12",y1:"16",x2:"12",y2:"12"}),t("line",{x1:"12",y1:"8",x2:"12.01",y2:"8"})]})}function tn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:t("polyline",{points:"6 9 12 15 18 9"})})}function lt({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"}),t("circle",{cx:"12",cy:"10",r:"3"})]})}function ct({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M9 18V5l12-2v13"}),t("circle",{cx:"6",cy:"18",r:"3"}),t("circle",{cx:"18",cy:"16",r:"3"})]})}function rn({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"}),t("circle",{cx:"12",cy:"13",r:"4"})]})}function dt({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("path",{d:"M19.4 14.9C20.2 13.4 20.6 11.7 20.6 10c0-5-4-9-9-9s-9 4-9 9 4 9 9 9c1.7 0 3.4-.4 4.9-1.2"}),t("path",{d:"M11.6 10l6.4 6.4"})]})}function ht({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2",ry:"2"}),t("line",{x1:"3",y1:"9",x2:"21",y2:"9"}),t("line",{x1:"9",y1:"21",x2:"9",y2:"9"})]})}function ut({className:e}){return t("svg",{className:e,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[t("polyline",{points:"17 1 21 5 17 9"}),t("path",{d:"M3 11V9a4 4 0 0 1 4-4h14"}),t("polyline",{points:"7 23 3 19 7 15"}),t("path",{d:"M21 13v2a4 4 0 0 1-4 4H3"})]})}const ft={dashboard:Qe,viewer:Kn,files:Yn,logs:nn};function pt({tabs:e,activeTab:n,onTabChange:r,lastUpdate:o,onRefresh:s}){const i=a=>a?a.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}):"";return t("header",{className:"app-header",children:[t("div",{className:"app-header-left",children:t("nav",{className:"dashboard-nav",children:e.map(a=>{const l=ft[a.id]||Qe;return t("button",{className:`nav-link ${n===a.id?"active":""}`,onClick:()=>r(a.id),children:[t(l,{}),t("span",{children:a.label})]},a.id)})})}),t("div",{className:"app-header-right desktop-status-bar",children:[t("span",{className:"app-last-update",style:{marginRight:"12px"},children:o&&`Updated ${i(o)}`}),t("button",{className:"btn",onClick:s,children:[t(pe,{}),t("span",{children:"Refresh"})]})]})]})}function _t({status:e,computed:n,config:r,expanded:o,onToggle:s,onRefresh:i}){const[a,l]=w(!1),[h,c]=w(!1),[f,d]=w(!1),[u,p]=w(null),[g,v]=w(null),k=async()=>{l(!0);try{await Fn(),setTimeout(i,1e3)}catch(x){console.error("Toggle drives failed:",x)}finally{l(!1)}},b=async()=>{if(confirm("Are you sure you want to restart TeslaUSB?")){c(!0);try{await An()}catch(x){console.error("Reboot failed:",x)}}},_=async()=>{d(!0),p(null);const x=new AbortController,$=setTimeout(()=>x.abort(),1e4);try{await Hn(S=>p(S.toFixed(1)),x.signal)}catch(S){S.name!=="AbortError"&&console.error("Speed test failed:",S)}finally{clearTimeout($),d(!1)}},y=async()=>{v("pairing");try{if(await En()){for(let $=0;$<60;$++)if(await new Promise(E=>setTimeout(E,2e3)),await Dn()){v("paired");return}v("timeout")}else v("error")}catch(x){console.error("BLE pairing failed:",x),v("error")}};return t("aside",{className:`app-sidebar ${o?"expanded":""}`,children:[t("div",{className:"device-info",children:[t("div",{className:"device-header",children:[t(rt,{}),t("span",{children:"System"}),t("button",{className:"sidebar-toggle-btn",onClick:s,children:t(tn,{className:o?"rotate-180":""})})]}),t("div",{className:"info-list",children:[(e==null?void 0:e.device_model)&&t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Model"}),t("span",{className:"info-value small-text",children:e.device_model})]}),t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Uptime"}),t("span",{className:"info-value",children:(n==null?void 0:n.uptimeFormatted)||"-"})]}),t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"CPU Temp"}),t("span",{className:`info-value ${mt(n==null?void 0:n.cpuTempC)}`,children:n!=null&&n.cpuTempC?`${n.cpuTempC}°C`:"-"})]}),t("div",{className:"info-item clickable",onClick:k,children:[t("span",{className:"info-label",children:"USB Drives"}),t("button",{className:`toggle-btn ${n!=null&&n.drivesActive?"active":"danger"}`,disabled:a,children:[a&&t(it,{style:{width:12,height:12},className:"spinning"}),n!=null&&n.drivesActive?"Disconnect from host":"Connect to host"]})]}),t("div",{className:"info-item clickable",onClick:b,children:[t("span",{className:"info-label",children:"Power"}),t("button",{className:"toggle-btn danger",disabled:h,children:[h&&t(ot,{style:{width:12,height:12},className:"spinning"}),"Restart"]})]})]})]}),t("div",{className:"device-info",children:[t("div",{className:"device-header",children:[t(tt,{}),t("span",{children:"Network"})]}),t("div",{className:"info-list",children:[(e==null?void 0:e.wifi_ssid)&&t(M,{children:[t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"WiFi SSID"}),t("span",{className:"info-value",children:e.wifi_ssid})]}),(e==null?void 0:e.wifi_freq)&&t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Frequency"}),t("span",{className:"info-value",children:vt(e.wifi_freq)})]}),t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Signal"}),t("span",{className:"info-value",children:[(n==null?void 0:n.wifiSignalPercent)||0,"%"]})]}),t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"IP Address"}),t("span",{className:"info-value small-text",children:e.wifi_ip||"-"})]})]}),(e==null?void 0:e.ether_ip)&&t(M,{children:[t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Ethernet"}),t("span",{className:"info-value",children:e.ether_speed||"Connected"})]}),t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"IP Address"}),t("span",{className:"info-value small-text",children:e.ether_ip})]})]}),!(e!=null&&e.wifi_ssid)&&!(e!=null&&e.ether_ip)&&t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Status"}),t("span",{className:"info-value status-unhealthy",children:"Not Connected"})]}),t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Speed Test"}),t("span",{className:"info-value-with-action",children:[u&&t("span",{className:"speed-result",children:[u," Mbps"]}),t("button",{className:"toggle-btn",onClick:_,disabled:f,children:[f&&t(dt,{style:{width:12,height:12},className:"spinning"}),f?"Testing...":"Run"]})]})]}),(r==null?void 0:r.uses_ble)==="yes"&&t("div",{className:"info-item clickable",onClick:y,children:[t("span",{className:"info-label",children:"Bluetooth"}),t("button",{className:"toggle-btn",disabled:g==="pairing",children:[g==="pairing"&&t(en,{style:{width:12,height:12},className:"spinning"}),g==="pairing"?"Pairing...":g==="paired"?"Paired":g==="error"?"Failed":"Pair"]})]})]})]}),(n==null?void 0:n.snapshotCount)>0&&t("div",{className:"device-info",children:[t("div",{className:"device-header",children:[t(rn,{}),t("span",{children:"Snapshots"})]}),t("div",{className:"info-list",children:[t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Count"}),t("span",{className:"info-value",children:n.snapshotCount})]}),(e==null?void 0:e.snapshot_oldest)&&t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Oldest"}),t("span",{className:"info-value compact-date",children:De(e.snapshot_oldest)})]}),(e==null?void 0:e.snapshot_newest)&&t("div",{className:"info-item",children:[t("span",{className:"info-label",children:"Newest"}),t("span",{className:"info-value compact-date",children:De(e.snapshot_newest)})]})]})]}),t("div",{className:"device-info",children:[t("div",{className:"device-header",children:[t(ut,{}),t("span",{children:"Switch UI"})]}),t("div",{className:"info-list",children:[t("div",{className:"info-item",children:t("a",{href:"/",className:"ui-switch-link",children:"Standard UI"})}),t("div",{className:"info-item",children:t("a",{href:"/new/",className:"ui-switch-link",children:"Vue UI"})})]})]})]})}function mt(e){if(!e)return"";const n=parseFloat(e);return n>=80?"status-unhealthy":n>=70?"status-degraded":"status-healthy"}function De(e){if(!e)return"-";try{return new Date(parseInt(e,10)*1e3).toLocaleDateString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch(n){return e}}function vt(e){if(!e)return"-";const n=e.match(/(\d+\.?\d*)/);if(n){const r=parseFloat(n[1]),o=t("span",{style:{fontWeight:"normal"},children:[" (",r.toFixed(3),")"]});return r>=2.4&&r<2.5?t(M,{children:["2.4 GHz",o]}):r>=5&&r<6?t(M,{children:["5 GHz",o]}):r>=6?t(M,{children:["6 GHz",o]}):`${r} GHz`}return e}function sn(e,n=2e3,r=!0){const[o,s]=w([]),[i,a]=w(!0),[l,h]=w(null),c=H(0),f=H(!0),d=H(e),u=B(async(v=!1)=>{if(e)try{v&&(c.current=0);const k=await Wn(e,c.current);if(k.truncated){c.current=0,s([]);return}if(k.content){const b=k.content.split(` +`).filter(Boolean);s(v?b.slice(-1e3):_=>[..._,...b].slice(-1e3))}c.current=k.size,h(null)}catch(k){h(k.message)}finally{a(!1)}},[e]);F(()=>{e!==d.current&&(d.current=e,c.current=0,s([]),a(!0),h(null))},[e]),F(()=>{if(!r||!e)return;u(!0);const v=setInterval(()=>u(!1),n);return()=>clearInterval(v)},[e,n,r]);const p=B(v=>{f.current=v},[]),g=B(()=>{s([]),c.current=0},[]);return{lines:o,loading:i,error:l,autoScroll:f.current,setAutoScroll:p,refresh:()=>u(!1),clear:g}}function gt(e){const n={state:"idle",totalFiles:0,archivedFiles:0,currentFile:null,startTime:null,elapsedTime:null,lastActivity:null,message:null};if(!e||e.length===0)return n;const r=e.slice(-100);r.length>0&&(n.lastActivity=yt(r[r.length-1]));for(let s=r.length-1;s>=0;s--){const i=r[s],a=i.match(/There are (\d+) event folder\(s\) with (\d+) file\(s\)(?: and (\d+) track mode file\(s\))?/);if(a){const h=parseInt(a[2],10),c=a[3]?parseInt(a[3],10):0;n.totalFiles=h+c;break}const l=i.match(/Archiving (\d+)(?: track mode)? file\(s\)/);l&&!i.includes("completed")&&(n.totalFiles=parseInt(l[1],10))}let o=!1;for(let s=r.length-1;s>=0;s--){const i=r[s];if(i.includes("Finished copying music")||i.includes("Copying music failed"))break;if(i.includes("Syncing music from archive")||i.includes("Starting music sync")){o=!0;break}if(i.includes("Connected usb to host")||i.includes("Waiting for archive to be unreachable"))break}if(o)return n.state="archiving",n.message="Syncing music...",n;for(let s=r.length-1;s>=0;s--){const i=r[s];if(i.includes("Archiving completed successfully")){n.state="complete",n.message="Archive completed";break}if(i.includes("Finished copying music")){n.state="complete",n.message="Music sync complete";break}if(i.includes("Copied")&&i.includes("file(s)")){const a=i.match(/Copied (\d+)/);if(a){n.state="complete",n.archivedFiles=parseInt(a[1],10),n.message=`Copied ${n.archivedFiles} files`;break}}if(i.includes("Starting recording archiving")){n.state="archiving",n.message=n.totalFiles>0?`Archiving ${n.totalFiles} files...`:"Archiving recordings...";break}if(i.includes("Archiving...")){n.state="archiving",n.message=n.totalFiles>0?`Archiving ${n.totalFiles} files...`:"Archiving files...";break}if(i.includes("Syncing music from archive")){n.state="archiving",n.message="Syncing music...";break}if(i.includes("Copying music")){n.state="archiving",n.message="Copying music...";break}if(i.includes("Finished archiving")){n.state="complete",n.message="Archive complete";break}if(i.includes("Running fsck")){n.state="archiving",n.message="Checking filesystem...";break}if(i.includes("Checking saved folder count")){n.state="archiving",n.message="Scanning files...";break}if(i.includes("Waiting for archive to be reachable")){n.state="connecting",n.message="Connecting to archive server...";break}if(i.includes("Archive is reachable")){n.state="archiving",n.message="Connected, preparing...";break}if(i.includes("Disconnecting usb from host")){n.state="archiving",n.message="Disconnecting from vehicle...";break}if(i.includes("Connected usb to host")){n.state="idle",n.message="Connected to vehicle";break}if(i.includes("Waiting for archive to be unreachable")){n.state="idle",n.message="Connected to vehicle";break}if(i.includes("snapshot")){n.state="idle",n.message="Managing snapshots...";break}if(i.includes("low space, deleting")){n.state="idle",n.message="Cleaning up old snapshots...";break}if(i.includes("waiting up to")&&i.includes("idle interval")){n.state="idle",n.message="Waiting for idle...";break}if(i.includes("mass storage process")){n.state="idle",n.message="Ready";break}if((i.includes("error")||i.includes("failed"))&&!i.includes("sntp failed")){n.state="error",n.message="Error occurred";break}}return n}function yt(e){const n=e.match(/^([A-Z][a-z]{2}\s+\d+\s+[A-Z][a-z]{2}\s+\d+:\d+:\d+\s+\w+\s+\d+):/);if(n){const r=new Date(n[1]);if(!isNaN(r.getTime()))return r}return null}function bt(e=!1,n=1500){const[r,o]=w({active:!1,bytesTransferred:0,percentage:0,speed:"",eta:""}),[s,i]=w(!1),[a,l]=w(null),h=H(e),c=B(async()=>{try{const f=await In();o(f),l(null)}catch(f){l(f.message)}finally{i(!1)}},[]);return F(()=>{h.current=e},[e]),F(()=>{if(!e){o({active:!1,bytesTransferred:0,percentage:0,speed:"",eta:""});return}i(!0),c();const f=setInterval(()=>{h.current&&c()},n);return()=>clearInterval(f)},[e,n,c]),{...r,loading:s,error:a,refresh:c}}function Re(e){if(e===0)return"0 B";const n=["B","KB","MB","GB","TB"],r=1024,o=Math.floor(Math.log(e)/Math.log(r));return`${(e/Math.pow(r,o)).toFixed(o>0?2:0)} ${n[o]}`}function kt(e){return!e||e==="0:00:00"?"":`~${e.replace(/^0:/,"")} remaining`}function wt(e=!1,n=1500){const[r,o]=w({active:!1,bytesTransferred:0,percentage:0,speed:"",eta:"",filesDone:0,filesTotal:0,currentFile:""}),[s,i]=w(!1),[a,l]=w(null),h=H(e),c=B(async()=>{try{const f=await Mn();o(f),l(null)}catch(f){l(f.message)}finally{i(!1)}},[]);return F(()=>{h.current=e},[e]),F(()=>{if(!e){o({active:!1,bytesTransferred:0,percentage:0,speed:"",eta:"",filesDone:0,filesTotal:0,currentFile:""});return}i(!0),c();const f=setInterval(()=>{h.current&&c()},n);return()=>clearInterval(f)},[e,n,c]),{...r,loading:s,error:a,refresh:c}}function Nt({storage:e,total:n,free:r,config:o}){var d;const s=u=>{if(u===0||u===null||u===void 0)return"0 B";const p=u/(1024*1024*1024);return p>=1?`${p.toFixed(1)} GB`:`${(u/(1024*1024)).toFixed(0)} MB`},i=((d=e==null?void 0:e.total)==null?void 0:d.free)||r,a=[],l=(u,p,g,v)=>{(o==null?void 0:o[v])!=="yes"||!u||a.push({type:p,label:g,allocated:u.total,used:u.used,mounted:u.mounted,isCached:u.cached||!1})};if(l(e==null?void 0:e.cam,"teslacam","TeslaCam","has_cam"),l(e==null?void 0:e.music,"music","Music","has_music"),l(e==null?void 0:e.lightshow,"lightshow","LightShow","has_lightshow"),l(e==null?void 0:e.boombox,"boombox","Boombox","has_boombox"),a.length===0&&!i)return t("div",{className:"storage-bar-container",children:t("div",{className:"storage-info",children:"No storage data available"})});const c=a.reduce((u,p)=>u+(p.allocated||0),0)+i,f=a.map(u=>({type:u.type,label:u.label,bytes:u.allocated,percent:Math.max(1,Math.round(u.allocated/c*100))}));return i>0&&f.push({type:"free",label:"Free",bytes:i,percent:Math.max(1,Math.round(i/c*100))}),t("div",{className:"storage-bar-container",children:[t("div",{className:"storage-bar",children:f.map((u,p)=>t("div",{className:`storage-segment ${u.type}`,style:{width:`${u.percent}%`},title:`${u.label}: ${s(u.bytes)}`},p))}),t("div",{className:"storage-legend",children:[a.map((u,p)=>{const g=u.used!==null&&u.allocated>0?Math.round(u.used/u.allocated*100):null;return t("div",{className:"storage-legend-item",children:[t("div",{className:`storage-legend-dot ${u.type}`}),t("span",{className:"storage-legend-label",children:u.label}),t("span",{className:"storage-legend-value",children:[s(u.allocated),u.used!==null&&t("span",{className:"storage-legend-used",children:[" ","(",s(u.used)," used",u.isCached?"~":"",g!==null&&`, ${g}%`,")"]})]})]},p)}),t("div",{className:"storage-legend-item",children:[t("div",{className:"storage-legend-dot free"}),t("span",{className:"storage-legend-label",children:"Free"}),t("span",{className:"storage-legend-value",children:s(i)})]})]}),a.some(u=>u.isCached)&&t("div",{className:"storage-note",children:"~ Last known (drive not currently mounted)"})]})}function xt({syncStatus:e,onTriggerSync:n,loading:r,musicProgress:o,camProgress:s}){const{state:i,message:a,elapsedTime:l,lastActivity:h}=e,c=(a==null?void 0:a.toLowerCase().includes("music"))&&i==="archiving",f=i==="archiving"&&!c,d=o==null?void 0:o.active,u=s==null?void 0:s.active,g=(()=>{switch(i){case"idle":return{label:"Idle",color:"idle",description:"Ready to archive"};case"connecting":return{label:"Connecting",color:"connecting",description:a||"Connecting to server..."};case"archiving":return{label:"Archiving",color:"archiving",description:a||"Syncing files..."};case"complete":return{label:"Complete",color:"idle",description:a||"Archive complete"};case"error":return{label:"Error",color:"error",description:a||"Archive failed"};default:return{label:"Unknown",color:"idle",description:"Status unknown"}}})(),v=i==="archiving"||i==="connecting",k=c&&d,b=f&&u;let _=null,y=null;k&&o.percentage>0?(_=o.percentage,y={type:"music",bytesTransferred:o.bytesTransferred,speed:o.speed,eta:o.eta}):b&&(s.percentage>0?_=s.percentage:s.filesTotal>0&&s.filesDone>0&&(_=Math.min(Math.round(s.filesDone/s.filesTotal*100),100)),y={type:"cam",bytesTransferred:s.bytesTransferred,speed:s.speed,eta:s.eta,filesDone:s.filesDone,filesTotal:s.filesTotal,currentFile:s.currentFile});const x=$=>{if(!$)return null;const E=Math.floor((new Date-$)/1e3);return E<60?"just now":E<3600?`${Math.floor(E/60)}m ago`:E<86400?`${Math.floor(E/3600)}h ago`:$.toLocaleDateString()};return t("div",{className:"sync-status-card",children:[t("div",{className:"sync-status-header",children:[t("span",{className:"sync-status-title",children:"Sync Status"}),t("div",{className:"sync-status-indicator",children:[t("div",{className:`sync-status-dot ${g.color}`}),t("span",{children:g.label})]})]}),t("div",{className:"sync-status-description",children:g.description}),v&&t("div",{className:"sync-progress-bar",children:t("div",{className:"sync-progress-fill",style:{width:_!==null?`${_}%`:"100%",animation:_===null?"pulse 1.5s ease-in-out infinite":"none",opacity:_===null?.6:1}})}),v&&y&&t("div",{className:"sync-details",children:[t("div",{className:"sync-progress-main",children:y.type==="music"?t(M,{children:[Re(y.bytesTransferred)," transferred",_!==null&&` (${_}%)`]}):t(M,{children:y.bytesTransferred>0?t(M,{children:[Re(y.bytesTransferred)," transferred",_!==null&&` (${_}%)`]}):y.filesTotal>0?t(M,{children:[y.filesDone," / ",y.filesTotal," files",_!==null&&` (${_}%)`]}):"Archiving..."})}),(y.speed||y.eta)&&t("div",{className:"sync-progress-secondary",children:[y.speed&&t("span",{children:y.speed}),y.speed&&y.eta&&t("span",{children:" · "}),y.eta&&t("span",{children:kt(y.eta)})]})]}),v&&!y&&i==="archiving"&&t("div",{className:"sync-details",children:t("div",{className:"sync-progress-main",children:"Preparing..."})}),!v&&h&&t("div",{className:"sync-details",children:["Last sync: ",x(h)]}),l&&i==="complete"&&t("div",{className:"sync-details",children:["Completed in ",l]}),(i==="idle"||i==="complete"||i==="error")&&t("button",{className:"btn btn-primary btn-sm",onClick:n,disabled:r,style:{marginTop:"0.75rem"},children:[t(st,{style:{width:14,height:14},className:r?"spinning":""}),t("span",{children:r?"Starting...":"Sync Now"})]})]})}function Ct({status:e,computed:n,config:r,storage:o,onRefresh:s}){const[i,a]=w(!1),{lines:l}=sn("archiveloop.log",3e3,!0),h=gt(l),c=se(()=>{var v;return h.state==="archiving"&&((v=h.message)==null?void 0:v.toLowerCase().includes("music"))},[h.state,h.message]),f=se(()=>{var v;return h.state==="archiving"&&!((v=h.message)!=null&&v.toLowerCase().includes("music"))},[h.state,h.message]),d=bt(c,1500),u=wt(f,1500),p=B(async()=>{a(!0);try{await Bn(),setTimeout(s,1e3)}catch(v){console.error("Trigger sync failed:",v)}finally{a(!1)}},[s]),g=[{key:"cam",label:"TeslaCam",icon:rn,enabled:(r==null?void 0:r.has_cam)==="yes"},{key:"music",label:"Music",icon:ct,enabled:(r==null?void 0:r.has_music)==="yes"},{key:"lightshow",label:"LightShow",icon:Ee,enabled:(r==null?void 0:r.has_lightshow)==="yes"},{key:"boombox",label:"Boombox",icon:Ee,enabled:(r==null?void 0:r.has_boombox)==="yes"}];return(r==null?void 0:r.uses_ble)==="yes"&&g.push({key:"ble",label:"BLE",icon:en,enabled:!0}),t("div",{className:"dashboard-content",children:[t("div",{className:"dashboard-section",children:[t("div",{className:"section-title",children:"Configured Features"}),t("div",{className:"features-row",children:g.map(({key:v,label:k,enabled:b})=>t("div",{className:`feature-badge ${b?"enabled":"disabled"}`,children:k},v))})]}),t("div",{className:"dashboard-section",children:t("div",{className:"card",children:[t("div",{className:"card-header",children:[t("span",{className:"card-title",children:"Storage"}),t("span",{className:"card-value",children:[(n==null?void 0:n.diskTotalGB)||"0"," GB"]})]}),t(Nt,{storage:o,total:e!=null&&e.total_space?parseInt(e.total_space,10):0,free:e!=null&&e.free_space?parseInt(e.free_space,10):0,config:r})]})}),t("div",{className:"dashboard-section",children:t(xt,{syncStatus:h,onTriggerSync:p,loading:i,musicProgress:d,camProgress:u})})]})}const _e={front:"Front",back:"Back",left_repeater:"Left Repeater",right_repeater:"Right Repeater",left_pillar:"Left Pillar",right_pillar:"Right Pillar"},je=[{id:"6",name:"All Cameras",cameras:Object.keys(_e),cols:3},{id:"4-front",name:"Front Focus",cameras:["front","left_repeater","right_repeater","back"],cols:2},{id:"4-rear",name:"Rear Focus",cameras:["back","left_repeater","right_repeater","front"],cols:2},{id:"2-side",name:"Side View",cameras:["left_repeater","right_repeater"],cols:2},{id:"1-front",name:"Front Only",cameras:["front"],cols:1},{id:"1-back",name:"Rear Only",cameras:["back"],cols:1}];function St(){const[e,n]=w(null),[r,o]=w(!0),[s,i]=w(null),[a,l]=w("SentryClips"),[h,c]=w(null),[f,d]=w(null),[u,p]=w(!1),[g,v]=w(0),[k,b]=w(0),[_,y]=w(je[0]),[x,$]=w(!1),[S,E]=w(null),U=H({}),R=H(null);F(()=>{z()},[]);const z=async()=>{try{o(!0);const m=await Tn();n(m);for(const C of["SentryClips","SavedClips","RecentClips"]){const I=Object.keys(m[C]||{});if(I.length>0){l(C),c(I[0]);break}}}catch(m){i(m.message)}finally{o(!1)}};F(()=>{R.current=null,v(0),b(0),a==="SentryClips"&&h?On(h).then(E):E(null)},[a,h]);const A=B(()=>{var O;if(!e||!h)return[];const m=((O=e[a])==null?void 0:O[h])||[],C=new Set;for(const G of m){const K=G.match(/(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})/);K&&C.add(K[1])}const I=Array.from(C).sort().reverse();return I.length>1?I:[]},[e,a,h])();F(()=>{A.length>0&&!f&&d(A[0])},[A,f]);const le=B(()=>{var O;if(!e||!h)return{};const m=((O=e[a])==null?void 0:O[h])||[],C={},I=A.length>0?f:null;for(const G of m)if(!(I&&!G.startsWith(I))){for(const K of Object.keys(_e))if(G.includes(`-${K}.mp4`)||G.includes(`_${K}.mp4`)){C[K]=Un(a,h,G);break}}return C},[e,a,h,f,A])(),on=B(()=>{Object.values(U.current).forEach(m=>{m&&m.play()}),p(!0)},[]),ke=B(()=>{Object.values(U.current).forEach(m=>{m&&m.pause()}),p(!1)},[]),q=B(m=>{Object.values(U.current).forEach(C=>{C&&(C.currentTime=m)}),v(m)},[]),an=B(()=>{q(Math.max(0,g-10))},[g,q]),ln=B(()=>{q(Math.min(k,g+30))},[g,k,q]),cn=B(m=>C=>{m===R.current&&v(C.target.currentTime)},[]),dn=B(m=>C=>{const I=C.target.duration;I&&I!==1/0&&!isNaN(I)&&(R.current||(R.current=m),m===R.current&&b(I))},[]),hn=B(m=>{const C=m.currentTarget.getBoundingClientRect(),O=(m.clientX-C.left)/C.width*k;q(O)},[k,q]),we=m=>{if(!m||isNaN(m))return"0:00";const C=Math.floor(m/60),I=Math.floor(m%60);return`${C}:${I.toString().padStart(2,"0")}`},un=e!=null&&e[a]?Object.keys(e[a]).sort().reverse():[];if(r)return t("div",{className:"video-viewer",style:{justifyContent:"center",alignItems:"center"},children:t("span",{className:"text-muted",children:"Loading recordings..."})});if(s)return t("div",{className:"video-viewer",style:{justifyContent:"center",alignItems:"center"},children:[t("span",{className:"status-unhealthy",children:["Error: ",s]}),t("button",{className:"btn",onClick:z,style:{marginTop:"1rem"},children:"Retry"})]});const fn=h&&Object.keys(le).length>0;return t("div",{className:"video-viewer",children:[t("div",{className:"video-selector",style:{display:"flex",gap:"0.5rem",padding:"8px 12px",background:"#1a1a1a",borderBottom:"1px solid #333",alignItems:"center",flexWrap:"wrap"},children:[t("select",{value:a,onChange:m=>{l(m.target.value);const C=Object.keys(e[m.target.value]||{});c(C[0]||null),d(null)},style:{background:"#333",color:"#fff",border:"1px solid #444",borderRadius:"4px",padding:"6px 10px",fontSize:"13px"},children:[t("option",{value:"SentryClips",children:"Sentry Clips"}),t("option",{value:"SavedClips",children:"Saved Clips"}),t("option",{value:"RecentClips",children:"Recent Clips"})]}),t("select",{value:h||"",onChange:m=>{c(m.target.value),d(null)},style:{background:"#333",color:"#fff",border:"1px solid #444",borderRadius:"4px",padding:"6px 10px",fontSize:"13px",flex:1,maxWidth:"180px"},children:un.map(m=>t("option",{value:m,children:Lt(m)},m))}),A.length>0&&t("select",{value:f||"",onChange:m=>d(m.target.value),style:{background:"#333",color:"#fff",border:"1px solid #444",borderRadius:"4px",padding:"6px 10px",fontSize:"13px",maxWidth:"120px"},children:A.map(m=>t("option",{value:m,children:Tt(m)},m))}),t("div",{style:{position:"relative"},children:[t("button",{className:"btn btn-sm btn-dark",onClick:()=>$(!x),children:[t(ht,{}),t("span",{children:_.name}),t(tn,{})]}),x&&t("div",{style:{position:"absolute",top:"100%",right:0,background:"#2a2a2a",border:"1px solid #444",borderRadius:"6px",marginTop:"4px",zIndex:100,minWidth:"150px"},children:je.map(m=>t("button",{onClick:()=>{y(m),$(!1)},style:{display:"block",width:"100%",padding:"8px 12px",background:_.id===m.id?"#0095f6":"transparent",color:"#fff",border:"none",textAlign:"left",cursor:"pointer",fontSize:"13px"},children:m.name},m.id))})]}),S&&S.city&&t("div",{style:{display:"flex",alignItems:"center",gap:"4px",color:"#888",fontSize:"12px",marginLeft:"auto"},children:[t(lt,{style:{width:14,height:14}}),t("span",{children:S.city})]})]}),fn?t(M,{children:[t("div",{className:`video-grid layout-${_.cameras.length}`,style:{gridTemplateColumns:`repeat(${_.cols}, 1fr)`},children:_.cameras.map(m=>t("div",{className:"video-cell",children:[le[m]?t("video",{ref:C=>{U.current[m]=C},src:le[m],onTimeUpdate:cn(m),onDurationChange:dn(m),onEnded:ke,muted:!0,playsInline:!0}):t("div",{style:{color:"#666",fontSize:"12px"},children:"No video"}),t("div",{className:"video-cell-label",children:_e[m]})]},m))}),t("div",{className:"video-controls",children:[t("button",{className:"btn btn-sm",onClick:an,title:"Skip back 10s",children:t(Xn,{})}),t("button",{className:"video-play-btn",onClick:u?ke:on,children:u?t(Jn,{}):t(Zn,{})}),t("button",{className:"btn btn-sm",onClick:ln,title:"Skip forward 30s",children:t(Qn,{})}),t("div",{className:"video-time",children:[we(g)," / ",we(k)]}),t("div",{className:"video-timeline",onClick:hn,children:t("div",{className:"video-timeline-progress",style:{width:k>0?`${g/k*100}%`:"0%"}})})]})]}):t("div",{style:{flex:1,display:"flex",alignItems:"center",justifyContent:"center",color:"#888",fontSize:"14px"},children:["No recordings in ",a==="SentryClips"?"Sentry Clips":a==="SavedClips"?"Saved Clips":"Recent Clips"]})]})}function Lt(e){const n=e.match(/(\d{4})-(\d{2})-(\d{2})(?:_(\d{2})-(\d{2})-(\d{2}))?/);if(n){const[,r,o,s,i,a,l]=n,h=new Date(r,o-1,s,i||0,a||0,l||0);return i?h.toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}):h.toLocaleDateString([],{month:"short",day:"numeric",year:"numeric"})}return e}function Tt(e){const n=e.match(/\d{4}-\d{2}-\d{2}_(\d{2})-(\d{2})-(\d{2})/);if(n){const[,r,o]=n;return new Date(2e3,0,1,parseInt(r),parseInt(o)).toLocaleTimeString([],{hour:"numeric",minute:"2-digit"})}return e}function $t({config:e}){const n=H(null),r=H(null),[o,s]=w(!!window.FileBrowser);F(()=>{if(!document.querySelector('link[href="/filebrowser.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/filebrowser.css",document.head.appendChild(c)}},[]),F(()=>{if(window.FileBrowser){s(!0);return}const c=document.createElement("script");c.src="/filebrowser.js",c.onload=()=>s(!0),c.onerror=()=>console.error("Failed to load filebrowser.js"),document.head.appendChild(c)},[]);const i=(e==null?void 0:e.has_music)==="yes",a=(e==null?void 0:e.has_lightshow)==="yes",l=(e==null?void 0:e.has_boombox)==="yes";return F(()=>{if(!o||!n.current)return;const c=[];if(i&&c.push({path:"fs/Music",label:"Music"}),a&&c.push({path:"fs/LightShow",label:"LightShow"}),l&&c.push({path:"fs/Boombox",label:"Boombox"}),c.length!==0&&!r.current){try{r.current=new window.FileBrowser(n.current,c)}catch(f){console.error("Failed to initialize FileBrowser:",f)}return()=>{n.current&&(n.current.innerHTML=""),r.current=null}}},[o,i,a,l]),(e==null?void 0:e.has_music)==="yes"||(e==null?void 0:e.has_lightshow)==="yes"||(e==null?void 0:e.has_boombox)==="yes"?o?t("div",{ref:n,style:{width:"100%",height:"calc(100% - 4px)",minHeight:"400px",flex:1,display:"flex",position:"relative"}}):t("div",{style:{display:"flex",alignItems:"center",justifyContent:"center",height:"100%",minHeight:"400px",color:"#9ca3af",fontSize:"14px"},children:"Loading file browser..."}):t("div",{style:{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",height:"100%",color:"#9ca3af",fontSize:"14px"},children:[t("svg",{style:{width:32,height:32,marginBottom:8,color:"#d1d5db"},viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",children:t("path",{d:"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"})}),t("span",{children:"No file drives configured"})]})}const ee={archiveloop:{label:"Archive Log",file:"archiveloop.log",description:"Sync and archive operations"},setup:{label:"Setup Log",file:"teslausb-headless-setup.log",description:"Initial setup and configuration"},diagnostics:{label:"Diagnostics",file:"diagnostics.txt",description:"System diagnostics report"}};function Bt(){const[e,n]=w("archiveloop"),[r,o]=w(!1),[s,i]=w(null),a=H(null),l=e!=="diagnostics",{lines:h,loading:c,error:f,refresh:d,clear:u}=sn(l?ee[e].file:"",2e3,l);F(()=>{a.current&&l&&(a.current.scrollTop=a.current.scrollHeight)},[h,l]);const p=B(async()=>{o(!0),i(null);try{await Rn();const _=await jn();i(_)}catch(_){i(`Error generating diagnostics: ${_.message}`)}finally{o(!1)}},[]);F(()=>{e==="diagnostics"&&!s&&p()},[e,s,p]);const g=B(()=>{const _=e==="diagnostics"?s:h.join(` +`),y=ee[e].file,x=new Blob([_],{type:"text/plain"}),$=URL.createObjectURL(x),S=document.createElement("a");S.href=$,S.download=y,document.body.appendChild(S),S.click(),document.body.removeChild(S),URL.revokeObjectURL($)},[e,h,s]),v=_=>{const y=_.toLowerCase();return y.includes("error")||y.includes("failed")||y.includes("fatal")?"error":y.includes("warning")||y.includes("warn")?"warning":y.includes("success")||y.includes("completed")||y.includes("finished")?"success":""},k=ee[e],b=e==="diagnostics"?(s==null?void 0:s.split(` +`))||[]:h;return t("div",{style:{display:"flex",flexDirection:"column",height:"100%"},children:[t("div",{style:{display:"flex",gap:"0.5rem",marginBottom:"1rem",flexWrap:"wrap"},children:Object.entries(ee).map(([_,y])=>t("button",{className:`btn ${e===_?"btn-primary":""}`,onClick:()=>n(_),children:[_==="diagnostics"?t(at,{}):t(nn,{}),t("span",{children:y.label})]},_))}),t("div",{className:"log-viewer",style:{flex:1},children:[t("div",{className:"log-viewer-header",children:[t("div",{className:"log-viewer-title",children:[k.label,t("span",{style:{fontWeight:400,marginLeft:"8px",opacity:.7},children:["— ",k.description]})]}),t("div",{className:"log-viewer-actions",children:[e==="diagnostics"?t("button",{className:"btn btn-sm log-action-btn",onClick:p,disabled:r,children:[t(pe,{className:r?"spinning":""}),t("span",{children:"Regenerate"})]}):t(M,{children:[t("button",{className:"btn btn-sm log-action-btn",onClick:d,disabled:c,title:"Refresh",children:t(pe,{className:c?"spinning":""})}),t("button",{className:"btn btn-sm log-action-btn",onClick:u,title:"Clear",children:t(nt,{})})]}),t("button",{className:"btn btn-sm log-action-btn",onClick:g,disabled:b.length===0,title:"Download",children:t(et,{})})]})]}),t("div",{className:"log-viewer-content",ref:a,children:c&&b.length===0||r?t("div",{style:{color:"#888",fontStyle:"italic"},children:"Loading..."}):f?f.includes("not found")?t("div",{style:{color:"#888",fontStyle:"italic"},children:["Log file not available. ",e==="setup"&&"The setup log is only present during initial setup."]}):t("div",{style:{color:"#f87171"},children:["Error: ",f]}):b.length===0?t("div",{style:{color:"#888",fontStyle:"italic"},children:"No log entries"}):b.map((_,y)=>t("div",{className:`log-line ${v(_)}`,children:_},y))})]}),l&&h.length>0&&t("div",{style:{marginTop:"0.5rem",fontSize:"11px",color:"#666",display:"flex",gap:"1rem"},children:[t("span",{children:[h.length," lines"]}),t("span",{children:"Auto-refreshing every 2s"})]})]})}function It(){return t("div",{className:"loading-container",children:[t("div",{className:"spinner"}),t("span",{children:"Loading..."})]})}const W={DASHBOARD:"dashboard",VIEWER:"viewer",FILES:"files",LOGS:"logs"};function Mt(){const[e,n]=w(W.DASHBOARD),[r,o]=w(!1),{status:s,config:i,storage:a,computed:l,loading:h,error:c,lastUpdate:f,refresh:d}=Vn(5e3),u=[];return u.push({id:W.DASHBOARD,label:"Dashboard"}),(i==null?void 0:i.has_cam)==="yes"&&u.push({id:W.VIEWER,label:"Viewer"}),((i==null?void 0:i.has_music)==="yes"||(i==null?void 0:i.has_lightshow)==="yes"||(i==null?void 0:i.has_boombox)==="yes")&&u.push({id:W.FILES,label:"Files"}),u.push({id:W.LOGS,label:"Logs"}),h&&!s?t(It,{}):c&&!s?t("div",{className:"error-container",children:[t("span",{children:["Failed to load: ",c]}),t("button",{className:"retry-btn",onClick:d,children:"Retry"})]}):t("div",{className:"app-shell",children:[t("div",{className:"app-topbar",children:[t("div",{className:"app-topbar-title",children:[t(Gn,{}),t("span",{children:"TeslaUSB"})]}),t("div",{className:"app-topbar-actions",children:t("span",{className:`app-status ${l!=null&&l.drivesActive?"healthy":"warning"}`,children:l!=null&&l.drivesActive?"Connected":"Disconnected"})})]}),t(pt,{tabs:u,activeTab:e,onTabChange:n,lastUpdate:f,onRefresh:d}),t("div",{className:"mobile-quick-status",children:[t("div",{className:"quick-status-item",children:[t("div",{className:`status-dot-mini ${l!=null&&l.drivesActive?"healthy":"unhealthy"}`}),t("span",{className:"quick-status-label",children:"USB"})]}),t("div",{className:"quick-status-item",children:[t("div",{className:`status-dot-mini ${l!=null&&l.wifiConnected?"healthy":"unhealthy"}`}),t("span",{className:"quick-status-label",children:"WiFi"})]}),t("div",{className:"quick-status-item",children:[t("div",{className:`status-dot-mini ${l!=null&&l.cpuTempC&&parseFloat(l.cpuTempC)<70?"healthy":"unhealthy"}`}),t("span",{className:"quick-status-label",children:[l==null?void 0:l.cpuTempC,"°C"]})]})]}),t("div",{className:"app-body",children:[e===W.DASHBOARD&&t(_t,{status:s,computed:l,config:i,expanded:r,onToggle:()=>o(!r),onRefresh:d}),t("main",{className:"app-main",children:[e===W.DASHBOARD&&t(Ct,{status:s,computed:l,config:i,storage:a,onRefresh:d}),e===W.VIEWER&&(i==null?void 0:i.has_cam)==="yes"&&t(St,{}),e===W.FILES&&t($t,{config:i}),e===W.LOGS&&t(Bt,{})]})]})]})}bn(t(Mt,{}),document.getElementById("app")); diff --git a/teslausb-www-react/dist/assets/index-Vc9in5g7.css b/teslausb-www-react/dist/assets/index-Vc9in5g7.css new file mode 100644 index 00000000..a8483914 --- /dev/null +++ b/teslausb-www-react/dist/assets/index-Vc9in5g7.css @@ -0,0 +1 @@ +*{margin:0;padding:0;box-sizing:border-box}body{font-family:Lato,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background-color:#f5f5f5;color:#333;overflow:hidden}@media(max-width:767px){body{overflow:visible;height:auto}}.app-shell{display:flex;flex-direction:column;min-height:100vh;overflow:hidden}@media(max-width:767px){.app-shell{overflow:visible;height:auto;min-height:100vh}}.app-topbar{display:flex;justify-content:space-between;align-items:center;background-color:#1f2937;color:#fff;padding:.75rem 1rem;border-bottom:1px solid #374151}.app-topbar-title{font-size:1.1rem;font-weight:600;display:flex;align-items:center;gap:.5rem}.app-topbar-title svg{width:24px;height:24px}.app-topbar-actions{display:flex;gap:1rem;align-items:center}.app-dashboard{display:flex;flex-direction:column;height:100vh;background-color:#f5f5f5}.app-header{background-color:#fff;border-bottom:1px solid #e1e5e9;padding:.75rem 1rem;display:flex;justify-content:space-between;align-items:center;height:60px;flex-shrink:0;position:sticky;top:0;z-index:40;backdrop-filter:saturate(180%) blur(4px)}.app-header-left{display:flex;align-items:center;gap:.5rem}.app-header-icon{width:20px;height:20px;color:#06c}.app-header-title{font-size:16px;font-weight:600;color:#333;letter-spacing:.1px}.app-header-subtitle{font-size:14px;color:#666;margin-left:.5rem}.app-header-right{display:flex;align-items:center;gap:1rem}.dashboard-nav{display:flex;gap:.5rem;align-items:center}.nav-link{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:6px;font-size:14px;font-weight:500;color:#6b7280;text-decoration:none;transition:all .15s ease;border:1px solid transparent;position:relative;cursor:pointer;background:none}.nav-link:hover{background-color:#f9fafb;color:#374151}.nav-link.active{background-color:#0095f6;color:#fff;border-color:#007dd1}.nav-link.active svg{color:#fff}.nav-link svg{color:#6b7280;transition:color .15s ease;width:16px;height:16px}.nav-link:hover svg{color:#374151}.nav-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;background-color:#ef4444;color:#fff;font-size:11px;font-weight:700;border-radius:9px;margin-left:4px;line-height:1}.nav-link.active .nav-badge{background-color:#fff;color:#ef4444}.app-last-update{font-size:13px;color:#6b7280}.app-status{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.3px}.app-status.healthy{color:#065f46;background:#d1fae5;border:1px solid #a7f3d0}.app-status.unhealthy{color:#7f1d1d;background:#fee2e2;border:1px solid #fecaca}.app-status.warning{color:#92400e;background:#fef3c7;border:1px solid #fde68a}.app-body{display:flex;flex:1;overflow:hidden}.app-sidebar{width:280px;background-color:#fff;border-right:1px solid #e1e5e9;padding:1rem;overflow-y:auto;overflow-x:hidden;flex-shrink:0;max-height:calc(100vh - 60px);-webkit-overflow-scrolling:touch}.device-info{display:flex;flex-direction:column;border-radius:10px;border:1px solid #e5e7eb;background:#fff;margin-bottom:1rem}.device-header{display:flex;align-items:center;gap:.5rem;padding:10px 12px;border-bottom:1px solid #eef2f7;font-size:14px;font-weight:600;color:#1f2937}.device-header svg{width:16px;height:16px;color:#6b7280}.info-list{display:flex;flex-direction:column;padding:8px 12px}.info-item{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #f0f0f0;font-size:13px}.info-item:last-child{border-bottom:none}.info-item.clickable{cursor:pointer;transition:background-color .15s ease;margin:0 -12px;padding:6px 12px;border-radius:4px}.info-item.clickable:hover{background-color:#f3f4f6}.info-item.clickable:active{background-color:#e5e7eb}.toggle-btn{display:inline-flex;align-items:center;justify-content:center;padding:6px 10px;border-radius:4px;font-size:11px;font-weight:600;line-height:1;border:1px solid #007dd1;background-color:#0095f6;color:#fff;cursor:pointer;transition:all .15s ease}.toggle-btn svg{flex-shrink:0;vertical-align:middle;margin-right:4px;color:#fff}.toggle-btn:hover:not(:disabled){background-color:#007dd1;border-color:#006bbd}.toggle-btn:disabled{opacity:.6;cursor:not-allowed}.toggle-btn.active{background-color:#dcfce7;border-color:#22c55e;color:#166534}.toggle-btn.danger{background-color:#fee2e2;border-color:#f87171;color:#b91c1c}.ui-switch-link{color:#0095f6;text-decoration:none;font-size:13px;padding:4px 0;display:block;width:100%}.ui-switch-link:hover{text-decoration:underline}.toggle-btn.danger:hover:not(:disabled){background-color:#fecaca;border-color:#ef4444}.toggle-btn.primary{background-color:#eff6ff;border-color:#3b82f6;color:#1d4ed8}.toggle-btn.primary:hover:not(:disabled){background-color:#dbeafe;border-color:#2563eb}.sidebar-action{margin-top:8px;padding:0}.action-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:8px 12px;border-radius:6px;font-size:12px;font-weight:600;border:none;cursor:pointer;transition:all .15s ease}.action-btn:disabled{opacity:.6;cursor:not-allowed}.action-btn.connected{background-color:#dcfce7;color:#166534;border:1px solid #22c55e}.action-btn.connected:hover:not(:disabled){background-color:#bbf7d0}.action-btn.disconnected{background-color:#fef2f2;color:#991b1b;border:1px solid #f87171}.action-btn.disconnected:hover:not(:disabled){background-color:#fee2e2}.action-btn.restart{background-color:#f3f4f6;color:#374151;border:1px solid #d1d5db}.action-btn.restart:hover:not(:disabled){background-color:#e5e7eb;border-color:#9ca3af}.info-label{color:#6b7280;font-weight:500}.info-value{color:#111827;font-weight:600;text-align:right}.info-value-with-action{display:flex;align-items:center;gap:8px}.speed-result{color:#111827;font-weight:600;font-size:12px}.small-text{font-size:11px!important;line-height:1.2}.compact-date{font-size:10px!important;line-height:1.1;white-space:nowrap;color:#4b5563}.progress-container{margin-top:.5rem}.progress-bar{width:100%;height:8px;background-color:#eef2f7;border-radius:999px;overflow:hidden;margin-bottom:.25rem}.progress-fill{height:100%;border-radius:999px;transition:width .3s ease}.progress-fill.blue{background-color:#3b82f6}.progress-fill.green{background-color:#10b981}.progress-fill.yellow{background-color:#f59e0b}.progress-fill.red{background-color:#ef4444}.progress-text{font-size:10px;color:#6b7280;font-weight:500;margin-top:6px;text-align:right}.status-healthy{color:#00b04f!important}.status-degraded{color:#ff9800!important}.status-unhealthy{color:#e74c3c!important}.app-main{flex:1;background-color:#fff;padding:1rem;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;max-height:calc(100vh - 60px)}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1px;background-color:#e1e5e9;border:1px solid #e1e5e9;border-radius:4px;overflow:visible;margin-bottom:1rem}.stats-section{background-color:#fff;padding:1rem}.stats-section h3{font-size:12px;font-weight:600;color:#666;text-transform:uppercase;letter-spacing:.5px;margin-bottom:.75rem}.stat-data{display:flex;flex-direction:column;gap:.5rem}.primary-stat{font-size:24px;font-weight:700;color:#333;line-height:1}.stat-details{display:flex;flex-direction:column;gap:.25rem}.stat-details span{font-size:11px;color:#666}.stat-details span:first-child{color:#333;font-weight:500;font-size:12px}.mono-value{font-family:Monaco,Menlo,Consolas,monospace;color:#333;font-weight:600;font-size:12px}.card{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:12px;box-shadow:0 1px #00000005;transition:transform .08s ease,box-shadow .12s ease,border-color .12s ease}.card:hover{transform:translateY(-1px);box-shadow:0 6px 18px #0000000f;border-color:#dee3ea}.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.card-title{font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.4px}.card-value{font-size:22px;font-weight:700;color:#111827;line-height:1}.card-sub{font-size:11px;color:#666;margin-top:4px}.dashboard-section{margin-bottom:1rem}.section-title{font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.4px;margin-bottom:.5rem}.features-row{display:flex;flex-wrap:wrap;gap:8px}.feature-badge{display:inline-block;padding:6px 12px;border-radius:6px;font-size:12px;font-weight:500;line-height:1;text-align:center}.feature-badge.enabled{background:#ecfdf5;color:#059669;border:1px solid #a7f3d0}.feature-badge.disabled{background:#f3f4f6;color:#9ca3af;border:1px solid #e5e7eb}.actions-row{display:flex;flex-wrap:wrap;gap:10px}.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:14px}.chart-container{background-color:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:1rem;margin-bottom:1rem}.chart-header{padding:10px 12px;border-bottom:1px solid #eef2f7;margin:-1rem -1rem 1rem}.chart-title{font-size:14px;font-weight:600;color:#1f2937}.loading-container{display:flex;align-items:center;justify-content:center;height:100vh;gap:.5rem;font-size:14px;color:#666}.spinner{width:20px;height:20px;border:2px solid #e5e7eb;border-top-color:#0095f6;border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.spinning{animation:spin 1s linear infinite}.error-container{display:flex;align-items:center;justify-content:center;height:100vh;gap:.5rem;font-size:14px;color:#e74c3c}.retry-btn{background-color:#e74c3c;border:none;color:#fff;padding:.375rem .75rem;border-radius:4px;font-size:12px;cursor:pointer;margin-left:.5rem}.retry-btn:hover{background-color:#c0392b}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;font-size:13px;font-weight:500;line-height:1.4;border-radius:6px;border:1px solid #cfd8e3;background:#fff;color:#374151;cursor:pointer;transition:all .15s ease-in-out;box-shadow:0 1px 2px #00000008}.btn svg{color:#374151;transition:color .15s ease-in-out;width:14px;height:14px}.btn:hover:not(:disabled){background:#0095f6;border-color:#007dd1;color:#fff;box-shadow:0 2px 8px #0000000f}.btn:hover:not(:disabled) svg{color:#fff}.btn:active:not(:disabled){background:#007dd1;border-color:#006bbd}.btn:disabled{opacity:.6;cursor:not-allowed;background:#f9fafb;color:#9ca3af;border-color:#e5e7eb}.btn-primary{background:#0095f6;border-color:#007dd1;color:#fff}.btn-primary svg{color:#fff}.btn-primary:hover:not(:disabled){background:#007dd1;border-color:#006bbd}.btn-danger{background:#ef4444;border-color:#dc2626;color:#fff}.btn-danger svg{color:#fff}.btn-danger:hover:not(:disabled){background:#dc2626;border-color:#b91c1c}.btn-dark{background:#333;border-color:#444;color:#fff}.btn-dark svg{color:#fff}.btn-dark:hover:not(:disabled){background:#444;border-color:#555;color:#fff}.btn-dark:hover:not(:disabled) svg{color:#fff}.btn-sm{padding:4px 8px;font-size:12px}.btn-lg{padding:10px 20px;font-size:15px}.storage-bar-container{margin:1rem 0}.storage-bar{display:flex;height:24px;border-radius:6px;overflow:hidden;background-color:#e5e7eb}.storage-segment{display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;color:#fff;transition:width .3s ease;min-width:0}.storage-segment.teslacam{background-color:#3b82f6}.storage-segment.music{background-color:#8b5cf6}.storage-segment.lightshow{background-color:#ec4899}.storage-segment.boombox{background-color:#f97316}.storage-segment.free{background-color:#d1d5db}.storage-legend{display:flex;flex-wrap:wrap;gap:1rem;margin-top:.75rem;font-size:12px}.storage-legend-item{display:flex;align-items:center;gap:6px}.storage-legend-dot{width:10px;height:10px;border-radius:2px}.storage-legend-dot.teslacam{background-color:#3b82f6}.storage-legend-dot.music{background-color:#8b5cf6}.storage-legend-dot.lightshow{background-color:#ec4899}.storage-legend-dot.boombox{background-color:#f97316}.storage-legend-dot.free{background-color:#d1d5db}.storage-legend-label{color:#374151;font-weight:500}.storage-legend-value{color:#6b7280}.storage-legend-used{color:#9ca3af;font-size:11px}.storage-legend-percent{margin-left:4px;color:#9ca3af;font-size:11px}.storage-note{margin-top:.5rem;font-size:10px;color:#9ca3af;font-style:italic}.sync-status-card{border:1px solid #e5e7eb;border-radius:10px;padding:1rem;background:#fff}.sync-status-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem}.sync-status-title{font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.4px}.sync-status-indicator{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600}.sync-status-dot{width:8px;height:8px;border-radius:50%}.sync-status-dot.idle{background-color:#10b981}.sync-status-dot.connecting{background-color:#f59e0b;animation:pulse 1.5s ease-in-out infinite}.sync-status-dot.archiving{background-color:#3b82f6;animation:pulse 1s ease-in-out infinite}.sync-status-dot.error{background-color:#ef4444}.sync-status-description{font-size:13px;color:#6b7280;margin-bottom:.5rem}.sync-details{font-size:12px;color:#9ca3af}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}.sync-progress-bar{height:8px;background-color:#e5e7eb;border-radius:4px;overflow:hidden;margin:.75rem 0}.sync-progress-fill{height:100%;background-color:#3b82f6;border-radius:4px;transition:width .3s ease}.sync-details{display:flex;flex-direction:column;gap:2px;font-size:11px;color:#6b7280}.sync-progress-main{font-weight:500}.sync-progress-secondary{font-size:10px;color:#9ca3af}.sync-file{max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:Monaco,Menlo,monospace}.log-viewer{background-color:#1e1e1e;border-radius:8px;font-family:Monaco,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;overflow:hidden;display:flex;flex-direction:column;height:400px}.log-viewer-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background-color:#2d2d2d;border-bottom:1px solid #3d3d3d}.log-viewer-title{color:#e0e0e0;font-size:13px;font-weight:600}.log-viewer-actions{display:flex;gap:8px}.log-action-btn{background:#4a4a4a!important;border-color:#666!important;color:#e0e0e0!important}.log-action-btn svg{color:#e0e0e0!important}.log-action-btn:hover:not(:disabled){background:#5a5a5a!important;border-color:#888!important}.log-action-btn:hover:not(:disabled) svg{color:#fff!important}.log-action-btn:disabled{opacity:.4}.log-viewer-content{flex:1;overflow-y:auto;padding:12px;color:#d4d4d4}.log-viewer-content::-webkit-scrollbar{width:8px}.log-viewer-content::-webkit-scrollbar-track{background:#1e1e1e}.log-viewer-content::-webkit-scrollbar-thumb{background:#4d4d4d;border-radius:4px}.log-line{white-space:pre-wrap;word-break:break-all}.log-line.error{color:#f87171}.log-line.warning{color:#fbbf24}.log-line.success{color:#34d399}.video-viewer{display:flex;flex-direction:column;height:100%;background:#000;border-radius:8px;overflow:hidden}.video-grid{display:grid;flex:1;gap:2px;background:#1a1a1a;padding:2px}.video-grid.layout-6{grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(2,1fr)}.video-grid.layout-4{grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(2,1fr)}.video-grid.layout-1{grid-template-columns:1fr;grid-template-rows:1fr}.video-cell{position:relative;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden}.video-cell video{width:100%;height:100%;object-fit:contain}.video-cell-label{position:absolute;top:8px;left:8px;background:#000000b3;color:#fff;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}.video-controls{display:flex;align-items:center;gap:1rem;padding:12px 16px;background:#1a1a1a;border-top:1px solid #333}.video-timeline{flex:1;height:6px;background:#333;border-radius:3px;cursor:pointer;position:relative}.video-timeline-progress{height:100%;background:#0095f6;border-radius:3px;transition:width .1s linear}.video-timeline-markers{position:absolute;inset:0;pointer-events:none}.video-timeline-marker{position:absolute;top:-2px;width:2px;height:10px;background:#ef4444;border-radius:1px}.video-play-btn{background:#0095f6;border:none;color:#fff;width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s}.video-play-btn svg{width:18px;height:18px}.video-play-btn:hover{background:#007dd1}.video-time{color:#fff;font-size:13px;font-family:Monaco,Menlo,monospace;min-width:100px}.file-browser{display:flex;height:100%;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden}.file-tree{width:250px;background:#f9fafb;border-right:1px solid #e5e7eb;overflow-y:auto}.file-tree-item{display:flex;align-items:center;gap:6px;padding:8px 12px;cursor:pointer;font-size:13px;color:#374151;border-bottom:1px solid #f0f0f0}.file-tree-item:hover{background:#f3f4f6}.file-tree-item.active{background:#e0f2fe;color:#0369a1}.file-tree-item svg{width:16px;height:16px;color:#6b7280;flex-shrink:0}.file-tree-item.active svg{color:#0369a1}.file-list{flex:1;overflow-y:auto;padding:1rem}.file-list-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;padding-bottom:.5rem;border-bottom:1px solid #e5e7eb}.file-list-title{font-size:14px;font-weight:600;color:#1f2937}.file-list-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px}.file-item{display:flex;flex-direction:column;align-items:center;padding:12px;border:1px solid transparent;border-radius:8px;cursor:pointer;transition:all .15s}.file-item:hover{background:#f9fafb;border-color:#e5e7eb}.file-item.selected{background:#e0f2fe;border-color:#0ea5e9}.file-item-icon{width:48px;height:48px;margin-bottom:8px;color:#6b7280}.file-item-icon.folder{color:#f59e0b}.file-item-icon.video{color:#8b5cf6}.file-item-icon.audio{color:#ec4899}.file-item-name{font-size:12px;text-align:center;word-break:break-word;color:#374151}.file-item-size{font-size:10px;color:#9ca3af;margin-top:2px}.dropzone{border:2px dashed #d1d5db;border-radius:8px;padding:2rem;text-align:center;color:#6b7280;transition:all .15s}.dropzone svg{width:24px;height:24px}.dropzone.active{border-color:#0095f6;background:#f0f9ff;color:#0369a1}.modal-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal{background:#fff;border-radius:12px;max-width:500px;width:90%;max-height:90vh;overflow:hidden;box-shadow:0 20px 25px -5px #0000001a,0 10px 10px -5px #0000000a}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border-bottom:1px solid #e5e7eb}.modal-title{font-size:16px;font-weight:600;color:#1f2937}.modal-close{background:none;border:none;cursor:pointer;color:#6b7280;padding:4px}.modal-close:hover{color:#374151}.modal-body{padding:1.5rem;overflow-y:auto}.modal-footer{display:flex;justify-content:flex-end;gap:.75rem;padding:1rem 1.5rem;border-top:1px solid #e5e7eb;background:#f9fafb}.toast-container{position:fixed;bottom:1rem;right:1rem;z-index:1100;display:flex;flex-direction:column;gap:.5rem}.toast{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;background:#1f2937;color:#fff;border-radius:8px;font-size:13px;box-shadow:0 10px 15px -3px #0000001a;animation:slideIn .2s ease}.toast.success{background:#059669}.toast.error{background:#dc2626}.toast.warning{background:#d97706}@keyframes slideIn{0%{transform:translate(100%);opacity:0}to{transform:translate(0);opacity:1}}@media(min-width:1024px)and (max-width:1366px){.app-sidebar{width:260px}.kpi-row{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.app-header-title{font-size:15px}}@media(min-width:768px)and (max-width:1023px){.app-body{flex-direction:row}.app-sidebar{width:220px;max-height:calc(100vh - 60px);overflow-y:auto}.kpi-row{grid-template-columns:repeat(2,1fr);gap:8px}.card{padding:10px}.card-value{font-size:18px}.card-title{font-size:11px}.card-sub{font-size:10px}.app-header-right{gap:8px}.app-last-update{font-size:11px}.chart-container{padding:12px;margin-bottom:8px}.chart-title{font-size:13px}}.mobile-quick-status{display:none}.desktop-status-bar{display:block}.sidebar-toggle-btn{display:none;background:none;border:none;color:#6b7280;cursor:pointer;padding:4px;margin-left:auto;transition:color .2s ease}.sidebar-toggle-btn:hover{color:#374151}@media(max-width:767px){.desktop-status-bar{display:none!important}.mobile-quick-status{display:flex;align-items:center;justify-content:space-around;padding:6px 8px;background-color:#f9fafb;border-bottom:1px solid #e5e7eb;gap:4px;flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch}.quick-status-item{display:flex;flex-direction:column;align-items:center;gap:3px;flex-shrink:0}.status-dot-mini{width:8px;height:8px;border-radius:50%;flex-shrink:0}.status-dot-mini.healthy{background-color:#10b981;box-shadow:0 0 4px #10b98180}.status-dot-mini.unhealthy{background-color:#ef4444;box-shadow:0 0 4px #ef444480}.quick-status-label{font-size:9px;color:#6b7280;font-weight:500;white-space:nowrap;text-align:center}.app-body{flex-direction:column;height:auto;overflow:visible}.app-sidebar{width:100%;height:auto;max-height:none;border-right:none;border-bottom:1px solid #e1e5e9;padding:8px;overflow:visible;overflow-x:hidden;flex-shrink:0;-webkit-overflow-scrolling:touch;transition:max-height .3s ease}.sidebar-toggle-btn{display:inline-flex}.app-sidebar:not(.expanded){max-height:60px;overflow:hidden}.app-sidebar:not(.expanded) .device-info:not(:first-child){display:none}.app-sidebar:not(.expanded) .info-item:nth-child(n+4){display:none}.app-sidebar.expanded{max-height:600px;overflow-y:auto}.device-info{margin-top:8px}.device-info:first-child{margin-top:0}.info-item{padding:4px 0;font-size:12px}.app-main{flex:1;padding:12px 8px 150px;overflow-y:auto;overflow-x:hidden;height:auto;min-height:0;max-height:none;-webkit-overflow-scrolling:touch}.kpi-row{grid-template-columns:repeat(2,1fr);gap:6px;margin-bottom:10px}.card{padding:6px 8px;min-height:75px}.card-header{margin-bottom:3px}.card-value{font-size:18px;line-height:1.1;margin-bottom:2px}.card-title{font-size:10px;letter-spacing:.3px}.card-sub{font-size:9px;line-height:1.3;margin-top:2px}.app-header{padding:8px 10px 10px!important;height:auto!important;flex-direction:column!important;gap:10px!important;align-items:stretch!important;position:static!important;top:auto!important;z-index:auto!important;backdrop-filter:none!important}.app-header-left{justify-content:center;padding-bottom:4px}.app-header-title{font-size:14px}.app-header-right{flex-direction:row;gap:6px;align-items:center;justify-content:space-between;width:100%;padding-bottom:2px}.dashboard-nav{gap:4px;flex:1;overflow-x:auto;-webkit-overflow-scrolling:touch}.nav-link{padding:7px 8px;font-size:11px;gap:4px;flex-shrink:0;justify-content:center;border-radius:4px}.nav-link svg{width:14px;height:14px}.nav-badge{min-width:16px;height:16px;font-size:10px;padding:0 4px;margin-left:2px}.app-last-update{font-size:10px}.app-status{font-size:9px;padding:1px 6px}.btn{padding:7px 8px;font-size:10px;gap:3px;white-space:nowrap;border-radius:4px;flex-shrink:0}.btn svg{width:12px;height:12px}.chart-container{padding:8px;margin-bottom:10px}.chart-title{font-size:11px;font-weight:600}.chart-header{margin-bottom:6px;padding-bottom:4px}.chart-container:last-child{margin-bottom:100px}.file-browser{flex-direction:column}.file-tree{width:100%;max-height:150px;border-right:none;border-bottom:1px solid #e5e7eb}.file-list-grid{grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}.video-grid.layout-6{grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(3,1fr)}.video-controls{flex-wrap:wrap;gap:.5rem;padding:8px 12px}.video-time{font-size:11px;min-width:80px}.log-viewer{height:auto;min-height:300px;flex:1}.log-viewer-content{font-size:10px}}@media(min-width:393px)and (max-width:430px){.kpi-row{grid-template-columns:1fr}.card{min-height:80px}.app-main{padding-bottom:140px}}@media(max-width:392px){.kpi-row{grid-template-columns:1fr}.app-header{padding:6px 8px;height:45px}.app-header-title{font-size:13px}.app-main{padding:6px 6px 160px}.card{padding:6px;min-height:70px}.card-value{font-size:14px}.chart-title{font-size:11px}}@media(pointer:coarse){.btn{min-height:44px;min-width:44px}.info-item{padding:8px 0;min-height:44px;align-items:center}.card{min-height:100px}.nav-link{min-height:44px}}@media(-webkit-min-device-pixel-ratio:2),(min-resolution:192dpi){.card,.chart-container{border-width:.5px}.app-header{border-bottom-width:.5px}}@media(min-width:768px)and (max-width:1024px)and (orientation:landscape){.kpi-row{grid-template-columns:repeat(3,1fr)}.app-sidebar{width:200px;max-height:calc(100vh - 60px);overflow-y:auto}.device-info{margin-top:.75rem}.device-info:first-child{margin-top:0}}@media(min-width:768px)and (max-width:1024px)and (orientation:portrait){.kpi-row{grid-template-columns:repeat(2,1fr)}.app-sidebar{width:240px;max-height:calc(100vh - 60px);overflow-y:auto}.device-info{margin-top:.75rem}.device-info:first-child{margin-top:0}}.app-dashboard{max-height:100vh;overflow:hidden}.dashboard-nav::-webkit-scrollbar{display:none}.dashboard-nav{-ms-overflow-style:none;scrollbar-width:none}.tab-content{display:none;flex:1;overflow:hidden}.tab-content.active{display:flex;flex-direction:column}.hidden{display:none!important}.flex{display:flex}.flex-1{flex:1}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.text-center{text-align:center}.text-right{text-align:right}.text-sm{font-size:12px}.text-xs{font-size:10px}.text-muted{color:#6b7280}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700} diff --git a/teslausb-www-react/dist/filebrowser.css b/teslausb-www-react/dist/filebrowser.css new file mode 100644 index 00000000..76e10734 --- /dev/null +++ b/teslausb-www-react/dist/filebrowser.css @@ -0,0 +1,592 @@ +/* FileBrowser CSS - Adapted for TeslaUSB React UI */ +/* Uses Lato font and consistent color scheme with main UI */ + +.fb-dragimage { + width: 300px; + height: 150px; + top: -500px; + background: #0095f6; + position: fixed; +} + +.fb-splitter { + width: 3px; + background: #e5e7eb; + border-style: solid; + border-color: #d1d5db; + border-width: 0 1px; + cursor: col-resize; + margin-left: 1px; + margin-right: 1px; + touch-action: none; +} + +.fb-splitterflag { + position: fixed; + width: 40px; + height: 64px; + top: 50%; + background: #e5e7eb; + border-style: solid; + border-color: #d1d5db; + border-width: 1px 0px 1px 1px; + cursor: col-resize; + touch-action: none; +} + +@media(hover: hover) { + .fb-splitterflag { + visibility: hidden; + } +} + +.fb-selection-rect { + position: absolute; + background-color: rgba(0, 149, 246, 0.3); +} + +.fb-tree { + overflow-y: auto; + flex-grow: 1; + padding-left: 30px; + margin-top: 4px; + margin-bottom: 0; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-tree ul { + padding-left: 20px; +} + +.fb-tree li { + display: block; + position: relative; +} + +details summary::-webkit-details-marker { + display: none; +} + +.fb-tree summary { + display: block; + cursor: pointer; + width: max-content; + padding: 4px 8px; + margin-left: 2px; + color: #1f2937; + font-weight: 500; + font-size: 13px; + border-radius: 4px; +} + +.fb-tree summary:hover { + color: #fff; + background: #0095f6; + border-radius: 4px; +} + +.fb-tree summary::before { + content: ''; + top: 4px; + left: -18px; + width: 14px; + height: 14px; + display: block; + position: absolute; + background: #e5e7eb; + border-radius: 50%; +} + +.fb-tree summary:has(+ ul:not(:empty))::before { + background: #93c5fd; +} + +.fb-tree details[open] > summary:has(+ ul:not(:empty))::before { + background: #0095f6; +} + +summary.fb-droptarget, +.fb-direntry.fb-droptarget, +.fb-fileentry.fb-droptarget { + color: #fff; + background: #22c55e; + border-radius: 4px; + box-shadow: 0 0 0 2px #22c55e; +} + +summary.fb-droptarget.fb-selected, +.fb-direntry.fb-droptarget.fb-selected, +.fb-fileentry.fb-droptarget.fb-selected { + color: #fff; + background: #22c55e; + border-radius: 4px; + box-shadow: 0 0 0 30px #dbeafe; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 50%) +} + +.fb-fileslist.fb-droptarget { + box-shadow: inset 0 0 0 3px #22c55e; +} + +.fb-treediv > ul.fb-droptarget, +details > ul.fb-droptarget { + box-shadow: inset 0 0 0 3px #22c55e; +} + +.fb-treerootpath { + position: relative; + padding: 8px 12px; + font-weight: 600; + font-size: 14px; + border-bottom: 1px solid #e5e7eb; + overflow: hidden; + white-space: nowrap; + min-height: 36px; + max-height: 36px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; + color: #1f2937; + display: flex; + align-items: center; +} + +.fb-diskinfo-outer { + position: absolute; + right: 4px; + bottom: 8px; + width: 20px; + height: 20px; + background: white; +} + +.fb-diskinfo-inner { + position: absolute; + left: 2px; + top: 2px; + width: 16px; + height: 16px; + border-color: #9ca3af; + border: 1px solid; + border-radius: 50%; + font-weight: normal; + color: #9ca3af; + text-align: center; + font-size: 11px; + line-height: 14px; +} + +.fb-diskinfo-inner::before { + content: 'i'; +} + +.fb-diskinfo-outer:hover .fb-diskinfo-inner { + color: #0095f6; + background: #dbeafe; + border-color: #0095f6; +} + +.fb-diskinfo { + visibility: hidden; + position: fixed; + margin-top: 24px; + height: auto; + width: auto; + z-index: 1; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 8px 12px; + text-align: center; + background: #fff; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + font-size: 12px; + color: #6b7280; +} + +.fb-diskinfo-outer:hover .fb-diskinfo { + visibility: visible; +} + +.fb-treerootpath:has(.fb-treerootpathsinglelabel) { + padding: 8px 12px; + min-height: 36px; + max-height: 36px; +} + +.fb-dirpath { + padding: 8px 12px; + font-weight: 600; + font-size: 14px; + border-bottom: 1px solid #e5e7eb; + overflow: hidden; + white-space: nowrap; + min-height: 36px; + max-height: 36px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; + color: #1f2937; +} + +.fb-crumb { + color: #0095f6; + cursor: pointer; + display: inline-block; +} + +.fb-crumb:hover { + text-decoration: underline; +} + +.fb-crumb.fb-droptarget { + text-decoration: underline; + text-decoration-thickness: 2px; +} + +.fb-fileentry { + cursor: pointer; + padding: 4px 8px; + width: max-content; + margin-left: 1px; + font-size: 13px; + color: #374151; + border-radius: 4px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-fileentry:hover:not([contenteditable="true"]) { + color: #fff; + background: #0095f6; + border-radius: 4px; +} + +.fb-fileentry.fb-selected { + color: #1e40af; + background: #dbeafe; +} + +.fb-fileentry.fb-selected:hover:not([contenteditable="true"]) { + color: #fff; + background: #2563eb; + border-radius: 4px; + box-shadow: 0 0 0 30px #dbeafe; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 50%) +} + +.fb-direntry { + cursor: pointer; + padding: 4px 8px; + font-weight: 500; + width: max-content; + margin-left: 1px; + font-size: 13px; + color: #1f2937; + border-radius: 4px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-direntry:hover:not([contenteditable="true"]) { + color: #fff; + background: #0095f6; + border-radius: 4px; +} + +.fb-direntry.fb-selected { + color: #1e40af; + background: #dbeafe; +} + +.fb-direntry.fb-selected:hover:not([contenteditable="true"]) { + color: #fff; + background: #2563eb; + box-shadow: 0 0 0 30px #dbeafe; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 50%) +} + +.fb-treediv { + display: flex; + flex-direction: column; + float: left; + background: #fafafa; + min-width: 15%; + max-width: 85%; + width: 30%; + height: 100%; + overflow-y: auto; + user-select: none; + border-right: 1px solid #e5e7eb; +} + +.fb-filesdiv { + flex: 1; + display: flex; + flex-direction: column; + float: left; + background: #fff; + height: 100%; + margin-left: 0; + overflow-x: auto; + user-select: none; +} + +.fb-fileslist { + position: relative; + height: 100%; + margin-top: 4px; + overflow-y: auto; + flex-grow: 1; + padding: 4px; +} + +.fb-playertitle { + color: #fff; + height: 30px; + padding: 10px 8px 6px 8px; + white-space: nowrap; + overflow: auto; + text-align: left; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-playertitle::-webkit-scrollbar { + display: none; +} + +.fb-player { + position: absolute; + width: 300px; + height: 100px; + left: 50%; + top: 75%; + background: #1f2937; + border: none; + border-radius: 12px; + padding: 10px; + transform: translate(-50%, -50%); +} + +.fb-dropinfo-holder { + position: fixed; + visibility: hidden; + z-index: 98; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0); +} + +.fb-dropinfo { + width: 350px; + height: 150px; + top: calc(100% / 2 - 75px); + left: calc(100% / 2 - 175px); + border-radius: 8px; + position: relative; + visibility: inherit; + background: white; + border: 1px solid #e5e7eb; + z-index: 99; + padding: 1px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + user-select: none; + outline: none !important; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-dropinfo-line1 { + position: absolute; + top: 16px; + left: 20px; + font-weight: 600; + color: #1f2937; +} + +.fb-dropinfo-line2 { + position: absolute; + left: 20px; + right: 20px; + top: 40px; + height: 30px; + white-space: nowrap; + overflow: auto; + text-align: left; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; + color: #6b7280; + font-size: 13px; +} + +.fb-dropinfo-line2::-webkit-scrollbar { + display: none; +} + +.fb-dropinfo-line3 { + position: absolute; + left: 20px; + right: 60px; + bottom: 5px; + height: 30px; + white-space: nowrap; + overflow: auto; + text-align: left; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; + color: #6b7280; + font-size: 12px; +} + +.fb-dropinfo-line3::-webkit-scrollbar { + display: none; +} + +.fb-dropinfo-closebutton { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + background: white; + text-align: center; + cursor: pointer; + border-radius: 4px; + color: #9ca3af; + line-height: 24px; +} + +.fb-dropinfo-closebutton:hover { + background: #f3f4f6; + color: #1f2937; +} + +.fb-dropinfo-cancel { + position: absolute; + bottom: 12px; + right: 12px; + padding: 6px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 13px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-dropinfo-cancel:hover { + background: #f3f4f6; +} + +.fb-dropinfo-progress { + position: absolute; + left: 20px; + width: calc(100% - 40px); + top: 90px; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: #e5e7eb; + -webkit-appearance: none; + appearance: none; +} + +.fb-dropinfo-progress::-webkit-progress-bar { + background: #e5e7eb; + border-radius: 4px; +} + +.fb-dropinfo-progress::-webkit-progress-value { + background: #0095f6; + border-radius: 4px; +} + +.fb-dropinfo-progress::-moz-progress-bar { + background: #0095f6; + border-radius: 4px; +} + +.fb-barbutton { + float: left; + width: 40px; + height: 40px; + border: none; + padding: 0; + z-index: 3; + display: none; + cursor: pointer; + border-radius: 8px; + margin: 4px; + background-size: 24px 24px; + background-position: center; + background-repeat: no-repeat; +} + +.fb-barbutton:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.fb-barbutton.fb-visiblebarbutton { + display: block; +} + +.fb-trashbutton { + background-image: url(icons/trash.svg); +} + +.fb-pencilbutton { + background-image: url(icons/pencil.svg); +} + +.fb-uploadbutton { + background-image: url(icons/upload.svg); +} + +.fb-downloadbutton { + background-image: url(icons/download.svg); +} + +.fb-newfolderbutton { + background-image: url(icons/newfolder.svg); +} + +.fb-locksoundbutton { + background-image: url(icons/locksound.svg); +} + +.fb-buttonbar { + position: fixed; + right: 24px; + bottom: 24px; + height: 48px; + padding: 0 4px; + background: #fff; + z-index: 2; + border-radius: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + display: flex; + align-items: center; +} + +@media(hover: hover) { + .uploadbutton { + display: none; + } +} + +.fb-driveselector { + font-size: 14px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; + background: transparent; + border-width: 1px; + border-style: solid; + border-color: #d1d5db; + border-radius: 4px; + padding: 4px 8px; + color: #1f2937; +} + +.fb-driveselector:focus { + outline: none; + border-color: #0095f6; +} diff --git a/teslausb-www-react/dist/filebrowser.js b/teslausb-www-react/dist/filebrowser.js new file mode 100644 index 00000000..c8b513fc --- /dev/null +++ b/teslausb-www-react/dist/filebrowser.js @@ -0,0 +1,1315 @@ +class FileBrowser { + DEBUG = false; + splitter_active = false; + splitter_clickoffset = 0; + root_path = ''; + root_label = ''; + anchor_elem = undefined; + dragged_path = undefined; + cancelUpload = false; + uploading = false; + drives = []; + curdrive = 0; + + constructor(anchor, drives) { + this.anchor_elem = anchor; + this.drives = drives; + this.root_path = drives[this.curdrive].path; + this.root_label = drives[this.curdrive].label; + + this.anchor_elem.style.position = "relative"; + this.anchor_elem.style.display = "flex"; + this.anchor_elem.spellcheck = false; + this.anchor_elem.innerHTML = + ` +
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
    +
    +
    +
    +
    +
    +
    +
    + `; + + this.dragStart = this.dragStart.bind(this); + this.dragEnd = this.dragEnd.bind(this); + this.allowDrop = this.allowDrop.bind(this); + this.dragEnter = this.dragEnter.bind(this); + this.drop = this.drop.bind(this); + this.readPaths = this.readPaths.bind(this); + this.dirClicked = this.dirClicked.bind(this); + this.fileClicked = this.fileClicked.bind(this); + this.listPointerDown = this.listPointerDown.bind(this); + this.splitterPointerDown = this.splitterPointerDown.bind(this); + this.splitterPointerMove = this.splitterPointerMove.bind(this); + this.splitterPointerUp = this.splitterPointerUp.bind(this); + this.showContextMenu = this.showContextMenu.bind(this); + this.hideContextMenu = this.hideContextMenu.bind(this); + + const splitter = this.anchor_elem.querySelector(".fb-splitter"); + splitter.onpointerdown = (e) => { this.splitterPointerDown(e); }; + const splitterflag = this.anchor_elem.querySelector(".fb-splitterflag"); + splitterflag.onpointerdown = (e) => { this.splitterPointerDown(e); }; + this.splitterSetFlagPos(); + + const fileList = this.anchor_elem.querySelector('.fb-fileslist'); + fileList.onpointerdown = (e) => { this.listPointerDown(e); }; + fileList.oncontextmenu = (e) => { this.showContextMenu(e); }; + fileList.addEventListener("dragstart", this.dragStart); + this.anchor_elem.addEventListener("dragend", this.dragEnd); + + const rootlabel = this.anchor_elem.querySelector(".fb-treerootpath"); + if (this.drives.length > 1) { + var rootlabeldropdown = '
    ' + rootlabel.innerHTML = rootlabeldropdown; + const selector = this.anchor_elem.querySelector(".fb-driveselector"); + selector.onchange = (e) => { + this.curdrive = selector.value; + this.root_path = this.drives[this.curdrive].path; + this.root_label = this.drives[this.curdrive].label; + this.ls(".", false); + this.ls(".", true); + this.updateButtonBar(); + }; + } else { + rootlabel.innerHTML = `${this.drives[0].label}
    `; + } + + this.buttonbar = this.anchor_elem.querySelector(".fb-buttonbar"); + this.buttonbar.querySelector(".fb-uploadbutton").onclick = (e) => { this.pickFile(); }; + this.buttonbar.querySelector(".fb-downloadbutton").onclick = (e) => { this.downloadSelection(); }; + this.buttonbar.querySelector(".fb-newfolderbutton").onclick = (e) => { this.newFolder(); }; + this.buttonbar.querySelector(".fb-trashbutton").onclick = (e) => { this.deleteItems(this.selection()); }; + this.buttonbar.querySelector(".fb-pencilbutton").onclick = (e) => { + const item = this.selection()[0]; + item.scrollIntoView({block: "nearest"}); + this.renameItem(item); + }; + this.buttonbar.querySelector(".fb-locksoundbutton").onclick = (e) => { + const item = this.selection()[0]; + this.makeLockChime(item); + }; + + this.ls(".", true); + this.updateButtonBar(); + } + + log(msg) { + if (this.DEBUG) { + console.log(msg); + } + } + + async refreshLists(callback) { + var tree = this.anchor_elem.querySelector(".fb-tree"); + var openPaths = []; + tree.querySelectorAll("details[open] > summary").forEach((s) => { openPaths.push(s.dataset.fullpath); }); + await this.ls(".", false); + openPaths.forEach((path) => { + var detail = tree.querySelector(`details:has(summary[data-fullpath="${path}"])`); + if (detail != null) { + detail.open = true; + } + }); + await this.ls(this.current_path, true); + if (callback) { + callback(); + } + } + + newFolder() { + var fl = this.anchor_elem.querySelector(".fb-fileslist"); + for (var i = 1; i < 100; i++) { + var str = `${this.current_path == "." ? "" : this.current_path + "/"}New folder${i == 1 ? '' : " (" + i + ")"}`; + this.log(str); + var item = fl.querySelector(`[data-fullpath="${this.stringEncode(str)}"]`); + if (item == null) { + this.readfile( + {url:`cgi-bin/mkdir.sh?${encodeURIComponent(this.root_path)}&${encodeURIComponent(str)}`, + callback:(response, data) => { + this.refreshLists(() => { + var item = fl.querySelector(`[data-fullpath="${this.stringEncode(str)}"]`); + if (item) { + item.scrollIntoView({block: "nearest"}); + this.selectItem(item); + this.renameItem(item); + } + }); + } + }); + return; + } + } + this.log("could not find empty new folder name"); + } + + deleteItems(items) { + var pathsList = ""; + items.forEach((item) => { + const fullpath = this.stringDecode(item.dataset.fullpath) + pathsList += "&"; + pathsList += encodeURIComponent(fullpath); + }); + this.readfile( + {url:`cgi-bin/rm.sh?${this.root_path}${pathsList}`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + } + + deleteItem(item) { + this.deleteItems([item]); + } + + downloadSelection() { + const url = this.downloadURLForSelection().substr(1); + const name = url.substr(0, url.indexOf(":")); + const url2 = url.substr(url.indexOf(":") + 1); + this.log(`name: ${name}, url: ${url2}`); + + var elem = document.createElement('a'); + elem.setAttribute('href', url2); + elem.setAttribute('download', name); + elem.style.display = 'none'; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + } + + selectItemContent(item) { + var range,selection; + if(document.createRange) + { + range = document.createRange(); + range.selectNodeContents(item); + selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + applyRename(item) { + const fullpath = this.stringDecode(item.dataset.fullpath); + const idx = fullpath.lastIndexOf("/"); + const oldname = fullpath.substr(idx + 1); + var newname = item.textContent; + this.log('renaming "' + oldname + '" to "' + newname + '"'); + this.readfile( + {url:`cgi-bin/mv.sh?${this.root_path}/${this.current_path}&${encodeURIComponent(oldname)}&${encodeURIComponent(newname)}`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + } + + stopEditingItem(item, oldValue) { + // clear onblur first because setting contentEditable to false immediately triggers a blur + item.onblur = undefined; + item.contentEditable = false; + item.onkeydown = undefined; + item.oncontextmenu = undefined; + if (item.textContent != oldValue) { + this.applyRename(item); + } + } + + renameItem(item) { + const oldValue = item.textContent; + item.contentEditable = true; + this.selectItemContent(item); + item.onkeydown = (e) => { + if (e.key == 'Enter') { + this.stopEditingItem(item, oldValue); + } else if (e.key == 'Escape') { + item.textContent = oldValue; + this.stopEditingItem(item, oldValue); + } + }; + item.onblur = (e) => { + this.stopEditingItem(item, oldValue); + }; + // Tapping on the selected text to position the cursor + // on mobile results in a context menu event, so intercept + // that while editing. + item.oncontextmenu = (e) => { e.stopPropagation(); }; + item.focus(); + } + + makeLockChime(item) { + // copy the selected item + this.readfile( + {url:`cgi-bin/cp.sh?${this.root_path}&${encodeURIComponent(item.dataset.fullpath)}&LockChime.wav`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + } + + showButton(name, show) { + if (show) { + this.buttonbar.querySelector(name).classList.add("fb-visiblebarbutton"); + } else { + this.buttonbar.querySelector(name).classList.remove("fb-visiblebarbutton"); + } + } + + updateButtonBar() { + const numsel = this.numSelected(); + const enabled = this.valid; + this.showButton(".fb-trashbutton", enabled && numsel > 0); + this.showButton(".fb-pencilbutton", enabled && numsel == 1); + this.showButton(".fb-uploadbutton", enabled && numsel == 0); + this.showButton(".fb-downloadbutton", enabled && numsel > 0); + this.showButton(".fb-newfolderbutton", enabled && numsel == 0); + this.showButton(".fb-locksoundbutton", enabled && numsel == 1 && this.isPotentialLockChime(this.selection()[0])); + if (this.buttonbar.querySelector(".fb-visiblebarbutton") == null) { + this.buttonbar.style.display = "none"; + } else { + this.buttonbar.style.display = "block"; + } + } + + eventCoordinates(e) { + if (e.targetTouches && e.targetTouches.length > 1) { + const t = e.targetTouches[0] + return [t.clientX, t.clientY]; + } + return [e.x, e.y]; + } + + makeMultiSelectContextMenu(event) { + new ContextMenu(this.anchor_elem, + [ + new ContextMenuItem("Download selected items", + () => { + this.downloadSelection(); + }, null), + new ContextMenuItem("Delete selected items", + () => { + this.deleteItems(this.selection()); + }, null) + + ]).show(...this.eventCoordinates(event)); + } + + makeListContextMenu(event) { + new ContextMenu(this.anchor_elem, + [ + new ContextMenuItem("New folder", () => { this.newFolder(); }, null) + ]).show(...this.eventCoordinates(event)); + } + + makeDirContextMenu(event) { + const e = event; + new ContextMenu(this.anchor_elem, + [ + new ContextMenuItem("Rename", () => { this.renameItem(e.target); }, null), + new ContextMenuItem("Download", () => { this.downloadSelection(); }, null), + new ContextMenuItem("Delete", () => { this.deleteItem(e.target); }, null) + ]).show(...this.eventCoordinates(event)); + } + + makeFileContextMenu(event) { + const filename = event.target.innerText; + const e = event; + new ContextMenu(this.anchor_elem, + [ + ...this.isPotentialLockChime(event.target) ? [ new ContextMenuItem("Use as lock sound", () => { this.makeLockChime(e.target); }, null) ] : [], + new ContextMenuItem("Rename", () => { this.renameItem(e.target); }, null), + new ContextMenuItem("Download", () => { this.downloadSelection(); }, null), + new ContextMenuItem("Delete", () => { this.deleteItem(e.target); }, null) + ]).show(...this.eventCoordinates(event)); + } + + hideContextMenu() { + var contextmenu = this.anchor_elem.querySelector(".cm-holder"); + if (contextmenu == null) { + return; + } + contextmenu.style.visibility = "hidden"; + } + + showContextMenu(event) { + this.log("context menu"); + if (!this.valid || event.ctrlKey) { + this.hideContextMenu(); + return; + } + if (event.pointerType == "touch") { + // long press triggered context menu + this.log("touch context menu"); + event.preventDefault(); + return; + } + var target = event.target; + try { + if (target.classList.contains("fb-fileslist")) { + this.unselectAll(); + this.makeListContextMenu(event); + } else if (this.numSelected() > 1 && this.isSelected(target)) { + this.makeMultiSelectContextMenu(event); + } else { + this.unselectAll(); + this.selectItem(target); + if (target.classList.contains("fb-direntry")) { + this.makeDirContextMenu(event); + } else if (target.classList.contains("fb-fileentry")) { + this.makeFileContextMenu(event); + } + } + event.preventDefault(); + } catch (e) { + this.log("error creating context menu"); + this.log(e); + } + } + + splitterSetFlagPos() { + const splitter = this.anchor_elem.querySelector(".fb-splitter"); + const splitterflag = this.anchor_elem.querySelector(".fb-splitterflag"); + splitterflag.style.left = (splitter.getBoundingClientRect().x - + splitterflag.getBoundingClientRect().width + 1) + "px"; + } + + splitterPointerMove(event) { + const treediv = this.anchor_elem.querySelector(".fb-splitter").previousElementSibling; + const treedivrect = treediv.getBoundingClientRect(); + const newwidth = event.clientX + this.splitter_clickoffset - treedivrect.x; + treediv.style.width = `${newwidth}px`; + this.splitterSetFlagPos(); + } + + splitterPointerUp(event) { + const splitter = event.target; + splitter.removeEventListener("pointermove", this.splitterPointerMove); + splitter.removeEventListener("pointerup", this.splitterPointerUp); + splitter.releasePointerCapture(event.pointerId); + this.splitter_active = false; + } + + splitterPointerDown(event) { + var splitter = event.target; + splitter.addEventListener("pointermove", this.splitterPointerMove); + splitter.addEventListener("pointerup", this.splitterPointerUp); + splitter.setPointerCapture(event.pointerId); + const treediv = this.anchor_elem.querySelector(".fb-splitter").previousElementSibling; + const treedivrect = treediv.getBoundingClientRect(); + this.splitter_clickoffset = treedivrect.x + treedivrect.width - event.clientX; + this.splitter_active = true; + } + + intersects(r1, r2) { + return !(r1.x + r1.width < r2.x || + r2.x + r2.width < r1.x || + r1.y + r1.height < r2.y || + r2.y + r2.height < r1.y); + } + + updateSelection(selectRectElem) { + const select = selectRectElem.getBoundingClientRect(); + + const {x, y, height, width} = select; + + selectRectElem.parentElement.querySelectorAll(".fb-direntry, .fb-fileentry").forEach((item) => { + if (this.intersects({x: x + window.scrollX, y: y + window.scrollY, height, width}, item.getBoundingClientRect())){ + item.classList.add("fb-selected"); + } else { + item.classList.remove("fb-selected"); + } + } ); + this.updateButtonBar(); + } + + unselectAll() { + this.selection().forEach((e) => { e.classList.remove("fb-selected");}); + this.updateButtonBar(); + } + + isSelected(elem) { + return elem.classList.contains("fb-selected") + } + + selectItem(elem) { + elem.classList.add("fb-selected"); + this.updateButtonBar(); + } + + selection() { + var fl = this.anchor_elem.querySelector(".fb-fileslist"); + return fl.querySelectorAll(".fb-selected"); + } + + numSelected() { + return this.selection().length; + } + + async createSelectionRectangle(thiz, event) { + const fileList = event.target; + const x = event.offsetX + fileList.scrollLeft; + const y = event.offsetY + fileList.scrollTop; + + const div = document.createElement("div"); + div.style.width = "0"; + div.style.height = "0"; + div.style.left = x + "px"; + div.style.top = y + "px"; + div.classList.add("fb-selection-rect"); + fileList.append(div); + + function resize(event) { + thiz.log("resize"); + if (event.buttons == 0) { + cancelSelectionRectangle(event); + return; + } + if (event.target != fileList) { + return; + } + + const rect = fileList.getBoundingClientRect(); + const offX = (event.touches ? event.touches[0].clientX - rect.left : + event.offsetX) + fileList.scrollLeft; + const offY = (event.touches ? event.touches[0].clientY - rect.top : + event.offsetY) + fileList.scrollTop; + const maxX = fileList.clientWidth + fileList.scrollLeft; + const maxY = fileList.clientHeight + fileList.scrollTop; + const curX = offX > maxX ? maxX : offX; + const curY = offY > maxY ? maxY : offY; + const dX = curX - x; + const dY = curY - y; + div.style.left = dX < 0 ? x + dX + "px" : x + "px"; + div.style.top = dY < 0 ? y + dY + "px" : y + "px"; + div.style.width = Math.abs(dX) + "px"; + div.style.height = Math.abs(dY) + "px"; + thiz.updateSelection(div); + if (event.cancelable) { + event.preventDefault(); + } + } + + if (! event.ctrlKey) { + this.unselectAll(); + } + + function cancelSelectionRectangle(event) { + thiz.log("cancelSelectionRectangle"); + event.target.removeEventListener("pointermove", resize); + try { + fileList.releasePointerCapture(event.pointerId); + } catch(error) { + thiz.log("release error"); + } + fileList.removeEventListener("touchmove", resize); + fileList.removeEventListener("touchend", pointerup); + fileList.removeEventListener("pointermove", resize); + fileList.removeEventListener("pointerup", pointerup); + fileList.removeEventListener("cancelselectionrect", cancelSelectionRectangle); + div.remove(); + } + + function pointerup(event) { + thiz.log("pointerup"); + cancelSelectionRectangle(event); + } + + fileList.setPointerCapture(event.pointerId); + if (event.pointerType == "touch") { + fileList.addEventListener("touchmove", resize); + fileList.addEventListener("touchend", pointerup); + } else { + fileList.addEventListener("pointermove", resize); + fileList.addEventListener("pointerup", pointerup); + } + fileList.addEventListener("cancelselectionrect", cancelSelectionRectangle); + } + + listPointerDown(event) { + /* only respond to left mouse button */ + if (!this.valid || event.button != 0) { + event.preventDefault(); + return; + } + event.target.dispatchEvent( + new Event("cancelselectionrect", { + bubbles: true, + cancelable: true, + composed: false + }) + ); + this.createSelectionRectangle(this, event); + } + + dirClicked(event, path) { + var expanderclicked = (event && event.offsetX < 0); + this.ls(path, !expanderclicked); + } + + isPlayable(filename) { + const lower = filename.toLowerCase(); + for (const ext of [".mp3", ".m4a", ".flac", ".ogg", ".wav"]) { + if (lower.endsWith(ext)) { + return true; + } + } + return false; + } + + isPotentialLockChime(item) { + const lower = item.dataset.fullpath.toLowerCase(); + if (lower == "lockchime.wav") { + return false; + } + if (!lower.endsWith(".wav")) { + return false; + } + if (item.dataset.filesize > 1024 * 1024) { + return false; + } + return true; + } + + fileClicked(event, path) { + this.log(`clicked: ${path}`); + + var displaypath = path; + if (this.isPlayable(path)) { + const div = document.createElement("div"); + div.style.position = "absolute"; + div.style.width = "100%"; + div.style.height = "100%"; + div.style.left = "0"; + div.style.top = "0"; + div.style.background = "#0008"; + div.onclick = (e) => { if (e.target === div) div.remove(); }; + document.firstElementChild.append(div); + div.innerHTML = `
    ${displaypath}
    `; + div.querySelector(".fb-playertitle").scrollLeft=1000; + } + } + + // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem. + base64ToBytes(base64) { + const binString = atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)); + } + + // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem. + bytesToBase64(bytes) { + const binString = String.fromCodePoint(...bytes); + return btoa(binString); + } + + stringEncode(str) { + return str; // this.bytesToBase64((new TextEncoder()).encode(str)); + } + + stringDecode(encstr) { + return encstr; // new TextDecoder().decode(this.base64ToBytes(encstr)); + } + + addCommonDragHooks(item) { + item.ondragover = this.allowDrop; + item.ondragenter = this.dragEnter; + item.ondragleave = this.dragLeave; + item.ondrop = this.drop; + } + + createTreeItem(label, fullPath) { + var li = document.createElement("li"); + li.innerHTML = '
    ' + + '' + label + '' + + '
      '; + const s = li.querySelector("summary"); + s.onclick = (e) => { this.dirClicked(e, this.stringDecode(e.target.dataset.fullpath)); }; + s.ondragstart = this.dragStart; + const u = li.querySelector("ul"); + u.dataset.fullpath = fullPath; + this.addCommonDragHooks(u); + return li; + } + + addDir(root, path) { + var pathParts = path.split("/"); + var pathSoFar = null; + for (var i = 0; i < pathParts.length; i++) { + if (pathSoFar) { + pathSoFar += "/" + pathParts[i]; + } else { + pathSoFar = pathParts[i]; + } + var node = root.querySelector(`[data-fullpath="${this.stringEncode(pathSoFar)}"]`); + if (node == null) { + /* level 'i' doesn't exist yet, add it */ + var newPath = this.createTreeItem(pathParts[i], pathSoFar); + root.appendChild(newPath); + root = newPath.querySelector("ul"); + } else { + /* level 'i' already exists, check the next level */ + root = node.nextElementSibling; + } + } + return root; + } + + readfile({url, callback, callbackarg}) { + var request = new XMLHttpRequest(); + request.open('GET', url); + request.onreadystatechange = function () { + if (request.readyState === XMLHttpRequest.DONE) { + if (request.status === 200) { + var type = request.getResponseHeader('Content-Type'); + if (type.indexOf("text") !== 1) { + if (callback != null) { + callback(request.responseText, callbackarg); + } + } + } else if (request.status > 400) { + if (callback != null) { + callback(null, null); + } + } + } + } + request.send(); + } + + selectFileEntry(ev) { + if (ev.button != 0) return; + ev.stopPropagation(); + if (! ev.ctrlKey) { + /* not multi-select, so deselect everything that was previously selected */ + this.unselectAll(); + } + ev.target.classList.toggle("fb-selected"); + this.updateButtonBar(); + } + + createFileEntry(isdir, name, path, size) { + var div = document.createElement("div"); + div.className = isdir ? "fb-direntry" : "fb-fileentry" + div.textContent = name; + div.draggable = true; + if (isdir) { + div.ondblclick = (e) => { this.dirClicked(null, path); }; + this.addCommonDragHooks(div); + div.dataset.fullpath = this.stringEncode(path); + } else { + const justThePath = path.substring(0, path.lastIndexOf(":")); + div.ondblclick = (e) => { this.fileClicked(null, justThePath); }; + div.dataset.fullpath = this.stringEncode(justThePath); + div.dataset.filesize = path.substring(path.lastIndexOf(":") + 1); + } + div.onclick = (e) => { this.selectFileEntry(e); }; + div.onpointerdown = (e) => { e.stopPropagation(); }; + return div; + } + + addFileEntry(path) { + var isDir = path.indexOf("d:") == 0; + path = path.substring(2); + var lastSlash = path.lastIndexOf("/"); + var name = path.substring(lastSlash + 1); + var lastColon = name.lastIndexOf(":"); + var size = 0; + if (lastColon > 0) { + size = name.substring(lastColon + 1); + name = name.substring(0, lastColon); + } + var newFile = this.createFileEntry(isDir, name, path, size); + var listDiv = this.anchor_elem.querySelector('.fb-fileslist'); + listDiv.appendChild(newFile); + } + + setDiskStats(freebytes, totalbytes) { + var diskinfospan = this.anchor_elem.querySelector('.fb-diskinfo'); + diskinfospan.innerText = `${this.niceNumber(freebytes)} free of ${this.niceNumber(totalbytes)}`; + } + + /* + switchtopath=false: only update the left-hand side tree view + switchtopath=true: update the right-hand side + */ + readPaths(path, paths, switchtopath) { + this.valid = (paths != null && switchtopath != null); + if (!this.valid) { + var pathdiv = this.anchor_elem.querySelector(".fb-dirpath"); + pathdiv.innerText = "<< error retrieving file list >>"; + } + paths = this.valid ? paths.trimEnd() : ""; + var root = this.anchor_elem.querySelector(".fb-tree"); + root.dataset.fullpath="."; + this.addCommonDragHooks(root); + if (path == "." && !switchtopath) { + root.innerHTML = ''; + } + var lines = paths.split('\n'); + if (! this.valid || switchtopath) { + this.anchor_elem.querySelector('.fb-fileslist').querySelectorAll(".fb-direntry,.fb-fileentry").forEach((entry) => entry.remove()); + } + for (var line of lines) { + if (line.indexOf("d:") == 0 || line.indexOf("D:") == 0) { + this.addDir(root, line.substring(2)); + } + if (line.indexOf("s:") == 0) { + let [freebytes, totalbytes] = line.substring(2).split(":"); + this.setDiskStats(freebytes, totalbytes); + } else if (switchtopath && ! line.indexOf("D:") == 0) { + this.addFileEntry(line); + } + } + this.updateButtonBar(); + } + + makeOnClick(thiz, path) { + return function() { thiz.dirClicked(null, path); }; + + } + + setClickablePath(container, path) { + var pathParts = []; + if (!path) { + path = "."; + } else if (path != ".") { + pathParts = path.split("/"); + } + this.current_path = path; + const fileList = this.anchor_elem.querySelector('.fb-fileslist'); + fileList.dataset.fullpath = this.stringEncode(path); + this.addCommonDragHooks(fileList); + + container.innerHTML = ""; + var pathSoFar = null; + + var a = document.createElement("a"); + if (pathParts.length > 0) { + a.className = "fb-crumb"; + a.onclick = this.makeOnClick(this, "."); + this.addCommonDragHooks(a); + a.dataset.fullpath = this.stringEncode("."); + } + a.innerText = '[' + this.root_label + ']'; + container.appendChild(a); + + for (var i = 0; i < pathParts.length; i++) { + if (pathSoFar) { + pathSoFar += "/" + pathParts[i]; + } else{ + pathSoFar = pathParts[i]; + } + var a = document.createElement("a"); + if (i < pathParts.length - 1) { + a.className = "fb-crumb"; + a.onclick = this.makeOnClick(this, pathSoFar); + this.addCommonDragHooks(a); + a.dataset.fullpath = this.stringEncode(pathSoFar); + } + a.innerText = pathParts[i]; + container.append("/"); + container.appendChild(a); + } + } + + async ls(path, switchtopath) { + return new Promise((resolve, reject) => { + + if (switchtopath) { + var pathdiv = this.anchor_elem.querySelector(".fb-dirpath"); + this.setClickablePath(pathdiv, path); + } + this.readfile({url:`cgi-bin/ls.sh?${encodeURIComponent(this.root_path)}&${encodeURIComponent(path)}`, callback:(paths,switchto) => { this.readPaths(path, paths, switchto); resolve(); }, callbackarg:switchtopath}); + }); + } + + isDropAllowedForTarget(ev) { + var destPath = this.stringDecode(ev.target.dataset.fullpath); + if (destPath == undefined) { + return false; + } + if (ev.target.classList.contains("fb-fileentry")) { + /* files are not drop targets */ + return false; + } + if (!this.dragged_path) { + /* external files are not subject to path checks */ + return true; + } + var pathList = []; + const selection = this.selection(); + if (selection.length == 0) { + // single item from the left side tree view + pathList.push(this.dragged_path); + } else { + // one or more items from the right side file/folder view + selection.forEach((srcItem) => { + pathList.push(this.stringDecode(srcItem.dataset.fullpath)); + }); + } + //this.log(`${pathList.toString()} => ${destPath}`); + for (var srcPath of pathList) { + if (destPath.startsWith(srcPath)) { + /* can't drop parent in child */ + return false; + } + const idx = srcPath.lastIndexOf("/"); + const srcDir = idx > 0 ? srcPath.substr(0, idx) : "."; + if (srcDir == destPath) { + //this.log(`not OK: ${srcDir} => ${destPath}`); + return; + } + //this.log(`OK: ${srcPath} => ${destPath}`); + } + return true; + } + + allowDrop(ev) { + if (!this.isDropAllowedForTarget(ev)) { + return; + } + ev.preventDefault(); + } + + dragEnter(ev) { + if (this.isDropAllowedForTarget(ev)) { + ev.target.classList.add("fb-droptarget"); + } + } + + dragLeave(ev) { + ev.target.classList.remove("fb-droptarget"); + } + + hasExternalFiles(ev) { + this.log(`${ev.dataTransfer.items.length} items dropped`); + this.log(ev.dataTransfer.items.length); + this.log(...ev.dataTransfer.items); + this.log(ev.dataTransfer); + this.log(ev); + if (ev.dataTransfer.items.length > 0) { + return true; + } + return false; + } + + handleInternalDrop(ev) { + const selection = this.selection(); + var pathList = []; + if (selection.length == 0) { + pathList.push(this.dragged_path); + } else { + selection.forEach((srcItem) => { + pathList.push(this.stringDecode(srcItem.dataset.fullpath)); + }); + } + var pathString = ""; + pathList.forEach((path) => { + pathString += `&${encodeURIComponent(path)}`; + }); + this.log(`${pathList} => ${this.stringDecode(ev.target.dataset.fullpath)}`); + this.readfile( + {url:`cgi-bin/mv.sh?${this.root_path}${pathString}&${this.stringDecode(ev.target.dataset.fullpath)}`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + + } + + async cancelDrop() { + if (!this.cancelUpload) { + this.cancelUpload = true; + const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); + while (this.uploading) { + await sleep(50); + } + } + this.hideDropInfo(); + } + + showDropInfo() { + var di = this.anchor_elem.querySelector(".fb-dropinfo-holder"); + di.style.visibility = "visible"; + var cb = this.anchor_elem.querySelector(".fb-dropinfo-closebutton"); + cb.onmousedown = (e) => { this.cancelDrop(); }; + var cb = this.anchor_elem.querySelector(".fb-dropinfo-cancel"); + cb.onclick = (e) => { this.cancelDrop(); }; + var l1 = this.anchor_elem.querySelector(".fb-dropinfo-line1"); + l1.innerText = "Building file list..."; + l1.style.visibility="inherit"; + var p = this.anchor_elem.querySelector(".fb-dropinfo-progress"); + p.style.visibility="hidden"; + } + + niceNumber(totalsize) { + var str = ""; + if (totalsize < 100000) { + str += `${totalsize} bytes` + } else if (totalsize < 2000000) { + str += `${(totalsize / 1024).toFixed(0)} KB` + } else if (totalsize < 1100000000) { + str += `${(totalsize / (1024 * 1024)).toFixed(0)} MB` + } else { + str += `${(totalsize / (1024 * 1024 * 1024)).toFixed(2)} GB` + } + return str; + } + + updateDropInfo(numfiles, totalsize) { + var l2 = this.anchor_elem.querySelector(".fb-dropinfo-line2"); + var str = `${numfiles} file`; + if (numfiles != 1) { + str += "s"; + } + str += ", " + this.niceNumber(totalsize); + l2.innerText = str; + + } + + hideDropInfo() { + var di = this.anchor_elem.querySelector(".fb-dropinfo-holder"); + di.style.visibility = "hidden"; + this.refreshLists(); + } + + async getFilePromise(entry) { + try { + if (entry instanceof File) { + return entry; + } + return await new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); + } catch (err) { + this.log(err); + } + } + + async readEntriesPromise(reader) { + try { + return await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + } catch (err) { + this.log(err); + } + } + + async readAllDirectoryEntries(reader) { + var entries = []; + var readEntries = await this.readEntriesPromise(reader); + while (readEntries.length > 0) { + entries.push(...readEntries); + readEntries = await this.readEntriesPromise(reader); + } + return entries; + } + + async handleExternalDrop(targetpath, datatransferitems, files) { + this.cancelUpload = false; + this.uploading = true; + var totalBytes = 0; + var fileList = []; + var queue = []; + if (datatransferitems) { + // Use DataTransferItemList interface to access the file(s) + [...datatransferitems].forEach((item, i) => { + var entry = item.webkitGetAsEntry(); + this.log(entry); + if (entry != null) { + queue.push(entry); + } + }); + } else if (files) { + queue.push(...files); + } + + while (queue.length > 0) { + if (this.cancelUpload) { + this.uploading = false; + return; + } + //this.log(`processing... (${fileList.length})`); + var entry = queue.shift(); + if (entry.isDirectory) { + queue.push(...await this.readAllDirectoryEntries(entry.createReader())); + } else { + fileList.push(entry); + if (fileList.length == 1) { + this.showDropInfo(); + } + var file = await this.getFilePromise(entry); + totalBytes += file.size; + this.updateDropInfo(fileList.length, totalBytes); + } + } + this.log(`total size: ${totalBytes}`); + var l1 = this.anchor_elem.querySelector(".fb-dropinfo-line1"); + l1.numitems = fileList.length; + + var p = this.anchor_elem.querySelector(".fb-dropinfo-progress"); + p.style.visibility="inherit"; + p.max = totalBytes; + p.value = 0; + this.uploadFiles(targetpath, fileList); + } + + pickFile() { + var input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (e) => { + if (input.files.length > 0) { + this.handleExternalDrop(this.current_path, null, input.files); + } + }; + input.click(); + } + + uploadFiles(destpath, fileList) { + var lastLoaded = 0; + if (fileList.length > 0 && ! this.cancelUpload) { + var f = fileList.shift(); + var l1 = this.anchor_elem.querySelector(".fb-dropinfo-line1"); + l1.innerText = `File ${l1.numitems - fileList.length} / ${l1.numitems}`; + var l2 = this.anchor_elem.querySelector(".fb-dropinfo-line2"); + l2.innerText = f.name; + this.uploadFile(destpath, f, + (status) => { + // completion function + if (status === 200) { + this.uploadFiles(destpath, fileList); + } else { + this.log(`status: ${status}`); + this.uploading = false; + } + }, + (e, request) => { + // progress function + const p = this.anchor_elem.querySelector(".fb-dropinfo-progress"); + p.value += (e.loaded - lastLoaded); + const size1 = this.niceNumber(p.value); + const size2 = this.niceNumber(p.max); + const s = `${size1} / ${size2}`; + const l3 = this.anchor_elem.querySelector(".fb-dropinfo-line3"); + if (l3.innerText != s) { + l3.innerText = s; + } + lastLoaded = e.loaded; + if (this.cancelUpload) { + this.log("cancelling upload"); + request.abort(); + } + }); + } else { + this.hideDropInfo(); + } + } + + async uploadFile(destpath, entry, completionCallback, progressCallback) { + var sent = 0; + var file = await this.getFilePromise(entry); + var relpath = (entry instanceof File) ? file.name : entry.fullPath.substr(1); + + const request = new XMLHttpRequest(); + request.open("POST", `cgi-bin/upload.sh?${encodeURIComponent(this.root_path + "/" + destpath)}&${encodeURIComponent(relpath)}`); + request.setRequestHeader("Content-Type", "application/octet-stream"); + request.onreadystatechange = () => { + // Call a function when the state changes. + if (request.readyState === XMLHttpRequest.DONE) { + completionCallback(request.status); + } + }; + request.upload.onprogress = (e) => { + progressCallback(e, request); + }; + this.log(`uploading with progress ${file.name}`); + + request.send(file); + } + + dragColor(counter) { + if (counter > 4) { + return "#00000000"; + } + return "#000000" + ["ff", "cc", "99", "66", "33"][counter]; + } + + createDragImage(selection) { + var img = this.anchor_elem.querySelector(".fb-dragimage"); + var ctx = img.getContext("2d"); + ctx.font = "16px Arial"; + ctx.clearRect(0, 0, 300, 150); + var counter = 0; + for (var item of selection) { + ctx.fillStyle = this.dragColor(counter); + ctx.fillText(item.innerText, 20 + counter * 4, 32 + counter * 6); + counter += 1; + if (counter > 4) { + break; + } + }; + if (selection.length > 1) { + const textwidth = ctx.measureText(selection.length); + ctx.fillStyle = "#ff0000"; + ctx.beginPath(); + ctx.roundRect(0, 0, textwidth.width + 8, 20, [10]); + ctx.fill(); + ctx.fillStyle = "#ffffff"; + ctx.fillText(selection.length, 4, 16); + } + return img; + } + + drop(ev) { + this.log(ev); + ev.preventDefault(); + ev.target.classList.remove("fb-droptarget"); + if (this.dragged_path == ev.dataTransfer.getData("text/plain")) { + this.handleInternalDrop(ev); + return; + } + if (this.hasExternalFiles(ev)) { + this.handleExternalDrop(this.stringDecode(ev.target.dataset.fullpath), ev.dataTransfer.items, null); + return; + } + this.log("internal path inconsistency"); + this.dragged_path = undefined; + } + + dragStart(ev) { + /* pointer capture on the splitter element doesn't seem to + prevent drag&drop being initiated on the tree view, so + check here if the splitter is being dragged and cancel + drag&drop if so. */ + if (this.splitter_active) { + ev.preventDefault(); + return; + } + /* sometimes the browser will initiate a drag on filelist, + even though its draggable attribute is not set. Check + specifically if the thing being dragged is actually + draggable. + */ + if (!ev.target.draggable) { + ev.preventDefault(); + return; + } + ev.dataTransfer.effectAllowed = "move"; + ev.dataTransfer.dropEffect = "move"; + + const leftSideDrag = ev.target.classList.contains("fb-treedirentry"); + + if (leftSideDrag) { + /* dragging from the left side tree view, so deselect everything on the right side */ + this.unselectAll(); + } else if (!this.isSelected(ev.target)) { + /* the item being dragged was not selected, so deselect everything else and then select it */ + this.unselectAll(); + this.selectItem(ev.target); + } + if (ev.target.classList.contains("fb-fileentry") || ev.target.classList.contains("fb-direntry")) { + ev.dataTransfer.setDragImage(this.createDragImage(this.selection()), 22, 18); + } + const num = this.numSelected(); + if (num > 1) { + this.log("multi-drag"); + } else if (num == 1) { + this.log("single-drag"); + } else if (leftSideDrag) { + this.log("single left side drag"); + } else { + this.log("no-drag"); + } + + /* DragEvent.dataTransfer's data is only available in ondrop, but will be + empty in ondragover/enter/leave. Since the source path is needed during + drag to determine whether something can actually be dropped, store it + elsewhere */ + this.dragged_path = this.stringDecode(ev.target.dataset.fullpath); + ev.dataTransfer.setData("text/plain", this.dragged_path); + if (ev.target.classList.contains("fb-treedirentry")) { + ev.dataTransfer.setData("DownloadURL", this.downloadURLForTreeItem(ev.target)); + } else { + ev.dataTransfer.setData("DownloadURL", this.downloadURLForSelection()); + } + } + + dragEnd(ev) { + this.dragged_path = undefined; + } + + downloadURLForTreeItem(item) { + const downloadName = item.innerText + ".zip"; + const fullpath = this.stringDecode(item.dataset.fullpath); + const idx = fullpath.lastIndexOf("/") + 1; + const root = encodeURIComponent(`${this.root_path}${idx?"/":""}${fullpath.substr(0,idx)}`); + const relpath = encodeURIComponent(fullpath.substr(idx)); + this.log(`:${downloadName}:${document.location.href}cgi-bin/downloadzip.sh?${root}&${relpath}`); + return `:${downloadName}:${document.location.href}cgi-bin/downloadzip.sh?${root}&${relpath}`; + } + + downloadURLForSelection() { + var filesOnly = true; + var downloadName = "TeslaUSB-download"; + const selection = this.selection(); + selection.forEach((e) => { if (e.classList.contains("fb-direntry")) filesOnly = false;}); + + if (this.numSelected() == 1) { + const selected = selection[0]; + downloadName = selected.innerText; + if (filesOnly) { + const fullpath = encodeURIComponent(this.stringDecode(selected.dataset.fullpath)); + const root = encodeURIComponent(this.root_path); + return `:${downloadName}:${document.location.href}cgi-bin/download.sh?${root}&${fullpath}`; + } + } + + // the user is dragging multiple entries, or a single directory-entry. + downloadName += ".zip"; + var pathsList = encodeURIComponent(`${this.root_path}`); + if (this.current_path != ".") { + pathsList += encodeURIComponent(`/${this.current_path}`); + } + selection.forEach((e) => { + const fullpath = this.stringDecode(e.dataset.fullpath) + const relpath = this.current_path != "." ? fullpath.substr(this.current_path.length + 1) : fullpath; + pathsList += "&"; + pathsList += encodeURIComponent(relpath); + }); + return `:${downloadName}:${document.location.href}cgi-bin/downloadzip.sh?${pathsList}`; + } +} + +// Export to window for dynamic loading +window.FileBrowser = FileBrowser; diff --git a/teslausb-www-react/dist/fonts/lato-bold.woff2 b/teslausb-www-react/dist/fonts/lato-bold.woff2 new file mode 100644 index 00000000..11de83fe Binary files /dev/null and b/teslausb-www-react/dist/fonts/lato-bold.woff2 differ diff --git a/teslausb-www-react/dist/fonts/lato-italic.woff2 b/teslausb-www-react/dist/fonts/lato-italic.woff2 new file mode 100644 index 00000000..851630ff Binary files /dev/null and b/teslausb-www-react/dist/fonts/lato-italic.woff2 differ diff --git a/teslausb-www-react/dist/fonts/lato-regular.woff2 b/teslausb-www-react/dist/fonts/lato-regular.woff2 new file mode 100644 index 00000000..ff60934d Binary files /dev/null and b/teslausb-www-react/dist/fonts/lato-regular.woff2 differ diff --git a/teslausb-www-react/dist/icons/android-chrome-192x192.png b/teslausb-www-react/dist/icons/android-chrome-192x192.png new file mode 100644 index 00000000..6af01ed2 Binary files /dev/null and b/teslausb-www-react/dist/icons/android-chrome-192x192.png differ diff --git a/teslausb-www-react/dist/icons/android-chrome-512x512.png b/teslausb-www-react/dist/icons/android-chrome-512x512.png new file mode 100644 index 00000000..54097db4 Binary files /dev/null and b/teslausb-www-react/dist/icons/android-chrome-512x512.png differ diff --git a/teslausb-www-react/dist/icons/apple-touch-icon.png b/teslausb-www-react/dist/icons/apple-touch-icon.png new file mode 100644 index 00000000..2eb8cd9a Binary files /dev/null and b/teslausb-www-react/dist/icons/apple-touch-icon.png differ diff --git a/teslausb-www-react/dist/icons/browserconfig.xml b/teslausb-www-react/dist/icons/browserconfig.xml new file mode 100644 index 00000000..e0322103 --- /dev/null +++ b/teslausb-www-react/dist/icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #00aba9 + + + diff --git a/teslausb-www-react/dist/icons/download.svg b/teslausb-www-react/dist/icons/download.svg new file mode 100644 index 00000000..d15e4615 --- /dev/null +++ b/teslausb-www-react/dist/icons/download.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/teslausb-www-react/dist/icons/favicon-16x16.png b/teslausb-www-react/dist/icons/favicon-16x16.png new file mode 100644 index 00000000..c3e47738 Binary files /dev/null and b/teslausb-www-react/dist/icons/favicon-16x16.png differ diff --git a/teslausb-www-react/dist/icons/favicon-32x32.png b/teslausb-www-react/dist/icons/favicon-32x32.png new file mode 100644 index 00000000..2cefcc5c Binary files /dev/null and b/teslausb-www-react/dist/icons/favicon-32x32.png differ diff --git a/teslausb-www-react/dist/icons/favicon.ico b/teslausb-www-react/dist/icons/favicon.ico new file mode 100644 index 00000000..49c8361c Binary files /dev/null and b/teslausb-www-react/dist/icons/favicon.ico differ diff --git a/teslausb-www-react/dist/icons/hamburger.svg b/teslausb-www-react/dist/icons/hamburger.svg new file mode 100644 index 00000000..bad58b08 --- /dev/null +++ b/teslausb-www-react/dist/icons/hamburger.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/teslausb-www-react/dist/icons/locksound.svg b/teslausb-www-react/dist/icons/locksound.svg new file mode 100644 index 00000000..5361095c --- /dev/null +++ b/teslausb-www-react/dist/icons/locksound.svg @@ -0,0 +1,4 @@ + + + + diff --git a/teslausb-www-react/dist/icons/mstile-144x144.png b/teslausb-www-react/dist/icons/mstile-144x144.png new file mode 100644 index 00000000..355ae85c Binary files /dev/null and b/teslausb-www-react/dist/icons/mstile-144x144.png differ diff --git a/teslausb-www-react/dist/icons/mstile-150x150.png b/teslausb-www-react/dist/icons/mstile-150x150.png new file mode 100644 index 00000000..c0c144a8 Binary files /dev/null and b/teslausb-www-react/dist/icons/mstile-150x150.png differ diff --git a/teslausb-www-react/dist/icons/mstile-310x150.png b/teslausb-www-react/dist/icons/mstile-310x150.png new file mode 100644 index 00000000..82381204 Binary files /dev/null and b/teslausb-www-react/dist/icons/mstile-310x150.png differ diff --git a/teslausb-www-react/dist/icons/mstile-310x310.png b/teslausb-www-react/dist/icons/mstile-310x310.png new file mode 100644 index 00000000..9eb3fcb7 Binary files /dev/null and b/teslausb-www-react/dist/icons/mstile-310x310.png differ diff --git a/teslausb-www-react/dist/icons/mstile-70x70.png b/teslausb-www-react/dist/icons/mstile-70x70.png new file mode 100644 index 00000000..a3ee0119 Binary files /dev/null and b/teslausb-www-react/dist/icons/mstile-70x70.png differ diff --git a/teslausb-www-react/dist/icons/newfolder.svg b/teslausb-www-react/dist/icons/newfolder.svg new file mode 100644 index 00000000..bd2e53eb --- /dev/null +++ b/teslausb-www-react/dist/icons/newfolder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/teslausb-www-react/dist/icons/pencil.svg b/teslausb-www-react/dist/icons/pencil.svg new file mode 100644 index 00000000..f69749db --- /dev/null +++ b/teslausb-www-react/dist/icons/pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/teslausb-www-react/dist/icons/safari-pinned-tab.svg b/teslausb-www-react/dist/icons/safari-pinned-tab.svg new file mode 100644 index 00000000..6ee3d0c2 --- /dev/null +++ b/teslausb-www-react/dist/icons/safari-pinned-tab.svg @@ -0,0 +1,38 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/teslausb-www-react/dist/icons/site.webmanifest b/teslausb-www-react/dist/icons/site.webmanifest new file mode 100644 index 00000000..f4fa12e6 --- /dev/null +++ b/teslausb-www-react/dist/icons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "TeslaUSB", + "short_name": "TeslaUSB", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/teslausb-www-react/dist/icons/trash.svg b/teslausb-www-react/dist/icons/trash.svg new file mode 100644 index 00000000..7b26e4d3 --- /dev/null +++ b/teslausb-www-react/dist/icons/trash.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/teslausb-www-react/dist/icons/upload.svg b/teslausb-www-react/dist/icons/upload.svg new file mode 100644 index 00000000..b8c22615 --- /dev/null +++ b/teslausb-www-react/dist/icons/upload.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/teslausb-www-react/dist/index.html b/teslausb-www-react/dist/index.html new file mode 100644 index 00000000..08076b20 --- /dev/null +++ b/teslausb-www-react/dist/index.html @@ -0,0 +1,51 @@ + + + + + + + + + TeslaUSB + + + + + + + + + + + + + +
      + + diff --git a/teslausb-www-react/dist/manifest.json b/teslausb-www-react/dist/manifest.json new file mode 100644 index 00000000..bffe30e1 --- /dev/null +++ b/teslausb-www-react/dist/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "TeslaUSB", + "short_name": "TeslaUSB", + "description": "TeslaUSB Dashboard - Manage your Tesla dashcam and music storage", + "start_url": "/", + "display": "standalone", + "background_color": "#1f2937", + "theme_color": "#1f2937", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/teslausb-www-react/index.html b/teslausb-www-react/index.html new file mode 100644 index 00000000..d4101ca2 --- /dev/null +++ b/teslausb-www-react/index.html @@ -0,0 +1,50 @@ + + + + + + + + + TeslaUSB + + + + + + + + + + + +
      + + + diff --git a/teslausb-www-react/package-lock.json b/teslausb-www-react/package-lock.json new file mode 100644 index 00000000..d6d9f78a --- /dev/null +++ b/teslausb-www-react/package-lock.json @@ -0,0 +1,2006 @@ +{ + "name": "teslausb-www-react", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "teslausb-www-react", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "preact": "^10.28.0", + "vite": "^7.2.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.2.tgz", + "integrity": "sha512-K9wHlJOtkE+cGqlyQ5v9kL3Ge0Ql4LlIZjkUTL+1zf3nNdF88F9UZN6VTV8jdzBX9Fl7WSzeNMSDG7qECPmSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@prefresh/vite": "^2.4.1", + "@rollup/pluginutils": "^4.1.1", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.3.4", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.3" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.2.tgz", + "integrity": "sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.11.tgz", + "integrity": "sha512-/XjURQqdRiCG3NpMmWqE9kJwrg9IchIOWHzulCfqg2sRe/8oQ1g5De7xrk9lbqPIQLn7ntBkKdqWXIj4E9YXyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", + "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.12.tgz", + "integrity": "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/teslausb-www-react/package.json b/teslausb-www-react/package.json new file mode 100644 index 00000000..550badcd --- /dev/null +++ b/teslausb-www-react/package.json @@ -0,0 +1,19 @@ +{ + "name": "teslausb-www-react", + "version": "1.0.0", + "description": "Modern web UI for TeslaUSB", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "preact": "^10.28.0", + "vite": "^7.2.7" + } +} diff --git a/teslausb-www-react/public/filebrowser.css b/teslausb-www-react/public/filebrowser.css new file mode 100644 index 00000000..76e10734 --- /dev/null +++ b/teslausb-www-react/public/filebrowser.css @@ -0,0 +1,592 @@ +/* FileBrowser CSS - Adapted for TeslaUSB React UI */ +/* Uses Lato font and consistent color scheme with main UI */ + +.fb-dragimage { + width: 300px; + height: 150px; + top: -500px; + background: #0095f6; + position: fixed; +} + +.fb-splitter { + width: 3px; + background: #e5e7eb; + border-style: solid; + border-color: #d1d5db; + border-width: 0 1px; + cursor: col-resize; + margin-left: 1px; + margin-right: 1px; + touch-action: none; +} + +.fb-splitterflag { + position: fixed; + width: 40px; + height: 64px; + top: 50%; + background: #e5e7eb; + border-style: solid; + border-color: #d1d5db; + border-width: 1px 0px 1px 1px; + cursor: col-resize; + touch-action: none; +} + +@media(hover: hover) { + .fb-splitterflag { + visibility: hidden; + } +} + +.fb-selection-rect { + position: absolute; + background-color: rgba(0, 149, 246, 0.3); +} + +.fb-tree { + overflow-y: auto; + flex-grow: 1; + padding-left: 30px; + margin-top: 4px; + margin-bottom: 0; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-tree ul { + padding-left: 20px; +} + +.fb-tree li { + display: block; + position: relative; +} + +details summary::-webkit-details-marker { + display: none; +} + +.fb-tree summary { + display: block; + cursor: pointer; + width: max-content; + padding: 4px 8px; + margin-left: 2px; + color: #1f2937; + font-weight: 500; + font-size: 13px; + border-radius: 4px; +} + +.fb-tree summary:hover { + color: #fff; + background: #0095f6; + border-radius: 4px; +} + +.fb-tree summary::before { + content: ''; + top: 4px; + left: -18px; + width: 14px; + height: 14px; + display: block; + position: absolute; + background: #e5e7eb; + border-radius: 50%; +} + +.fb-tree summary:has(+ ul:not(:empty))::before { + background: #93c5fd; +} + +.fb-tree details[open] > summary:has(+ ul:not(:empty))::before { + background: #0095f6; +} + +summary.fb-droptarget, +.fb-direntry.fb-droptarget, +.fb-fileentry.fb-droptarget { + color: #fff; + background: #22c55e; + border-radius: 4px; + box-shadow: 0 0 0 2px #22c55e; +} + +summary.fb-droptarget.fb-selected, +.fb-direntry.fb-droptarget.fb-selected, +.fb-fileentry.fb-droptarget.fb-selected { + color: #fff; + background: #22c55e; + border-radius: 4px; + box-shadow: 0 0 0 30px #dbeafe; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 50%) +} + +.fb-fileslist.fb-droptarget { + box-shadow: inset 0 0 0 3px #22c55e; +} + +.fb-treediv > ul.fb-droptarget, +details > ul.fb-droptarget { + box-shadow: inset 0 0 0 3px #22c55e; +} + +.fb-treerootpath { + position: relative; + padding: 8px 12px; + font-weight: 600; + font-size: 14px; + border-bottom: 1px solid #e5e7eb; + overflow: hidden; + white-space: nowrap; + min-height: 36px; + max-height: 36px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; + color: #1f2937; + display: flex; + align-items: center; +} + +.fb-diskinfo-outer { + position: absolute; + right: 4px; + bottom: 8px; + width: 20px; + height: 20px; + background: white; +} + +.fb-diskinfo-inner { + position: absolute; + left: 2px; + top: 2px; + width: 16px; + height: 16px; + border-color: #9ca3af; + border: 1px solid; + border-radius: 50%; + font-weight: normal; + color: #9ca3af; + text-align: center; + font-size: 11px; + line-height: 14px; +} + +.fb-diskinfo-inner::before { + content: 'i'; +} + +.fb-diskinfo-outer:hover .fb-diskinfo-inner { + color: #0095f6; + background: #dbeafe; + border-color: #0095f6; +} + +.fb-diskinfo { + visibility: hidden; + position: fixed; + margin-top: 24px; + height: auto; + width: auto; + z-index: 1; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 8px 12px; + text-align: center; + background: #fff; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + font-size: 12px; + color: #6b7280; +} + +.fb-diskinfo-outer:hover .fb-diskinfo { + visibility: visible; +} + +.fb-treerootpath:has(.fb-treerootpathsinglelabel) { + padding: 8px 12px; + min-height: 36px; + max-height: 36px; +} + +.fb-dirpath { + padding: 8px 12px; + font-weight: 600; + font-size: 14px; + border-bottom: 1px solid #e5e7eb; + overflow: hidden; + white-space: nowrap; + min-height: 36px; + max-height: 36px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; + color: #1f2937; +} + +.fb-crumb { + color: #0095f6; + cursor: pointer; + display: inline-block; +} + +.fb-crumb:hover { + text-decoration: underline; +} + +.fb-crumb.fb-droptarget { + text-decoration: underline; + text-decoration-thickness: 2px; +} + +.fb-fileentry { + cursor: pointer; + padding: 4px 8px; + width: max-content; + margin-left: 1px; + font-size: 13px; + color: #374151; + border-radius: 4px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-fileentry:hover:not([contenteditable="true"]) { + color: #fff; + background: #0095f6; + border-radius: 4px; +} + +.fb-fileentry.fb-selected { + color: #1e40af; + background: #dbeafe; +} + +.fb-fileentry.fb-selected:hover:not([contenteditable="true"]) { + color: #fff; + background: #2563eb; + border-radius: 4px; + box-shadow: 0 0 0 30px #dbeafe; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 50%) +} + +.fb-direntry { + cursor: pointer; + padding: 4px 8px; + font-weight: 500; + width: max-content; + margin-left: 1px; + font-size: 13px; + color: #1f2937; + border-radius: 4px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-direntry:hover:not([contenteditable="true"]) { + color: #fff; + background: #0095f6; + border-radius: 4px; +} + +.fb-direntry.fb-selected { + color: #1e40af; + background: #dbeafe; +} + +.fb-direntry.fb-selected:hover:not([contenteditable="true"]) { + color: #fff; + background: #2563eb; + box-shadow: 0 0 0 30px #dbeafe; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 50%) +} + +.fb-treediv { + display: flex; + flex-direction: column; + float: left; + background: #fafafa; + min-width: 15%; + max-width: 85%; + width: 30%; + height: 100%; + overflow-y: auto; + user-select: none; + border-right: 1px solid #e5e7eb; +} + +.fb-filesdiv { + flex: 1; + display: flex; + flex-direction: column; + float: left; + background: #fff; + height: 100%; + margin-left: 0; + overflow-x: auto; + user-select: none; +} + +.fb-fileslist { + position: relative; + height: 100%; + margin-top: 4px; + overflow-y: auto; + flex-grow: 1; + padding: 4px; +} + +.fb-playertitle { + color: #fff; + height: 30px; + padding: 10px 8px 6px 8px; + white-space: nowrap; + overflow: auto; + text-align: left; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-playertitle::-webkit-scrollbar { + display: none; +} + +.fb-player { + position: absolute; + width: 300px; + height: 100px; + left: 50%; + top: 75%; + background: #1f2937; + border: none; + border-radius: 12px; + padding: 10px; + transform: translate(-50%, -50%); +} + +.fb-dropinfo-holder { + position: fixed; + visibility: hidden; + z-index: 98; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0); +} + +.fb-dropinfo { + width: 350px; + height: 150px; + top: calc(100% / 2 - 75px); + left: calc(100% / 2 - 175px); + border-radius: 8px; + position: relative; + visibility: inherit; + background: white; + border: 1px solid #e5e7eb; + z-index: 99; + padding: 1px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + user-select: none; + outline: none !important; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-dropinfo-line1 { + position: absolute; + top: 16px; + left: 20px; + font-weight: 600; + color: #1f2937; +} + +.fb-dropinfo-line2 { + position: absolute; + left: 20px; + right: 20px; + top: 40px; + height: 30px; + white-space: nowrap; + overflow: auto; + text-align: left; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; + color: #6b7280; + font-size: 13px; +} + +.fb-dropinfo-line2::-webkit-scrollbar { + display: none; +} + +.fb-dropinfo-line3 { + position: absolute; + left: 20px; + right: 60px; + bottom: 5px; + height: 30px; + white-space: nowrap; + overflow: auto; + text-align: left; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; + color: #6b7280; + font-size: 12px; +} + +.fb-dropinfo-line3::-webkit-scrollbar { + display: none; +} + +.fb-dropinfo-closebutton { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + background: white; + text-align: center; + cursor: pointer; + border-radius: 4px; + color: #9ca3af; + line-height: 24px; +} + +.fb-dropinfo-closebutton:hover { + background: #f3f4f6; + color: #1f2937; +} + +.fb-dropinfo-cancel { + position: absolute; + bottom: 12px; + right: 12px; + padding: 6px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 13px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.fb-dropinfo-cancel:hover { + background: #f3f4f6; +} + +.fb-dropinfo-progress { + position: absolute; + left: 20px; + width: calc(100% - 40px); + top: 90px; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: #e5e7eb; + -webkit-appearance: none; + appearance: none; +} + +.fb-dropinfo-progress::-webkit-progress-bar { + background: #e5e7eb; + border-radius: 4px; +} + +.fb-dropinfo-progress::-webkit-progress-value { + background: #0095f6; + border-radius: 4px; +} + +.fb-dropinfo-progress::-moz-progress-bar { + background: #0095f6; + border-radius: 4px; +} + +.fb-barbutton { + float: left; + width: 40px; + height: 40px; + border: none; + padding: 0; + z-index: 3; + display: none; + cursor: pointer; + border-radius: 8px; + margin: 4px; + background-size: 24px 24px; + background-position: center; + background-repeat: no-repeat; +} + +.fb-barbutton:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.fb-barbutton.fb-visiblebarbutton { + display: block; +} + +.fb-trashbutton { + background-image: url(icons/trash.svg); +} + +.fb-pencilbutton { + background-image: url(icons/pencil.svg); +} + +.fb-uploadbutton { + background-image: url(icons/upload.svg); +} + +.fb-downloadbutton { + background-image: url(icons/download.svg); +} + +.fb-newfolderbutton { + background-image: url(icons/newfolder.svg); +} + +.fb-locksoundbutton { + background-image: url(icons/locksound.svg); +} + +.fb-buttonbar { + position: fixed; + right: 24px; + bottom: 24px; + height: 48px; + padding: 0 4px; + background: #fff; + z-index: 2; + border-radius: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + display: flex; + align-items: center; +} + +@media(hover: hover) { + .uploadbutton { + display: none; + } +} + +.fb-driveselector { + font-size: 14px; + font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif; + background: transparent; + border-width: 1px; + border-style: solid; + border-color: #d1d5db; + border-radius: 4px; + padding: 4px 8px; + color: #1f2937; +} + +.fb-driveselector:focus { + outline: none; + border-color: #0095f6; +} diff --git a/teslausb-www-react/public/filebrowser.js b/teslausb-www-react/public/filebrowser.js new file mode 100644 index 00000000..c8b513fc --- /dev/null +++ b/teslausb-www-react/public/filebrowser.js @@ -0,0 +1,1315 @@ +class FileBrowser { + DEBUG = false; + splitter_active = false; + splitter_clickoffset = 0; + root_path = ''; + root_label = ''; + anchor_elem = undefined; + dragged_path = undefined; + cancelUpload = false; + uploading = false; + drives = []; + curdrive = 0; + + constructor(anchor, drives) { + this.anchor_elem = anchor; + this.drives = drives; + this.root_path = drives[this.curdrive].path; + this.root_label = drives[this.curdrive].label; + + this.anchor_elem.style.position = "relative"; + this.anchor_elem.style.display = "flex"; + this.anchor_elem.spellcheck = false; + this.anchor_elem.innerHTML = + ` +
      +
      +
      +
      +
      +
      + +
      + +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + +
      +
      +
        +
        +
        +
        +
        +
        +
        +
        + `; + + this.dragStart = this.dragStart.bind(this); + this.dragEnd = this.dragEnd.bind(this); + this.allowDrop = this.allowDrop.bind(this); + this.dragEnter = this.dragEnter.bind(this); + this.drop = this.drop.bind(this); + this.readPaths = this.readPaths.bind(this); + this.dirClicked = this.dirClicked.bind(this); + this.fileClicked = this.fileClicked.bind(this); + this.listPointerDown = this.listPointerDown.bind(this); + this.splitterPointerDown = this.splitterPointerDown.bind(this); + this.splitterPointerMove = this.splitterPointerMove.bind(this); + this.splitterPointerUp = this.splitterPointerUp.bind(this); + this.showContextMenu = this.showContextMenu.bind(this); + this.hideContextMenu = this.hideContextMenu.bind(this); + + const splitter = this.anchor_elem.querySelector(".fb-splitter"); + splitter.onpointerdown = (e) => { this.splitterPointerDown(e); }; + const splitterflag = this.anchor_elem.querySelector(".fb-splitterflag"); + splitterflag.onpointerdown = (e) => { this.splitterPointerDown(e); }; + this.splitterSetFlagPos(); + + const fileList = this.anchor_elem.querySelector('.fb-fileslist'); + fileList.onpointerdown = (e) => { this.listPointerDown(e); }; + fileList.oncontextmenu = (e) => { this.showContextMenu(e); }; + fileList.addEventListener("dragstart", this.dragStart); + this.anchor_elem.addEventListener("dragend", this.dragEnd); + + const rootlabel = this.anchor_elem.querySelector(".fb-treerootpath"); + if (this.drives.length > 1) { + var rootlabeldropdown = '
        ' + rootlabel.innerHTML = rootlabeldropdown; + const selector = this.anchor_elem.querySelector(".fb-driveselector"); + selector.onchange = (e) => { + this.curdrive = selector.value; + this.root_path = this.drives[this.curdrive].path; + this.root_label = this.drives[this.curdrive].label; + this.ls(".", false); + this.ls(".", true); + this.updateButtonBar(); + }; + } else { + rootlabel.innerHTML = `${this.drives[0].label}
        `; + } + + this.buttonbar = this.anchor_elem.querySelector(".fb-buttonbar"); + this.buttonbar.querySelector(".fb-uploadbutton").onclick = (e) => { this.pickFile(); }; + this.buttonbar.querySelector(".fb-downloadbutton").onclick = (e) => { this.downloadSelection(); }; + this.buttonbar.querySelector(".fb-newfolderbutton").onclick = (e) => { this.newFolder(); }; + this.buttonbar.querySelector(".fb-trashbutton").onclick = (e) => { this.deleteItems(this.selection()); }; + this.buttonbar.querySelector(".fb-pencilbutton").onclick = (e) => { + const item = this.selection()[0]; + item.scrollIntoView({block: "nearest"}); + this.renameItem(item); + }; + this.buttonbar.querySelector(".fb-locksoundbutton").onclick = (e) => { + const item = this.selection()[0]; + this.makeLockChime(item); + }; + + this.ls(".", true); + this.updateButtonBar(); + } + + log(msg) { + if (this.DEBUG) { + console.log(msg); + } + } + + async refreshLists(callback) { + var tree = this.anchor_elem.querySelector(".fb-tree"); + var openPaths = []; + tree.querySelectorAll("details[open] > summary").forEach((s) => { openPaths.push(s.dataset.fullpath); }); + await this.ls(".", false); + openPaths.forEach((path) => { + var detail = tree.querySelector(`details:has(summary[data-fullpath="${path}"])`); + if (detail != null) { + detail.open = true; + } + }); + await this.ls(this.current_path, true); + if (callback) { + callback(); + } + } + + newFolder() { + var fl = this.anchor_elem.querySelector(".fb-fileslist"); + for (var i = 1; i < 100; i++) { + var str = `${this.current_path == "." ? "" : this.current_path + "/"}New folder${i == 1 ? '' : " (" + i + ")"}`; + this.log(str); + var item = fl.querySelector(`[data-fullpath="${this.stringEncode(str)}"]`); + if (item == null) { + this.readfile( + {url:`cgi-bin/mkdir.sh?${encodeURIComponent(this.root_path)}&${encodeURIComponent(str)}`, + callback:(response, data) => { + this.refreshLists(() => { + var item = fl.querySelector(`[data-fullpath="${this.stringEncode(str)}"]`); + if (item) { + item.scrollIntoView({block: "nearest"}); + this.selectItem(item); + this.renameItem(item); + } + }); + } + }); + return; + } + } + this.log("could not find empty new folder name"); + } + + deleteItems(items) { + var pathsList = ""; + items.forEach((item) => { + const fullpath = this.stringDecode(item.dataset.fullpath) + pathsList += "&"; + pathsList += encodeURIComponent(fullpath); + }); + this.readfile( + {url:`cgi-bin/rm.sh?${this.root_path}${pathsList}`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + } + + deleteItem(item) { + this.deleteItems([item]); + } + + downloadSelection() { + const url = this.downloadURLForSelection().substr(1); + const name = url.substr(0, url.indexOf(":")); + const url2 = url.substr(url.indexOf(":") + 1); + this.log(`name: ${name}, url: ${url2}`); + + var elem = document.createElement('a'); + elem.setAttribute('href', url2); + elem.setAttribute('download', name); + elem.style.display = 'none'; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + } + + selectItemContent(item) { + var range,selection; + if(document.createRange) + { + range = document.createRange(); + range.selectNodeContents(item); + selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + applyRename(item) { + const fullpath = this.stringDecode(item.dataset.fullpath); + const idx = fullpath.lastIndexOf("/"); + const oldname = fullpath.substr(idx + 1); + var newname = item.textContent; + this.log('renaming "' + oldname + '" to "' + newname + '"'); + this.readfile( + {url:`cgi-bin/mv.sh?${this.root_path}/${this.current_path}&${encodeURIComponent(oldname)}&${encodeURIComponent(newname)}`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + } + + stopEditingItem(item, oldValue) { + // clear onblur first because setting contentEditable to false immediately triggers a blur + item.onblur = undefined; + item.contentEditable = false; + item.onkeydown = undefined; + item.oncontextmenu = undefined; + if (item.textContent != oldValue) { + this.applyRename(item); + } + } + + renameItem(item) { + const oldValue = item.textContent; + item.contentEditable = true; + this.selectItemContent(item); + item.onkeydown = (e) => { + if (e.key == 'Enter') { + this.stopEditingItem(item, oldValue); + } else if (e.key == 'Escape') { + item.textContent = oldValue; + this.stopEditingItem(item, oldValue); + } + }; + item.onblur = (e) => { + this.stopEditingItem(item, oldValue); + }; + // Tapping on the selected text to position the cursor + // on mobile results in a context menu event, so intercept + // that while editing. + item.oncontextmenu = (e) => { e.stopPropagation(); }; + item.focus(); + } + + makeLockChime(item) { + // copy the selected item + this.readfile( + {url:`cgi-bin/cp.sh?${this.root_path}&${encodeURIComponent(item.dataset.fullpath)}&LockChime.wav`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + } + + showButton(name, show) { + if (show) { + this.buttonbar.querySelector(name).classList.add("fb-visiblebarbutton"); + } else { + this.buttonbar.querySelector(name).classList.remove("fb-visiblebarbutton"); + } + } + + updateButtonBar() { + const numsel = this.numSelected(); + const enabled = this.valid; + this.showButton(".fb-trashbutton", enabled && numsel > 0); + this.showButton(".fb-pencilbutton", enabled && numsel == 1); + this.showButton(".fb-uploadbutton", enabled && numsel == 0); + this.showButton(".fb-downloadbutton", enabled && numsel > 0); + this.showButton(".fb-newfolderbutton", enabled && numsel == 0); + this.showButton(".fb-locksoundbutton", enabled && numsel == 1 && this.isPotentialLockChime(this.selection()[0])); + if (this.buttonbar.querySelector(".fb-visiblebarbutton") == null) { + this.buttonbar.style.display = "none"; + } else { + this.buttonbar.style.display = "block"; + } + } + + eventCoordinates(e) { + if (e.targetTouches && e.targetTouches.length > 1) { + const t = e.targetTouches[0] + return [t.clientX, t.clientY]; + } + return [e.x, e.y]; + } + + makeMultiSelectContextMenu(event) { + new ContextMenu(this.anchor_elem, + [ + new ContextMenuItem("Download selected items", + () => { + this.downloadSelection(); + }, null), + new ContextMenuItem("Delete selected items", + () => { + this.deleteItems(this.selection()); + }, null) + + ]).show(...this.eventCoordinates(event)); + } + + makeListContextMenu(event) { + new ContextMenu(this.anchor_elem, + [ + new ContextMenuItem("New folder", () => { this.newFolder(); }, null) + ]).show(...this.eventCoordinates(event)); + } + + makeDirContextMenu(event) { + const e = event; + new ContextMenu(this.anchor_elem, + [ + new ContextMenuItem("Rename", () => { this.renameItem(e.target); }, null), + new ContextMenuItem("Download", () => { this.downloadSelection(); }, null), + new ContextMenuItem("Delete", () => { this.deleteItem(e.target); }, null) + ]).show(...this.eventCoordinates(event)); + } + + makeFileContextMenu(event) { + const filename = event.target.innerText; + const e = event; + new ContextMenu(this.anchor_elem, + [ + ...this.isPotentialLockChime(event.target) ? [ new ContextMenuItem("Use as lock sound", () => { this.makeLockChime(e.target); }, null) ] : [], + new ContextMenuItem("Rename", () => { this.renameItem(e.target); }, null), + new ContextMenuItem("Download", () => { this.downloadSelection(); }, null), + new ContextMenuItem("Delete", () => { this.deleteItem(e.target); }, null) + ]).show(...this.eventCoordinates(event)); + } + + hideContextMenu() { + var contextmenu = this.anchor_elem.querySelector(".cm-holder"); + if (contextmenu == null) { + return; + } + contextmenu.style.visibility = "hidden"; + } + + showContextMenu(event) { + this.log("context menu"); + if (!this.valid || event.ctrlKey) { + this.hideContextMenu(); + return; + } + if (event.pointerType == "touch") { + // long press triggered context menu + this.log("touch context menu"); + event.preventDefault(); + return; + } + var target = event.target; + try { + if (target.classList.contains("fb-fileslist")) { + this.unselectAll(); + this.makeListContextMenu(event); + } else if (this.numSelected() > 1 && this.isSelected(target)) { + this.makeMultiSelectContextMenu(event); + } else { + this.unselectAll(); + this.selectItem(target); + if (target.classList.contains("fb-direntry")) { + this.makeDirContextMenu(event); + } else if (target.classList.contains("fb-fileentry")) { + this.makeFileContextMenu(event); + } + } + event.preventDefault(); + } catch (e) { + this.log("error creating context menu"); + this.log(e); + } + } + + splitterSetFlagPos() { + const splitter = this.anchor_elem.querySelector(".fb-splitter"); + const splitterflag = this.anchor_elem.querySelector(".fb-splitterflag"); + splitterflag.style.left = (splitter.getBoundingClientRect().x - + splitterflag.getBoundingClientRect().width + 1) + "px"; + } + + splitterPointerMove(event) { + const treediv = this.anchor_elem.querySelector(".fb-splitter").previousElementSibling; + const treedivrect = treediv.getBoundingClientRect(); + const newwidth = event.clientX + this.splitter_clickoffset - treedivrect.x; + treediv.style.width = `${newwidth}px`; + this.splitterSetFlagPos(); + } + + splitterPointerUp(event) { + const splitter = event.target; + splitter.removeEventListener("pointermove", this.splitterPointerMove); + splitter.removeEventListener("pointerup", this.splitterPointerUp); + splitter.releasePointerCapture(event.pointerId); + this.splitter_active = false; + } + + splitterPointerDown(event) { + var splitter = event.target; + splitter.addEventListener("pointermove", this.splitterPointerMove); + splitter.addEventListener("pointerup", this.splitterPointerUp); + splitter.setPointerCapture(event.pointerId); + const treediv = this.anchor_elem.querySelector(".fb-splitter").previousElementSibling; + const treedivrect = treediv.getBoundingClientRect(); + this.splitter_clickoffset = treedivrect.x + treedivrect.width - event.clientX; + this.splitter_active = true; + } + + intersects(r1, r2) { + return !(r1.x + r1.width < r2.x || + r2.x + r2.width < r1.x || + r1.y + r1.height < r2.y || + r2.y + r2.height < r1.y); + } + + updateSelection(selectRectElem) { + const select = selectRectElem.getBoundingClientRect(); + + const {x, y, height, width} = select; + + selectRectElem.parentElement.querySelectorAll(".fb-direntry, .fb-fileentry").forEach((item) => { + if (this.intersects({x: x + window.scrollX, y: y + window.scrollY, height, width}, item.getBoundingClientRect())){ + item.classList.add("fb-selected"); + } else { + item.classList.remove("fb-selected"); + } + } ); + this.updateButtonBar(); + } + + unselectAll() { + this.selection().forEach((e) => { e.classList.remove("fb-selected");}); + this.updateButtonBar(); + } + + isSelected(elem) { + return elem.classList.contains("fb-selected") + } + + selectItem(elem) { + elem.classList.add("fb-selected"); + this.updateButtonBar(); + } + + selection() { + var fl = this.anchor_elem.querySelector(".fb-fileslist"); + return fl.querySelectorAll(".fb-selected"); + } + + numSelected() { + return this.selection().length; + } + + async createSelectionRectangle(thiz, event) { + const fileList = event.target; + const x = event.offsetX + fileList.scrollLeft; + const y = event.offsetY + fileList.scrollTop; + + const div = document.createElement("div"); + div.style.width = "0"; + div.style.height = "0"; + div.style.left = x + "px"; + div.style.top = y + "px"; + div.classList.add("fb-selection-rect"); + fileList.append(div); + + function resize(event) { + thiz.log("resize"); + if (event.buttons == 0) { + cancelSelectionRectangle(event); + return; + } + if (event.target != fileList) { + return; + } + + const rect = fileList.getBoundingClientRect(); + const offX = (event.touches ? event.touches[0].clientX - rect.left : + event.offsetX) + fileList.scrollLeft; + const offY = (event.touches ? event.touches[0].clientY - rect.top : + event.offsetY) + fileList.scrollTop; + const maxX = fileList.clientWidth + fileList.scrollLeft; + const maxY = fileList.clientHeight + fileList.scrollTop; + const curX = offX > maxX ? maxX : offX; + const curY = offY > maxY ? maxY : offY; + const dX = curX - x; + const dY = curY - y; + div.style.left = dX < 0 ? x + dX + "px" : x + "px"; + div.style.top = dY < 0 ? y + dY + "px" : y + "px"; + div.style.width = Math.abs(dX) + "px"; + div.style.height = Math.abs(dY) + "px"; + thiz.updateSelection(div); + if (event.cancelable) { + event.preventDefault(); + } + } + + if (! event.ctrlKey) { + this.unselectAll(); + } + + function cancelSelectionRectangle(event) { + thiz.log("cancelSelectionRectangle"); + event.target.removeEventListener("pointermove", resize); + try { + fileList.releasePointerCapture(event.pointerId); + } catch(error) { + thiz.log("release error"); + } + fileList.removeEventListener("touchmove", resize); + fileList.removeEventListener("touchend", pointerup); + fileList.removeEventListener("pointermove", resize); + fileList.removeEventListener("pointerup", pointerup); + fileList.removeEventListener("cancelselectionrect", cancelSelectionRectangle); + div.remove(); + } + + function pointerup(event) { + thiz.log("pointerup"); + cancelSelectionRectangle(event); + } + + fileList.setPointerCapture(event.pointerId); + if (event.pointerType == "touch") { + fileList.addEventListener("touchmove", resize); + fileList.addEventListener("touchend", pointerup); + } else { + fileList.addEventListener("pointermove", resize); + fileList.addEventListener("pointerup", pointerup); + } + fileList.addEventListener("cancelselectionrect", cancelSelectionRectangle); + } + + listPointerDown(event) { + /* only respond to left mouse button */ + if (!this.valid || event.button != 0) { + event.preventDefault(); + return; + } + event.target.dispatchEvent( + new Event("cancelselectionrect", { + bubbles: true, + cancelable: true, + composed: false + }) + ); + this.createSelectionRectangle(this, event); + } + + dirClicked(event, path) { + var expanderclicked = (event && event.offsetX < 0); + this.ls(path, !expanderclicked); + } + + isPlayable(filename) { + const lower = filename.toLowerCase(); + for (const ext of [".mp3", ".m4a", ".flac", ".ogg", ".wav"]) { + if (lower.endsWith(ext)) { + return true; + } + } + return false; + } + + isPotentialLockChime(item) { + const lower = item.dataset.fullpath.toLowerCase(); + if (lower == "lockchime.wav") { + return false; + } + if (!lower.endsWith(".wav")) { + return false; + } + if (item.dataset.filesize > 1024 * 1024) { + return false; + } + return true; + } + + fileClicked(event, path) { + this.log(`clicked: ${path}`); + + var displaypath = path; + if (this.isPlayable(path)) { + const div = document.createElement("div"); + div.style.position = "absolute"; + div.style.width = "100%"; + div.style.height = "100%"; + div.style.left = "0"; + div.style.top = "0"; + div.style.background = "#0008"; + div.onclick = (e) => { if (e.target === div) div.remove(); }; + document.firstElementChild.append(div); + div.innerHTML = `
        ${displaypath}
        `; + div.querySelector(".fb-playertitle").scrollLeft=1000; + } + } + + // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem. + base64ToBytes(base64) { + const binString = atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)); + } + + // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem. + bytesToBase64(bytes) { + const binString = String.fromCodePoint(...bytes); + return btoa(binString); + } + + stringEncode(str) { + return str; // this.bytesToBase64((new TextEncoder()).encode(str)); + } + + stringDecode(encstr) { + return encstr; // new TextDecoder().decode(this.base64ToBytes(encstr)); + } + + addCommonDragHooks(item) { + item.ondragover = this.allowDrop; + item.ondragenter = this.dragEnter; + item.ondragleave = this.dragLeave; + item.ondrop = this.drop; + } + + createTreeItem(label, fullPath) { + var li = document.createElement("li"); + li.innerHTML = '
        ' + + '' + label + '' + + '
          '; + const s = li.querySelector("summary"); + s.onclick = (e) => { this.dirClicked(e, this.stringDecode(e.target.dataset.fullpath)); }; + s.ondragstart = this.dragStart; + const u = li.querySelector("ul"); + u.dataset.fullpath = fullPath; + this.addCommonDragHooks(u); + return li; + } + + addDir(root, path) { + var pathParts = path.split("/"); + var pathSoFar = null; + for (var i = 0; i < pathParts.length; i++) { + if (pathSoFar) { + pathSoFar += "/" + pathParts[i]; + } else { + pathSoFar = pathParts[i]; + } + var node = root.querySelector(`[data-fullpath="${this.stringEncode(pathSoFar)}"]`); + if (node == null) { + /* level 'i' doesn't exist yet, add it */ + var newPath = this.createTreeItem(pathParts[i], pathSoFar); + root.appendChild(newPath); + root = newPath.querySelector("ul"); + } else { + /* level 'i' already exists, check the next level */ + root = node.nextElementSibling; + } + } + return root; + } + + readfile({url, callback, callbackarg}) { + var request = new XMLHttpRequest(); + request.open('GET', url); + request.onreadystatechange = function () { + if (request.readyState === XMLHttpRequest.DONE) { + if (request.status === 200) { + var type = request.getResponseHeader('Content-Type'); + if (type.indexOf("text") !== 1) { + if (callback != null) { + callback(request.responseText, callbackarg); + } + } + } else if (request.status > 400) { + if (callback != null) { + callback(null, null); + } + } + } + } + request.send(); + } + + selectFileEntry(ev) { + if (ev.button != 0) return; + ev.stopPropagation(); + if (! ev.ctrlKey) { + /* not multi-select, so deselect everything that was previously selected */ + this.unselectAll(); + } + ev.target.classList.toggle("fb-selected"); + this.updateButtonBar(); + } + + createFileEntry(isdir, name, path, size) { + var div = document.createElement("div"); + div.className = isdir ? "fb-direntry" : "fb-fileentry" + div.textContent = name; + div.draggable = true; + if (isdir) { + div.ondblclick = (e) => { this.dirClicked(null, path); }; + this.addCommonDragHooks(div); + div.dataset.fullpath = this.stringEncode(path); + } else { + const justThePath = path.substring(0, path.lastIndexOf(":")); + div.ondblclick = (e) => { this.fileClicked(null, justThePath); }; + div.dataset.fullpath = this.stringEncode(justThePath); + div.dataset.filesize = path.substring(path.lastIndexOf(":") + 1); + } + div.onclick = (e) => { this.selectFileEntry(e); }; + div.onpointerdown = (e) => { e.stopPropagation(); }; + return div; + } + + addFileEntry(path) { + var isDir = path.indexOf("d:") == 0; + path = path.substring(2); + var lastSlash = path.lastIndexOf("/"); + var name = path.substring(lastSlash + 1); + var lastColon = name.lastIndexOf(":"); + var size = 0; + if (lastColon > 0) { + size = name.substring(lastColon + 1); + name = name.substring(0, lastColon); + } + var newFile = this.createFileEntry(isDir, name, path, size); + var listDiv = this.anchor_elem.querySelector('.fb-fileslist'); + listDiv.appendChild(newFile); + } + + setDiskStats(freebytes, totalbytes) { + var diskinfospan = this.anchor_elem.querySelector('.fb-diskinfo'); + diskinfospan.innerText = `${this.niceNumber(freebytes)} free of ${this.niceNumber(totalbytes)}`; + } + + /* + switchtopath=false: only update the left-hand side tree view + switchtopath=true: update the right-hand side + */ + readPaths(path, paths, switchtopath) { + this.valid = (paths != null && switchtopath != null); + if (!this.valid) { + var pathdiv = this.anchor_elem.querySelector(".fb-dirpath"); + pathdiv.innerText = "<< error retrieving file list >>"; + } + paths = this.valid ? paths.trimEnd() : ""; + var root = this.anchor_elem.querySelector(".fb-tree"); + root.dataset.fullpath="."; + this.addCommonDragHooks(root); + if (path == "." && !switchtopath) { + root.innerHTML = ''; + } + var lines = paths.split('\n'); + if (! this.valid || switchtopath) { + this.anchor_elem.querySelector('.fb-fileslist').querySelectorAll(".fb-direntry,.fb-fileentry").forEach((entry) => entry.remove()); + } + for (var line of lines) { + if (line.indexOf("d:") == 0 || line.indexOf("D:") == 0) { + this.addDir(root, line.substring(2)); + } + if (line.indexOf("s:") == 0) { + let [freebytes, totalbytes] = line.substring(2).split(":"); + this.setDiskStats(freebytes, totalbytes); + } else if (switchtopath && ! line.indexOf("D:") == 0) { + this.addFileEntry(line); + } + } + this.updateButtonBar(); + } + + makeOnClick(thiz, path) { + return function() { thiz.dirClicked(null, path); }; + + } + + setClickablePath(container, path) { + var pathParts = []; + if (!path) { + path = "."; + } else if (path != ".") { + pathParts = path.split("/"); + } + this.current_path = path; + const fileList = this.anchor_elem.querySelector('.fb-fileslist'); + fileList.dataset.fullpath = this.stringEncode(path); + this.addCommonDragHooks(fileList); + + container.innerHTML = ""; + var pathSoFar = null; + + var a = document.createElement("a"); + if (pathParts.length > 0) { + a.className = "fb-crumb"; + a.onclick = this.makeOnClick(this, "."); + this.addCommonDragHooks(a); + a.dataset.fullpath = this.stringEncode("."); + } + a.innerText = '[' + this.root_label + ']'; + container.appendChild(a); + + for (var i = 0; i < pathParts.length; i++) { + if (pathSoFar) { + pathSoFar += "/" + pathParts[i]; + } else{ + pathSoFar = pathParts[i]; + } + var a = document.createElement("a"); + if (i < pathParts.length - 1) { + a.className = "fb-crumb"; + a.onclick = this.makeOnClick(this, pathSoFar); + this.addCommonDragHooks(a); + a.dataset.fullpath = this.stringEncode(pathSoFar); + } + a.innerText = pathParts[i]; + container.append("/"); + container.appendChild(a); + } + } + + async ls(path, switchtopath) { + return new Promise((resolve, reject) => { + + if (switchtopath) { + var pathdiv = this.anchor_elem.querySelector(".fb-dirpath"); + this.setClickablePath(pathdiv, path); + } + this.readfile({url:`cgi-bin/ls.sh?${encodeURIComponent(this.root_path)}&${encodeURIComponent(path)}`, callback:(paths,switchto) => { this.readPaths(path, paths, switchto); resolve(); }, callbackarg:switchtopath}); + }); + } + + isDropAllowedForTarget(ev) { + var destPath = this.stringDecode(ev.target.dataset.fullpath); + if (destPath == undefined) { + return false; + } + if (ev.target.classList.contains("fb-fileentry")) { + /* files are not drop targets */ + return false; + } + if (!this.dragged_path) { + /* external files are not subject to path checks */ + return true; + } + var pathList = []; + const selection = this.selection(); + if (selection.length == 0) { + // single item from the left side tree view + pathList.push(this.dragged_path); + } else { + // one or more items from the right side file/folder view + selection.forEach((srcItem) => { + pathList.push(this.stringDecode(srcItem.dataset.fullpath)); + }); + } + //this.log(`${pathList.toString()} => ${destPath}`); + for (var srcPath of pathList) { + if (destPath.startsWith(srcPath)) { + /* can't drop parent in child */ + return false; + } + const idx = srcPath.lastIndexOf("/"); + const srcDir = idx > 0 ? srcPath.substr(0, idx) : "."; + if (srcDir == destPath) { + //this.log(`not OK: ${srcDir} => ${destPath}`); + return; + } + //this.log(`OK: ${srcPath} => ${destPath}`); + } + return true; + } + + allowDrop(ev) { + if (!this.isDropAllowedForTarget(ev)) { + return; + } + ev.preventDefault(); + } + + dragEnter(ev) { + if (this.isDropAllowedForTarget(ev)) { + ev.target.classList.add("fb-droptarget"); + } + } + + dragLeave(ev) { + ev.target.classList.remove("fb-droptarget"); + } + + hasExternalFiles(ev) { + this.log(`${ev.dataTransfer.items.length} items dropped`); + this.log(ev.dataTransfer.items.length); + this.log(...ev.dataTransfer.items); + this.log(ev.dataTransfer); + this.log(ev); + if (ev.dataTransfer.items.length > 0) { + return true; + } + return false; + } + + handleInternalDrop(ev) { + const selection = this.selection(); + var pathList = []; + if (selection.length == 0) { + pathList.push(this.dragged_path); + } else { + selection.forEach((srcItem) => { + pathList.push(this.stringDecode(srcItem.dataset.fullpath)); + }); + } + var pathString = ""; + pathList.forEach((path) => { + pathString += `&${encodeURIComponent(path)}`; + }); + this.log(`${pathList} => ${this.stringDecode(ev.target.dataset.fullpath)}`); + this.readfile( + {url:`cgi-bin/mv.sh?${this.root_path}${pathString}&${this.stringDecode(ev.target.dataset.fullpath)}`, + callback:(response, data) => { + this.log(response); + this.refreshLists(); + } + }); + + } + + async cancelDrop() { + if (!this.cancelUpload) { + this.cancelUpload = true; + const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); + while (this.uploading) { + await sleep(50); + } + } + this.hideDropInfo(); + } + + showDropInfo() { + var di = this.anchor_elem.querySelector(".fb-dropinfo-holder"); + di.style.visibility = "visible"; + var cb = this.anchor_elem.querySelector(".fb-dropinfo-closebutton"); + cb.onmousedown = (e) => { this.cancelDrop(); }; + var cb = this.anchor_elem.querySelector(".fb-dropinfo-cancel"); + cb.onclick = (e) => { this.cancelDrop(); }; + var l1 = this.anchor_elem.querySelector(".fb-dropinfo-line1"); + l1.innerText = "Building file list..."; + l1.style.visibility="inherit"; + var p = this.anchor_elem.querySelector(".fb-dropinfo-progress"); + p.style.visibility="hidden"; + } + + niceNumber(totalsize) { + var str = ""; + if (totalsize < 100000) { + str += `${totalsize} bytes` + } else if (totalsize < 2000000) { + str += `${(totalsize / 1024).toFixed(0)} KB` + } else if (totalsize < 1100000000) { + str += `${(totalsize / (1024 * 1024)).toFixed(0)} MB` + } else { + str += `${(totalsize / (1024 * 1024 * 1024)).toFixed(2)} GB` + } + return str; + } + + updateDropInfo(numfiles, totalsize) { + var l2 = this.anchor_elem.querySelector(".fb-dropinfo-line2"); + var str = `${numfiles} file`; + if (numfiles != 1) { + str += "s"; + } + str += ", " + this.niceNumber(totalsize); + l2.innerText = str; + + } + + hideDropInfo() { + var di = this.anchor_elem.querySelector(".fb-dropinfo-holder"); + di.style.visibility = "hidden"; + this.refreshLists(); + } + + async getFilePromise(entry) { + try { + if (entry instanceof File) { + return entry; + } + return await new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); + } catch (err) { + this.log(err); + } + } + + async readEntriesPromise(reader) { + try { + return await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + } catch (err) { + this.log(err); + } + } + + async readAllDirectoryEntries(reader) { + var entries = []; + var readEntries = await this.readEntriesPromise(reader); + while (readEntries.length > 0) { + entries.push(...readEntries); + readEntries = await this.readEntriesPromise(reader); + } + return entries; + } + + async handleExternalDrop(targetpath, datatransferitems, files) { + this.cancelUpload = false; + this.uploading = true; + var totalBytes = 0; + var fileList = []; + var queue = []; + if (datatransferitems) { + // Use DataTransferItemList interface to access the file(s) + [...datatransferitems].forEach((item, i) => { + var entry = item.webkitGetAsEntry(); + this.log(entry); + if (entry != null) { + queue.push(entry); + } + }); + } else if (files) { + queue.push(...files); + } + + while (queue.length > 0) { + if (this.cancelUpload) { + this.uploading = false; + return; + } + //this.log(`processing... (${fileList.length})`); + var entry = queue.shift(); + if (entry.isDirectory) { + queue.push(...await this.readAllDirectoryEntries(entry.createReader())); + } else { + fileList.push(entry); + if (fileList.length == 1) { + this.showDropInfo(); + } + var file = await this.getFilePromise(entry); + totalBytes += file.size; + this.updateDropInfo(fileList.length, totalBytes); + } + } + this.log(`total size: ${totalBytes}`); + var l1 = this.anchor_elem.querySelector(".fb-dropinfo-line1"); + l1.numitems = fileList.length; + + var p = this.anchor_elem.querySelector(".fb-dropinfo-progress"); + p.style.visibility="inherit"; + p.max = totalBytes; + p.value = 0; + this.uploadFiles(targetpath, fileList); + } + + pickFile() { + var input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (e) => { + if (input.files.length > 0) { + this.handleExternalDrop(this.current_path, null, input.files); + } + }; + input.click(); + } + + uploadFiles(destpath, fileList) { + var lastLoaded = 0; + if (fileList.length > 0 && ! this.cancelUpload) { + var f = fileList.shift(); + var l1 = this.anchor_elem.querySelector(".fb-dropinfo-line1"); + l1.innerText = `File ${l1.numitems - fileList.length} / ${l1.numitems}`; + var l2 = this.anchor_elem.querySelector(".fb-dropinfo-line2"); + l2.innerText = f.name; + this.uploadFile(destpath, f, + (status) => { + // completion function + if (status === 200) { + this.uploadFiles(destpath, fileList); + } else { + this.log(`status: ${status}`); + this.uploading = false; + } + }, + (e, request) => { + // progress function + const p = this.anchor_elem.querySelector(".fb-dropinfo-progress"); + p.value += (e.loaded - lastLoaded); + const size1 = this.niceNumber(p.value); + const size2 = this.niceNumber(p.max); + const s = `${size1} / ${size2}`; + const l3 = this.anchor_elem.querySelector(".fb-dropinfo-line3"); + if (l3.innerText != s) { + l3.innerText = s; + } + lastLoaded = e.loaded; + if (this.cancelUpload) { + this.log("cancelling upload"); + request.abort(); + } + }); + } else { + this.hideDropInfo(); + } + } + + async uploadFile(destpath, entry, completionCallback, progressCallback) { + var sent = 0; + var file = await this.getFilePromise(entry); + var relpath = (entry instanceof File) ? file.name : entry.fullPath.substr(1); + + const request = new XMLHttpRequest(); + request.open("POST", `cgi-bin/upload.sh?${encodeURIComponent(this.root_path + "/" + destpath)}&${encodeURIComponent(relpath)}`); + request.setRequestHeader("Content-Type", "application/octet-stream"); + request.onreadystatechange = () => { + // Call a function when the state changes. + if (request.readyState === XMLHttpRequest.DONE) { + completionCallback(request.status); + } + }; + request.upload.onprogress = (e) => { + progressCallback(e, request); + }; + this.log(`uploading with progress ${file.name}`); + + request.send(file); + } + + dragColor(counter) { + if (counter > 4) { + return "#00000000"; + } + return "#000000" + ["ff", "cc", "99", "66", "33"][counter]; + } + + createDragImage(selection) { + var img = this.anchor_elem.querySelector(".fb-dragimage"); + var ctx = img.getContext("2d"); + ctx.font = "16px Arial"; + ctx.clearRect(0, 0, 300, 150); + var counter = 0; + for (var item of selection) { + ctx.fillStyle = this.dragColor(counter); + ctx.fillText(item.innerText, 20 + counter * 4, 32 + counter * 6); + counter += 1; + if (counter > 4) { + break; + } + }; + if (selection.length > 1) { + const textwidth = ctx.measureText(selection.length); + ctx.fillStyle = "#ff0000"; + ctx.beginPath(); + ctx.roundRect(0, 0, textwidth.width + 8, 20, [10]); + ctx.fill(); + ctx.fillStyle = "#ffffff"; + ctx.fillText(selection.length, 4, 16); + } + return img; + } + + drop(ev) { + this.log(ev); + ev.preventDefault(); + ev.target.classList.remove("fb-droptarget"); + if (this.dragged_path == ev.dataTransfer.getData("text/plain")) { + this.handleInternalDrop(ev); + return; + } + if (this.hasExternalFiles(ev)) { + this.handleExternalDrop(this.stringDecode(ev.target.dataset.fullpath), ev.dataTransfer.items, null); + return; + } + this.log("internal path inconsistency"); + this.dragged_path = undefined; + } + + dragStart(ev) { + /* pointer capture on the splitter element doesn't seem to + prevent drag&drop being initiated on the tree view, so + check here if the splitter is being dragged and cancel + drag&drop if so. */ + if (this.splitter_active) { + ev.preventDefault(); + return; + } + /* sometimes the browser will initiate a drag on filelist, + even though its draggable attribute is not set. Check + specifically if the thing being dragged is actually + draggable. + */ + if (!ev.target.draggable) { + ev.preventDefault(); + return; + } + ev.dataTransfer.effectAllowed = "move"; + ev.dataTransfer.dropEffect = "move"; + + const leftSideDrag = ev.target.classList.contains("fb-treedirentry"); + + if (leftSideDrag) { + /* dragging from the left side tree view, so deselect everything on the right side */ + this.unselectAll(); + } else if (!this.isSelected(ev.target)) { + /* the item being dragged was not selected, so deselect everything else and then select it */ + this.unselectAll(); + this.selectItem(ev.target); + } + if (ev.target.classList.contains("fb-fileentry") || ev.target.classList.contains("fb-direntry")) { + ev.dataTransfer.setDragImage(this.createDragImage(this.selection()), 22, 18); + } + const num = this.numSelected(); + if (num > 1) { + this.log("multi-drag"); + } else if (num == 1) { + this.log("single-drag"); + } else if (leftSideDrag) { + this.log("single left side drag"); + } else { + this.log("no-drag"); + } + + /* DragEvent.dataTransfer's data is only available in ondrop, but will be + empty in ondragover/enter/leave. Since the source path is needed during + drag to determine whether something can actually be dropped, store it + elsewhere */ + this.dragged_path = this.stringDecode(ev.target.dataset.fullpath); + ev.dataTransfer.setData("text/plain", this.dragged_path); + if (ev.target.classList.contains("fb-treedirentry")) { + ev.dataTransfer.setData("DownloadURL", this.downloadURLForTreeItem(ev.target)); + } else { + ev.dataTransfer.setData("DownloadURL", this.downloadURLForSelection()); + } + } + + dragEnd(ev) { + this.dragged_path = undefined; + } + + downloadURLForTreeItem(item) { + const downloadName = item.innerText + ".zip"; + const fullpath = this.stringDecode(item.dataset.fullpath); + const idx = fullpath.lastIndexOf("/") + 1; + const root = encodeURIComponent(`${this.root_path}${idx?"/":""}${fullpath.substr(0,idx)}`); + const relpath = encodeURIComponent(fullpath.substr(idx)); + this.log(`:${downloadName}:${document.location.href}cgi-bin/downloadzip.sh?${root}&${relpath}`); + return `:${downloadName}:${document.location.href}cgi-bin/downloadzip.sh?${root}&${relpath}`; + } + + downloadURLForSelection() { + var filesOnly = true; + var downloadName = "TeslaUSB-download"; + const selection = this.selection(); + selection.forEach((e) => { if (e.classList.contains("fb-direntry")) filesOnly = false;}); + + if (this.numSelected() == 1) { + const selected = selection[0]; + downloadName = selected.innerText; + if (filesOnly) { + const fullpath = encodeURIComponent(this.stringDecode(selected.dataset.fullpath)); + const root = encodeURIComponent(this.root_path); + return `:${downloadName}:${document.location.href}cgi-bin/download.sh?${root}&${fullpath}`; + } + } + + // the user is dragging multiple entries, or a single directory-entry. + downloadName += ".zip"; + var pathsList = encodeURIComponent(`${this.root_path}`); + if (this.current_path != ".") { + pathsList += encodeURIComponent(`/${this.current_path}`); + } + selection.forEach((e) => { + const fullpath = this.stringDecode(e.dataset.fullpath) + const relpath = this.current_path != "." ? fullpath.substr(this.current_path.length + 1) : fullpath; + pathsList += "&"; + pathsList += encodeURIComponent(relpath); + }); + return `:${downloadName}:${document.location.href}cgi-bin/downloadzip.sh?${pathsList}`; + } +} + +// Export to window for dynamic loading +window.FileBrowser = FileBrowser; diff --git a/teslausb-www-react/public/fonts/lato-bold.woff2 b/teslausb-www-react/public/fonts/lato-bold.woff2 new file mode 100644 index 00000000..11de83fe Binary files /dev/null and b/teslausb-www-react/public/fonts/lato-bold.woff2 differ diff --git a/teslausb-www-react/public/fonts/lato-italic.woff2 b/teslausb-www-react/public/fonts/lato-italic.woff2 new file mode 100644 index 00000000..851630ff Binary files /dev/null and b/teslausb-www-react/public/fonts/lato-italic.woff2 differ diff --git a/teslausb-www-react/public/fonts/lato-regular.woff2 b/teslausb-www-react/public/fonts/lato-regular.woff2 new file mode 100644 index 00000000..ff60934d Binary files /dev/null and b/teslausb-www-react/public/fonts/lato-regular.woff2 differ diff --git a/teslausb-www-react/public/icons/android-chrome-192x192.png b/teslausb-www-react/public/icons/android-chrome-192x192.png new file mode 100644 index 00000000..6af01ed2 Binary files /dev/null and b/teslausb-www-react/public/icons/android-chrome-192x192.png differ diff --git a/teslausb-www-react/public/icons/android-chrome-512x512.png b/teslausb-www-react/public/icons/android-chrome-512x512.png new file mode 100644 index 00000000..54097db4 Binary files /dev/null and b/teslausb-www-react/public/icons/android-chrome-512x512.png differ diff --git a/teslausb-www-react/public/icons/apple-touch-icon.png b/teslausb-www-react/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..2eb8cd9a Binary files /dev/null and b/teslausb-www-react/public/icons/apple-touch-icon.png differ diff --git a/teslausb-www-react/public/icons/browserconfig.xml b/teslausb-www-react/public/icons/browserconfig.xml new file mode 100644 index 00000000..e0322103 --- /dev/null +++ b/teslausb-www-react/public/icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #00aba9 + + + diff --git a/teslausb-www-react/public/icons/download.svg b/teslausb-www-react/public/icons/download.svg new file mode 100644 index 00000000..d15e4615 --- /dev/null +++ b/teslausb-www-react/public/icons/download.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/teslausb-www-react/public/icons/favicon-16x16.png b/teslausb-www-react/public/icons/favicon-16x16.png new file mode 100644 index 00000000..c3e47738 Binary files /dev/null and b/teslausb-www-react/public/icons/favicon-16x16.png differ diff --git a/teslausb-www-react/public/icons/favicon-32x32.png b/teslausb-www-react/public/icons/favicon-32x32.png new file mode 100644 index 00000000..2cefcc5c Binary files /dev/null and b/teslausb-www-react/public/icons/favicon-32x32.png differ diff --git a/teslausb-www-react/public/icons/favicon.ico b/teslausb-www-react/public/icons/favicon.ico new file mode 100644 index 00000000..49c8361c Binary files /dev/null and b/teslausb-www-react/public/icons/favicon.ico differ diff --git a/teslausb-www-react/public/icons/hamburger.svg b/teslausb-www-react/public/icons/hamburger.svg new file mode 100644 index 00000000..bad58b08 --- /dev/null +++ b/teslausb-www-react/public/icons/hamburger.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/teslausb-www-react/public/icons/locksound.svg b/teslausb-www-react/public/icons/locksound.svg new file mode 100644 index 00000000..5361095c --- /dev/null +++ b/teslausb-www-react/public/icons/locksound.svg @@ -0,0 +1,4 @@ + + + + diff --git a/teslausb-www-react/public/icons/mstile-144x144.png b/teslausb-www-react/public/icons/mstile-144x144.png new file mode 100644 index 00000000..355ae85c Binary files /dev/null and b/teslausb-www-react/public/icons/mstile-144x144.png differ diff --git a/teslausb-www-react/public/icons/mstile-150x150.png b/teslausb-www-react/public/icons/mstile-150x150.png new file mode 100644 index 00000000..c0c144a8 Binary files /dev/null and b/teslausb-www-react/public/icons/mstile-150x150.png differ diff --git a/teslausb-www-react/public/icons/mstile-310x150.png b/teslausb-www-react/public/icons/mstile-310x150.png new file mode 100644 index 00000000..82381204 Binary files /dev/null and b/teslausb-www-react/public/icons/mstile-310x150.png differ diff --git a/teslausb-www-react/public/icons/mstile-310x310.png b/teslausb-www-react/public/icons/mstile-310x310.png new file mode 100644 index 00000000..9eb3fcb7 Binary files /dev/null and b/teslausb-www-react/public/icons/mstile-310x310.png differ diff --git a/teslausb-www-react/public/icons/mstile-70x70.png b/teslausb-www-react/public/icons/mstile-70x70.png new file mode 100644 index 00000000..a3ee0119 Binary files /dev/null and b/teslausb-www-react/public/icons/mstile-70x70.png differ diff --git a/teslausb-www-react/public/icons/newfolder.svg b/teslausb-www-react/public/icons/newfolder.svg new file mode 100644 index 00000000..bd2e53eb --- /dev/null +++ b/teslausb-www-react/public/icons/newfolder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/teslausb-www-react/public/icons/pencil.svg b/teslausb-www-react/public/icons/pencil.svg new file mode 100644 index 00000000..f69749db --- /dev/null +++ b/teslausb-www-react/public/icons/pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/teslausb-www-react/public/icons/safari-pinned-tab.svg b/teslausb-www-react/public/icons/safari-pinned-tab.svg new file mode 100644 index 00000000..6ee3d0c2 --- /dev/null +++ b/teslausb-www-react/public/icons/safari-pinned-tab.svg @@ -0,0 +1,38 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/teslausb-www-react/public/icons/site.webmanifest b/teslausb-www-react/public/icons/site.webmanifest new file mode 100644 index 00000000..f4fa12e6 --- /dev/null +++ b/teslausb-www-react/public/icons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "TeslaUSB", + "short_name": "TeslaUSB", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/teslausb-www-react/public/icons/trash.svg b/teslausb-www-react/public/icons/trash.svg new file mode 100644 index 00000000..7b26e4d3 --- /dev/null +++ b/teslausb-www-react/public/icons/trash.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/teslausb-www-react/public/icons/upload.svg b/teslausb-www-react/public/icons/upload.svg new file mode 100644 index 00000000..b8c22615 --- /dev/null +++ b/teslausb-www-react/public/icons/upload.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/teslausb-www-react/public/manifest.json b/teslausb-www-react/public/manifest.json new file mode 100644 index 00000000..bffe30e1 --- /dev/null +++ b/teslausb-www-react/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "TeslaUSB", + "short_name": "TeslaUSB", + "description": "TeslaUSB Dashboard - Manage your Tesla dashcam and music storage", + "start_url": "/", + "display": "standalone", + "background_color": "#1f2937", + "theme_color": "#1f2937", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/teslausb-www-react/src/App.jsx b/teslausb-www-react/src/App.jsx new file mode 100644 index 00000000..c06d3357 --- /dev/null +++ b/teslausb-www-react/src/App.jsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from 'preact/hooks'; +import { useStatus } from './hooks/useStatus'; +import { Header } from './components/Header'; +import { Sidebar } from './components/Sidebar'; +import { Dashboard } from './components/Dashboard'; +import { VideoViewer } from './components/VideoViewer'; +import { FileBrowser } from './components/FileBrowser'; +import { LogViewer } from './components/LogViewer'; +import { LoadingScreen } from './components/LoadingScreen'; +import { CarIcon } from './components/Icons'; + +// Tab definitions +const TABS = { + DASHBOARD: 'dashboard', + VIEWER: 'viewer', + FILES: 'files', + LOGS: 'logs', +}; + +export function App() { + const [activeTab, setActiveTab] = useState(TABS.DASHBOARD); + const [sidebarExpanded, setSidebarExpanded] = useState(false); + const { status, config, storage, computed, loading, error, lastUpdate, refresh } = useStatus(5000); + + // Note: Dashboard is always the default tab, no auto-switching + + // Determine which tabs are available + const availableTabs = []; + availableTabs.push({ id: TABS.DASHBOARD, label: 'Dashboard' }); + + if (config?.has_cam === 'yes') { + availableTabs.push({ id: TABS.VIEWER, label: 'Viewer' }); + } + + if (config?.has_music === 'yes' || config?.has_lightshow === 'yes' || config?.has_boombox === 'yes') { + availableTabs.push({ id: TABS.FILES, label: 'Files' }); + } + + availableTabs.push({ id: TABS.LOGS, label: 'Logs' }); + + if (loading && !status) { + return ; + } + + if (error && !status) { + return ( +
          + Failed to load: {error} + +
          + ); + } + + return ( +
          + {/* Top bar */} +
          +
          + + TeslaUSB +
          +
          + + {computed?.drivesActive ? 'Connected' : 'Disconnected'} + +
          +
          + + {/* Header with tabs */} +
          + + {/* Mobile quick status bar */} +
          +
          +
          + USB +
          +
          +
          + WiFi +
          +
          +
          + {computed?.cpuTempC}°C +
          +
          + + {/* Main content */} +
          + {/* Sidebar - only on dashboard */} + {activeTab === TABS.DASHBOARD && ( + setSidebarExpanded(!sidebarExpanded)} + onRefresh={refresh} + /> + )} + + {/* Tab content */} +
          + {activeTab === TABS.DASHBOARD && ( + + )} + + {activeTab === TABS.VIEWER && config?.has_cam === 'yes' && ( + + )} + + {activeTab === TABS.FILES && ( + + )} + + {activeTab === TABS.LOGS && ( + + )} +
          +
          +
          + ); +} + +export default App; diff --git a/teslausb-www-react/src/components/Dashboard.jsx b/teslausb-www-react/src/components/Dashboard.jsx new file mode 100644 index 00000000..d2a33a6f --- /dev/null +++ b/teslausb-www-react/src/components/Dashboard.jsx @@ -0,0 +1,108 @@ +import { useState, useCallback, useMemo } from 'preact/hooks'; +import { useLogTail, parseSyncStatus } from '../hooks/useLogTail'; +import { useMusicSyncProgress, useCamSyncProgress } from '../hooks/useMusicSyncProgress'; +import { triggerSync } from '../services/api'; +import { + CameraIcon, + MusicIcon, + HardDriveIcon, + BluetoothIcon, +} from './Icons'; +import { StorageBar } from './StorageBar'; +import { SyncStatus } from './SyncStatus'; + +export function Dashboard({ status, computed, config, storage, onRefresh }) { + const [syncLoading, setSyncLoading] = useState(false); + + // Tail archiveloop log for sync status + const { lines: logLines } = useLogTail('archiveloop.log', 3000, true); + const syncStatus = parseSyncStatus(logLines); + + // Check if music sync is active (for enabling progress polling) + const isMusicSyncActive = useMemo(() => { + return syncStatus.state === 'archiving' && + syncStatus.message?.toLowerCase().includes('music'); + }, [syncStatus.state, syncStatus.message]); + + // Check if CAM archiving is active (for enabling progress polling) + const isCamSyncActive = useMemo(() => { + return syncStatus.state === 'archiving' && + !syncStatus.message?.toLowerCase().includes('music'); + }, [syncStatus.state, syncStatus.message]); + + // Poll music sync progress when music sync is active + const musicProgress = useMusicSyncProgress(isMusicSyncActive, 1500); + + // Poll CAM sync progress when CAM archiving is active + const camProgress = useCamSyncProgress(isCamSyncActive, 1500); + + const handleTriggerSync = useCallback(async () => { + setSyncLoading(true); + try { + await triggerSync(); + setTimeout(onRefresh, 1000); + } catch (e) { + console.error('Trigger sync failed:', e); + } finally { + setSyncLoading(false); + } + }, [onRefresh]); + + // Feature status items for compact display + const features = [ + { key: 'cam', label: 'TeslaCam', icon: CameraIcon, enabled: config?.has_cam === 'yes' }, + { key: 'music', label: 'Music', icon: MusicIcon, enabled: config?.has_music === 'yes' }, + { key: 'lightshow', label: 'LightShow', icon: HardDriveIcon, enabled: config?.has_lightshow === 'yes' }, + { key: 'boombox', label: 'Boombox', icon: HardDriveIcon, enabled: config?.has_boombox === 'yes' }, + ]; + + // Only show BLE if configured + if (config?.uses_ble === 'yes') { + features.push({ key: 'ble', label: 'BLE', icon: BluetoothIcon, enabled: true }); + } + + return ( +
          + {/* Configured Features */} +
          +
          Configured Features
          +
          + {features.map(({ key, label, enabled }) => ( +
          + {label} +
          + ))} +
          +
          + + {/* Storage Visualization */} +
          +
          +
          + Storage + {computed?.diskTotalGB || '0'} GB +
          + +
          +
          + + {/* Sync Status */} +
          + +
          +
          + ); +} + +export default Dashboard; diff --git a/teslausb-www-react/src/components/FileBrowser.jsx b/teslausb-www-react/src/components/FileBrowser.jsx new file mode 100644 index 00000000..c9ded0c9 --- /dev/null +++ b/teslausb-www-react/src/components/FileBrowser.jsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +/** + * FileBrowser component - wraps the native filebrowser.js + * Dynamically loads the script and CSS, then initializes the FileBrowser class + */ +export function FileBrowser({ config }) { + const containerRef = useRef(null); + const browserRef = useRef(null); + const [scriptLoaded, setScriptLoaded] = useState(!!window.FileBrowser); + + // Load CSS on mount + useEffect(() => { + if (!document.querySelector('link[href="/filebrowser.css"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/filebrowser.css'; + document.head.appendChild(link); + } + }, []); + + // Load script on mount + useEffect(() => { + if (window.FileBrowser) { + setScriptLoaded(true); + return; + } + + const script = document.createElement('script'); + script.src = '/filebrowser.js'; + script.onload = () => setScriptLoaded(true); + script.onerror = () => console.error('Failed to load filebrowser.js'); + document.head.appendChild(script); + }, []); + + // Extract config values to use as stable dependencies + const hasMusic = config?.has_music === 'yes'; + const hasLightshow = config?.has_lightshow === 'yes'; + const hasBoombox = config?.has_boombox === 'yes'; + + // Initialize FileBrowser when script is loaded and config is ready + useEffect(() => { + if (!scriptLoaded || !containerRef.current) { + return; + } + + // Build drives array based on config + const drives = []; + if (hasMusic) { + drives.push({ path: 'fs/Music', label: 'Music' }); + } + if (hasLightshow) { + drives.push({ path: 'fs/LightShow', label: 'LightShow' }); + } + if (hasBoombox) { + drives.push({ path: 'fs/Boombox', label: 'Boombox' }); + } + + if (drives.length === 0) { + return; + } + + // Only initialize once - don't recreate if already exists + if (browserRef.current) { + return; + } + + // Create new FileBrowser instance + try { + browserRef.current = new window.FileBrowser(containerRef.current, drives); + } catch (e) { + console.error('Failed to initialize FileBrowser:', e); + } + + // Cleanup on unmount + return () => { + if (containerRef.current) { + containerRef.current.innerHTML = ''; + } + browserRef.current = null; + }; + }, [scriptLoaded, hasMusic, hasLightshow, hasBoombox]); + + // Check if any drives are configured + const hasDrives = config?.has_music === 'yes' || + config?.has_lightshow === 'yes' || + config?.has_boombox === 'yes'; + + if (!hasDrives) { + return ( +
          + + + + No file drives configured +
          + ); + } + + // Show loading state while script loads + if (!scriptLoaded) { + return ( +
          + Loading file browser... +
          + ); + } + + return ( +
          + ); +} + +export default FileBrowser; diff --git a/teslausb-www-react/src/components/Header.jsx b/teslausb-www-react/src/components/Header.jsx new file mode 100644 index 00000000..9b8bd17b --- /dev/null +++ b/teslausb-www-react/src/components/Header.jsx @@ -0,0 +1,49 @@ +import { DashboardIcon, VideoIcon, FolderIcon, TerminalIcon, RefreshIcon } from './Icons'; + +const TAB_ICONS = { + dashboard: DashboardIcon, + viewer: VideoIcon, + files: FolderIcon, + logs: TerminalIcon, +}; + +export function Header({ tabs, activeTab, onTabChange, lastUpdate, onRefresh }) { + const formatTime = (date) => { + if (!date) return ''; + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
          +
          + +
          + +
          + + {lastUpdate && `Updated ${formatTime(lastUpdate)}`} + + +
          +
          + ); +} + +export default Header; diff --git a/teslausb-www-react/src/components/Icons.jsx b/teslausb-www-react/src/components/Icons.jsx new file mode 100644 index 00000000..bc7a4d05 --- /dev/null +++ b/teslausb-www-react/src/components/Icons.jsx @@ -0,0 +1,373 @@ +/** + * SVG Icon components for TeslaUSB UI + * Using inline SVGs for minimal bundle size + */ + +export function CarIcon({ className }) { + return ( + + + + + + ); +} + +export function DashboardIcon({ className }) { + return ( + + + + + + + ); +} + +export function VideoIcon({ className }) { + return ( + + + + + ); +} + +export function FolderIcon({ className }) { + return ( + + + + ); +} + +export function FileIcon({ className }) { + return ( + + + + + ); +} + +export function SettingsIcon({ className }) { + return ( + + + + + ); +} + +export function RefreshIcon({ className }) { + return ( + + + + + ); +} + +export function PlayIcon({ className }) { + return ( + + + + ); +} + +export function PauseIcon({ className }) { + return ( + + + + + ); +} + +export function SkipBackIcon({ className }) { + return ( + + + + + ); +} + +export function SkipForwardIcon({ className }) { + return ( + + + + + ); +} + +export function UploadIcon({ className }) { + return ( + + + + + + ); +} + +export function DownloadIcon({ className }) { + return ( + + + + + + ); +} + +export function TrashIcon({ className }) { + return ( + + + + + ); +} + +export function PlusIcon({ className }) { + return ( + + + + + ); +} + +export function XIcon({ className }) { + return ( + + + + + ); +} + +export function CheckIcon({ className }) { + return ( + + + + ); +} + +export function AlertIcon({ className }) { + return ( + + + + + + ); +} + +export function WifiIcon({ className }) { + return ( + + + + + + + ); +} + +export function HardDriveIcon({ className }) { + return ( + + + + + + + ); +} + +export function CpuIcon({ className }) { + return ( + + + + + + + + + + + + + ); +} + +export function UsbIcon({ className }) { + return ( + + + + + + + + + + ); +} + +export function SyncIcon({ className }) { + return ( + + + + + + + ); +} + +export function PowerIcon({ className }) { + return ( + + + + + ); +} + +export function BluetoothIcon({ className }) { + return ( + + + + ); +} + +export function ClockIcon({ className }) { + return ( + + + + + ); +} + +export function TerminalIcon({ className }) { + return ( + + + + + ); +} + +export function InfoIcon({ className }) { + return ( + + + + + + ); +} + +export function ChevronDownIcon({ className }) { + return ( + + + + ); +} + +export function ChevronRightIcon({ className }) { + return ( + + + + ); +} + +export function MenuIcon({ className }) { + return ( + + + + + + ); +} + +export function MapPinIcon({ className }) { + return ( + + + + + ); +} + +export function MusicIcon({ className }) { + return ( + + + + + + ); +} + +export function CameraIcon({ className }) { + return ( + + + + + ); +} + +export function SpeedIcon({ className }) { + return ( + + + + + ); +} + +export function GridIcon({ className }) { + return ( + + + + + + + ); +} + +export function LayoutIcon({ className }) { + return ( + + + + + + ); +} + +export function SwitchIcon({ className }) { + return ( + + + + + + + ); +} diff --git a/teslausb-www-react/src/components/LoadingScreen.jsx b/teslausb-www-react/src/components/LoadingScreen.jsx new file mode 100644 index 00000000..7a620c42 --- /dev/null +++ b/teslausb-www-react/src/components/LoadingScreen.jsx @@ -0,0 +1,10 @@ +export function LoadingScreen() { + return ( +
          +
          + Loading... +
          + ); +} + +export default LoadingScreen; diff --git a/teslausb-www-react/src/components/LogViewer.jsx b/teslausb-www-react/src/components/LogViewer.jsx new file mode 100644 index 00000000..8e55f859 --- /dev/null +++ b/teslausb-www-react/src/components/LogViewer.jsx @@ -0,0 +1,226 @@ +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; +import { useLogTail } from '../hooks/useLogTail'; +import { generateDiagnostics, fetchDiagnostics } from '../services/api'; +import { + TerminalIcon, + RefreshIcon, + DownloadIcon, + TrashIcon, + InfoIcon, +} from './Icons'; + +// Log types +const LOG_TYPES = { + archiveloop: { + label: 'Archive Log', + file: 'archiveloop.log', + description: 'Sync and archive operations', + }, + setup: { + label: 'Setup Log', + file: 'teslausb-headless-setup.log', + description: 'Initial setup and configuration', + }, + diagnostics: { + label: 'Diagnostics', + file: 'diagnostics.txt', + description: 'System diagnostics report', + }, +}; + +export function LogViewer() { + const [activeLog, setActiveLog] = useState('archiveloop'); + const [diagnosticsLoading, setDiagnosticsLoading] = useState(false); + const [diagnosticsContent, setDiagnosticsContent] = useState(null); + const logContainerRef = useRef(null); + + // Use log tailing hook for archive and setup logs + const isRegularLog = activeLog !== 'diagnostics'; + const { + lines, + loading, + error, + refresh, + clear, + } = useLogTail( + isRegularLog ? LOG_TYPES[activeLog].file : '', + 2000, + isRegularLog + ); + + // Auto-scroll to bottom + useEffect(() => { + if (logContainerRef.current && isRegularLog) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [lines, isRegularLog]); + + // Generate and load diagnostics + const handleGenerateDiagnostics = useCallback(async () => { + setDiagnosticsLoading(true); + setDiagnosticsContent(null); + try { + await generateDiagnostics(); + const content = await fetchDiagnostics(); + setDiagnosticsContent(content); + } catch (e) { + setDiagnosticsContent(`Error generating diagnostics: ${e.message}`); + } finally { + setDiagnosticsLoading(false); + } + }, []); + + // Load diagnostics when tab is selected + useEffect(() => { + if (activeLog === 'diagnostics' && !diagnosticsContent) { + handleGenerateDiagnostics(); + } + }, [activeLog, diagnosticsContent, handleGenerateDiagnostics]); + + // Download log file + const handleDownload = useCallback(() => { + const content = activeLog === 'diagnostics' ? diagnosticsContent : lines.join('\n'); + const filename = LOG_TYPES[activeLog].file; + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [activeLog, lines, diagnosticsContent]); + + // Get line class for syntax highlighting + const getLineClass = (line) => { + const lower = line.toLowerCase(); + if (lower.includes('error') || lower.includes('failed') || lower.includes('fatal')) { + return 'error'; + } + if (lower.includes('warning') || lower.includes('warn')) { + return 'warning'; + } + if (lower.includes('success') || lower.includes('completed') || lower.includes('finished')) { + return 'success'; + } + return ''; + }; + + const currentLogInfo = LOG_TYPES[activeLog]; + const displayLines = activeLog === 'diagnostics' + ? (diagnosticsContent?.split('\n') || []) + : lines; + + return ( +
          + {/* Log type selector */} +
          + {Object.entries(LOG_TYPES).map(([key, log]) => ( + + ))} +
          + + {/* Log viewer */} +
          +
          +
          + {currentLogInfo.label} + + — {currentLogInfo.description} + +
          +
          + {activeLog === 'diagnostics' ? ( + + ) : ( + <> + + + + )} + +
          +
          + +
          + {(loading && displayLines.length === 0) || diagnosticsLoading ? ( +
          Loading...
          + ) : error ? ( + error.includes('not found') ? ( +
          + Log file not available. {activeLog === 'setup' && 'The setup log is only present during initial setup.'} +
          + ) : ( +
          Error: {error}
          + ) + ) : displayLines.length === 0 ? ( +
          No log entries
          + ) : ( + displayLines.map((line, idx) => ( +
          + {line} +
          + )) + )} +
          +
          + + {/* Log stats */} + {isRegularLog && lines.length > 0 && ( +
          + {lines.length} lines + Auto-refreshing every 2s +
          + )} +
          + ); +} + +export default LogViewer; diff --git a/teslausb-www-react/src/components/Sidebar.jsx b/teslausb-www-react/src/components/Sidebar.jsx new file mode 100644 index 00000000..09a89a44 --- /dev/null +++ b/teslausb-www-react/src/components/Sidebar.jsx @@ -0,0 +1,310 @@ +import { useState } from 'preact/hooks'; +import { + CpuIcon, + WifiIcon, + UsbIcon, + CameraIcon, + ChevronDownIcon, + PowerIcon, + SpeedIcon, + BluetoothIcon, + SwitchIcon, +} from './Icons'; +import { toggleDrives, reboot, runSpeedTest, startBLEPairing, checkBLEStatus } from '../services/api'; + +export function Sidebar({ status, computed, config, expanded, onToggle, onRefresh }) { + const [usbLoading, setUsbLoading] = useState(false); + const [rebootLoading, setRebootLoading] = useState(false); + const [speedTestRunning, setSpeedTestRunning] = useState(false); + const [speedTestResult, setSpeedTestResult] = useState(null); + const [bleStatus, setBleStatus] = useState(null); + + const handleToggleDrives = async () => { + setUsbLoading(true); + try { + await toggleDrives(); + setTimeout(onRefresh, 1000); + } catch (e) { + console.error('Toggle drives failed:', e); + } finally { + setUsbLoading(false); + } + }; + + const handleReboot = async () => { + if (confirm('Are you sure you want to restart TeslaUSB?')) { + setRebootLoading(true); + try { + await reboot(); + } catch (e) { + console.error('Reboot failed:', e); + } + } + }; + + const handleSpeedTest = async () => { + setSpeedTestRunning(true); + setSpeedTestResult(null); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + try { + await runSpeedTest( + (speed) => setSpeedTestResult(speed.toFixed(1)), + controller.signal + ); + } catch (e) { + if (e.name !== 'AbortError') { + console.error('Speed test failed:', e); + } + } finally { + clearTimeout(timeout); + setSpeedTestRunning(false); + } + }; + + const handleBLEPairing = async () => { + setBleStatus('pairing'); + try { + const started = await startBLEPairing(); + if (started) { + for (let i = 0; i < 60; i++) { + await new Promise(r => setTimeout(r, 2000)); + const paired = await checkBLEStatus(); + if (paired) { + setBleStatus('paired'); + return; + } + } + setBleStatus('timeout'); + } else { + setBleStatus('error'); + } + } catch (e) { + console.error('BLE pairing failed:', e); + setBleStatus('error'); + } + }; + + return ( + + ); +} + +function getTempClass(temp) { + if (!temp) return ''; + const t = parseFloat(temp); + if (t >= 80) return 'status-unhealthy'; + if (t >= 70) return 'status-degraded'; + return 'status-healthy'; +} + +function getStorageClass(percent) { + if (!percent) return 'blue'; + if (percent >= 90) return 'red'; + if (percent >= 75) return 'yellow'; + return 'blue'; +} + +function formatSnapshotDate(timestamp) { + if (!timestamp) return '-'; + try { + const date = new Date(parseInt(timestamp, 10) * 1000); + return date.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch { + return timestamp; + } +} + +function formatWifiFreq(freq) { + if (!freq) return '-'; + // iwgetid returns frequency like "2.412 GHz" or just the number + const match = freq.match(/(\d+\.?\d*)/); + if (match) { + const ghz = parseFloat(match[1]); + const rawFreq = ({ghz.toFixed(3)}); + // Show band label (2.4 or 5 GHz) with actual frequency + if (ghz >= 2.4 && ghz < 2.5) { + return <>2.4 GHz{rawFreq}; + } else if (ghz >= 5 && ghz < 6) { + return <>5 GHz{rawFreq}; + } else if (ghz >= 6) { + return <>6 GHz{rawFreq}; + } + return `${ghz} GHz`; + } + return freq; +} + +export default Sidebar; diff --git a/teslausb-www-react/src/components/StorageBar.jsx b/teslausb-www-react/src/components/StorageBar.jsx new file mode 100644 index 00000000..dbf95b56 --- /dev/null +++ b/teslausb-www-react/src/components/StorageBar.jsx @@ -0,0 +1,123 @@ +/** + * Storage visualization component + * Shows per-drive allocation/usage and available free space + */ +export function StorageBar({ storage, total, free, config }) { + // Format bytes to human readable + const formatBytes = (bytes) => { + if (bytes === 0 || bytes === null || bytes === undefined) return '0 B'; + const gb = bytes / (1024 * 1024 * 1024); + if (gb >= 1) return `${gb.toFixed(1)} GB`; + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(0)} MB`; + }; + + const freeBytes = storage?.total?.free || free; + + // Build list of configured drives + const drives = []; + + const addDrive = (driveData, type, label, configKey) => { + if (config?.[configKey] !== 'yes' || !driveData) return; + + drives.push({ + type, + label, + allocated: driveData.total, + used: driveData.used, + mounted: driveData.mounted, + isCached: driveData.cached || false, + }); + }; + + addDrive(storage?.cam, 'teslacam', 'TeslaCam', 'has_cam'); + addDrive(storage?.music, 'music', 'Music', 'has_music'); + addDrive(storage?.lightshow, 'lightshow', 'LightShow', 'has_lightshow'); + addDrive(storage?.boombox, 'boombox', 'Boombox', 'has_boombox'); + + if (drives.length === 0 && !freeBytes) { + return ( +
          +
          No storage data available
          +
          + ); + } + + // Calculate total for bar (sum of all allocations + free) + const totalAllocated = drives.reduce((sum, d) => sum + (d.allocated || 0), 0); + const barTotal = totalAllocated + freeBytes; + + // Build segments for bar + const segments = drives.map(drive => ({ + type: drive.type, + label: drive.label, + bytes: drive.allocated, + percent: Math.max(1, Math.round((drive.allocated / barTotal) * 100)), + })); + + // Add free space segment + if (freeBytes > 0) { + segments.push({ + type: 'free', + label: 'Free', + bytes: freeBytes, + percent: Math.max(1, Math.round((freeBytes / barTotal) * 100)), + }); + } + + return ( +
          + {/* Visual bar */} +
          + {segments.map((seg, idx) => ( +
          + ))} +
          + + {/* Legend with details */} +
          + {drives.map((drive, idx) => { + // Calculate usage percentage within the drive (not allocation percentage) + const usagePercent = drive.used !== null && drive.allocated > 0 + ? Math.round((drive.used / drive.allocated) * 100) + : null; + return ( +
          +
          + {drive.label} + + {formatBytes(drive.allocated)} + {drive.used !== null && ( + + {' '}({formatBytes(drive.used)} used{drive.isCached ? '~' : ''} + {usagePercent !== null && `, ${usagePercent}%`}) + + )} + +
          + ); + })} +
          +
          + Free + + {formatBytes(freeBytes)} + +
          +
          + + {drives.some(d => d.isCached) && ( +
          + ~ Last known (drive not currently mounted) +
          + )} +
          + ); +} + +export default StorageBar; diff --git a/teslausb-www-react/src/components/SyncStatus.jsx b/teslausb-www-react/src/components/SyncStatus.jsx new file mode 100644 index 00000000..f74c3713 --- /dev/null +++ b/teslausb-www-react/src/components/SyncStatus.jsx @@ -0,0 +1,189 @@ +import { SyncIcon, CheckIcon, AlertIcon, ClockIcon } from './Icons'; +import { formatBytes, formatEta } from '../hooks/useMusicSyncProgress'; + +/** + * Compact Sync Status Card + */ +export function SyncStatus({ syncStatus, onTriggerSync, loading, musicProgress, camProgress }) { + const { state, message, elapsedTime, lastActivity } = syncStatus; + + // Determine sync type based on message + const isMusicSync = message?.toLowerCase().includes('music') && state === 'archiving'; + const isCamSync = state === 'archiving' && !isMusicSync; + + // Check if we have active progress data from the respective APIs + const hasMusicProgress = musicProgress?.active; + const hasCamProgress = camProgress?.active; + + const getStateInfo = () => { + switch (state) { + case 'idle': + return { label: 'Idle', color: 'idle', description: 'Ready to archive' }; + case 'connecting': + return { label: 'Connecting', color: 'connecting', description: message || 'Connecting to server...' }; + case 'archiving': + return { label: 'Archiving', color: 'archiving', description: message || 'Syncing files...' }; + case 'complete': + return { label: 'Complete', color: 'idle', description: message || 'Archive complete' }; + case 'error': + return { label: 'Error', color: 'error', description: message || 'Archive failed' }; + default: + return { label: 'Unknown', color: 'idle', description: 'Status unknown' }; + } + }; + + const stateInfo = getStateInfo(); + const isActive = state === 'archiving' || state === 'connecting'; + + // Determine which progress source to use + const showMusicProgress = isMusicSync && hasMusicProgress; + const showCamProgress = isCamSync && hasCamProgress; + const showAnyProgress = showMusicProgress || showCamProgress; + + // Calculate progress percentage from the active source + let progressPercent = null; + let progressDetails = null; + + if (showMusicProgress && musicProgress.percentage > 0) { + progressPercent = musicProgress.percentage; + progressDetails = { + type: 'music', + bytesTransferred: musicProgress.bytesTransferred, + speed: musicProgress.speed, + eta: musicProgress.eta, + }; + } else if (showCamProgress) { + // Use percentage if available, otherwise calculate from files + if (camProgress.percentage > 0) { + progressPercent = camProgress.percentage; + } else if (camProgress.filesTotal > 0 && camProgress.filesDone > 0) { + progressPercent = Math.min(Math.round((camProgress.filesDone / camProgress.filesTotal) * 100), 100); + } + progressDetails = { + type: 'cam', + bytesTransferred: camProgress.bytesTransferred, + speed: camProgress.speed, + eta: camProgress.eta, + filesDone: camProgress.filesDone, + filesTotal: camProgress.filesTotal, + currentFile: camProgress.currentFile, + }; + } + + const formatLastActivity = (date) => { + if (!date) return null; + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return date.toLocaleDateString(); + }; + + return ( +
          +
          + Sync Status +
          +
          + {stateInfo.label} +
          +
          + +
          + {stateInfo.description} +
          + + {/* Progress bar - shown during archiving */} + {isActive && ( +
          +
          +
          + )} + + {/* Progress details - consistent format for both sync types */} + {isActive && progressDetails && ( +
          + {/* Primary progress line */} +
          + {progressDetails.type === 'music' ? ( + <> + {formatBytes(progressDetails.bytesTransferred)} transferred + {progressPercent !== null && ` (${progressPercent}%)`} + + ) : ( + <> + {progressDetails.bytesTransferred > 0 ? ( + <> + {formatBytes(progressDetails.bytesTransferred)} transferred + {progressPercent !== null && ` (${progressPercent}%)`} + + ) : progressDetails.filesTotal > 0 ? ( + <> + {progressDetails.filesDone} / {progressDetails.filesTotal} files + {progressPercent !== null && ` (${progressPercent}%)`} + + ) : ( + 'Archiving...' + )} + + )} +
          + {/* Secondary line with speed and ETA */} + {(progressDetails.speed || progressDetails.eta) && ( +
          + {progressDetails.speed && {progressDetails.speed}} + {progressDetails.speed && progressDetails.eta && · } + {progressDetails.eta && {formatEta(progressDetails.eta)}} +
          + )} +
          + )} + + {/* Indeterminate progress message when archiving but no progress data yet */} + {isActive && !progressDetails && state === 'archiving' && ( +
          +
          + Preparing... +
          +
          + )} + + {/* Last activity for idle state */} + {!isActive && lastActivity && ( +
          + Last sync: {formatLastActivity(lastActivity)} +
          + )} + + {/* Elapsed time for complete state */} + {elapsedTime && state === 'complete' && ( +
          + Completed in {elapsedTime} +
          + )} + + {/* Manual trigger button */} + {(state === 'idle' || state === 'complete' || state === 'error') && ( + + )} +
          + ); +} + +export default SyncStatus; diff --git a/teslausb-www-react/src/components/VideoViewer.jsx b/teslausb-www-react/src/components/VideoViewer.jsx new file mode 100644 index 00000000..b4cb36a7 --- /dev/null +++ b/teslausb-www-react/src/components/VideoViewer.jsx @@ -0,0 +1,487 @@ +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; +import { fetchVideoList, fetchEventData, getVideoUrl } from '../services/api'; +import { + PlayIcon, + PauseIcon, + SkipBackIcon, + SkipForwardIcon, + LayoutIcon, + MapPinIcon, + ChevronDownIcon, +} from './Icons'; + +// Camera angles in Tesla vehicles +const CAMERAS = { + front: 'Front', + back: 'Back', + left_repeater: 'Left Repeater', + right_repeater: 'Right Repeater', + left_pillar: 'Left Pillar', + right_pillar: 'Right Pillar', +}; + +// Layout configurations +const LAYOUTS = [ + { id: '6', name: 'All Cameras', cameras: Object.keys(CAMERAS), cols: 3 }, + { id: '4-front', name: 'Front Focus', cameras: ['front', 'left_repeater', 'right_repeater', 'back'], cols: 2 }, + { id: '4-rear', name: 'Rear Focus', cameras: ['back', 'left_repeater', 'right_repeater', 'front'], cols: 2 }, + { id: '2-side', name: 'Side View', cameras: ['left_repeater', 'right_repeater'], cols: 2 }, + { id: '1-front', name: 'Front Only', cameras: ['front'], cols: 1 }, + { id: '1-back', name: 'Rear Only', cameras: ['back'], cols: 1 }, +]; + +export function VideoViewer() { + const [videoList, setVideoList] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Playback state + const [selectedCategory, setSelectedCategory] = useState('SentryClips'); + const [selectedSequence, setSelectedSequence] = useState(null); + const [selectedTimestamp, setSelectedTimestamp] = useState(null); // For RecentClips time selection + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [layout, setLayout] = useState(LAYOUTS[0]); + const [showLayoutMenu, setShowLayoutMenu] = useState(false); + + // Event data for sentry clips + const [eventData, setEventData] = useState(null); + + // Video refs for synchronized playback + const videoRefs = useRef({}); + const masterCamera = useRef(null); + + // Load video list + useEffect(() => { + loadVideoList(); + }, []); + + const loadVideoList = async () => { + try { + setLoading(true); + const list = await fetchVideoList(); + setVideoList(list); + + // Select first available sequence + for (const category of ['SentryClips', 'SavedClips', 'RecentClips']) { + const sequences = Object.keys(list[category] || {}); + if (sequences.length > 0) { + setSelectedCategory(category); + setSelectedSequence(sequences[0]); + break; + } + } + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + // Load event data when sequence changes + useEffect(() => { + // Reset master camera when sequence changes + masterCamera.current = null; + setCurrentTime(0); + setDuration(0); + + if (selectedCategory === 'SentryClips' && selectedSequence) { + fetchEventData(selectedSequence).then(setEventData); + } else { + setEventData(null); + } + }, [selectedCategory, selectedSequence]); + + // Extract unique timestamps from files (for all clip types with multiple timestamps) + const getTimestamps = useCallback(() => { + if (!videoList || !selectedSequence) return []; + + const files = videoList[selectedCategory]?.[selectedSequence] || []; + const timestamps = new Set(); + + for (const file of files) { + // Extract timestamp: 2025-12-14_14-15-05-front.mp4 -> 2025-12-14_14-15-05 + const match = file.match(/(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})/); + if (match) { + timestamps.add(match[1]); + } + } + + // Only show time selector if there's more than one timestamp + const result = Array.from(timestamps).sort().reverse(); + return result.length > 1 ? result : []; + }, [videoList, selectedCategory, selectedSequence]); + + const timestamps = getTimestamps(); + + // Auto-select most recent timestamp when timestamps change + useEffect(() => { + if (timestamps.length > 0 && !selectedTimestamp) { + setSelectedTimestamp(timestamps[0]); + } + }, [timestamps, selectedTimestamp]); + + // Get video files for current sequence + const getVideoFiles = useCallback(() => { + if (!videoList || !selectedSequence) return {}; + + const files = videoList[selectedCategory]?.[selectedSequence] || []; + const result = {}; + + // Only filter by timestamp when there are multiple timestamps to choose from + const filterTimestamp = timestamps.length > 0 ? selectedTimestamp : null; + + for (const file of files) { + // If filtering by timestamp, skip files that don't match + if (filterTimestamp && !file.startsWith(filterTimestamp)) { + continue; + } + + // Extract camera from filename: 2025-01-15_12-30-45-front.mp4 + for (const camera of Object.keys(CAMERAS)) { + if (file.includes(`-${camera}.mp4`) || file.includes(`_${camera}.mp4`)) { + result[camera] = getVideoUrl(selectedCategory, selectedSequence, file); + break; + } + } + } + + return result; + }, [videoList, selectedCategory, selectedSequence, selectedTimestamp, timestamps]); + + const videoFiles = getVideoFiles(); + + // Synchronized playback controls + const playAll = useCallback(() => { + Object.values(videoRefs.current).forEach(video => { + if (video) video.play(); + }); + setPlaying(true); + }, []); + + const pauseAll = useCallback(() => { + Object.values(videoRefs.current).forEach(video => { + if (video) video.pause(); + }); + setPlaying(false); + }, []); + + const seekAll = useCallback((time) => { + Object.values(videoRefs.current).forEach(video => { + if (video) video.currentTime = time; + }); + setCurrentTime(time); + }, []); + + const skipBack = useCallback(() => { + seekAll(Math.max(0, currentTime - 10)); + }, [currentTime, seekAll]); + + const skipForward = useCallback(() => { + seekAll(Math.min(duration, currentTime + 30)); + }, [currentTime, duration, seekAll]); + + // Handle time updates from videos - only use master camera to avoid jumping + const handleTimeUpdate = useCallback((camera) => (e) => { + if (camera === masterCamera.current) { + setCurrentTime(e.target.currentTime); + } + }, []); + + const handleDurationChange = useCallback((camera) => (e) => { + const dur = e.target.duration; + // Only accept valid, finite durations + if (dur && dur !== Infinity && !isNaN(dur)) { + // Set master camera when we first get a valid duration + if (!masterCamera.current) { + masterCamera.current = camera; + } + // Only update duration from master camera + if (camera === masterCamera.current) { + setDuration(dur); + } + } + }, []); + + // Handle timeline click + const handleTimelineClick = useCallback((e) => { + const rect = e.currentTarget.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + const time = percent * duration; + seekAll(time); + }, [duration, seekAll]); + + // Format time display + const formatTime = (seconds) => { + if (!seconds || isNaN(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Get sequences for category dropdown + const sequences = videoList?.[selectedCategory] ? Object.keys(videoList[selectedCategory]).sort().reverse() : []; + + if (loading) { + return ( +
          + Loading recordings... +
          + ); + } + + if (error) { + return ( +
          + Error: {error} + +
          + ); + } + + const hasVideos = selectedSequence && Object.keys(videoFiles).length > 0; + + return ( +
          + {/* Clip selector bar - always visible so users can switch categories */} +
          + {/* Category selector */} + + + {/* Sequence selector */} + + + {/* Timestamp selector (when multiple timestamps exist in a folder) */} + {timestamps.length > 0 && ( + + )} + + {/* Layout selector */} +
          + + {showLayoutMenu && ( +
          + {LAYOUTS.map(l => ( + + ))} +
          + )} +
          + + {/* Event location info */} + {eventData && eventData.city && ( +
          + + {eventData.city} +
          + )} +
          + + {/* Video grid or empty state */} + {hasVideos ? ( + <> +
          + {layout.cameras.map(camera => ( +
          + {videoFiles[camera] ? ( +
          + ))} +
          + + {/* Video controls */} +
          + + + + + + +
          + {formatTime(currentTime)} / {formatTime(duration)} +
          + +
          +
          0 ? `${(currentTime / duration) * 100}%` : '0%' }} + /> +
          +
          + + ) : ( +
          + No recordings in {selectedCategory === 'SentryClips' ? 'Sentry Clips' : + selectedCategory === 'SavedClips' ? 'Saved Clips' : 'Recent Clips'} +
          + )} +
          + ); +} + +// Format sequence name (date/time folder) to readable string +function formatSequenceName(sequence) { + // Format: 2025-01-15_12-30-45 or 2025-01-15 + const match = sequence.match(/(\d{4})-(\d{2})-(\d{2})(?:_(\d{2})-(\d{2})-(\d{2}))?/); + if (match) { + const [, year, month, day, hour, min, sec] = match; + const date = new Date(year, month - 1, day, hour || 0, min || 0, sec || 0); + if (hour) { + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); + } + return sequence; +} + +// Format timestamp to just show time (HH:MM) +function formatTimestamp(timestamp) { + // Format: 2025-12-14_14-15-05 -> 2:15 PM + const match = timestamp.match(/\d{4}-\d{2}-\d{2}_(\d{2})-(\d{2})-(\d{2})/); + if (match) { + const [, hour, min] = match; + const date = new Date(2000, 0, 1, parseInt(hour), parseInt(min)); + return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + return timestamp; +} + +export default VideoViewer; diff --git a/teslausb-www-react/src/hooks/useLogTail.js b/teslausb-www-react/src/hooks/useLogTail.js new file mode 100644 index 00000000..ed4d72f0 --- /dev/null +++ b/teslausb-www-react/src/hooks/useLogTail.js @@ -0,0 +1,347 @@ +import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; +import { fetchLog } from '../services/api'; + +/** + * Hook for tailing log files with efficient range requests + * @param {string} logFile - Log file path (e.g., 'archiveloop.log') + * @param {number} pollInterval - Polling interval in ms (default 2000) + * @param {boolean} enabled - Whether to enable polling + * @returns {Object} Log content, sync state, and control functions + */ +export function useLogTail(logFile, pollInterval = 2000, enabled = true) { + const [lines, setLines] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const lastSizeRef = useRef(0); + const autoScrollRef = useRef(true); + const logFileRef = useRef(logFile); + + const refresh = useCallback(async (reset = false) => { + if (!logFile) return; + + try { + if (reset) { + lastSizeRef.current = 0; + } + + const result = await fetchLog(logFile, lastSizeRef.current); + + if (result.truncated) { + // Log was truncated, start fresh + lastSizeRef.current = 0; + setLines([]); + return; + } + + if (result.content) { + const newLines = result.content.split('\n').filter(Boolean); + if (reset) { + setLines(newLines.slice(-1000)); + } else { + setLines(prev => [...prev, ...newLines].slice(-1000)); // Keep last 1000 lines + } + } + + lastSizeRef.current = result.size; + setError(null); + } catch (e) { + // Only set error, don't clear lines on error + setError(e.message); + } finally { + setLoading(false); + } + }, [logFile]); + + // Reset when log file changes + useEffect(() => { + if (logFile !== logFileRef.current) { + logFileRef.current = logFile; + lastSizeRef.current = 0; + setLines([]); + setLoading(true); + setError(null); + } + }, [logFile]); + + useEffect(() => { + if (!enabled || !logFile) return; + + refresh(true); // Initial load + const interval = setInterval(() => refresh(false), pollInterval); + return () => clearInterval(interval); + }, [logFile, pollInterval, enabled]); // Don't depend on refresh to avoid recreation + + const setAutoScroll = useCallback((value) => { + autoScrollRef.current = value; + }, []); + + const clear = useCallback(() => { + setLines([]); + lastSizeRef.current = 0; + }, []); + + return { + lines, + loading, + error, + autoScroll: autoScrollRef.current, + setAutoScroll, + refresh: () => refresh(false), + clear, + }; +} + +/** + * Parse archiveloop.log to extract sync status + * @param {string[]} lines - Log lines + * @returns {Object} Sync status object + */ +export function parseSyncStatus(lines) { + const status = { + state: 'idle', // idle, connecting, archiving, complete, error + totalFiles: 0, + archivedFiles: 0, + currentFile: null, + startTime: null, + elapsedTime: null, + lastActivity: null, + message: null, + }; + + if (!lines || lines.length === 0) { + return status; + } + + // Parse from most recent lines backwards to find current state + const recentLines = lines.slice(-100); + + // Always extract timestamp from most recent line first + if (recentLines.length > 0) { + status.lastActivity = extractTimestamp(recentLines[recentLines.length - 1]); + } + + // First pass: look for file counts and completion messages + for (let i = recentLines.length - 1; i >= 0; i--) { + const line = recentLines[i]; + + // File count before archiving: "There are X event folder(s) with Y file(s) and Z track mode file(s)" + const fileCountMatch = line.match(/There are (\d+) event folder\(s\) with (\d+) file\(s\)(?: and (\d+) track mode file\(s\))?/); + if (fileCountMatch) { + const sentryFiles = parseInt(fileCountMatch[2], 10); + const trackModeFiles = fileCountMatch[3] ? parseInt(fileCountMatch[3], 10) : 0; + status.totalFiles = sentryFiles + trackModeFiles; + break; // Found the count for this archive session + } + + // Archiving message with count: "Archiving X file(s) including Y event folder(s)" + const archivingMatch = line.match(/Archiving (\d+)(?: track mode)? file\(s\)/); + if (archivingMatch && !line.includes('completed')) { + status.totalFiles = parseInt(archivingMatch[1], 10); + } + } + + // Check if music sync is in progress (started but not finished) + // This needs to be detected first because snapshot/idle messages can appear during music sync + let musicSyncActive = false; + for (let i = recentLines.length - 1; i >= 0; i--) { + const line = recentLines[i]; + if (line.includes('Finished copying music') || line.includes('Copying music failed')) { + // Music sync completed, not active + break; + } + if (line.includes('Syncing music from archive') || line.includes('Starting music sync')) { + // Music sync started and not yet finished + musicSyncActive = true; + break; + } + // If we hit "Connected usb to host" or "Waiting for archive to be unreachable", + // we're in a new cycle - no active music sync + if (line.includes('Connected usb to host') || line.includes('Waiting for archive to be unreachable')) { + break; + } + } + + // If music sync is active, show that status + if (musicSyncActive) { + status.state = 'archiving'; + status.message = 'Syncing music...'; + return status; + } + + // Second pass: determine current state + for (let i = recentLines.length - 1; i >= 0; i--) { + const line = recentLines[i]; + + // Archiving completed successfully + if (line.includes('Archiving completed successfully')) { + status.state = 'complete'; + status.message = 'Archive completed'; + break; + } + + // Finished copying music + if (line.includes('Finished copying music')) { + status.state = 'complete'; + status.message = 'Music sync complete'; + break; + } + + // Completion message with stats: "Copied X music file(s)" + if (line.includes('Copied') && line.includes('file(s)')) { + const match = line.match(/Copied (\d+)/); + if (match) { + status.state = 'complete'; + status.archivedFiles = parseInt(match[1], 10); + status.message = `Copied ${status.archivedFiles} files`; + break; + } + } + + // Starting recording archiving + if (line.includes('Starting recording archiving')) { + status.state = 'archiving'; + status.message = status.totalFiles > 0 + ? `Archiving ${status.totalFiles} files...` + : 'Archiving recordings...'; + break; + } + + // Currently archiving (fallback) + if (line.includes('Archiving...')) { + status.state = 'archiving'; + status.message = status.totalFiles > 0 + ? `Archiving ${status.totalFiles} files...` + : 'Archiving files...'; + break; + } + + // Syncing music (fallback if musicSyncActive didn't catch it) + if (line.includes('Syncing music from archive')) { + status.state = 'archiving'; + status.message = 'Syncing music...'; + break; + } + + // Copying music + if (line.includes('Copying music')) { + status.state = 'archiving'; + status.message = 'Copying music...'; + break; + } + + // Finished archiving + if (line.includes('Finished archiving')) { + status.state = 'complete'; + status.message = 'Archive complete'; + break; + } + + // Running fsck + if (line.includes('Running fsck')) { + status.state = 'archiving'; + status.message = 'Checking filesystem...'; + break; + } + + // Checking folder count + if (line.includes('Checking saved folder count')) { + status.state = 'archiving'; + status.message = 'Scanning files...'; + break; + } + + // Waiting for archive to be reachable (connecting) + if (line.includes('Waiting for archive to be reachable')) { + status.state = 'connecting'; + status.message = 'Connecting to archive server...'; + break; + } + + // Archive is reachable + if (line.includes('Archive is reachable')) { + status.state = 'archiving'; + status.message = 'Connected, preparing...'; + break; + } + + // Disconnecting USB (preparing to archive) + if (line.includes('Disconnecting usb from host')) { + status.state = 'archiving'; + status.message = 'Disconnecting from vehicle...'; + break; + } + + // Connected to host (idle) + if (line.includes('Connected usb to host')) { + status.state = 'idle'; + status.message = 'Connected to vehicle'; + break; + } + + // Waiting for archive to be unreachable (idle, connected to car) + if (line.includes('Waiting for archive to be unreachable')) { + status.state = 'idle'; + status.message = 'Connected to vehicle'; + break; + } + + // Taking snapshot or snapshot-related messages + if (line.includes('snapshot')) { + status.state = 'idle'; + status.message = 'Managing snapshots...'; + break; + } + + // Low space cleanup + if (line.includes('low space, deleting')) { + status.state = 'idle'; + status.message = 'Cleaning up old snapshots...'; + break; + } + + // Waiting for idle interval + if (line.includes('waiting up to') && line.includes('idle interval')) { + status.state = 'idle'; + status.message = 'Waiting for idle...'; + break; + } + + // Mass storage process check + if (line.includes('mass storage process')) { + status.state = 'idle'; + status.message = 'Ready'; + break; + } + + // Check for errors + if (line.includes('error') || line.includes('failed')) { + // Only set error if it's a significant error, not just "sntp failed" + if (!line.includes('sntp failed')) { + status.state = 'error'; + status.message = 'Error occurred'; + break; + } + } + } + + return status; +} + +/** + * Extract timestamp from log line + * @param {string} line - Log line + * @returns {Date|null} Parsed date or null + */ +function extractTimestamp(line) { + // Format: "Tue 9 Dec 13:22:02 PST 2025:" or "Wed 27 Aug 20:14:11 PDT 2025:" + const match = line.match(/^([A-Z][a-z]{2}\s+\d+\s+[A-Z][a-z]{2}\s+\d+:\d+:\d+\s+\w+\s+\d+):/); + if (match) { + const date = new Date(match[1]); + if (!isNaN(date.getTime())) { + return date; + } + } + return null; +} + +export default useLogTail; diff --git a/teslausb-www-react/src/hooks/useMusicSyncProgress.js b/teslausb-www-react/src/hooks/useMusicSyncProgress.js new file mode 100644 index 00000000..97a01c7c --- /dev/null +++ b/teslausb-www-react/src/hooks/useMusicSyncProgress.js @@ -0,0 +1,174 @@ +import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; +import { fetchMusicSyncProgress, fetchCamSyncProgress } from '../services/api'; + +/** + * Hook for polling music sync progress + * @param {boolean} enabled - Whether to enable polling (should be true when music sync is active) + * @param {number} pollInterval - Polling interval in ms (default 1500) + * @returns {Object} Music sync progress data + */ +export function useMusicSyncProgress(enabled = false, pollInterval = 1500) { + const [progress, setProgress] = useState({ + active: false, + bytesTransferred: 0, + percentage: 0, + speed: '', + eta: '', + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const enabledRef = useRef(enabled); + + const refresh = useCallback(async () => { + try { + const data = await fetchMusicSyncProgress(); + setProgress(data); + setError(null); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + + // Update ref when enabled changes + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + useEffect(() => { + if (!enabled) { + // Reset progress when disabled + setProgress({ + active: false, + bytesTransferred: 0, + percentage: 0, + speed: '', + eta: '', + }); + return; + } + + setLoading(true); + refresh(); // Initial fetch + + const interval = setInterval(() => { + if (enabledRef.current) { + refresh(); + } + }, pollInterval); + + return () => clearInterval(interval); + }, [enabled, pollInterval, refresh]); + + return { + ...progress, + loading, + error, + refresh, + }; +} + +/** + * Format bytes to human-readable string + * @param {number} bytes - Bytes to format + * @returns {string} Formatted string (e.g., "1.23 GB") + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${(bytes / Math.pow(k, i)).toFixed(i > 0 ? 2 : 0)} ${units[i]}`; +} + +/** + * Format ETA to human-readable string + * @param {string} eta - ETA in format "H:MM:SS" or "MM:SS" + * @returns {string} Formatted string (e.g., "~5:23 remaining") + */ +export function formatEta(eta) { + if (!eta || eta === '0:00:00') return ''; + + // Remove leading zeros from hours if present + const cleaned = eta.replace(/^0:/, ''); + return `~${cleaned} remaining`; +} + +/** + * Hook for polling TeslaCam archive progress + * @param {boolean} enabled - Whether to enable polling (should be true when cam archiving is active) + * @param {number} pollInterval - Polling interval in ms (default 1500) + * @returns {Object} Cam sync progress data + */ +export function useCamSyncProgress(enabled = false, pollInterval = 1500) { + const [progress, setProgress] = useState({ + active: false, + bytesTransferred: 0, + percentage: 0, + speed: '', + eta: '', + filesDone: 0, + filesTotal: 0, + currentFile: '', + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const enabledRef = useRef(enabled); + + const refresh = useCallback(async () => { + try { + const data = await fetchCamSyncProgress(); + setProgress(data); + setError(null); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + + // Update ref when enabled changes + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + useEffect(() => { + if (!enabled) { + // Reset progress when disabled + setProgress({ + active: false, + bytesTransferred: 0, + percentage: 0, + speed: '', + eta: '', + filesDone: 0, + filesTotal: 0, + currentFile: '', + }); + return; + } + + setLoading(true); + refresh(); // Initial fetch + + const interval = setInterval(() => { + if (enabledRef.current) { + refresh(); + } + }, pollInterval); + + return () => clearInterval(interval); + }, [enabled, pollInterval, refresh]); + + return { + ...progress, + loading, + error, + refresh, + }; +} + +export default useMusicSyncProgress; diff --git a/teslausb-www-react/src/hooks/useStatus.js b/teslausb-www-react/src/hooks/useStatus.js new file mode 100644 index 00000000..6df06b68 --- /dev/null +++ b/teslausb-www-react/src/hooks/useStatus.js @@ -0,0 +1,113 @@ +import { useState, useEffect, useCallback } from 'preact/hooks'; +import { fetchStatus, fetchConfig, fetchStorage } from '../services/api'; + +/** + * Hook for managing system status with polling + * @param {number} pollInterval - Polling interval in ms (default 5000) + * @returns {Object} Status, config, storage, loading state, and refresh function + */ +export function useStatus(pollInterval = 5000) { + const [status, setStatus] = useState(null); + const [config, setConfig] = useState(null); + const [storage, setStorage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const refresh = useCallback(async () => { + try { + const [statusData, configData, storageData] = await Promise.all([ + fetchStatus(), + fetchConfig(), + fetchStorage().catch(() => null), // Storage API is optional + ]); + setStatus(statusData); + setConfig(configData); + setStorage(storageData); + setLastUpdate(new Date()); + setError(null); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + const interval = setInterval(refresh, pollInterval); + return () => clearInterval(interval); + }, [refresh, pollInterval]); + + // Compute derived values + const computed = status ? { + cpuTempC: status.cpu_temp ? (parseInt(status.cpu_temp, 10) / 1000).toFixed(1) : null, + uptimeFormatted: formatUptime(parseInt(status.uptime || '0', 10)), + diskUsedPercent: status.total_space && status.free_space + ? Math.round(((parseInt(status.total_space, 10) - parseInt(status.free_space, 10)) / parseInt(status.total_space, 10)) * 100) + : 0, + diskUsedGB: status.total_space && status.free_space + ? ((parseInt(status.total_space, 10) - parseInt(status.free_space, 10)) / (1024 * 1024 * 1024)).toFixed(1) + : '0', + diskTotalGB: status.total_space + ? (parseInt(status.total_space, 10) / (1024 * 1024 * 1024)).toFixed(1) + : '0', + diskFreeGB: status.free_space + ? (parseInt(status.free_space, 10) / (1024 * 1024 * 1024)).toFixed(1) + : '0', + drivesActive: status.drives_active === 'yes', + wifiConnected: !!status.wifi_ssid && status.wifi_ssid !== '', + wifiSignalPercent: parseWifiSignal(status.wifi_strength), + ethernetConnected: !!status.ether_ip && status.ether_ip !== '', + snapshotCount: parseInt(status.num_snapshots || '0', 10), + } : null; + + return { + status, + config, + storage, + computed, + loading, + error, + lastUpdate, + refresh, + }; +} + +/** + * Format uptime seconds into human-readable string + * @param {number} seconds - Uptime in seconds + * @returns {string} Formatted uptime + */ +function formatUptime(seconds) { + if (!seconds || isNaN(seconds)) return '0s'; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(' '); +} + +/** + * Parse WiFi signal strength string + * @param {string} strength - e.g., "40/70" + * @returns {number} Signal percentage + */ +function parseWifiSignal(strength) { + if (!strength) return 0; + const parts = strength.split('/'); + if (parts.length !== 2) return 0; + const [current, max] = parts.map(Number); + if (isNaN(current) || isNaN(max) || max === 0) return 0; + return Math.round((current / max) * 100); +} + +export default useStatus; diff --git a/teslausb-www-react/src/main.jsx b/teslausb-www-react/src/main.jsx new file mode 100644 index 00000000..d39a8332 --- /dev/null +++ b/teslausb-www-react/src/main.jsx @@ -0,0 +1,5 @@ +import { render } from 'preact'; +import { App } from './App'; +import './styles/index.css'; + +render(, document.getElementById('app')); diff --git a/teslausb-www-react/src/services/api.js b/teslausb-www-react/src/services/api.js new file mode 100644 index 00000000..4159f6f2 --- /dev/null +++ b/teslausb-www-react/src/services/api.js @@ -0,0 +1,464 @@ +/** + * TeslaUSB API Service + * Handles all communication with the backend CGI scripts + */ + +const API_BASE = '/cgi-bin'; + +/** + * Fetch system status + * @returns {Promise} Status object with cpu_temp, disk space, wifi, etc. + */ +export async function fetchStatus() { + const response = await fetch(`${API_BASE}/status.sh`); + if (!response.ok) throw new Error('Failed to fetch status'); + return response.json(); +} + +/** + * Fetch system configuration + * @returns {Promise} Config object with has_cam, has_music, etc. + */ +export async function fetchConfig() { + const response = await fetch(`${API_BASE}/config.sh`); + if (!response.ok) throw new Error('Failed to fetch config'); + return response.json(); +} + +/** + * Fetch per-drive storage information + * @returns {Promise} Storage object with cam, music, lightshow, boombox, total + */ +export async function fetchStorage() { + const response = await fetch(`${API_BASE}/storage.sh`); + if (!response.ok) throw new Error('Failed to fetch storage'); + return response.json(); +} + +/** + * List directory contents + * @param {string} rootPath - Root path (e.g., /mnt/music) + * @param {string} dirPath - Directory path relative to root + * @returns {Promise} Parsed directory listing + */ +export async function listDirectory(rootPath, dirPath = '') { + const params = new URLSearchParams(); + params.append('root', rootPath); + if (dirPath) params.append('path', dirPath); + + const response = await fetch(`${API_BASE}/ls.sh?${encodeURIComponent(rootPath)}&${encodeURIComponent(dirPath)}`); + if (!response.ok) throw new Error('Failed to list directory'); + + const text = await response.text(); + return parseDirectoryListing(text); +} + +/** + * Parse directory listing from ls.sh output + * @param {string} text - Raw text output + * @returns {Object} Parsed listing with directories, files, and storage stats + */ +function parseDirectoryListing(text) { + const lines = text.trim().split('\n').filter(Boolean); + const result = { + directories: [], + files: [], + storage: null, + }; + + for (const line of lines) { + if (line.startsWith('d:')) { + result.directories.push({ name: line.slice(2), depth: 1 }); + } else if (line.startsWith('D:')) { + result.directories.push({ name: line.slice(2), depth: 2 }); + } else if (line.startsWith('f:')) { + const parts = line.slice(2).split(':'); + const size = parseInt(parts.pop(), 10); + const path = parts.join(':'); + result.files.push({ path, size }); + } else if (line.startsWith('s:')) { + const [, free, total] = line.split(':'); + result.storage = { + free: parseInt(free, 10), + total: parseInt(total, 10), + }; + } + } + + return result; +} + +/** + * Upload a file + * @param {string} rootPath - Root path + * @param {string} destPath - Destination path + * @param {File} file - File to upload + * @param {Function} onProgress - Progress callback (0-100) + * @returns {Promise} + */ +export async function uploadFile(rootPath, destPath, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/upload.sh?${encodeURIComponent(rootPath)}&${encodeURIComponent(destPath)}`); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + resolve(); + } else { + reject(new Error(`Upload failed: ${xhr.status}`)); + } + }; + + xhr.onerror = () => reject(new Error('Upload failed')); + xhr.send(file); + }); +} + +/** + * Download a file + * @param {string} rootPath - Root path + * @param {string} filePath - File path + * @returns {string} Download URL + */ +export function getDownloadUrl(rootPath, filePath) { + return `${API_BASE}/download.sh?${encodeURIComponent(rootPath)}&${encodeURIComponent(filePath)}`; +} + +/** + * Download multiple files as ZIP + * @param {string} rootPath - Root path + * @param {string[]} paths - Array of file paths + * @returns {string} Download URL + */ +export function getZipDownloadUrl(rootPath, paths) { + const params = [encodeURIComponent(rootPath), ...paths.map(p => encodeURIComponent(p))].join('&'); + return `${API_BASE}/downloadzip.sh?${params}`; +} + +/** + * Create a directory + * @param {string} rootPath - Root path + * @param {string} dirName - Directory name to create + * @returns {Promise} + */ +export async function createDirectory(rootPath, dirName) { + const response = await fetch(`${API_BASE}/mkdir.sh?${encodeURIComponent(rootPath)}&${encodeURIComponent(dirName)}`); + if (!response.ok) throw new Error('Failed to create directory'); +} + +/** + * Move/Rename a file or directory + * @param {string} rootPath - Root path + * @param {string} currentPath - Current path + * @param {string} newName - New name + * @returns {Promise} + */ +export async function moveItem(rootPath, currentPath, newName) { + const response = await fetch(`${API_BASE}/mv.sh?${encodeURIComponent(rootPath)}&${encodeURIComponent(currentPath)}&${encodeURIComponent(newName)}`); + if (!response.ok) throw new Error('Failed to move/rename item'); +} + +/** + * Delete files or directories + * @param {string} rootPath - Root path + * @param {string[]} paths - Paths to delete + * @returns {Promise} + */ +export async function deleteItems(rootPath, paths) { + const params = [encodeURIComponent(rootPath), ...paths.map(p => encodeURIComponent(p))].join('&'); + const response = await fetch(`${API_BASE}/rm.sh?${params}`); + if (!response.ok) throw new Error('Failed to delete items'); +} + +/** + * Copy a file (used for lock chime) + * @param {string} rootPath - Root path + * @param {string} sourcePath - Source file path + * @param {string} destName - Destination name + * @returns {Promise} + */ +export async function copyFile(rootPath, sourcePath, destName) { + const response = await fetch(`${API_BASE}/cp.sh?${encodeURIComponent(rootPath)}&${encodeURIComponent(sourcePath)}&${encodeURIComponent(destName)}`); + if (!response.ok) throw new Error('Failed to copy file'); +} + +/** + * Fetch video list + * @returns {Promise} Organized video list by category + */ +export async function fetchVideoList() { + const response = await fetch(`${API_BASE}/videolist.sh`); + if (!response.ok) throw new Error('Failed to fetch video list'); + + const text = await response.text(); + return parseVideoList(text); +} + +/** + * Parse video list into organized structure + * @param {string} text - Raw video list text + * @returns {Object} Organized by category > date > files + */ +function parseVideoList(text) { + const lines = text.trim().split('\n').filter(Boolean); + const result = { + RecentClips: {}, + SavedClips: {}, + SentryClips: {}, + }; + + for (const line of lines) { + const parts = line.split('/'); + if (parts.length >= 2) { + const [category, ...rest] = parts; + if (result[category]) { + const dateOrFile = rest[0]; + if (!result[category][dateOrFile]) { + result[category][dateOrFile] = []; + } + if (rest.length > 1) { + result[category][dateOrFile].push(rest.slice(1).join('/')); + } + } + } + } + + return result; +} + +/** + * Trigger sync operation + * @returns {Promise} + */ +export async function triggerSync() { + const response = await fetch(`${API_BASE}/trigger_sync.sh`); + if (!response.ok) throw new Error('Failed to trigger sync'); +} + +/** + * Fetch music sync progress + * @returns {Promise} Progress object with active, bytesTransferred, percentage, speed, eta + */ +export async function fetchMusicSyncProgress() { + const response = await fetch(`${API_BASE}/music_sync_progress.sh`); + if (!response.ok) throw new Error('Failed to fetch music sync progress'); + return response.json(); +} + +/** + * Fetch TeslaCam archive progress + * @returns {Promise} Progress object with active, bytesTransferred, percentage, speed, eta, filesDone, filesTotal + */ +export async function fetchCamSyncProgress() { + const response = await fetch(`${API_BASE}/cam_sync_progress.sh`); + if (!response.ok) throw new Error('Failed to fetch cam sync progress'); + return response.json(); +} + +/** + * Toggle USB drives visibility + * @returns {Promise} + */ +export async function toggleDrives() { + const response = await fetch(`${API_BASE}/toggledrives.sh`); + if (!response.ok) throw new Error('Failed to toggle drives'); +} + +/** + * Reboot the system + * @returns {Promise} + */ +export async function reboot() { + const response = await fetch(`${API_BASE}/reboot.sh`); + if (!response.ok) throw new Error('Failed to reboot'); +} + +/** + * Start BLE pairing + * @returns {Promise} True if pairing initiated + */ +export async function startBLEPairing() { + const response = await fetch(`${API_BASE}/pairBLEkey.sh`); + return response.status === 202; +} + +/** + * Check BLE pairing status + * @returns {Promise} True if paired + */ +export async function checkBLEStatus() { + const response = await fetch(`${API_BASE}/checkBLEstatus.sh`); + const text = await response.text(); + return text.includes('

          paired

          '); +} + +/** + * Generate diagnostics + * @returns {Promise} + */ +export async function generateDiagnostics() { + const response = await fetch(`${API_BASE}/diagnose.sh`); + // Wait for the response body to ensure the script completes + await response.text(); + if (!response.ok) throw new Error('Failed to generate diagnostics'); +} + +/** + * Fetch diagnostics file + * @returns {Promise} Diagnostics content + */ +export async function fetchDiagnostics() { + const response = await fetch('/diagnostics.txt'); + if (!response.ok) throw new Error('Failed to fetch diagnostics'); + return response.text(); +} + +/** + * Fetch log file with range support for efficient tailing + * @param {string} logFile - Log file path + * @param {number} lastSize - Last known size for range request + * @returns {Promise} { content, size, truncated } + */ +export async function fetchLog(logFile, lastSize = 0) { + // Use HEAD request first to check file size and avoid 416 errors + if (lastSize > 0) { + const headResponse = await fetch(`/${logFile}`, { method: 'HEAD' }); + if (headResponse.ok) { + const contentLength = parseInt(headResponse.headers.get('Content-Length') || '0', 10); + if (contentLength <= lastSize) { + // No new content or file was truncated + if (contentLength < lastSize) { + // File was truncated, return truncated flag to trigger full reload + return { content: '', size: 0, truncated: true }; + } + // No new content + return { content: '', size: lastSize, truncated: false }; + } + } + } + + const headers = {}; + if (lastSize > 0) { + headers['Range'] = `bytes=${lastSize}-`; + } + + const response = await fetch(`/${logFile}`, { headers }); + + if (response.status === 416) { + // Range not satisfiable - shouldn't happen now but handle just in case + return { content: '', size: lastSize, truncated: false }; + } + + if (response.status === 404) { + throw new Error('Log file not found'); + } + + if (!response.ok && response.status !== 206) { + throw new Error(`Failed to fetch log: ${response.status}`); + } + + const content = await response.text(); + + // For range requests (206), calculate new size from content-range header or content length + let newSize = lastSize; + if (response.status === 206) { + const contentRange = response.headers.get('Content-Range'); + if (contentRange) { + // Format: bytes start-end/total + const match = contentRange.match(/bytes \d+-(\d+)\/(\d+)/); + if (match) { + newSize = parseInt(match[1], 10) + 1; + } else { + newSize = lastSize + content.length; + } + } else { + newSize = lastSize + content.length; + } + } else { + // Full response (200) + newSize = content.length; + } + + return { + content, + size: newSize, + truncated: false, + }; +} + +/** + * Run network speed test + * @param {Function} onProgress - Progress callback with speed in Mbps + * @param {AbortSignal} signal - Abort signal + * @returns {Promise} Final speed in Mbps + */ +export async function runSpeedTest(onProgress, signal) { + const response = await fetch(`${API_BASE}/randomdata.sh`, { signal }); + if (!response.ok) throw new Error('Failed to start speed test'); + + const reader = response.body.getReader(); + let totalBytes = 0; + const startTime = performance.now(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBytes += value.length; + const elapsed = (performance.now() - startTime) / 1000; + const speedMbps = (totalBytes * 8) / (elapsed * 1000000); + + if (onProgress) { + onProgress(speedMbps); + } + } + } catch (e) { + if (e.name !== 'AbortError') throw e; + } + + const elapsed = (performance.now() - startTime) / 1000; + return (totalBytes * 8) / (elapsed * 1000000); +} + +/** + * Get video URL for a specific camera angle + * @param {string} category - RecentClips, SavedClips, or SentryClips + * @param {string} sequence - Date/time folder + * @param {string} filename - Video filename + * @returns {string} Video URL + */ +export function getVideoUrl(category, sequence, filename) { + return `/TeslaCam/${category}/${sequence}/${filename}`; +} + +/** + * Get event.json URL for sentry events + * @param {string} sequence - Date/time folder + * @returns {string} Event JSON URL + */ +export function getEventJsonUrl(sequence) { + return `/TeslaCam/SentryClips/${sequence}/event.json`; +} + +/** + * Fetch sentry event data + * @param {string} sequence - Date/time folder + * @returns {Promise} Event data or null + */ +export async function fetchEventData(sequence) { + try { + const response = await fetch(getEventJsonUrl(sequence)); + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } +} diff --git a/teslausb-www-react/src/styles/fonts.css b/teslausb-www-react/src/styles/fonts.css new file mode 100644 index 00000000..4021b92e --- /dev/null +++ b/teslausb-www-react/src/styles/fonts.css @@ -0,0 +1,52 @@ +/* Lato Font - Local fallback for offline use */ +/* These fonts should be placed in /public/fonts/ */ + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local('Lato Regular'), local('Lato-Regular'), + url('/fonts/lato-regular.woff2') format('woff2'), + url('/fonts/lato-regular.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local('Lato Medium'), local('Lato-Medium'), + url('/fonts/lato-medium.woff2') format('woff2'), + url('/fonts/lato-medium.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local('Lato Semibold'), local('Lato-Semibold'), + url('/fonts/lato-semibold.woff2') format('woff2'), + url('/fonts/lato-semibold.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local('Lato Bold'), local('Lato-Bold'), + url('/fonts/lato-bold.woff2') format('woff2'), + url('/fonts/lato-bold.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local('Lato Italic'), local('Lato-Italic'), + url('/fonts/lato-italic.woff2') format('woff2'), + url('/fonts/lato-italic.woff') format('woff'); +} diff --git a/teslausb-www-react/src/styles/index.css b/teslausb-www-react/src/styles/index.css new file mode 100644 index 00000000..2d3a19f8 --- /dev/null +++ b/teslausb-www-react/src/styles/index.css @@ -0,0 +1,2253 @@ +/* TeslaUSB Modern Dashboard Styles */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + background-color: #f5f5f5; + color: #333; + overflow: hidden; +} + +/* Mobile body adjustments for proper scrolling */ +@media (max-width: 767px) { + body { + overflow: visible; + height: auto; + } +} + +/* Container holding top bar + the grid */ +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + overflow: hidden; +} + +/* Mobile-specific shell adjustments */ +@media (max-width: 767px) { + .app-shell { + overflow: visible; + height: auto; + min-height: 100vh; + } +} + +/* Top bar styles */ +.app-topbar { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #1f2937; + color: #ffffff; + padding: 0.75rem 1rem; + border-bottom: 1px solid #374151; +} + +.app-topbar-title { + font-size: 1.1rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.app-topbar-title svg { + width: 24px; + height: 24px; +} + +.app-topbar-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +/* Main Dashboard Container */ +.app-dashboard { + display: flex; + flex-direction: column; + height: 100vh; + background-color: #f5f5f5; +} + +/* Header */ +.app-header { + background-color: #ffffff; + border-bottom: 1px solid #e1e5e9; + padding: 0.75rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + height: 60px; + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 40; + backdrop-filter: saturate(180%) blur(4px); +} + +.app-header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.app-header-icon { + width: 20px; + height: 20px; + color: #0066cc; +} + +.app-header-title { + font-size: 16px; + font-weight: 600; + color: #333; + letter-spacing: 0.1px; +} + +.app-header-subtitle { + font-size: 14px; + color: #666; + margin-left: 0.5rem; +} + +.app-header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +/* Navigation Links / Tabs */ +.dashboard-nav { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + color: #6b7280; + text-decoration: none; + transition: all 0.15s ease; + border: 1px solid transparent; + position: relative; + cursor: pointer; + background: none; +} + +.nav-link:hover { + background-color: #f9fafb; + color: #374151; +} + +.nav-link.active { + background-color: #0095f6; + color: #ffffff; + border-color: #007dd1; +} + +.nav-link.active svg { + color: #ffffff; +} + +.nav-link svg { + color: #6b7280; + transition: color 0.15s ease; + width: 16px; + height: 16px; +} + +.nav-link:hover svg { + color: #374151; +} + +/* Badge for counts */ +.nav-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + background-color: #ef4444; + color: #ffffff; + font-size: 11px; + font-weight: 700; + border-radius: 9px; + margin-left: 4px; + line-height: 1; +} + +.nav-link.active .nav-badge { + background-color: #ffffff; + color: #ef4444; +} + +.app-last-update { + font-size: 13px; + color: #6b7280; +} + +.app-status { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.app-status.healthy { + color: #065f46; + background: #d1fae5; + border: 1px solid #a7f3d0; +} + +.app-status.unhealthy { + color: #7f1d1d; + background: #fee2e2; + border: 1px solid #fecaca; +} + +.app-status.warning { + color: #92400e; + background: #fef3c7; + border: 1px solid #fde68a; +} + +/* Main Body */ +.app-body { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Left Sidebar */ +.app-sidebar { + width: 280px; + background-color: #ffffff; + border-right: 1px solid #e1e5e9; + padding: 1rem; + overflow-y: auto; + overflow-x: hidden; + flex-shrink: 0; + max-height: calc(100vh - 60px); + -webkit-overflow-scrolling: touch; +} + +.device-info { + display: flex; + flex-direction: column; + border-radius: 10px; + border: 1px solid #e5e7eb; + background: #fff; + margin-bottom: 1rem; +} + +.device-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 10px 12px; + border-bottom: 1px solid #eef2f7; + font-size: 14px; + font-weight: 600; + color: #1f2937; +} + +.device-header svg { + width: 16px; + height: 16px; + color: #6b7280; +} + +/* Info List Style */ +.info-list { + display: flex; + flex-direction: column; + padding: 8px 12px; +} + +.info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; + font-size: 13px; +} + +.info-item:last-child { + border-bottom: none; +} + +.info-item.clickable { + cursor: pointer; + transition: background-color 0.15s ease; + margin: 0 -12px; + padding: 6px 12px; + border-radius: 4px; +} + +.info-item.clickable:hover { + background-color: #f3f4f6; +} + +.info-item.clickable:active { + background-color: #e5e7eb; +} + +/* Toggle Button in sidebar */ +.toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + line-height: 1; + border: 1px solid #007dd1; + background-color: #0095f6; + color: #ffffff; + cursor: pointer; + transition: all 0.15s ease; +} + +.toggle-btn svg { + flex-shrink: 0; + vertical-align: middle; + margin-right: 4px; + color: #ffffff; +} + +.toggle-btn:hover:not(:disabled) { + background-color: #007dd1; + border-color: #006bbd; +} + +.toggle-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.toggle-btn.active { + background-color: #dcfce7; + border-color: #22c55e; + color: #166534; +} + +.toggle-btn.danger { + background-color: #fee2e2; + border-color: #f87171; + color: #b91c1c; +} + +/* UI Switch Links */ +.ui-switch-link { + color: #0095f6; + text-decoration: none; + font-size: 13px; + padding: 4px 0; + display: block; + width: 100%; +} + +.ui-switch-link:hover { + text-decoration: underline; +} + +.toggle-btn.danger:hover:not(:disabled) { + background-color: #fecaca; + border-color: #ef4444; +} + +.toggle-btn.primary { + background-color: #eff6ff; + border-color: #3b82f6; + color: #1d4ed8; +} + +.toggle-btn.primary:hover:not(:disabled) { + background-color: #dbeafe; + border-color: #2563eb; +} + +/* Sidebar Action Buttons (full width) */ +.sidebar-action { + margin-top: 8px; + padding: 0; +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + border: none; + cursor: pointer; + transition: all 0.15s ease; +} + +.action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-btn.connected { + background-color: #dcfce7; + color: #166534; + border: 1px solid #22c55e; +} + +.action-btn.connected:hover:not(:disabled) { + background-color: #bbf7d0; +} + +.action-btn.disconnected { + background-color: #fef2f2; + color: #991b1b; + border: 1px solid #f87171; +} + +.action-btn.disconnected:hover:not(:disabled) { + background-color: #fee2e2; +} + +.action-btn.restart { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.action-btn.restart:hover:not(:disabled) { + background-color: #e5e7eb; + border-color: #9ca3af; +} + +.info-label { + color: #6b7280; + font-weight: 500; +} + +.info-value { + color: #111827; + font-weight: 600; + text-align: right; +} + +.info-value-with-action { + display: flex; + align-items: center; + gap: 8px; +} + +.speed-result { + color: #111827; + font-weight: 600; + font-size: 12px; +} + +/* Small text class */ +.small-text { + font-size: 11px !important; + line-height: 1.2; +} + +/* Compact date formatting */ +.compact-date { + font-size: 10px !important; + line-height: 1.1; + white-space: nowrap; + color: #4b5563; +} + +/* Progress Bar */ +.progress-container { + margin-top: 0.5rem; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: #eef2f7; + border-radius: 999px; + overflow: hidden; + margin-bottom: 0.25rem; +} + +.progress-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease; +} + +.progress-fill.blue { + background-color: #3b82f6; +} + +.progress-fill.green { + background-color: #10b981; +} + +.progress-fill.yellow { + background-color: #f59e0b; +} + +.progress-fill.red { + background-color: #ef4444; +} + +.progress-text { + font-size: 10px; + color: #6b7280; + font-weight: 500; + margin-top: 6px; + text-align: right; +} + +/* Status Colors */ +.status-healthy { + color: #00b04f !important; +} + +.status-degraded { + color: #ff9800 !important; +} + +.status-unhealthy { + color: #e74c3c !important; +} + +/* Main Content Area */ +.app-main { + flex: 1; + background-color: #ffffff; + padding: 1rem 1rem 3.5rem 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; + max-height: calc(100vh - 60px); +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1px; + background-color: #e1e5e9; + border: 1px solid #e1e5e9; + border-radius: 4px; + overflow: visible; + margin-bottom: 1rem; +} + +.stats-section { + background-color: #ffffff; + padding: 1rem; +} + +.stats-section h3 { + font-size: 12px; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.75rem; +} + +.stat-data { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.primary-stat { + font-size: 24px; + font-weight: 700; + color: #333; + line-height: 1; +} + +.stat-details { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.stat-details span { + font-size: 11px; + color: #666; +} + +.stat-details span:first-child { + color: #333; + font-weight: 500; + font-size: 12px; +} + +/* Monospace values */ +.mono-value { + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + color: #333; + font-weight: 600; + font-size: 12px; +} + +/* Chart/Card Containers */ +.card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02); + transition: transform 0.08s ease, box-shadow 0.12s ease, border-color 0.12s ease; +} + +.card:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06); + border-color: #dee3ea; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.card-title { + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.card-value { + font-size: 22px; + font-weight: 700; + color: #111827; + line-height: 1; +} + +.card-sub { + font-size: 11px; + color: #666; + margin-top: 4px; +} + +/* Dashboard sections with spacing */ +.dashboard-section { + margin-bottom: 1rem; +} + +.section-title { + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 0.5rem; +} + +/* Features Row - compact badges */ +.features-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.feature-badge { + display: inline-block; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + line-height: 1; + text-align: center; +} + +.feature-badge.enabled { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; +} + +.feature-badge.disabled { + background: #f3f4f6; + color: #9ca3af; + border: 1px solid #e5e7eb; +} + +/* Actions Row */ +.actions-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* KPI Row */ +.kpi-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +/* Chart container */ +.chart-container { + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; +} + +.chart-header { + padding: 10px 12px; + border-bottom: 1px solid #eef2f7; + margin: -1rem -1rem 1rem -1rem; +} + +.chart-title { + font-size: 14px; + font-weight: 600; + color: #1f2937; +} + +/* Loading and Error States */ +.loading-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + gap: 0.5rem; + font-size: 14px; + color: #666; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e5e7eb; + border-top-color: #0095f6; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spinning { + animation: spin 1s linear infinite; +} + +.error-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + gap: 0.5rem; + font-size: 14px; + color: #e74c3c; +} + +.retry-btn { + background-color: #e74c3c; + border: none; + color: white; + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + margin-left: 0.5rem; +} + +.retry-btn:hover { + background-color: #c0392b; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + border-radius: 6px; + border: 1px solid #cfd8e3; + background: #ffffff; + color: #374151; + cursor: pointer; + transition: all 0.15s ease-in-out; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); +} + +.btn svg { + color: #374151; + transition: color 0.15s ease-in-out; + width: 14px; + height: 14px; +} + +.btn:hover:not(:disabled) { + background: #0095f6; + border-color: #007dd1; + color: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.btn:hover:not(:disabled) svg { + color: #ffffff; +} + +.btn:active:not(:disabled) { + background: #007dd1; + border-color: #006bbd; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background: #f9fafb; + color: #9ca3af; + border-color: #e5e7eb; +} + +.btn-primary { + background: #0095f6; + border-color: #007dd1; + color: #ffffff; +} + +.btn-primary svg { + color: #ffffff; +} + +.btn-primary:hover:not(:disabled) { + background: #007dd1; + border-color: #006bbd; +} + +.btn-danger { + background: #ef4444; + border-color: #dc2626; + color: #ffffff; +} + +.btn-danger svg { + color: #ffffff; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; + border-color: #b91c1c; +} + +.btn-dark { + background: #333; + border-color: #444; + color: #fff; +} + +.btn-dark svg { + color: #fff; +} + +.btn-dark:hover:not(:disabled) { + background: #444; + border-color: #555; + color: #fff; +} + +.btn-dark:hover:not(:disabled) svg { + color: #fff; +} + +.btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +.btn-lg { + padding: 10px 20px; + font-size: 15px; +} + +/* Storage Bar */ +.storage-bar-container { + margin: 1rem 0; +} + +/* Storage Bar */ +.storage-bar { + display: flex; + height: 24px; + border-radius: 6px; + overflow: hidden; + background-color: #e5e7eb; +} + +.storage-segment { + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: #fff; + transition: width 0.3s ease; + min-width: 0; +} + +.storage-segment.teslacam { + background-color: #3b82f6; +} + +.storage-segment.music { + background-color: #8b5cf6; +} + +.storage-segment.lightshow { + background-color: #ec4899; +} + +.storage-segment.boombox { + background-color: #f97316; +} + +.storage-segment.free { + background-color: #d1d5db; +} + +/* Storage Legend */ +.storage-legend { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.75rem; + font-size: 12px; +} + +.storage-legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.storage-legend-dot { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.storage-legend-dot.teslacam { + background-color: #3b82f6; +} + +.storage-legend-dot.music { + background-color: #8b5cf6; +} + +.storage-legend-dot.lightshow { + background-color: #ec4899; +} + +.storage-legend-dot.boombox { + background-color: #f97316; +} + +.storage-legend-dot.free { + background-color: #d1d5db; +} + +.storage-legend-label { + color: #374151; + font-weight: 500; +} + +.storage-legend-value { + color: #6b7280; +} + +.storage-legend-used { + color: #9ca3af; + font-size: 11px; +} + +.storage-legend-percent { + margin-left: 4px; + color: #9ca3af; + font-size: 11px; +} + +.storage-note { + margin-top: 0.5rem; + font-size: 10px; + color: #9ca3af; + font-style: italic; +} + +/* Sync Status Card */ +.sync-status-card { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 1rem; + background: #fff; +} + +.sync-status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.sync-status-title { + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.sync-status-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; +} + +.sync-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.sync-status-dot.idle { + background-color: #10b981; +} + +.sync-status-dot.connecting { + background-color: #f59e0b; + animation: pulse 1.5s ease-in-out infinite; +} + +.sync-status-dot.archiving { + background-color: #3b82f6; + animation: pulse 1s ease-in-out infinite; +} + +.sync-status-dot.error { + background-color: #ef4444; +} + +.sync-status-description { + font-size: 13px; + color: #6b7280; + margin-bottom: 0.5rem; +} + +.sync-details { + font-size: 12px; + color: #9ca3af; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.sync-progress-bar { + height: 8px; + background-color: #e5e7eb; + border-radius: 4px; + overflow: hidden; + margin: 0.75rem 0; +} + +.sync-progress-fill { + height: 100%; + background-color: #3b82f6; + border-radius: 4px; + transition: width 0.3s ease; +} + +.sync-details { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + color: #6b7280; +} + +.sync-progress-main { + font-weight: 500; +} + +.sync-progress-secondary { + font-size: 10px; + color: #9ca3af; +} + +.sync-file { + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'Monaco', 'Menlo', monospace; +} + +/* Log Viewer */ +.log-viewer { + background-color: #1e1e1e; + border-radius: 8px; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 12px; + line-height: 1.5; + overflow: hidden; + display: flex; + flex-direction: column; + height: 400px; +} + +.log-viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: #2d2d2d; + border-bottom: 1px solid #3d3d3d; +} + +.log-viewer-title { + color: #e0e0e0; + font-size: 13px; + font-weight: 600; +} + +.log-viewer-actions { + display: flex; + gap: 8px; +} + +.log-action-btn { + background: #4a4a4a !important; + border-color: #666 !important; + color: #e0e0e0 !important; +} + +.log-action-btn svg { + color: #e0e0e0 !important; +} + +.log-action-btn:hover:not(:disabled) { + background: #5a5a5a !important; + border-color: #888 !important; +} + +.log-action-btn:hover:not(:disabled) svg { + color: #ffffff !important; +} + +.log-action-btn:disabled { + opacity: 0.4; +} + +.log-viewer-content { + flex: 1; + overflow-y: auto; + padding: 12px; + color: #d4d4d4; +} + +.log-viewer-content::-webkit-scrollbar { + width: 8px; +} + +.log-viewer-content::-webkit-scrollbar-track { + background: #1e1e1e; +} + +.log-viewer-content::-webkit-scrollbar-thumb { + background: #4d4d4d; + border-radius: 4px; +} + +.log-line { + white-space: pre-wrap; + word-break: break-all; +} + +.log-line.error { + color: #f87171; +} + +.log-line.warning { + color: #fbbf24; +} + +.log-line.success { + color: #34d399; +} + +/* Video Viewer */ +.video-viewer { + display: flex; + flex-direction: column; + height: 100%; + background: #000; + border-radius: 8px; + overflow: hidden; +} + +.video-grid { + display: grid; + flex: 1; + gap: 2px; + background: #1a1a1a; + padding: 2px; +} + +.video-grid.layout-6 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); +} + +.video-grid.layout-4 { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); +} + +.video-grid.layout-1 { + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.video-cell { + position: relative; + background: #000; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.video-cell video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.video-cell-label { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.video-controls { + display: flex; + align-items: center; + gap: 1rem; + padding: 12px 16px; + background: #1a1a1a; + border-top: 1px solid #333; +} + +.video-timeline { + flex: 1; + height: 6px; + background: #333; + border-radius: 3px; + cursor: pointer; + position: relative; +} + +.video-timeline-progress { + height: 100%; + background: #0095f6; + border-radius: 3px; + transition: width 0.1s linear; +} + +.video-timeline-markers { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.video-timeline-marker { + position: absolute; + top: -2px; + width: 2px; + height: 10px; + background: #ef4444; + border-radius: 1px; +} + +.video-play-btn { + background: #0095f6; + border: none; + color: #fff; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s; +} + +.video-play-btn svg { + width: 18px; + height: 18px; +} + +.video-play-btn:hover { + background: #007dd1; +} + +.video-time { + color: #fff; + font-size: 13px; + font-family: 'Monaco', 'Menlo', monospace; + min-width: 100px; +} + +/* File Browser */ +.file-browser { + display: flex; + height: 100%; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; +} + +.file-tree { + width: 250px; + background: #f9fafb; + border-right: 1px solid #e5e7eb; + overflow-y: auto; +} + +.file-tree-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: #374151; + border-bottom: 1px solid #f0f0f0; +} + +.file-tree-item:hover { + background: #f3f4f6; +} + +.file-tree-item.active { + background: #e0f2fe; + color: #0369a1; +} + +.file-tree-item svg { + width: 16px; + height: 16px; + color: #6b7280; + flex-shrink: 0; +} + +.file-tree-item.active svg { + color: #0369a1; +} + +.file-list { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.file-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e5e7eb; +} + +.file-list-title { + font-size: 14px; + font-weight: 600; + color: #1f2937; +} + +.file-list-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; +} + +.file-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.file-item:hover { + background: #f9fafb; + border-color: #e5e7eb; +} + +.file-item.selected { + background: #e0f2fe; + border-color: #0ea5e9; +} + +.file-item-icon { + width: 48px; + height: 48px; + margin-bottom: 8px; + color: #6b7280; +} + +.file-item-icon.folder { + color: #f59e0b; +} + +.file-item-icon.video { + color: #8b5cf6; +} + +.file-item-icon.audio { + color: #ec4899; +} + +.file-item-name { + font-size: 12px; + text-align: center; + word-break: break-word; + color: #374151; +} + +.file-item-size { + font-size: 10px; + color: #9ca3af; + margin-top: 2px; +} + +/* Dropzone */ +.dropzone { + border: 2px dashed #d1d5db; + border-radius: 8px; + padding: 2rem; + text-align: center; + color: #6b7280; + transition: all 0.15s; +} + +.dropzone svg { + width: 24px; + height: 24px; +} + +.dropzone.active { + border-color: #0095f6; + background: #f0f9ff; + color: #0369a1; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: #fff; + border-radius: 12px; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow: hidden; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e5e7eb; +} + +.modal-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; +} + +.modal-close { + background: none; + border: none; + cursor: pointer; + color: #6b7280; + padding: 4px; +} + +.modal-close:hover { + color: #374151; +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid #e5e7eb; + background: #f9fafb; +} + +/* Toast notifications */ +.toast-container { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 1100; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toast { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: #1f2937; + color: #fff; + border-radius: 8px; + font-size: 13px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + animation: slideIn 0.2s ease; +} + +.toast.success { + background: #059669; +} + +.toast.error { + background: #dc2626; +} + +.toast.warning { + background: #d97706; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Responsive Design */ + +/* iPad Pro (1024px and up) */ +@media (min-width: 1024px) and (max-width: 1366px) { + .app-sidebar { + width: 260px; + } + + .kpi-row { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + } + + .app-header-title { + font-size: 15px; + } +} + +/* iPad (768px - 1023px) */ +@media (min-width: 768px) and (max-width: 1023px) { + .app-body { + flex-direction: row; + } + + .app-sidebar { + width: 220px; + max-height: calc(100vh - 60px); + overflow-y: auto; + } + + .kpi-row { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .card { + padding: 10px; + } + + .card-value { + font-size: 18px; + } + + .card-title { + font-size: 11px; + } + + .card-sub { + font-size: 10px; + } + + .app-header-right { + gap: 8px; + } + + .app-last-update { + font-size: 11px; + } + + .chart-container { + padding: 12px; + margin-bottom: 8px; + } + + .chart-title { + font-size: 13px; + } +} + +/* Mobile Quick Status Bar */ +.mobile-quick-status { + display: none; +} + +/* Desktop Status Bar */ +.desktop-status-bar { + display: block; +} + +/* Sidebar Toggle Button */ +.sidebar-toggle-btn { + display: none; + background: none; + border: none; + color: #6b7280; + cursor: pointer; + padding: 4px; + margin-left: auto; + transition: color 0.2s ease; +} + +.sidebar-toggle-btn:hover { + color: #374151; +} + +/* Mobile (below 768px) */ +@media (max-width: 767px) { + .desktop-status-bar { + display: none !important; + } + + .mobile-quick-status { + display: flex; + align-items: center; + justify-content: space-around; + padding: 6px 8px; + background-color: #f9fafb; + border-bottom: 1px solid #e5e7eb; + gap: 4px; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .quick-status-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + flex-shrink: 0; + } + + .status-dot-mini { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .status-dot-mini.healthy { + background-color: #10b981; + box-shadow: 0 0 4px rgba(16, 185, 129, 0.5); + } + + .status-dot-mini.unhealthy { + background-color: #ef4444; + box-shadow: 0 0 4px rgba(239, 68, 68, 0.5); + } + + .quick-status-label { + font-size: 9px; + color: #6b7280; + font-weight: 500; + white-space: nowrap; + text-align: center; + } + + .app-body { + flex-direction: column; + height: auto; + overflow: visible; + } + + .app-sidebar { + width: 100%; + height: auto; + max-height: none; + border-right: none; + border-bottom: 1px solid #e1e5e9; + padding: 8px; + overflow: visible; + overflow-x: hidden; + flex-shrink: 0; + -webkit-overflow-scrolling: touch; + transition: max-height 0.3s ease; + } + + .sidebar-toggle-btn { + display: inline-flex; + } + + .app-sidebar:not(.expanded) { + max-height: 60px; + overflow: hidden; + } + + .app-sidebar:not(.expanded) .device-info:not(:first-child) { + display: none; + } + + .app-sidebar:not(.expanded) .info-item:nth-child(n + 4) { + display: none; + } + + .app-sidebar.expanded { + max-height: 600px; + overflow-y: auto; + } + + .device-info { + margin-top: 8px; + } + + .device-info:first-child { + margin-top: 0; + } + + .info-item { + padding: 4px 0; + font-size: 12px; + } + + .app-main { + flex: 1; + padding: 12px 8px 150px 8px; + overflow-y: auto; + overflow-x: hidden; + height: auto; + min-height: 0; + max-height: none; + -webkit-overflow-scrolling: touch; + } + + .kpi-row { + grid-template-columns: repeat(2, 1fr); + gap: 6px; + margin-bottom: 10px; + } + + .card { + padding: 6px 8px; + min-height: 75px; + } + + .card-header { + margin-bottom: 3px; + } + + .card-value { + font-size: 18px; + line-height: 1.1; + margin-bottom: 2px; + } + + .card-title { + font-size: 10px; + letter-spacing: 0.3px; + } + + .card-sub { + font-size: 9px; + line-height: 1.3; + margin-top: 2px; + } + + .app-header { + padding: 8px 10px 10px 10px !important; + height: auto !important; + flex-direction: column !important; + gap: 10px !important; + align-items: stretch !important; + position: static !important; + top: auto !important; + z-index: auto !important; + backdrop-filter: none !important; + } + + .app-header-left { + justify-content: center; + padding-bottom: 4px; + } + + .app-header-title { + font-size: 14px; + } + + .app-header-right { + flex-direction: row; + gap: 6px; + align-items: center; + justify-content: space-between; + width: 100%; + padding-bottom: 2px; + } + + .dashboard-nav { + gap: 4px; + flex: 1; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .nav-link { + padding: 7px 8px; + font-size: 11px; + gap: 4px; + flex-shrink: 0; + justify-content: center; + border-radius: 4px; + } + + .nav-link svg { + width: 14px; + height: 14px; + } + + .nav-badge { + min-width: 16px; + height: 16px; + font-size: 10px; + padding: 0 4px; + margin-left: 2px; + } + + .app-last-update { + font-size: 10px; + } + + .app-status { + font-size: 9px; + padding: 1px 6px; + } + + .btn { + padding: 7px 8px; + font-size: 10px; + gap: 3px; + white-space: nowrap; + border-radius: 4px; + flex-shrink: 0; + } + + .btn svg { + width: 12px; + height: 12px; + } + + .chart-container { + padding: 8px; + margin-bottom: 10px; + } + + .chart-title { + font-size: 11px; + font-weight: 600; + } + + .chart-header { + margin-bottom: 6px; + padding-bottom: 4px; + } + + .chart-container:last-child { + margin-bottom: 100px; + } + + /* File browser mobile */ + .file-browser { + flex-direction: column; + } + + .file-tree { + width: 100%; + max-height: 150px; + border-right: none; + border-bottom: 1px solid #e5e7eb; + } + + .file-list-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + } + + /* Video viewer mobile */ + .video-grid.layout-6 { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr); + } + + .video-controls { + flex-wrap: wrap; + gap: 0.5rem; + padding: 8px 12px; + } + + .video-time { + font-size: 11px; + min-width: 80px; + } + + /* Log viewer mobile */ + .log-viewer { + height: auto; + min-height: 300px; + flex: 1; + } + + .log-viewer-content { + font-size: 10px; + } +} + +/* iPhone Pro Max and similar (393px - 430px) */ +@media (min-width: 393px) and (max-width: 430px) { + .kpi-row { + grid-template-columns: 1fr; + } + + .card { + min-height: 80px; + } + + .app-main { + padding-bottom: 140px; + } +} + +/* Smaller phones (below 393px) */ +@media (max-width: 392px) { + .kpi-row { + grid-template-columns: 1fr; + } + + .app-header { + padding: 6px 8px; + height: 45px; + } + + .app-header-title { + font-size: 13px; + } + + .app-main { + padding: 6px; + padding-bottom: 160px; + } + + .card { + padding: 6px; + min-height: 70px; + } + + .card-value { + font-size: 14px; + } + + .chart-title { + font-size: 11px; + } +} + +/* Touch-friendly improvements */ +@media (pointer: coarse) { + .btn { + min-height: 44px; + min-width: 44px; + } + + .info-item { + padding: 8px 0; + min-height: 44px; + align-items: center; + } + + .card { + min-height: 100px; + } + + .nav-link { + min-height: 44px; + } +} + +/* High DPI displays */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + .card { + border-width: 0.5px; + } + + .chart-container { + border-width: 0.5px; + } + + .app-header { + border-bottom-width: 0.5px; + } +} + +/* Landscape orientation for tablets */ +@media (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) { + .kpi-row { + grid-template-columns: repeat(3, 1fr); + } + + .app-sidebar { + width: 200px; + max-height: calc(100vh - 60px); + overflow-y: auto; + } + + .device-info { + margin-top: 0.75rem; + } + + .device-info:first-child { + margin-top: 0; + } +} + +/* Portrait orientation for tablets */ +@media (min-width: 768px) and (max-width: 1024px) and (orientation: portrait) { + .kpi-row { + grid-template-columns: repeat(2, 1fr); + } + + .app-sidebar { + width: 240px; + max-height: calc(100vh - 60px); + overflow-y: auto; + } + + .device-info { + margin-top: 0.75rem; + } + + .device-info:first-child { + margin-top: 0; + } +} + +/* Ensure no scrolling on dashboard */ +.app-dashboard { + max-height: 100vh; + overflow: hidden; +} + +/* Hide scrollbar for nav on mobile but allow scrolling */ +.dashboard-nav::-webkit-scrollbar { + display: none; +} + +.dashboard-nav { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Tab content */ +.tab-content { + display: none; + flex: 1; + overflow: hidden; +} + +.tab-content.active { + display: flex; + flex-direction: column; +} + +/* Utility classes */ +.hidden { + display: none !important; +} + +.flex { + display: flex; +} + +.flex-1 { + flex: 1; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-4 { + gap: 1rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.text-sm { + font-size: 12px; +} + +.text-xs { + font-size: 10px; +} + +.text-muted { + color: #6b7280; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} diff --git a/teslausb-www-react/vite.config.js b/teslausb-www-react/vite.config.js new file mode 100644 index 00000000..33b9902f --- /dev/null +++ b/teslausb-www-react/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; + +export default defineConfig({ + plugins: [preact()], + build: { + outDir: 'dist', + // Use esbuild for minification (faster, no extra dependency) + minify: 'esbuild', + // Target older browsers for maximum compatibility + target: 'es2018', + // Generate smaller chunks for Raspberry Pi + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + }, + // Base path for deployment + // Set VITE_BASE_PATH=/react/ for tarball releases, defaults to / for deploy.sh + base: process.env.VITE_BASE_PATH || '/', +}); diff --git a/teslausb-www/html/cgi-bin/cam_sync_progress.sh b/teslausb-www/html/cgi-bin/cam_sync_progress.sh new file mode 100755 index 00000000..3acc5de7 --- /dev/null +++ b/teslausb-www/html/cgi-bin/cam_sync_progress.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# Returns current TeslaCam archive progress by parsing rsync output or counting files + +# Possible rsync log locations for cam archiving +RSYNC_LOGS=("/tmp/rsyncarclog.txt" "/tmp/rsync-cam.log" "/tmp/archive-rsync.log") + +# Default values +active="false" +bytes="0" +percentage="0" +speed="" +eta="" +files_done="0" +files_total="0" +current_file="" + +# Check if cam archiving is active (rsync process for TeslaCam) +# Look for rsync processes that involve TeslaCam or cam paths +if pgrep -f "rsync.*TeslaCam" > /dev/null 2>&1 || \ + pgrep -f "rsync.*/mnt/cam" > /dev/null 2>&1 || \ + pgrep -f "rsync.*archive" > /dev/null 2>&1; then + active="true" + + # Try each possible log file location + LOG="" + for log_file in "${RSYNC_LOGS[@]}"; do + if [ -f "$log_file" ]; then + LOG="$log_file" + break + fi + done + + # If we found a log file, parse it for progress + if [ -n "$LOG" ] && [ -f "$LOG" ]; then + # Get the last progress update from rsync --info=progress2 output + # rsync uses \r for in-place updates, so the latest progress may not have a trailing newline + progress_line=$(tail -c 500 "$LOG" 2>/dev/null | tr '\r' '\n' | grep -E '[0-9]+%' | tail -1) + + if [ -n "$progress_line" ]; then + # Parse using more flexible pattern matching + # Extract bytes: first number (with possible commas) + bytes=$(echo "$progress_line" | grep -oE '^[[:space:]]*[0-9,]+' | tr -d ', ') + + # Extract percentage: number followed by % + percentage=$(echo "$progress_line" | grep -oE '[0-9]+%' | head -1 | tr -d '%') + + # Extract speed: number followed by unit/s (e.g., 1.07MB/s, 500kB/s) + speed=$(echo "$progress_line" | grep -oE '[0-9.]+[kMGT]?B/s' | head -1) + + # Extract ETA: time format like 0:01:23 or 6:27:37 + eta=$(echo "$progress_line" | grep -oE '[0-9]+:[0-9]+:[0-9]+' | head -1) + + # Ensure we have valid defaults + [ -z "$bytes" ] && bytes="0" + [ -z "$percentage" ] && percentage="0" + fi + + # Try to get file counts from rsync stats (at the end of the log) + # Format: "Number of regular files transferred: X" + xfr_line=$(grep -E 'xfr#[0-9]+' "$LOG" 2>/dev/null | tail -1) + if [ -n "$xfr_line" ]; then + # Extract xfr#N where N is files transferred so far + files_done=$(echo "$xfr_line" | grep -oE 'xfr#[0-9]+' | grep -oE '[0-9]+') + # Try to get total from to-chk=X/Y + total_from_chk=$(echo "$xfr_line" | grep -oE 'to-chk=[0-9]+/[0-9]+' | cut -d'/' -f2) + if [ -n "$total_from_chk" ]; then + files_total="$total_from_chk" + fi + fi + + # Try to get current file being transferred + current_file=$(tail -c 500 "$LOG" 2>/dev/null | tr '\r' '\n' | grep -E '^\S+\.(mp4|MP4)' | tail -1 | head -c 100) + fi +fi + +# If not active via rsync detection, check archiveloop.log for archiving state +if [ "$active" = "false" ]; then + ARCHIVELOG="/mutable/archiveloop.log" + if [ -f "$ARCHIVELOG" ]; then + # Get last 50 lines to check state + recent=$(tail -50 "$ARCHIVELOG" 2>/dev/null) + + # Check if we're in archiving state (started but not finished) + if echo "$recent" | grep -q "Starting recording archiving\|Archiving\.\.\.\|Running fsck\|Checking saved folder count"; then + # Check if archiving has completed + if ! echo "$recent" | grep -q "Archiving completed successfully\|Finished archiving"; then + active="true" + + # Try to get file counts from log + # Format: "There are X event folder(s) with Y file(s) and Z track mode file(s)" + file_count_line=$(echo "$recent" | grep -E 'There are [0-9]+ event folder' | tail -1) + if [ -n "$file_count_line" ]; then + sentry_files=$(echo "$file_count_line" | grep -oE 'with [0-9]+ file' | grep -oE '[0-9]+') + track_files=$(echo "$file_count_line" | grep -oE 'and [0-9]+ track mode' | grep -oE '[0-9]+') + [ -z "$track_files" ] && track_files="0" + files_total=$((sentry_files + track_files)) + fi + + # Alternative format: "Archiving X file(s)" + if [ "$files_total" = "0" ]; then + archiving_line=$(echo "$recent" | grep -E 'Archiving [0-9]+ (track mode )?file' | tail -1) + if [ -n "$archiving_line" ]; then + files_total=$(echo "$archiving_line" | grep -oE '[0-9]+' | head -1) + fi + fi + fi + fi + fi +fi + +# Output JSON +echo "HTTP/1.0 200 OK" +echo "Content-type: application/json" +echo "" +echo "{" +echo " \"active\": $active," +echo " \"bytesTransferred\": $bytes," +echo " \"percentage\": $percentage," +echo " \"speed\": \"$speed\"," +echo " \"eta\": \"$eta\"," +echo " \"filesDone\": $files_done," +echo " \"filesTotal\": $files_total," +echo " \"currentFile\": \"$current_file\"" +echo "}" diff --git a/teslausb-www/html/cgi-bin/music_sync_progress.sh b/teslausb-www/html/cgi-bin/music_sync_progress.sh new file mode 100755 index 00000000..9f57fe8a --- /dev/null +++ b/teslausb-www/html/cgi-bin/music_sync_progress.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Returns current music sync progress by parsing rsync output + +LOG="/tmp/rsyncmusiclog.txt" + +# Default values +active="false" +bytes="0" +percentage="0" +speed="" +eta="" + +# Check if music sync is active (rsync process for music) +if pgrep -f "rsync.*music" > /dev/null 2>&1; then + active="true" + + # Get last progress line from rsync --info=progress2 output + # Format: " 1,234,567,890 45% 12.34MB/s 0:01:23" + # Or: "1695349328 6% 1.07MB/s 6:27:37" + if [ -f "$LOG" ]; then + # Get the last progress update from rsync --info=progress2 output + # rsync uses \r for in-place updates, so the latest progress may not have a trailing newline + # Use tail -c to get the last chunk, then extract the most recent progress line + progress_line=$(tail -c 500 "$LOG" 2>/dev/null | tr '\r' '\n' | grep -E '[0-9]+%' | tail -1) + + if [ -n "$progress_line" ]; then + # Parse using more flexible pattern matching + # Extract bytes: first number (with possible commas) + bytes=$(echo "$progress_line" | grep -oE '^[[:space:]]*[0-9,]+' | tr -d ', ') + + # Extract percentage: number followed by % + percentage=$(echo "$progress_line" | grep -oE '[0-9]+%' | head -1 | tr -d '%') + + # Extract speed: number followed by unit/s (e.g., 1.07MB/s, 500kB/s) + speed=$(echo "$progress_line" | grep -oE '[0-9.]+[kMGT]?B/s' | head -1) + + # Extract ETA: time format like 0:01:23 or 6:27:37 + eta=$(echo "$progress_line" | grep -oE '[0-9]+:[0-9]+:[0-9]+' | head -1) + + # Ensure we have valid defaults + [ -z "$bytes" ] && bytes="0" + [ -z "$percentage" ] && percentage="0" + fi + fi +fi + +# Output JSON +echo "HTTP/1.0 200 OK" +echo "Content-type: application/json" +echo "" +echo "{" +echo " \"active\": $active," +echo " \"bytesTransferred\": $bytes," +echo " \"percentage\": $percentage," +echo " \"speed\": \"$speed\"," +echo " \"eta\": \"$eta\"" +echo "}" diff --git a/teslausb-www/html/cgi-bin/status.sh b/teslausb-www/html/cgi-bin/status.sh index fa981dbc..62b121b0 100755 --- a/teslausb-www/html/cgi-bin/status.sh +++ b/teslausb-www/html/cgi-bin/status.sh @@ -46,12 +46,26 @@ fi read -r -d ' ' ut < /proc/uptime +# Get device model (works on Raspberry Pi, Radxa, etc.) +device_model=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null || echo "Unknown") + +# Get CPU temperature (check multiple locations for different devices) +cpu_temp="" +if [ -f /sys/class/thermal/thermal_zone0/temp ]; then + # Raspberry Pi and many other devices + cpu_temp=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null) +elif [ -f /sys/class/hwmon/hwmon0/temp1_input ]; then + # Radxa Rock Pi and similar devices + cpu_temp=$(cat /sys/class/hwmon/hwmon0/temp1_input 2>/dev/null) +fi + cat << EOF HTTP/1.0 200 OK Content-type: application/json { - "cpu_temp": "$(cat /sys/class/thermal/thermal_zone0/temp)", + "device_model": "$device_model", + "cpu_temp": "$cpu_temp", "num_snapshots": "$numsnapshots", "snapshot_oldest": "$oldestsnapshot", "snapshot_newest": "$newestsnapshot", diff --git a/teslausb-www/html/cgi-bin/storage.sh b/teslausb-www/html/cgi-bin/storage.sh new file mode 100755 index 00000000..c69273bb --- /dev/null +++ b/teslausb-www/html/cgi-bin/storage.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +cat << EOF +HTTP/1.0 200 OK +Content-type: application/json + +EOF + +# Cache directory for storing last known usage when drives were mounted +CACHE_DIR="/tmp/teslausb-storage-cache" +mkdir -p "$CACHE_DIR" 2>/dev/null + +echo "{" + +first=true + +# Function to output drive info +output_drive() { + local name=$1 + local mount_point=$2 + local backing_file=$3 + local cache_file="$CACHE_DIR/${name}_usage" + + if [ "$first" = false ]; then + echo "," + fi + first=false + + # Check if mount point has actual filesystem mounted (not just the root fs) + mount_info=$(df "$mount_point" 2>/dev/null | tail -1) + mount_device=$(echo "$mount_info" | awk '{print $1}') + + # Check if it's mounted from a backing file or loop device (not root filesystem) + if echo "$mount_device" | grep -qE "(backingfiles|loop)"; then + drive_info=$(df -B1 "$mount_point" 2>/dev/null | tail -1) + if [ -n "$drive_info" ]; then + total=$(echo "$drive_info" | awk '{print $2}') + used=$(echo "$drive_info" | awk '{print $3}') + free=$(echo "$drive_info" | awk '{print $4}') + # Cache the current values for when drive is unmounted + echo "$used $total $free" > "$cache_file" + echo -n " \"$name\": { \"total\": $total, \"used\": $used, \"free\": $free, \"mounted\": true }" + return + fi + fi + + # Fall back to backing file size if exists + if [ -f "$backing_file" ]; then + total=$(stat -c%s "$backing_file" 2>/dev/null) + if [ -n "$total" ]; then + # Check if we have cached usage data from when it was last mounted + if [ -f "$cache_file" ]; then + read cached_used cached_total cached_free < "$cache_file" + # Use cached values if total matches (same drive) + if [ "$cached_total" = "$total" ] || [ -n "$cached_used" ]; then + echo -n " \"$name\": { \"total\": $total, \"used\": $cached_used, \"free\": $cached_free, \"mounted\": false, \"cached\": true }" + return + fi + fi + # No cache available, report allocation only + echo -n " \"$name\": { \"total\": $total, \"used\": null, \"free\": null, \"mounted\": false }" + return + fi + fi + + # Drive not configured + echo -n " \"$name\": null" +} + +# TeslaCam drive +output_drive "cam" "/mnt/cam" "/backingfiles/cam_disk.bin" + +# Music drive +output_drive "music" "/mnt/music" "/backingfiles/music_disk.bin" + +# LightShow drive +output_drive "lightshow" "/mnt/lightshow" "/backingfiles/lightshow_disk.bin" + +# Boombox drive +output_drive "boombox" "/mnt/boombox" "/backingfiles/boombox_disk.bin" + +# Overall backingfiles partition (total storage) +echo "," +bf_info=$(df -B1 /backingfiles 2>/dev/null | tail -1) +if [ -n "$bf_info" ]; then + bf_total=$(echo "$bf_info" | awk '{print $2}') + bf_used=$(echo "$bf_info" | awk '{print $3}') + bf_free=$(echo "$bf_info" | awk '{print $4}') + echo " \"total\": { \"total\": $bf_total, \"used\": $bf_used, \"free\": $bf_free }" +else + echo " \"total\": null" +fi + +echo "}"