diff --git a/embedded_resources/web_interface/index.css b/embedded_resources/web_interface/index.css index 380fbc9c4..8012ceae0 100644 --- a/embedded_resources/web_interface/index.css +++ b/embedded_resources/web_interface/index.css @@ -1,520 +1,557 @@ -:root { - --color: #ff3ec8; - --background: #242424; - --light-color: color-mix(in srgb, var(--color) 20%, gray 20%); -} -* { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - font-family: - "DejaVu Sans Mono", "Consolas", "Menlo", "Courier New", "Lucida Console", - monospace; - color: var(--color); -} -html { - padding: 0; - margin: 0; -} -body { - background-color: var(--background); - font-size: 12px; - padding: 10px; - margin: 0; -} -.ml-10 { - margin-left: 10px !important; -} -.act-browse, -.btn-action, -.icon-action, -.act-edit-file { - cursor: pointer; - outline: 0; -} -.btn-action { - border: 1px solid var(--color); - background-color: transparent; - padding: 2px 7px; - text-decoration: none; - text-align: center; -} -.btn-action:hover:not(:disabled), -.btn-action.active, -.btn-action:hover:not(:disabled) > *, -.btn-action.active > * { - background-color: var(--color); - color: var(--background); -} -.btn-action:disabled { - filter: brightness(50%); - cursor: not-allowed; -} -.btn-action.btn-default { - font-weight: bold; - border: 2px solid var(--color); - padding: 1px 7px; -} -.icon-action { - border: 0; - background-color: transparent; - width: 24px; - height: 24px; - padding: 1px; - margin: 0; - text-align: center; - vertical-align: middle; - display: inline-block; - position: relative; - overflow: hidden; -} -svg > g, -svg > path { - fill: var(--color); -} -.icon-action.act-download svg, -.icon-action.act-download svg > path { - stroke: var(--color); - fill: transparent; -} -.icon-action:hover { - background-color: var(--color); -} -.icon-action:hover svg > g, -.icon-action:hover svg > path { - fill: var(--background); -} -.icon-action.act-download:hover svg, -.icon-action.act-download:hover svg > path { - stroke: var(--background); - fill: var(--color); -} -.inp-uploader { - opacity: 0; - position: absolute; - cursor: pointer; - top: 0; -} -.container { - max-width: 800px; - margin: auto; - border: 1px solid var(--color); -} -.container .header { - display: flex; - justify-content: space-between; - border-bottom: 1px solid var(--color); - align-items: center; -} -.container .header .title { - padding: 9px 12px; - cursor: pointer; - text-align: center; -} -.left-part, -.right-part { - padding: 4px 6px; -} -.left-part { - border-left: 1px solid var(--color); - margin-right: auto; - width: auto; -} -.right-part { - margin-left: auto; - text-align: right; -} -.container .header button { - margin-top: 2px; - margin-bottom: 2px; - width: 100px; -} -.container .free-space { - border-bottom: 1px solid var(--color); - justify-content: space-between; - display: flex; -} -.container .free-space .block-space { - width: 50%; - border: 0px; - border-right: 1px solid var(--color); - padding: 5px; - white-space: normal; - word-wrap: break-word; - overflow: visible; -} -.container .free-space .block-space:last-child { - border: 0px; -} -.container .action-content { - padding: 5px; - border-bottom: 1px solid var(--color); - display: flex; - justify-content: space-between; - align-items: center; -} -.container .action-content .breadcrumb { - display: flex; - align-items: center; - gap: 5px; -} -.container .content .table { - width: 100%; - border: 0px; - border-collapse: collapse; - table-layout: fixed; -} -.container .content .table .col-name { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.container .content .table .col-size { - width: 90px; - text-align: right; -} -.container .content .table .col-action { - width: 130px; - text-align: right; -} -.container .content .table tbody tr td { - padding: 5px; - height: 34px; -} -.container .content .table tbody tr:hover { - background-color: var(--light-color); -} -.container .content .table thead tr th { - border-bottom: 1px solid var(--color); - font-weight: bold; - font-size: 14px; - padding: 5px; -} -.table .col-action.type-folder .act-download, -.table .col-action .act-play { - display: none; -} -.table .col-action.executable .act-play { - display: inherit; -} -.dialog-background { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.8); - display: flex; - justify-content: center; - align-items: center; - padding: 10px; -} -.dialog { - border: 1px solid var(--color); - min-width: 250px; - max-width: 400px; - background-color: var(--background); -} -.dialog .dialog-head { - border-bottom: 1px solid var(--color); - padding: 5px 10px; - font-size: 14px; - font-weight: bold; - align-items: center; - display: flex; - gap: 5px; -} -.dialog .dialog-head span { - display: inline-block; -} -.dialog .dialog-body { - padding: 10px; -} -.dialog .dialog-body p:first-child { - margin-top: 0; -} -.dialog .dialog-body input { - display: block; - min-width: 300px; - width: 100%; - border: 1px solid var(--color); - background-color: var(--background); - outline: 0px; - padding: 2px 4px; -} -.dialog .dialog-footer { - padding: 10px; - border-top: 1px solid var(--color); - text-align: right; -} -.dialog.navigator { - max-width: 500px; -} -.dialog.navigator .dialog-head { - display: flex; - justify-content: space-between; - padding-right: 5px; -} -.dialog.navigator .dialog-head select { - background-color: var(--background); - padding: 1px; - border: 1px solid var(--color); -} -.dialog.navigator .dialog-body { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - gap: 10px; -} -.dialog.navigator #navigator-screen { - border: 1px solid var(--color); - border-radius: 3px; -} -.dialog.navigator .navigator-canvas { - position: relative; - display: flex; - flex-direction: column; - gap: 1px; - border: 1px solid var(--color); - border-radius: 3px; - /* background-color: var(--color); */ -} -.dialog.navigator .navigator-canvas > div { - display: flex; - flex-direction: row; - justify-content: center; - gap: 1px; -} -.dialog.navigator .navigator-canvas .nav { - width: 38px; - height: 38px; - border-radius: 3px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - background: var(--background); -} -.dialog.navigator .navigator-canvas .nav.nav-ok:before { - content: ""; - width: 16px; - height: 16px; - display: inline-block; - background-color: var(--color); - border-radius: 100%; -} -.dialog.navigator .navigator-canvas .nav.nav-page-up svg { - transform: rotate(-90deg); -} -.dialog.navigator .navigator-canvas .nav.nav-page-down svg { - transform: rotate(90deg); -} -.dialog.navigator .navigator-canvas .nav.nav-down svg { - transform: rotate(180deg); -} -.dialog.navigator .navigator-canvas .nav.nav-left svg { - transform: rotate(-90deg); -} -.dialog.navigator .navigator-canvas .nav.nav-right svg { - transform: rotate(90deg); -} -.dialog.navigator .navigator-canvas .nav:hover { - color: var(--background); - background: var(--color); -} -.dialog.navigator .navigator-canvas .nav:hover svg path, -.dialog.navigator .navigator-canvas .nav:hover svg line { - color: var(--background); - fill: var(--background); -} -.dialog.navigator .navigator-canvas .nav:hover svg circle { - stroke: var(--background); -} -.dialog.navigator .navigator-canvas .nav.nav-ok:hover:before { - background-color: var(--background); -} -.dialog.navigator .navigator-warning { - width: 100%; - padding: 8px; - margin-bottom: 5px; - background-color: rgba(255, 165, 0, 0.15); - border: 1px solid orange; - border-radius: 3px; - text-align: center; - font-size: 11px; - color: orange; -} -.dialog.navigator .navigator-warning.hidden { - display: none; -} -.dialog.status .dialog-body { - padding: 10px 0; - text-align: center; -} -.dialog.settings .dialog-body { - display: flex; - flex-direction: column; - gap: 5px; -} -.dialog.settings .dialog-body .btn-action { - display: block; - padding: 6px 5px; -} -.dialog.editor { - width: 100%; - max-width: 100%; - height: 100%; -} -.dialog.editor .dialog-body { - width: 100%; - height: calc(100% - 70px); - padding: 0; - overflow: hidden; -} -.dialog.editor .editor-container { - display: flex; - width: 100%; - height: 100%; - background-color: var(--background); - font-family: - "DejaVu Sans Mono", "Consolas", "Menlo", "Courier New", "Lucida Console", - monospace; - font-size: 14px; - line-height: 1.4; -} -.dialog.editor .line-numbers { - background-color: color-mix(in srgb, var(--background) 95%, var(--color) 5%); - border-right: 1px solid var(--light-color); - color: var(--light-color); - padding: 7px 8px; - text-align: right; - user-select: none; - white-space: pre; - overflow: hidden; -} -.dialog.editor .file-content { - flex: 1; - background-color: var(--background); - border: 0; - padding: 5px; - outline: 0; - resize: none; - font-family: - "DejaVu Sans Mono", "Consolas", "Menlo", "Courier New", "Lucida Console", - monospace; - font-size: 14px; - line-height: 1.4; - white-space: pre; - overflow-wrap: normal; - overflow: auto; -} -.dialog.upload .dialog-body { - padding: 10px; -} -.dialog.upload .upload-loading { - width: 350px; - border: 1px solid var(--color); - margin-bottom: 5px; - position: relative; - padding: 3px 7px; -} -.dialog.upload .dialog-body .upload-loading:last-child { - margin-bottom: 0; -} -.dialog.upload .upload-loading .bar { - width: 0; - background-color: var(--light-color); - position: absolute; - top: 0; - bottom: 0; - left: 0; - z-index: 1; -} -.dialog.upload .upload-loading .upload-name { - z-index: 2; - position: relative; - font-weight: bold; -} -.hidden { - display: none !important; -} -.upload-area, -.loading-area { - position: fixed; - top: 0; - left: 0; - background-color: rgba(0, 0, 0, 0.8); - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} -.upload-area::before { - content: "Drop files here to upload them to current folder"; - border: 3px dashed var(--color); - background-color: var(--background); - font-size: 24px; - padding: 20px; - height: 300px; - width: 300px; - text-align: center; - font-weight: bold; - vertical-align: center; - display: flex; - align-items: center; -} -.loading-area .text { - color: var(--color); - border: 1px solid var(--color); - padding: 10px 20px; - min-width: 250px; - text-align: center; - max-width: 350px; - background-color: var(--background); -} -.oinput-file-name { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 250px; -} -.dialog.navigator .dialog-head div { - display: flex; - gap: 5px; -} -::selection { - background: var(--color); - color: var(--background); -} -#force-reload, -#refresh-folder { - padding: 0; - height: 21px; - display: flex; - width: 21px; - justify-content: center; - align-items: center; - border: 0; -} -#force-reload svg path, -#refresh-folder svg path { - fill: var(--background); -} -#force-reload:hover svg path, -#refresh-folder:hover svg path { - fill: var(--color); - stroke: var(--background); -} -#force-reload.reloading svg path, -#refresh-folder.reloading svg path { - animation: rotation 1s linear infinite; - transform-origin: 50% 50%; -} -@keyframes rotation { - 100% { - transform: rotate(360deg); - } -} +:root { + --color: #10b981; + --sec-color: #34d399; + --background: #09090b; + --surface: #111113; + --surface-2: #18181b; + --surface-hover: #1e1e22; + --border: #1e1e22; + --border-light: #2a2a30; + --text: #ececef; + --text-sec: #a1a1aa; + --text-muted: #52525b; + --danger: #ef4444; + --danger-hover: #dc2626; + --radius: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --tr: 150ms ease; + --shadow-sm: 0 1px 2px rgba(0,0,0,.3); + --shadow-md: 0 4px 12px rgba(0,0,0,.4); + --shadow-lg: 0 12px 40px rgba(0,0,0,.5); + --icon-sm: 16px; + --icon-md: 18px; +} +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { padding: 0; margin: 0; } +body { + background: var(--background); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + min-height: 100vh; + line-height: 1.5; +} +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } +::selection { background: var(--color); color: var(--background); } +a { color: var(--color); text-decoration: none; } +a:hover { text-decoration: underline; } + +.app { max-width: 940px; margin: 0 auto; padding: 20px 16px; } + +/* Header */ +.header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 0; margin-bottom: 20px; border-bottom: 1px solid var(--border); + gap: 12px; +} +.header-left { display: flex; align-items: center; flex-shrink: 0; } +.logo { cursor: pointer; display: flex; align-items: center; gap: 10px; } +.logo-text { + font-size: 22px; font-weight: 800; color: var(--text); + letter-spacing: -0.5px; font-family: 'Segoe UI', system-ui, sans-serif; +} +.bruce-version { + font-size: 10px; color: var(--text-muted); background: var(--surface-2); + padding: 2px 10px; border-radius: 20px; border: 1px solid var(--border); + letter-spacing: .3px; line-height: 1.4; +} +.header-nav { display: flex; gap: 6px; align-items: center; } +.mobile-menu-btn { display: none !important; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: var(--radius); + border: 1px solid var(--border); background: var(--surface); + color: var(--text); font-size: 13px; font-family: inherit; + cursor: pointer; transition: all var(--tr); white-space: nowrap; + line-height: 1.4; +} +.btn:hover { background: var(--surface-hover); border-color: var(--border-light); transform: translateY(-1px); } +.btn:active { transform: translateY(0); transition-duration: 50ms; } +.btn-primary { background: var(--color); color: #000; border-color: var(--color); font-weight: 600; } +.btn-primary:hover { background: var(--sec-color); border-color: var(--sec-color); } +.btn-danger { color: var(--danger); border-color: rgba(239,68,68,.3); background: transparent; } +.btn-danger:hover { background: rgba(239,68,68,.08); border-color: var(--danger); } +.btn-ghost { border-color: transparent; background: transparent; } +.btn-ghost:hover { background: var(--surface-2); border-color: var(--border); } +.btn-sm { padding: 5px 10px; font-size: 12px; } +.btn:disabled { opacity: .4; cursor: not-allowed; transform: none !important; } +.btn svg { width: var(--icon-sm); height: var(--icon-sm); flex-shrink: 0; } + +.btn-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 34px; height: 34px; border-radius: var(--radius); + border: 1px solid var(--border); background: var(--surface); + color: var(--text); cursor: pointer; transition: all var(--tr); + position: relative; overflow: hidden; flex-shrink: 0; +} +.btn-icon:hover { background: var(--surface-hover); border-color: var(--border-light); } +.btn-icon:active { transform: scale(.93); transition-duration: 50ms; } +.btn-icon svg { width: var(--icon-sm); height: var(--icon-sm); } + +/* Storage */ +.storage-section { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; } +.storage-card { + display: flex; align-items: center; gap: 14px; + padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); cursor: pointer; transition: all var(--tr); + min-height: 72px; +} +.storage-card:hover { border-color: var(--border-light); transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.storage-card.active { border-color: var(--color); background: color-mix(in srgb, var(--color) 4%, var(--surface)); } +.storage-icon { + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; flex-shrink: 0; + border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); + color: var(--text-sec); transition: all var(--tr); +} +.storage-card:hover .storage-icon { color: var(--text); } +.storage-card.active .storage-icon { color: var(--color); background: color-mix(in srgb, var(--color) 10%, var(--surface-2)); border-color: color-mix(in srgb, var(--color) 20%, var(--border)); } +.storage-icon svg { width: 20px; height: 20px; } +.storage-detail { flex: 1; min-width: 0; } +.storage-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.storage-name { font-weight: 600; font-size: 13px; color: var(--text); } +.storage-usage { font-size: 11px; color: var(--text-sec); font-family: 'Consolas', 'Fira Code', monospace; } +.storage-bar { height: 4px; background: var(--surface-2); border-radius: 3px; overflow: hidden; } +.storage-fill { + height: 100%; background: var(--color); border-radius: 3px; + transition: width .6s cubic-bezier(.4,0,.2,1); width: 0; +} + +/* Toolbar */ +.toolbar { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 10px 0; margin-bottom: 12px; border-bottom: 1px solid var(--border); +} +.toolbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; } +.toolbar-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } +.breadcrumb { display: flex; align-items: center; gap: 4px; min-width: 0; } +.current-path { font-size: 13px; color: var(--text-sec); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'Consolas', 'Fira Code', monospace; } +.toolbar-divider { width: 1px; height: 20px; background: var(--border); margin: 0 2px; } +.search-wrapper { position: relative; } +.search-wrapper svg { + position: absolute; left: 9px; top: 50%; transform: translateY(-50%); + width: 14px; height: 14px; color: var(--text-muted); pointer-events: none; +} +.search-input { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px 6px 30px; color: var(--text); font-size: 13px; font-family: inherit; + width: 160px; outline: none; transition: all var(--tr); +} +.search-input:focus { border-color: var(--color); width: 200px; } +.search-input::placeholder { color: var(--text-muted); } +.inp-uploader { opacity: 0; position: absolute; cursor: pointer; top: 0; left: 0; width: 100%; height: 100%; } + +/* Multi-select bar */ +.multi-bar { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 14px; background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); margin-bottom: 8px; animation: slideDown .2s ease; +} +.multi-bar-left { display: flex; align-items: center; gap: 12px; } +.multi-bar-right { display: flex; align-items: center; gap: 8px; } +#selected-count { font-size: 13px; color: var(--text-sec); font-weight: 500; } + +/* Content / File Table */ +.content { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); overflow: hidden; +} +.table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.table thead th { + padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; + color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; + border-bottom: 1px solid var(--border); user-select: none; +} +.table thead th.sortable { cursor: pointer; } +.table thead th.sortable:hover { color: var(--text-sec); } +.sort-icon { font-size: 10px; margin-left: 2px; opacity: .5; } +.table tbody tr { transition: background var(--tr); border-bottom: 1px solid var(--border); } +.table tbody tr:last-child { border-bottom: none; } +.table tbody tr:hover { background: var(--surface-hover); } +.table tbody tr.selected { background: color-mix(in srgb, var(--color) 6%, var(--surface)); } +.table tbody td { padding: 7px 14px; font-size: 13px; } +.col-check { width: 42px; text-align: center; } +.col-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.col-size { width: 110px; text-align: right; color: var(--text-sec); font-size: 12px; font-family: 'Consolas', monospace; } +.col-action { width: 140px; text-align: right; white-space: nowrap; } +.col-name.act-browse, .col-name.act-edit-file { cursor: pointer; transition: color var(--tr); } +.col-name.act-browse:hover, .col-name.act-edit-file:hover { color: var(--color); } +.path-row td { cursor: pointer; color: var(--text-muted); font-size: 13px; } +.path-row:hover td { color: var(--text); } +.col-action .btn-icon { width: 28px; height: 28px; border: none; background: transparent; } +.col-action .btn-icon:hover { background: var(--surface-2); } +.col-action .btn-icon svg { width: var(--icon-sm); height: var(--icon-sm); } +.table .col-action.type-folder .act-download, +.table .col-action .act-play { display: none; } +.table .col-action.executable .act-play { display: inline-flex; } + +/* Checkboxes */ +input[type="checkbox"] { + -webkit-appearance: none; appearance: none; + width: 16px; height: 16px; border: 1.5px solid var(--border-light); + border-radius: 4px; background: var(--surface); cursor: pointer; + transition: all var(--tr); position: relative; vertical-align: middle; + flex-shrink: 0; +} +input[type="checkbox"]:hover { border-color: var(--text-muted); } +input[type="checkbox"]:checked { background: var(--color); border-color: var(--color); } +input[type="checkbox"]:checked::after { + content: ''; position: absolute; top: 2px; left: 5px; + width: 4px; height: 8px; border: solid #000; border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* Dialogs */ +.dialog-background { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); + display: flex; justify-content: center; align-items: center; + padding: 16px; z-index: 1000; + opacity: 0; pointer-events: none; transition: opacity .2s ease; +} +.dialog-background.visible { opacity: 1; pointer-events: all; } +.dialog { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-xl); width: 100%; max-width: 420px; + transform: scale(.96) translateY(8px); opacity: 0; + transition: all .25s cubic-bezier(.4,0,.2,1); overflow: hidden; + box-shadow: var(--shadow-lg); +} +.dialog-background.visible .dialog:not(.hidden) { + transform: scale(1) translateY(0); opacity: 1; +} +.dialog-head { + padding: 16px 20px; font-size: 15px; font-weight: 600; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; +} +.dialog-body { padding: 16px 20px; } +.dialog-body p { color: var(--text-sec); line-height: 1.65; margin-bottom: 8px; } +.dialog-body p:last-child { margin-bottom: 0; } +.dialog-footer { + padding: 12px 20px; border-top: 1px solid var(--border); + display: flex; justify-content: flex-end; gap: 8px; +} +.dialog-body input[type="text"], +.dialog-body input[type="password"] { + width: 100%; padding: 9px 12px; background: var(--background); + border: 1px solid var(--border); border-radius: var(--radius); + color: #fff; font-size: 13px; font-family: inherit; + outline: none; transition: border-color var(--tr); +} +.dialog-body input:focus { border-color: var(--color); } +.dialog-body label { + display: block; font-size: 12px; color: var(--text-sec); + margin-bottom: 6px; font-weight: 500; +} + +/* Navigator */ +.dialog.navigator { max-width: 520px; } +.dialog.navigator .dialog-head { display: flex; justify-content: space-between; padding-right: 16px; } +.dialog.navigator .dialog-head > div { display: flex; gap: 6px; align-items: center; } +.dialog.navigator .dialog-head select { + background: var(--background); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 5px 10px; font-size: 12px; font-family: inherit; outline: none; +} +.dialog.navigator .dialog-body { + display: flex; flex-wrap: wrap; justify-content: center; + align-items: center; gap: 14px; +} +#navigator-screen { border: 1px solid var(--border); border-radius: var(--radius); } +.navigator-canvas { + display: flex; flex-direction: column; gap: 3px; + border: 1px solid var(--border); border-radius: var(--radius); padding: 3px; +} +.navigator-canvas > div { display: flex; justify-content: center; gap: 3px; } +.nav { + width: 44px; height: 44px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; background: var(--surface-2); border: 1px solid var(--border); + transition: all var(--tr); color: var(--text); +} +.nav:hover { background: var(--color); color: #000; border-color: var(--color); } +.nav:active { transform: scale(.9); transition-duration: 50ms; } +.nav:hover svg path, .nav:hover svg line, .nav:hover svg polyline, +.nav:hover svg polygon { stroke: #000; color: #000; } +.nav:hover svg circle { stroke: #000; } +.nav.nav-ok { position: relative; } +.nav.nav-ok::before { + content: ''; width: 18px; height: 18px; + background: var(--color); border-radius: 50%; display: block; +} +.nav.nav-ok:hover::before { background: #000; } +.nav-left svg { transform: rotate(-90deg); } +.nav-right svg { transform: rotate(90deg); } +.nav-down svg { transform: rotate(180deg); } +.nav-page-up svg { transform: rotate(-90deg); } +.nav-page-down svg { transform: rotate(90deg); } + +/* Editor */ +.dialog.editor { width: 100%; max-width: 100%; height: 100%; border-radius: 0; max-height: none; } +.dialog.editor .dialog-body { height: calc(100% - 110px); padding: 0; overflow: hidden; } +.editor-container { display: flex; width: 100%; height: 100%; } +.line-numbers { + background: var(--background); border-right: 1px solid var(--border); + color: var(--text-muted); padding: 10px 10px; text-align: right; + user-select: none; white-space: pre; overflow: hidden; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 13px; line-height: 1.5; min-width: 50px; +} +.file-content { + flex: 1; background: var(--background); border: none; + padding: 10px; color: var(--text); outline: none; resize: none; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 13px; line-height: 1.5; white-space: pre; overflow: auto; +} + +/* Settings */ +.dialog.settings .dialog-body { display: flex; flex-direction: column; gap: 8px; } +.dialog.settings .btn { width: 100%; justify-content: center; padding: 10px; } + +/* Upload */ +.dialog.upload { max-width: 520px; } +.dialog.upload .dialog-head { display: flex; justify-content: space-between; align-items: center; } +.upload-counter { font-size: 12px; font-weight: 600; color: var(--color); letter-spacing: .3px; } +.upload-stats { padding: 0 20px 8px; } +.upload-overall-bar { + height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; +} +.upload-overall-fill { + height: 100%; width: 0; background: var(--color); border-radius: 3px; + transition: width .3s ease; +} +.upload-meta { + display: flex; justify-content: space-between; margin-top: 6px; + font-size: 11px; color: var(--text-sec); +} +.dialog.upload .dialog-body { + max-height: 280px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px; + padding-top: 4px; +} +.upload-loading { + position: relative; padding: 8px 12px; + border: 1px solid var(--border); border-radius: var(--radius); + overflow: hidden; display: flex; align-items: center; gap: 8px; + transition: border-color .3s ease; +} +.upload-loading.done { border-color: color-mix(in srgb, var(--color) 40%, transparent); } +.upload-loading.error { border-color: #ef4444; } +.upload-loading .bar { + position: absolute; inset: 0; right: auto; width: 0; + background: color-mix(in srgb, var(--color) 12%, transparent); + transition: width .3s ease; z-index: 0; +} +.upload-loading.done .bar { background: color-mix(in srgb, var(--color) 8%, transparent); width: 100% !important; } +.upload-loading.error .bar { background: rgba(239,68,68,.08); width: 100% !important; } +.upload-status { + position: relative; z-index: 1; flex-shrink: 0; width: 16px; height: 16px; + display: flex; align-items: center; justify-content: center; +} +.upload-status .dot { + width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); +} +.upload-loading.active .upload-status .dot { + background: var(--color); animation: pulse 1s ease-in-out infinite; +} +.upload-loading.done .upload-status .dot { background: var(--color); width: 8px; height: 8px; } +.upload-loading.error .upload-status .dot { background: #ef4444; width: 8px; height: 8px; } +.upload-name { position: relative; z-index: 1; font-size: 11px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } +.upload-pct { position: relative; z-index: 1; font-size: 10px; color: var(--text-sec); flex-shrink: 0; min-width: 32px; text-align: right; } +.upload-pages { + display: flex; align-items: center; justify-content: center; gap: 12px; + padding: 8px 20px 16px; font-size: 12px; color: var(--text-sec); +} +.upload-pages .btn-icon { width: 28px; height: 28px; } +.upload-pages .btn-icon:disabled { opacity: .3; cursor: default; } + +/* Credential */ +.dialog.credential .dialog-body > label { margin-top: 12px; } +.dialog.credential .dialog-body > label:first-child { margin-top: 0; } + +/* Loading */ +.loading-area { + position: fixed; inset: 0; background: rgba(0,0,0,.7); + -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); + display: flex; flex-direction: column; justify-content: center; + align-items: center; gap: 14px; z-index: 2000; +} +.spinner { + width: 28px; height: 28px; border: 2.5px solid var(--border); + border-top-color: var(--color); border-radius: 50%; + animation: spin .7s linear infinite; +} +.loading-area .text { color: var(--text-sec); font-size: 13px; } + +/* Upload Drop */ +.upload-area { + position: fixed; inset: 0; background: rgba(0,0,0,.8); + display: flex; justify-content: center; align-items: center; z-index: 3000; +} +.upload-area::before { + content: 'Drop files here to upload'; + border: 2px dashed var(--color); background: var(--surface); + font-size: 18px; font-weight: 600; padding: 48px 64px; + border-radius: var(--radius-xl); color: var(--text); + display: flex; align-items: center; justify-content: center; + animation: pulse 1.5s ease-in-out infinite; + box-shadow: var(--shadow-lg); +} + +/* Toast */ +.toast-container { + position: fixed; top: 16px; right: 16px; + display: flex; flex-direction: column; gap: 8px; + z-index: 5000; pointer-events: none; +} +.toast { + padding: 10px 16px; background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); font-size: 13px; pointer-events: all; + animation: toastIn .3s ease; box-shadow: var(--shadow-lg); + display: flex; align-items: center; gap: 10px; max-width: 380px; + color: var(--text); +} +.toast.success { border-left: 3px solid var(--color); } +.toast.error { border-left: 3px solid var(--danger); } +.toast.removing { animation: toastOut .2s ease forwards; } + +/* Empty state */ +.empty-state { text-align: center; padding: 48px 20px; color: var(--text-muted); } +.empty-state p { font-size: 14px; } + +/* Hidden */ +.hidden { display: none !important; } + +/* Animations */ +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes toastIn { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } } +@keyframes toastOut { to { opacity: 0; transform: translateX(24px); } } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .7; } } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.reloading svg { animation: spin .7s linear infinite; } + +/* Navigator shortcuts */ +.dialog.navigator-shortcut ul { list-style: none; padding: 0; } +.dialog.navigator-shortcut li { + padding: 6px 0; color: var(--text-sec); font-size: 13px; + border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; +} +.dialog.navigator-shortcut li:last-child { border-bottom: none; } +.dialog.navigator-shortcut li kbd { + background: var(--surface-2); border: 1px solid var(--border); + padding: 1px 8px; border-radius: 4px; font-size: 12px; font-family: monospace; +} +.dialog.navigator-shortcut p { color: var(--text-sec); margin-bottom: 10px; font-size: 13px; } + +/* SVG defaults */ +svg { flex-shrink: 0; } + +/* Info dialog */ +.dialog.info .dialog-body { max-height: 300px; overflow-y: auto; } + +.oinput-title { font-weight: 600; } +.oinput-label { display: block; font-size: 12px; color: var(--text-sec); margin-bottom: 6px; font-weight: 500; } + +/* Selection for inputs */ +#force-reload { border: none; background: transparent; } +#force-reload:hover { background: var(--surface-2); } + +/* Login specific */ +.login-wrapper { + min-height: 100vh; display: flex; align-items: center; + justify-content: center; padding: 16px; +} +.login-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-xl); padding: 0; width: 100%; max-width: 360px; + box-shadow: var(--shadow-lg); overflow: hidden; +} +.login-card .dialog-head { + padding: 18px 20px; font-size: 18px; font-weight: 600; + border-bottom: 1px solid var(--border); +} +.login-card .dialog-footer { + padding: 16px 20px; border-top: 1px solid var(--border); + background: var(--surface-2); +} +.login-card .dialog-footer .btn { width: 100%; justify-content: center; padding: 10px; font-size: 14px; } +.login-card .btn-primary { + background: var(--surface-2); color: var(--text); + border-color: var(--border); font-weight: 600; +} +.login-card .btn-primary:hover { background: var(--surface-hover); border-color: var(--border-light); } +.login-card .dialog-body input[type="text"], +.login-card .dialog-body input[type="password"] { + color: #fff !important; -webkit-text-fill-color: #fff !important; +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active{ + -webkit-box-shadow: 0 0 0 30px var(--background) inset !important; + -webkit-text-fill-color: var(--text) !important; + transition: background-color 5000s ease-in-out 0s; +} + +#notice { + padding: 10px 14px; border-radius: var(--radius); + background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.2); + color: var(--danger); font-size: 13px; font-weight: 500; margin-bottom: 8px; +} +#notice.success-notice { + background: color-mix(in srgb, var(--color) 10%, transparent); + border-color: color-mix(in srgb, var(--color) 20%, transparent); + color: var(--color); +} + +/* Responsive */ +@media (max-width: 768px) { + .app { padding: 12px; } + .storage-section { grid-template-columns: 1fr 1fr; gap: 8px; } + .storage-card { padding: 10px 12px; min-height: 64px; gap: 10px; } + .storage-icon { width: 34px; height: 34px; border-radius: 8px; } + .storage-icon svg { width: 16px; height: 16px; } + .storage-name { font-size: 12px; } + .storage-usage { font-size: 10px; } + .toolbar { flex-wrap: wrap; gap: 8px; } + .toolbar-right { width: 100%; } + .search-input { width: 120px; } + .search-input:focus { width: 150px; } + .col-action { width: 110px; } + .col-size { width: 70px; } + .header-nav { + display: none; position: fixed; top: 52px; left: 12px; right: 12px; + flex-direction: column; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 6px; z-index: 100; box-shadow: var(--shadow-lg); + animation: slideDown .2s ease; + } + .header-nav.open { display: flex; } + .header-nav .btn { width: 100%; justify-content: center; } + .mobile-menu-btn { display: flex !important; } + .multi-bar { flex-direction: column; gap: 8px; } + .multi-bar-left, .multi-bar-right { width: 100%; justify-content: center; } + .table tbody td { padding: 6px 10px; } + .table thead th { padding: 8px 10px; } + .dialog { max-width: calc(100vw - 32px); } +} +@media (max-width: 480px) { + .storage-section { grid-template-columns: 1fr; gap: 8px; } + .toolbar-divider { display: none; } + .search-input { width: 100px; } + .search-input:focus { width: 130px; } + .col-check { width: 32px; } + .col-name { font-size: 12px; } +} + +:focus-visible { outline: 2px solid var(--color); outline-offset: 2px; } +button:focus:not(:focus-visible), a:focus:not(:focus-visible) { outline: none; } diff --git a/embedded_resources/web_interface/index.html b/embedded_resources/web_interface/index.html index 5290300b6..6d794eb02 100644 --- a/embedded_resources/web_interface/index.html +++ b/embedded_resources/web_interface/index.html @@ -1,700 +1,353 @@ - - - - - - Bruce - - - - - -
-
-
- Bruce [dev] -
-
- - -
-
- - -
-
-
- - SDCard [0 MB] - - - LittleFS [0 MB] - -
-
- - - - - - - - - - LittleFS:// - -
- - - - -
-
-
- - - - - - - - - -
NameSizeAction
-
-
- - - - - - - + + + + + + Bruce + + + + +
+ +
+
+
+ +
+ + +
+ +
+
+
+ +
+
+
+ SD Card + 0 B +
+
+
+
+
+
+ +
+
+
+ LittleFS + 0 B +
+
+
+
+
+ +
+
+ + +
+
+
+ + +
+ + + + + +
+
+ + + +
+ + + + + + + + + + +
Name Size Actions
+ +
+
+ + + + + + + + + + diff --git a/embedded_resources/web_interface/index.js b/embedded_resources/web_interface/index.js index fd6160294..8f4ba03ee 100644 --- a/embedded_resources/web_interface/index.js +++ b/embedded_resources/web_interface/index.js @@ -1,1723 +1,1661 @@ -function $(s) { - return document.querySelector(s); -} -const IS_DEV = window.location.host === "127.0.0.1:8080"; -const T = { - master: $("#t"), - fileRow: function () { - const tmp = document.createElement("template"); - tmp.innerHTML = - this.master.content.querySelector("table tr.file-row").outerHTML; - return tmp.content; - }, - pathRow: function () { - const tmp = document.createElement("template"); - tmp.innerHTML = - this.master.content.querySelector("table tr.path-row").outerHTML; - return tmp.content; - }, - uploadLoading: function () { - const tmp = document.createElement("template"); - tmp.innerHTML = - this.master.content.querySelector(".upload-loading").outerHTML; - return tmp.content; - }, -}; - -const EXECUTABLE = { - ir: "ir tx_from_file", - sub: "subghz tx_from_file", - js: "js run_from_file", - bjs: "js run_from_file", - txt: "badusb run_from_file", - mp3: "play", - wav: "play", -}; - -const Dialog = { - _bg: function (show) { - let bg = $(".dialog-background"); - let dialogs = document.querySelectorAll(".dialog"); - dialogs.forEach((dialog) => { - if (!dialog.classList.contains("hidden")) dialog.classList.add("hidden"); - }); - if (show) { - bg.classList.remove("hidden"); - } else { - bg.classList.add("hidden"); - } - }, - show: function (dialogName) { - this._bg(true); - let dialog = $(".dialog." + dialogName); - dialog.classList.remove("hidden"); - }, - hide: function () { - this._bg(false); - this.loading.hide(); - - if (currentDrive && currentPath) { - updateURL(currentDrive, currentPath, null); - } - }, - loading: { - show: function (message) { - $(".loading-area").classList.remove("hidden"); - $(".loading-area .text").textContent = message || "Loading..."; - }, - hide: function () { - $(".loading-area").classList.add("hidden"); - }, - }, - showOneInput: function (name, inputVal, data) { - const dbForm = { - renameFolder: { - title: "Rename Folder: " + inputVal, - label: `New Folder Name:`, - action: "Rename", - }, - renameFile: { - title: "Rename File: " + inputVal, - label: `New File Name:`, - action: "Rename", - }, - createFolder: { - title: "Create Folder", - label: `Folder Name:`, - action: "Create Folder", - }, - createFile: { - title: "Create File", - label: `File Name:`, - action: "Create File", - }, - serial: { - title: "Serial Command", - label: `Command:`, - action: "Run", - }, - }; - - let config = dbForm[name]; - if (!config) { - alert("Invalid dialog name: " + name); - console.error("Dialog.showOneInput: Invalid dialog name", name); - return; - } - - let dialog = $(".dialog.oinput"); - dialog.setAttribute("data-cache", data); - dialog.querySelector(".oinput-title").textContent = config.title; - dialog.querySelector(".oinput-label").textContent = config.label; - dialog.querySelector("#oinput-input").value = inputVal; - dialog.querySelector(".act-save-oinput-file").textContent = config.action; - this.show("oinput"); - dialog.querySelector("#oinput-input").select(); - return dialog; - }, -}; - -function handleAuthError() { - if ( - confirm( - "Session expired or unauthorized. Would you like to go to the login page?", - ) - ) { - window.location.href = "/"; - } else { - Dialog.loading.hide(); - } -} - -async function requestGet(url, data) { - return new Promise((resolve, reject) => { - let req = new XMLHttpRequest(); - let realUrl = url; - if (IS_DEV) realUrl = "/bruce" + url; - if (data) { - let urlParams = new URLSearchParams(data); - realUrl += "?" + urlParams.toString(); - } - req.open("GET", realUrl, true); - req.onload = () => { - if (req.status >= 200 && req.status < 300) { - resolve(req.responseText); - } else if (req.status === 401) { - handleAuthError(); - reject(new Error(`Unauthorized access (401)`)); - } else { - reject(new Error(`Request failed with status ${req.status}`)); - } - }; - req.onerror = () => { - reject(new Error("Network error")); - }; - req.send(); - }); -} - -async function requestPost(url, data) { - return new Promise((resolve, reject) => { - let fd = new FormData(); - for (let key in data) { - if (data.hasOwnProperty(key)) fd.append(key, data[key]); - } - - let realUrl = url; - if (IS_DEV) realUrl = "/bruce" + url; - let req = new XMLHttpRequest(); - req.open("POST", realUrl, true); - req.onload = () => { - if (req.status >= 200 && req.status < 300) { - resolve(req.responseText); - } else if (req.status === 401) { - handleAuthError(); - reject(new Error(`Unauthorized access (401)`)); - } else { - reject(new Error(`Request failed with status ${req.status}`)); - } - }; - req.onerror = () => reject(new Error("Network error")); - req.send(fd); - }); -} - -function stringToId(str) { - let hash = 0, - i, - chr; - if (str.length === 0) return hash.toString(); - for (i = 0; i < str.length; i++) { - chr = str.charCodeAt(i); - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - return "id_" + Math.abs(hash); -} - -const _queueUpload = []; -let _runningUpload = false; -function appendFileToQueue(files) { - Dialog.show("upload"); - let d = $(".dialog.upload"); - for (let i = 0; i < files.length; i++) { - let file = files[i]; - let filename = file.webkitRelativePath || file.name; - let fileId = stringToId(filename); - let progressBar = T.uploadLoading(); - progressBar.querySelector(".upload-name").textContent = filename; - progressBar - .querySelector(".upload-loading .bar") - .setAttribute("id", fileId); - - d.querySelector(".dialog-body").appendChild(progressBar); - } -} -async function appendDroppedFiles(entry) { - return new Promise((resolve, reject) => { - if (entry.isFile) { - entry.file((file) => { - let fileWithPath = new File([file], entry.fullPath.substring(1), { - type: file.type, - }); - appendFileToQueue([fileWithPath]); - _queueUpload.push(fileWithPath); - resolve(); - }); - } else if (entry.isDirectory) { - let proms = []; - let reader = entry.createReader(); - reader.readEntries((entries) => { - for (let e of entries) proms.push(appendDroppedFiles(e)); - }); - - Promise.all(proms).then(resolve); - } - }); -} -async function uploadFile() { - if (_queueUpload.length === 0) { - _runningUpload = false; - $(".dialog.upload .dialog-body").innerHTML = ""; - fetchSystemInfo(); - fetchFiles(currentDrive, currentPath); - Dialog.hide(); - return; - } - - return new Promise((resolve, reject) => { - _runningUpload = true; - let file = _queueUpload.shift(); - let fd = new FormData(); - let filename = file.webkitRelativePath || file.name; - let fileId = stringToId(filename); - fd.append("file", file, filename); - fd.append("folder", currentPath); - fd.append("fs", currentDrive); - - let realUrl = `/upload`; - if (IS_DEV) realUrl = "/bruce" + realUrl; - let req = new XMLHttpRequest(); - req.upload.onprogress = (e) => { - if (e.lengthComputable) { - var percent = (e.loaded / e.total) * 100; - $("#" + fileId).style.width = Math.round(percent) + "%"; - } - }; - req.onload = () => { - uploadFile(); - if (req.status >= 200 && req.status < 300) { - resolve(req.responseText); - } else { - reject(); - } - }; - req.onabort = () => reject(); - req.onerror = () => reject(); - req.open("POST", realUrl, true); - req.send(fd); - }); -} - -async function runCommand(cmd) { - Dialog.loading.show("Running command..."); - try { - await requestPost("/cm", { cmnd: cmd }); - } catch (error) { - alert("Failed to run command: " + error.message); - } finally { - Dialog.loading.hide(); - } -} - -function getSerialCommand(fileName) { - let extension = fileName.split("."); - if (extension.length > 1) { - extension = extension[extension.length - 1].toLowerCase(); - return EXECUTABLE[extension]; - } - - return undefined; -} - -function calcHash(str) { - let hash = 5381; - str = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) + hash) ^ str.charCodeAt(i); // djb2 xor variant - hash = hash >>> 0; // force unsigned 32-bit - } - - return hash.toString(16).padStart(8, "0"); -} - -// Line numbers functionality -function updateLineNumbers() { - const textarea = $(".dialog.editor .file-content"); - const lineNumbers = $(".dialog.editor .line-numbers"); - - if (!textarea || !lineNumbers) return; - - const lines = textarea.value.split("\n"); - const lineCount = lines.length; - - // Generate line numbers - let lineNumbersHTML = ""; - for (let i = 1; i <= lineCount; i++) { - lineNumbersHTML += i + "\n"; - } - - lineNumbers.textContent = lineNumbersHTML; -} - -function syncScrolling() { - const textarea = $(".dialog.editor .file-content"); - const lineNumbers = $(".dialog.editor .line-numbers"); - - if (!textarea || !lineNumbers) return; - - lineNumbers.scrollTop = textarea.scrollTop; -} - -function renderFileRow(fileList) { - $("table.explorer tbody").innerHTML = ""; - fileList - .split("\n") - .sort((a, b) => { - let [aFirst, ...aRest] = a.split(":"); - let [bFirst, ...bRest] = b.split(":"); - - if (aFirst !== bFirst) { - return bFirst.localeCompare(aFirst); - } - - let aRestStr = aRest.join(":").toLowerCase(); - let bRestStr = bRest.join(":").toLowerCase(); - return aRestStr.localeCompare(bRestStr); - }) - .forEach((line) => { - let e; - let [type, name, size] = line.split(":"); - if (size === undefined) return; - let dPath = ( - (currentPath.endsWith("/") ? currentPath : currentPath + "/") + name - ).replace(/\/\//g, "/"); - if (type === "pa") { - if (dPath === "/") return; - e = T.pathRow(); - let preFolder = currentPath.substring(0, currentPath.lastIndexOf("/")); - if (preFolder === "") preFolder = "/"; - e.querySelector(".path-row").setAttribute("data-path", preFolder); - e.querySelector(".path-row td").classList.add("act-browse"); - } else if (type === "Fi") { - e = T.fileRow(); - e.querySelector(".file-row").setAttribute("data-file", dPath); - e.querySelector(".act-rename").setAttribute( - "data-action", - "renameFile", - ); - e.querySelector(".col-name").classList.add("act-edit-file"); - e.querySelector(".col-name").textContent = name; - e.querySelector(".col-name").setAttribute("title", name); - e.querySelector(".col-size").textContent = size; - e.querySelector(".col-action").classList.add("type-file"); - - let downloadUrl = `/file?fs=${currentDrive}&name=${encodeURIComponent(dPath)}&action=download`; - if (IS_DEV) downloadUrl = "/bruce" + downloadUrl; - e.querySelector(".act-download").setAttribute("download", name); - e.querySelector(".act-download").setAttribute("href", downloadUrl); - - let serialCmd = getSerialCommand(name); - if (serialCmd) { - e.querySelector(".act-play").setAttribute( - "data-cmd", - serialCmd + ' "' + dPath + '"', - ); - e.querySelector(".col-action").classList.add("executable"); - } - } else if (type === "Fo") { - e = T.fileRow(); - e.querySelector(".col-name").classList.add("act-browse"); - e.querySelector(".file-row").setAttribute("data-path", dPath); - e.querySelector(".act-rename").setAttribute( - "data-action", - "renameFolder", - ); - e.querySelector(".col-name").textContent = name; - e.querySelector(".col-name").setAttribute("title", name); - e.querySelector(".col-action").classList.add("type-folder"); - } - $("table.explorer tbody").appendChild(e); - }); -} - -let sdCardAvailable = false; -let currentDrive; -let currentPath; -const btnRefreshFolder = $("#refresh-folder"); - -// URL state management -function updateURL(drive, path, editFile = null) { - const params = new URLSearchParams(); - if (drive) params.set("drive", drive); - if (path && path !== "/") params.set("path", path); - if (editFile) params.set("edit", editFile); - - const newURL = - window.location.pathname + - (params.toString() ? "?" + params.toString() : ""); - window.history.replaceState({ drive, path, editFile }, "", newURL); -} - -function getURLParams() { - const params = new URLSearchParams(window.location.search); - return { - drive: params.get("drive"), - path: params.get("path") || "/", - editFile: params.get("edit"), - }; -} - -async function fetchFiles(drive, path) { - btnRefreshFolder.classList.add("reloading"); - $("table.explorer tbody").innerHTML = - 'Loading...'; - currentDrive = drive; - currentPath = path; - - // Update URL state (preserving edit file if still valid) - const urlParams = getURLParams(); - updateURL(drive, path, urlParams.editFile); - - $(`.act-browse.active`)?.classList.remove("active"); - $(`.act-browse[data-drive='${drive}']`).classList.add("active"); - $(".current-path").textContent = drive + ":/" + path; - let req = await requestGet("/listfiles", { - fs: drive, - folder: path, - }); - renderFileRow(req); - btnRefreshFolder.classList.remove("reloading"); -} - -async function fetchSystemInfo() { - Dialog.loading.show("Fetching system info..."); - let req = await requestGet("/systeminfo"); - let info = JSON.parse(req); - $(".bruce-version").textContent = info.BRUCE_VERSION; - $(".free-space .free-sd span").innerHTML = - `${info.SD.used} / ${info.SD.total}`; - $(".free-space .free-fs span").innerHTML = - `${info.LittleFS.used} / ${info.LittleFS.total}`; - sdCardAvailable = info.SD.total != "0 B"; - Dialog.loading.hide(); -} - -async function saveEditorFile(runFile = false) { - Dialog.loading.show("Saving..."); - let editor = $(".dialog.editor .file-content"); - let filename = $(".dialog.editor .editor-file-name").textContent.trim(); - if (isModified(editor)) { - $(".act-save-edit-file").disabled = true; - editor.setAttribute("data-hash", calcHash(editor.value)); - await requestPost("/edit", { - fs: currentDrive, - name: filename, - content: editor.value, - }); - } - - if (runFile) { - let serial = getSerialCommand(filename); - if (serial !== undefined) { - await runCommand(serial + ' "' + filename + '"'); - } - } - Dialog.loading.hide(); -} - -function isModified(target) { - let oldHash = target.getAttribute("data-hash"); - let newHash = calcHash(target.value); - return oldHash !== newHash; -} - -async function openNavigator() { - Dialog.show("navigator"); - await reloadScreen(); - autoReloadScreen(); -} - -let SCREEN_NAVIGATING = false; -async function runNavigation(direction) { - if (SCREEN_NAVIGATING) return; - SCREEN_NAVIGATING = true; - try { - drawCanvasLoading(); - await requestPost("/cm", { cmnd: `nav ${direction.toLowerCase()}` }); - await reloadScreen(); - } catch (error) { - alert("Failed to run command: " + error.message); - console.error(error); - } finally { - SCREEN_NAVIGATING = false; - } -} - -const btnForceReload = $("#force-reload"); -let SCREEN_RELOAD = false; -async function reloadScreen() { - if (SCREEN_RELOAD) return; - SCREEN_RELOAD = true; - btnForceReload.classList.add("reloading"); - try { - let binResponse = await fetch((IS_DEV ? "/bruce" : "") + "/getscreen"); - let arrayBuffer = await binResponse.arrayBuffer(); - let screenData = new Uint8Array(arrayBuffer); - await renderTFT(screenData); - } catch (error) { - console.error("Failed to reload screen:", error); - alert("Failed to reload screen: " + error.message); - } finally { - btnForceReload.classList.remove("reloading"); - SCREEN_RELOAD = false; - } -} - -const eConfigAutoReload = $("#navigator-auto-reload"); -let AUTO_RELOAD_SCREEN = null; -async function taskReloader() { - let timer = parseInt(eConfigAutoReload.value); - let navigatorOpen = $(".dialog.navigator:not(.hidden)"); - if (timer <= 0 || !navigatorOpen) { - if (AUTO_RELOAD_SCREEN) { - clearTimeout(AUTO_RELOAD_SCREEN); - AUTO_RELOAD_SCREEN = null; - } - - return; - } - - await reloadScreen(); - setTimeout(taskReloader, timer); - // better use setTimeout instead of setInterval to avoid overlapping calls -} -async function autoReloadScreen() { - let timer = parseInt(eConfigAutoReload.value); - - if (AUTO_RELOAD_SCREEN) { - clearTimeout(AUTO_RELOAD_SCREEN); - AUTO_RELOAD_SCREEN = null; - } - - if (timer > 0) taskReloader(); -} - -/// TFT RENDER -let loadingDrawn = false; -const imageCache = {}; // global -async function renderTFT(data) { - loadingDrawn = false; - const canvas = $("#navigator-screen"); - const ctx = canvas.getContext("2d"); - - const loadImage = async (url) => { - if (imageCache[url]) return imageCache[url]; - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => { - imageCache[url] = img; - resolve(img); - }; - img.onerror = (err) => reject(err); - img.src = url; - }); - }; - - const drawImageCached = async (img_url, input) => { - if (IS_DEV) img_url = "/bruce" + img_url; - let img = await loadImage(img_url); - let drawX = input.x; - let drawY = input.y; - - if (input.center === 1) { - drawX += (canvas.width - img.width) / 2; - drawY += (canvas.height - img.height) / 2; - } - ctx.drawImage(img, drawX, drawY); - }; - - const color565toCSS = (color565) => { - const r = (((color565 >> 11) & 0x1f) * 255) / 31; - const g = (((color565 >> 5) & 0x3f) * 255) / 63; - const b = ((color565 & 0x1f) * 255) / 31; - return `rgb(${r},${g},${b})`; - }; - - const drawRoundRect = (ctx, input, fill) => { - const { x, y, w, h, r } = input; - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.arcTo(x + w, y, x + w, y + h, r); - ctx.arcTo(x + w, y + h, x, y + h, r); - ctx.arcTo(x, y + h, x, y, r); - ctx.arcTo(x, y, x + w, y, r); - ctx.closePath(); - if (fill) ctx.fill(); - else ctx.stroke(); - }; - - let startData = 0; - const getByteValue = (dataType) => { - if (dataType === "int8") { - return data[startData++]; - } else if (dataType === "int16") { - let value = (data[startData] << 8) | data[startData + 1]; - startData += 2; - return value; - } else if (dataType.startsWith("s")) { - let strLength = parseInt(dataType.substring(1)); - let strBytes = data.slice(startData, startData + strLength); - startData += strLength; - return new TextDecoder().decode(strBytes); - } - }; - - const byteToObject = (fn, size) => { - let keysMap = { - 0: ["fg"], // FILLSCREEN - 1: ["x", "y", "w", "h", "fg"], // DRAWRECT - 2: ["x", "y", "w", "h", "fg"], // FILLRECT - 3: ["x", "y", "w", "h", "r", "fg"], // DRAWROUNDRECT - 4: ["x", "y", "w", "h", "r", "fg"], // FILLROUNDRECT - 5: ["x", "y", "r", "fg"], // DRAWCIRCLE - 6: ["x", "y", "r", "fg"], // FILLCIRCLE - 7: ["x", "y", "x2", "y2", "x3", "y3", "fg"], // DRAWTRIANGLE - 8: ["x", "y", "x2", "y2", "x3", "y3", "fg"], // FILLTRIANGLE - 9: ["x", "y", "rx", "ry", "fg"], // DRAWELLIPSE - 10: ["x", "y", "rx", "ry", "fg"], // FILLELLIPSE - 11: ["x", "y", "x1", "y1", "fg"], // DRAWLINE - 12: ["x", "y", "r", "ir", "startAngle", "endAngle", "fg", "bg"], // DRAWARC - 13: ["x", "y", "bx", "by", "wd", "fg", "bg"], // DRAWWIDELINE - 14: ["x", "y", "size", "fg", "bg", "txt"], // DRAWCENTRESTRING - 15: ["x", "y", "size", "fg", "bg", "txt"], // DRAWRIGHTSTRING - 16: ["x", "y", "size", "fg", "bg", "txt"], // DRAWSTRING - 17: ["x", "y", "size", "fg", "bg", "txt"], // PRINT - 18: ["x", "y", "center", "ms", "fs", "file"], // DRAWIMAGE - 20: ["x", "y", "h", "fg"], // DRAWFASTVLINE - 21: ["x", "y", "w", "fg"], // DRAWFASTHLINE - 99: ["w", "h", "rotation"], // SCREEN_INFO - }; - - let r = {}; - let lengthLeft = size - 3; - for (let key of keysMap[fn]) { - if (["txt", "file"].includes(key)) { - r[key] = getByteValue(`s${lengthLeft}`); - } else if (["rotation", "fs"].includes(key)) { - lengthLeft -= 1; - r[key] = getByteValue("int8"); - if (key === "fs") { - r[key] = r[key] === 0 ? "SD" : "FS"; // 0 for SD, 1 for FS - } - } else { - lengthLeft -= 2; - r[key] = getByteValue("int16"); - } - } - return r; - }; - - let offset = 0; - ctx.clearRect(0, 0, canvas.width, canvas.height); - let screenText = []; // Collect all text rendered on screen - - while (offset < data.length) { - ctx.beginPath(); - if (data[offset] !== 0xaa) { - console.warn("Invalid header at offset", offset); - break; - } - - startData = offset + 1; - let size = getByteValue("int8"); - let fn = getByteValue("int8"); - offset += size; - - let input = byteToObject(fn, size); - // reset to default before drawing again - ctx.lineWidth = 1; - ctx.fillStyle = "black"; - ctx.strokeStyle = "black"; - switch (fn) { - case 99: // SCREEN_INFO - canvas.width = input.w; - canvas.height = input.h; - case 0: // FILLSCREEN - ctx.fillStyle = color565toCSS(input.fg); - ctx.fillRect(0, 0, canvas.width, canvas.height); - break; - - case 1: // DRAWRECT - ctx.strokeStyle = color565toCSS(input.fg); - ctx.strokeRect(input.x, input.y, input.w, input.h); - break; - - case 2: // FILLRECT - ctx.fillStyle = color565toCSS(input.fg); - ctx.fillRect(input.x, input.y, input.w, input.h); - break; - - case 3: // DRAWROUNDRECT - ctx.strokeStyle = color565toCSS(input.fg); - drawRoundRect(ctx, input, false); - break; - - case 4: // FILLROUNDRECT - ctx.fillStyle = color565toCSS(input.fg); - drawRoundRect(ctx, input, true); - break; - - case 5: // DRAWCIRCLE - ctx.strokeStyle = color565toCSS(input.fg); - ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2); - ctx.stroke(); - break; - - case 6: // FILLCIRCLE - ctx.fillStyle = color565toCSS(input.fg); - ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2); - ctx.fill(); - break; - case 7: // DRAWTRIANGLE - ctx.strokeStyle = color565toCSS(input.fg); - ctx.beginPath(); - ctx.moveTo(input.x, input.y); - ctx.lineTo(input.x2, input.y2); - ctx.lineTo(input.x3, input.y3); - ctx.closePath(); - ctx.stroke(); - break; - - case 8: // FILLTRIANGLE - ctx.fillStyle = color565toCSS(input.fg); - ctx.beginPath(); - ctx.moveTo(input.x, input.y); - ctx.lineTo(input.x2, input.y2); - ctx.lineTo(input.x3, input.y3); - ctx.closePath(); - ctx.fill(); - break; - case 9: // DRAWELLIPSE - ctx.strokeStyle = color565toCSS(input.fg); - ctx.beginPath(); - ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2); - ctx.stroke(); - break; - - case 10: // FILLELLIPSE - ctx.fillStyle = color565toCSS(input.fg); - ctx.beginPath(); - ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2); - ctx.fill(); - break; - - case 11: // DRAWLINE - ctx.strokeStyle = color565toCSS(input.fg); - ctx.moveTo(input.x, input.y); - ctx.lineTo(input.x1, input.y1); - ctx.stroke(); - break; - - case 12: // DRAWARC - ctx.strokeStyle = color565toCSS(input.fg); - ctx.lineWidth = input.r - input.ir || 1; - const sa = ((input.startAngle + 90 || 0) * Math.PI) / 180; - const ea = ((input.endAngle + 90 || 0) * Math.PI) / 180; - const radius = (input.r + input.ir) / 2; - ctx.beginPath(); - ctx.arc(input.x, input.y, radius, sa, ea); - ctx.stroke(); - break; - - case 13: // DRAWWIDELINE - ctx.strokeStyle = color565toCSS(input.fg); - ctx.lineWidth = input.wd || 1; - ctx.moveTo(input.x, input.y); - ctx.lineTo(input.bx, input.by); - ctx.stroke(); - break; - - case 14: // DRAWCENTRESTRING - case 15: // DRAWRIGHTSTRING - case 16: // DRAWSTRING - case 17: // PRINT - // This must be enhanced to make font width be multiple of 6px, the font used here is multiple of 4.5px, - // "\n" are not treated, and long lines do not split into multi lines.. - if (input.bg == input.fg) { - input.bg = 0; - } - ctx.fillStyle = color565toCSS(input.bg); - - input.txt = input.txt.replaceAll("\\n", ""); // remove new lines - screenText.push(input.txt); // Collect text for WiFi detection - - var fw = input.size === 3 ? 13.5 : input.size === 2 ? 9 : 4.5; - var o = 0; - if (fn === 15) o = input.txt.length * fw; - if (fn === 14) o = (input.txt.length * fw) / 2; - // draw a rectangle at the text area, to avoid overlapping texts - ctx.fillRect( - input.x - o, - input.y, - input.txt.length * fw, - input.size * 8, - ); - - ctx.fillStyle = color565toCSS(input.fg); - ctx.font = `${input.size * 8}px monospace`; - ctx.textBaseline = "top"; - ctx.textAlign = fn === 14 ? "center" : fn === 15 ? "right" : "left"; - ctx.fillText(input.txt, input.x, input.y); - break; - - case 18: // DRAWIMAGE - let url = `/file?fs=${input.fs}&name=${encodeURIComponent(input.file)}&action=image`; - await drawImageCached(url, input); - break; - - case 19: // DRAWPIXEL - ctx.fillStyle = color565toCSS(input.fg); - ctx.fillRect(input.x, input.y, 1, 1); - break; - case 20: // DRAWFASTVLINE - ctx.fillStyle = color565toCSS(input.fg); - ctx.fillRect(input.x, input.y, 1, input.h); - break; - - case 21: // DRAWFASTHLINE - ctx.fillStyle = color565toCSS(input.fg); - ctx.fillRect(input.x, input.y, input.w, 1); - break; - } - } - - // Check if WiFi menu is present on screen and show/hide warning - const wifiWarning = $("#wifi-warning"); - const allText = screenText.join(" ").toLowerCase(); - const isWiFiMenu = - allText.includes("wifi") || - allText.includes("evil portal") || - allText.includes("deauth") || - allText.includes("handshake"); - - if (isWiFiMenu) { - wifiWarning.classList.remove("hidden"); - } else { - wifiWarning.classList.add("hidden"); - } -} -function drawCanvasLoading() { - if (loadingDrawn || !showNavigating) return; - loadingDrawn = true; - const canvas = $("#navigator-screen"); - const ctx = canvas.getContext("2d"); - const width = canvas.width; - const height = canvas.height; - - // Draw semi-transparent black background - ctx.save(); - ctx.globalAlpha = 0.8; - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - ctx.globalAlpha = 1.0; - - // Draw "Loading" text in the center - ctx.fillStyle = "#fff"; - ctx.font = "bold 14px 'DejaVu Sans Mono', Consolas, Menlo"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("Navigating...", width / 2, height / 2); - ctx.restore(); -} - -let oldTimerSession = sessionStorage.getItem("autoReload") || "0"; -eConfigAutoReload.querySelector(`option[value="${oldTimerSession}"]`).selected = - true; -eConfigAutoReload.addEventListener("change", async (e) => { - e.preventDefault(); - autoReloadScreen(); - sessionStorage.setItem("autoReload", eConfigAutoReload.value); -}); - -btnForceReload.addEventListener("click", async (e) => { - e.preventDefault(); - drawCanvasLoading(); - await reloadScreen(); -}); - -window.ondragenter = () => $(".upload-area").classList.remove("hidden"); -$(".upload-area").ondragleave = () => $(".upload-area").classList.add("hidden"); -$(".upload-area").ondragover = (e) => e.preventDefault(); -$(".upload-area").ondrop = async (e) => { - e.preventDefault(); - $(".upload-area").classList.add("hidden"); - const items = e.dataTransfer.items; - if (!items || items.length === 0) return; - - for (let i of items) { - let entry = i.webkitGetAsEntry(); - if (!entry) continue; - await appendDroppedFiles(entry); - } - - if (!_runningUpload) - setTimeout(() => { - if (_queueUpload.length === 0) return; - uploadFile(); - }, 100); -}; - -document.querySelectorAll(".inp-uploader").forEach((el) => { - el.addEventListener("change", async (e) => { - let files = e.target.files; - if (!files || files.length === 0) return; - - appendFileToQueue(files); - _queueUpload.push(...files); - if (!_runningUpload) uploadFile(); - - this.value = ""; - }); -}); - -$(".container").addEventListener("click", async (e) => { - let browseAction = e.target.closest(".act-browse"); - if (browseAction) { - e.preventDefault(); - let drive = - browseAction.getAttribute("data-drive") || currentDrive || "LittleFS"; - let path = - browseAction.getAttribute("data-path") || - browseAction.closest("tr").getAttribute("data-path") || - "/"; - if (drive === currentDrive && path === currentPath) return; - - fetchFiles(drive, path); - return; - } - - let editFileAction = e.target.closest(".act-edit-file"); - if (editFileAction) { - e.preventDefault(); - let editor = $(".dialog.editor .file-content"); - let file = editFileAction.closest("tr").getAttribute("data-file"); - if (!file) return; - $(".dialog.editor .editor-file-name").textContent = file; - editor.value = ""; - - // Load file content - Dialog.loading.show("Fetching content..."); - let r = await requestGet( - `/file?fs=${currentDrive}&name=${encodeURIComponent(file)}&action=edit`, - ); - editor.value = r; - editor.setAttribute("data-hash", calcHash(r)); - - // Update line numbers - updateLineNumbers(); - - $(".act-save-edit-file").disabled = true; - - let serial = getSerialCommand(file); - if (serial === undefined) { - $(".act-run-edit-file").classList.add("hidden"); - } else { - $(".act-run-edit-file").classList.remove("hidden"); - } - - Dialog.loading.hide(); - Dialog.show("editor"); - - // Update URL to include edit state - updateURL(currentDrive, currentPath, file); - return; - } - - let oActionOInput = e.target.closest(".act-oinput"); - if (oActionOInput) { - e.preventDefault(); - let action = oActionOInput.getAttribute("data-action"); - if (!action) return; - - let value = "", - data = ""; - if (action.startsWith("rename")) { - let row = oActionOInput.closest("tr"); - let filePath = - row.getAttribute("data-file") || row.getAttribute("data-path"); - - if (filePath != "") { - value = filePath.substring(filePath.lastIndexOf("/") + 1); - data = `${action}|${filePath}`; - } - } else if (action.startsWith("create")) { - filePath = currentPath; - data = `${action}|${filePath}`; - } else { - data = `${action}`; - } - Dialog.showOneInput(action, value, data); - return; - } - - let actDeleteFile = e.target.closest(".act-delete"); - if (actDeleteFile) { - e.preventDefault(); - let file = - actDeleteFile.closest(".file-row").getAttribute("data-file") || - actDeleteFile.closest(".file-row").getAttribute("data-path"); - if (!file) return; - - if ( - !confirm( - `Are you sure you want to DELETE ${file}?\n\nTHIS ACTION CANNOT BE UNDONE!`, - ) - ) - return; - - Dialog.loading.show("Deleting..."); - await requestGet("/file", { - fs: currentDrive, - action: "delete", - name: file, - }); - Dialog.loading.hide(); - fetchSystemInfo(); - fetchFiles(currentDrive, currentPath); - return; - } - - let actPlay = e.target.closest(".act-play"); - if (actPlay) { - e.preventDefault(); - let cmd = actPlay.getAttribute("data-cmd"); - if (!cmd) return; - - actPlay.blur(); - await runCommand(cmd); - return; - } -}); - -$(".dialog-background").addEventListener("click", async (e) => { - if (e.target.matches(".act-dialog-close")) { - e.preventDefault(); - Dialog.hide(); - return; - } -}); - -$(".act-save-oinput-file").addEventListener("click", async (e) => { - let dialog = $(".dialog.oinput"); - let fileInput = $("#oinput-input"); - let fileName = fileInput.value.trim(); - if (!fileName) { - alert("Filename cannot be empty."); - return; - } - let action = dialog.getAttribute("data-cache"); - if (!action) { - alert("No action specified."); - return; - } - - let refreshList = true; - let [actionType, path] = action.split("|"); - if (actionType.startsWith("rename")) { - Dialog.loading.show("Renaming..."); - await requestPost("/rename", { - fs: currentDrive, - filePath: path, - fileName: fileName, - }); - } else if (actionType === "createFolder") { - Dialog.loading.show("Creating Folder..."); - let urlQuery = new URLSearchParams({ - fs: currentDrive, - action: "create", - name: path.replace(/\/+$/, "") + "/" + fileName, - }); - await requestGet("/file?" + urlQuery.toString()); - } else if (actionType === "createFile") { - Dialog.loading.show("Creating File..."); - let urlQuery = new URLSearchParams({ - fs: currentDrive, - action: "createfile", - name: path.replace(/\/+$/, "") + "/" + fileName, - }); - await requestGet("/file?" + urlQuery.toString()); - } else if (actionType === "serial") { - Dialog.loading.show("Running Serial Command..."); - await runCommand(fileName); - refreshList = false; // No need to refresh file list for serial commands - } - - if (refreshList) fetchFiles(currentDrive, currentPath); - Dialog.hide(); -}); - -$(".act-save-credential").addEventListener("click", async (e) => { - let username = $("#cred-username").value.trim(); - let password = $("#cred-password").value.trim(); - if (!username || !password) { - alert("Username and password cannot be empty."); - return; - } - - Dialog.loading.show("Saving WiFi Credentials..."); - await requestGet("/wifi", { - usr: username, - pwd: password, - }); - Dialog.loading.hide(); - alert("Credentials saved successfully!"); -}); - -$(".act-save-edit-file").addEventListener("click", async (e) => { - await saveEditorFile(); -}); - -const runEditorBtn = $(".act-run-edit-file"); -runEditorBtn.addEventListener("click", async (e) => { - await saveEditorFile(true); - runEditorBtn.blur(); // remove focus -}); - -let showNavigating = localStorage.getItem("showNavigating") || false; -updateShowHideNavigatingButton(); -$(".act-hide-show-navigating").addEventListener("click", async (e) => { - e.preventDefault(); - showNavigating = !showNavigating; - localStorage.setItem("showNavigating", showNavigating); - updateShowHideNavigatingButton(); -}); - -function updateShowHideNavigatingButton() { - document.querySelector(".act-hide-show-navigating").innerHTML = - "'Navigating...' Overlay
" + - (showNavigating ? "Shown" : "Hidden") + - "
(click to toggle)"; -} - -$(".act-reboot").addEventListener("click", async (e) => { - e.preventDefault(); - if (!confirm("Are you sure you want to REBOOT the device?")) return; - Dialog.loading.show("Rebooting..."); - await requestGet("/reboot"); - setTimeout(() => { - location.reload(); - }, 1000); -}); - -$(".navigator-canvas").addEventListener("click", async (e) => { - let nav = e.target.matches(".nav") ? e.target : e.target.closest(".nav"); - if (nav === null) return; - - let direction = nav.getAttribute("data-direction"); - if (direction === "Menu") { - direction = "Sel 500"; - } - - await runNavigation(direction.toLowerCase()); -}); - -window.addEventListener("keydown", async (e) => { - let key = e.key.toLowerCase(); - if ($(".dialog.editor:not(.hidden)")) { - // means editor tab is open - if ((e.ctrlKey || e.metaKey) && key === "s") { - e.preventDefault(); - e.stopImmediatePropagation(); - - await saveEditorFile(); - } else if (e.altKey && key === "enter") { - e.preventDefault(); - e.stopImmediatePropagation(); - - await saveEditorFile(true); - } - } - - if ($(".dialog.navigator:not(.hidden)")) { - const map_navigator = { - arrowup: "Up", - arrowdown: "Down", - arrowleft: "Prev", - arrowright: "Next", - enter: "Sel", - backspace: "Esc", - m: "Menu", - pageup: "NextPage", - pagedown: "PrevPage", - }; - - if (key === "r") { - e.preventDefault(); - e.stopImmediatePropagation(); - reloadScreen(); - return; - } - - if (key in map_navigator) { - e.preventDefault(); - e.stopImmediatePropagation(); - $( - `.navigator-canvas .nav[data-direction="${map_navigator[key]}"]`, - ).click(); - return; - } - } - - if (key === "escape" && $(".dialog-background:not(.hidden)")) { - if ($(".dialog.editor:not(.hidden)")) { - let editor = $(".dialog.editor .file-content"); - if (isModified(editor)) { - if ( - !confirm("You have unsaved changes. Do you want to discard them?") - ) { - return; - } - } - } - - let btnEscape = $(".dialog:not(.hidden) .act-escape"); - if (btnEscape) btnEscape.click(); - return; - } -}); - -$(".file-content").addEventListener("keydown", function (e) { - if (!$(".dialog.editor:not(.hidden)")) return; - - const textarea = this; - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const TAB_SIZE = 2; - const tabSpaces = " ".repeat(TAB_SIZE); - - const leadingSpacesRegex = /^ */; - const closingCharRegex = /^[\}\)\]]/; - - const insertText = (text, newStart, newEnd, preserveSelection = true) => { - textarea.setSelectionRange(start, end); - document.execCommand("insertText", false, text); - if (preserveSelection) { - textarea.setSelectionRange(newStart, newEnd); - } else { - textarea.setSelectionRange(newStart, newStart); - } - }; - - const getCurrentLine = (pos) => { - const lineStart = textarea.value.lastIndexOf("\n", pos - 1) + 1; - const lineEnd = textarea.value.indexOf("\n", pos); - const line = textarea.value.slice( - lineStart, - lineEnd === -1 ? undefined : lineEnd, - ); - return { - line, - lineStart, - lineEnd: lineEnd === -1 ? textarea.value.length : lineEnd, - }; - }; - - const handleTab = (shift) => { - if (start === end) { - const { line, lineStart, lineEnd } = getCurrentLine(start); - if (shift) { - const remove = Math.min( - line.match(leadingSpacesRegex)[0].length, - TAB_SIZE, - ); - textarea.setSelectionRange(lineStart, lineEnd); - document.execCommand("insertText", false, line.slice(remove)); - textarea.setSelectionRange(start - remove, start - remove); - } else { - insertText(tabSpaces, start + TAB_SIZE, start + TAB_SIZE, false); - } - return; - } - - // Expand selection to full first and last lines - const { lineStart: firstLineStart } = getCurrentLine(start); - const { lineEnd: lastLineEnd } = getCurrentLine( - end === start ? end : end - 1, - ); - - const selectedFullText = textarea.value.slice(firstLineStart, lastLineEnd); - const fullLines = selectedFullText.split("\n"); - - let totalChange = 0; - const newTextLines = fullLines.map((line, idx) => { - const isLast = idx === fullLines.length - 1; - const skipLast = isLast && /^\s*$/.test(line); - - if (skipLast) return line; - - const leadingSpaces = line.match(leadingSpacesRegex)[0].length; - - if (shift) { - const remove = Math.min(leadingSpaces, TAB_SIZE); - totalChange -= remove; - return line.slice(remove); - } else { - const add = TAB_SIZE - (leadingSpaces % TAB_SIZE); - totalChange += add; - return " ".repeat(add) + line; - } - }); - - // Replace the expanded selection using execCommand to preserve undo - // This may become an issue when execCommand is removed since it's deprecated but only way to preserve undo for now - textarea.setSelectionRange(firstLineStart, lastLineEnd); - document.execCommand("insertText", false, newTextLines.join("\n")); - textarea.setSelectionRange( - firstLineStart, - firstLineStart + newTextLines.join("\n").length, - ); - }; - - const handleEnter = () => { - const { line } = getCurrentLine(start); - const indentation = line.match(leadingSpacesRegex)[0] || ""; - - const nextChar = start < textarea.value.length ? textarea.value[start] : ""; - const prevChar = start > 0 ? textarea.value[start - 1] : ""; - const pairs = { "{": "}", "(": ")", "[": "]" }; - - if (pairs[prevChar] === nextChar) { - const extraIndent = " ".repeat(TAB_SIZE); - const insert = `\n${indentation + extraIndent}\n${indentation}`; - insertText( - insert, - start + indentation.length + extraIndent.length + 1, - start + indentation.length + extraIndent.length + 1, - ); - } else { - const closingLine = closingCharRegex.test(nextChar) - ? "\n" + indentation - : ""; - insertText( - "\n" + indentation + closingLine, - start + indentation.length + 1, - start + indentation.length + 1, - ); - } - }; - - const handleAutoPair = (key) => { - const pairs = { - "(": ")", - "{": "}", - "[": "]", - '"': '"', - "'": "'", - "`": "`", - "<": ">", - }; - - if (start === end) { - // No selection - insert pair at cursor - insertText(key + pairs[key], start + 1, start + 1, false); - } else { - // Has selection - wrap selected text with pair - const selectedText = textarea.value.slice(start, end); - const wrappedText = key + selectedText + pairs[key]; - insertText(wrappedText, start + 1, start + 1 + selectedText.length, true); - } - }; - - const handleSkipCloser = () => { - textarea.setSelectionRange(start + 1, start + 1); - }; - - const handleComment = (commentStr) => { - const toggleComment = (line) => { - const indentation = line.match(leadingSpacesRegex)[0] || ""; - const content = line.slice(indentation.length); - - if (content.startsWith(commentStr + " ")) { - return { - line: indentation + content.slice(commentStr.length + 1), - offset: -(commentStr.length + 1), - }; - } else if (content.startsWith(commentStr)) { - return { - line: indentation + content.slice(commentStr.length), - offset: -commentStr.length, - }; - } else { - return { - line: indentation + commentStr + " " + content, - offset: commentStr.length + 1, - }; - } - }; - - const isCommented = (line) => { - const content = line.slice( - (line.match(leadingSpacesRegex)[0] || "").length, - ); - return ( - content.startsWith(commentStr + " ") || content.startsWith(commentStr) - ); - }; - - if (start === end) { - // Single line - toggle comment - const { line, lineStart, lineEnd } = getCurrentLine(start); - const { line: newLine, offset: cursorOffset } = toggleComment(line); - - textarea.setSelectionRange(lineStart, lineEnd); - document.execCommand("insertText", false, newLine); - textarea.setSelectionRange(start + cursorOffset, start + cursorOffset); - return; - } - - // Multiple lines - toggle comment for all lines - const { lineStart: firstLineStart } = getCurrentLine(start); - const { lineEnd: lastLineEnd } = getCurrentLine( - end === start ? end : end - 1, - ); - const fullLines = textarea.value - .slice(firstLineStart, lastLineEnd) - .split("\n"); - - // Find the minimum indentation level (excluding empty lines) - const nonEmptyLines = fullLines.filter((line) => line.trim().length > 0); - const minIndentation = Math.min( - ...nonEmptyLines.map( - (line) => (line.match(leadingSpacesRegex)[0] || "").length, - ), - ); - const commentIndent = " ".repeat(minIndentation); - - const allCommented = nonEmptyLines.every(isCommented); - - const newTextLines = fullLines.map((line, idx) => { - const isLast = idx === fullLines.length - 1; - const skipLast = isLast && /^\s*$/.test(line); - - if (skipLast || line.trim().length === 0) return line; - - const indentation = line.match(leadingSpacesRegex)[0] || ""; - const content = line.slice(indentation.length); - - if (allCommented) { - // Remove comments - if (content.startsWith(commentStr + " ")) { - return indentation + content.slice(commentStr.length + 1); - } else if (content.startsWith(commentStr)) { - return indentation + content.slice(commentStr.length); - } - return line; - } else { - // Add comments at minimum indentation level - return commentIndent + commentStr + " " + line.slice(minIndentation); - } - }); - - textarea.setSelectionRange(firstLineStart, lastLineEnd); - document.execCommand("insertText", false, newTextLines.join("\n")); - textarea.setSelectionRange( - firstLineStart, - firstLineStart + newTextLines.join("\n").length, - ); - }; - - switch (e.key) { - case "Tab": - e.preventDefault(); - handleTab(e.shiftKey); - return; - case "Enter": - e.preventDefault(); - handleEnter(); - return; - case "/": - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - handleComment("//"); - return; - } - break; - case "#": - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - handleComment("#"); - return; - } - break; - } - - const nextChar = start < textarea.value.length ? textarea.value[start] : ""; - const closers = [")", "}", "]", ">", '"', "'", "`"]; - if (closers.includes(e.key) && nextChar === e.key) { - e.preventDefault(); - handleSkipCloser(); - return; - } - - const pairs = { - "(": ")", - "{": "}", - "[": "]", - '"': '"', - "'": "'", - "`": "`", - "<": ">", - }; - if (e.key in pairs) { - e.preventDefault(); - handleAutoPair(e.key); - return; - } -}); - -$(".file-content").addEventListener("keyup", function (e) { - if ($(".dialog.editor:not(.hidden)")) { - $(".act-save-edit-file").disabled = !isModified(e.target); - // Update line numbers when content changes - updateLineNumbers(); - } -}); - -$(".file-content").addEventListener("scroll", function (e) { - if ($(".dialog.editor:not(.hidden)")) { - // Sync scrolling between textarea and line numbers - syncScrolling(); - } -}); - -$(".file-content").addEventListener("input", function (e) { - if ($(".dialog.editor:not(.hidden)")) { - // Update line numbers on any input change - updateLineNumbers(); - } -}); - -$(".oinput-text-submit").addEventListener("keyup", function (e) { - // Submit using default button on Enter key - if (e.key === "Enter" || e.keyCode === 13) { - e.preventDefault(); - const dialog = this.closest(".dialog"); - const btn = dialog.querySelector(".btn-default"); - if (btn) btn.click(); - } -}); - -// Handle browser back/forward navigation -window.addEventListener("popstate", (event) => { - if (event.state && event.state.drive && event.state.path) { - fetchFiles(event.state.drive, event.state.path); - - // Restore edit state if present - if (event.state.editFile) { - setTimeout(async () => { - try { - let editor = $(".dialog.editor .file-content"); - $(".dialog.editor .editor-file-name").textContent = - event.state.editFile; - editor.value = ""; - - Dialog.loading.show("Fetching content..."); - let r = await requestGet( - `/file?fs=${event.state.drive}&name=${encodeURIComponent(event.state.editFile)}&action=edit`, - ); - editor.value = r; - editor.setAttribute("data-hash", calcHash(r)); - - // Update line numbers - updateLineNumbers(); - - $(".act-save-edit-file").disabled = true; - - let serial = getSerialCommand(event.state.editFile); - if (serial === undefined) { - $(".act-run-edit-file").classList.add("hidden"); - } else { - $(".act-run-edit-file").classList.remove("hidden"); - } - - Dialog.loading.hide(); - Dialog.show("editor"); - } catch (error) { - console.error("Failed to restore file editor:", error); - } - }, 100); - } - } else { - // Fallback: parse URL parameters - const urlParams = getURLParams(); - const drive = urlParams.drive || (sdCardAvailable ? "SD" : "LittleFS"); - const path = urlParams.path || "/"; - fetchFiles(drive, path); - - // Handle edit file restoration from URL - if (urlParams.editFile) { - setTimeout(async () => { - try { - let editor = $(".dialog.editor .file-content"); - $(".dialog.editor .editor-file-name").textContent = - urlParams.editFile; - editor.value = ""; - - Dialog.loading.show("Fetching content..."); - let r = await requestGet( - `/file?fs=${drive}&name=${encodeURIComponent(urlParams.editFile)}&action=edit`, - ); - editor.value = r; - editor.setAttribute("data-hash", calcHash(r)); - - // Update line numbers - updateLineNumbers(); - - $(".act-save-edit-file").disabled = true; - - let serial = getSerialCommand(urlParams.editFile); - if (serial === undefined) { - $(".act-run-edit-file").classList.add("hidden"); - } else { - $(".act-run-edit-file").classList.remove("hidden"); - } - - Dialog.loading.hide(); - Dialog.show("editor"); - } catch (error) { - console.error("Failed to restore file editor from URL:", error); - updateURL(drive, path, null); - } - }, 100); - } - } -}); - -(async function () { - await fetchSystemInfo(); - - // Get initial state from URL parameters or use defaults - const urlParams = getURLParams(); - let initialDrive = urlParams.drive; - let initialPath = urlParams.path; - let editFile = urlParams.editFile; - - // Validate and fallback to defaults if needed - if (!initialDrive) { - initialDrive = sdCardAvailable ? "SD" : "LittleFS"; - } - if (!initialPath) { - initialPath = "/"; - } - - await fetchFiles(initialDrive, initialPath); - - // If there's an edit file parameter, open the file editor - if (editFile) { - setTimeout(async () => { - try { - let editor = $(".dialog.editor .file-content"); - $(".dialog.editor .editor-file-name").textContent = editFile; - editor.value = ""; - - // Load file content - Dialog.loading.show("Fetching content..."); - let r = await requestGet( - `/file?fs=${currentDrive}&name=${encodeURIComponent(editFile)}&action=edit`, - ); - editor.value = r; - editor.setAttribute("data-hash", calcHash(r)); - - // Update line numbers - updateLineNumbers(); - - $(".act-save-edit-file").disabled = true; - - let serial = getSerialCommand(editFile); - if (serial === undefined) { - $(".act-run-edit-file").classList.add("hidden"); - } else { - $(".act-run-edit-file").classList.remove("hidden"); - } - - Dialog.loading.hide(); - Dialog.show("editor"); - } catch (error) { - console.error("Failed to open file for editing:", error); - // Remove edit parameter from URL if file loading fails - updateURL(currentDrive, currentPath, null); - } - }, 100); // Small delay to ensure the file list is loaded first - } -})(); +function $(s) { return document.querySelector(s); } +const IS_DEV = (window.location.host === "127.0.0.1:8080"); + +const T = { + master: $('#t'), + fileRow: function () { + const tmp = document.createElement('template'); + tmp.innerHTML = this.master.content.querySelector('table tr.file-row').outerHTML; + return tmp.content; + }, + pathRow: function () { + const tmp = document.createElement('template'); + tmp.innerHTML = this.master.content.querySelector('table tr.path-row').outerHTML; + return tmp.content; + }, + uploadLoading: function () { + const tmp = document.createElement('template'); + tmp.innerHTML = this.master.content.querySelector('.upload-loading').outerHTML; + return tmp.content; + } +}; + +const EXECUTABLE = { + ir: "ir tx_from_file", + sub: "subghz tx_from_file", + js: "js run_from_file", + bjs: "js run_from_file", + txt: "badusb run_from_file", + mp3: "play", + wav: "play" +}; + +// Toast notification system +const Toast = { + show: function (message, type, duration) { + type = type || 'success'; + duration = duration || 3000; + const container = $('#toast-container'); + const toast = document.createElement('div'); + toast.className = 'toast ' + type; + toast.textContent = message; + container.appendChild(toast); + setTimeout(function () { + toast.classList.add('removing'); + setTimeout(function () { toast.remove(); }, 200); + }, duration); + } +}; + +// Dialog system with animations +const Dialog = { + _bg: function (show) { + var bg = $(".dialog-background"); + var dialogs = document.querySelectorAll(".dialog"); + dialogs.forEach(function (d) { d.classList.add("hidden"); }); + + if (show) { + bg.classList.remove("hidden"); + bg.offsetHeight; // force reflow + bg.classList.add("visible"); + } else { + bg.classList.remove("visible"); + setTimeout(function () { + if (!bg.classList.contains("visible")) { + bg.classList.add("hidden"); + } + }, 250); + } + }, + show: function (dialogName) { + this._bg(true); + $(".dialog." + dialogName).classList.remove("hidden"); + }, + hide: function () { + this._bg(false); + this.loading.hide(); + if (currentDrive && currentPath) updateURL(currentDrive, currentPath, null); + }, + loading: { + show: function (message) { + $(".loading-area").classList.remove("hidden"); + $(".loading-area .text").textContent = message || "Loading..."; + }, + hide: function () { + $(".loading-area").classList.add("hidden"); + } + }, + showOneInput: function (name, inputVal, data) { + var dbForm = { + renameFolder: { title: "Rename Folder", label: "New folder name:", action: "Rename" }, + renameFile: { title: "Rename File", label: "New file name:", action: "Rename" }, + createFolder: { title: "New Folder", label: "Folder name:", action: "Create" }, + createFile: { title: "New File", label: "File name:", action: "Create" }, + serial: { title: "Serial Command", label: "Command:", action: "Run" } + }; + + var config = dbForm[name]; + if (!config) return; + + var dialog = $(".dialog.oinput"); + dialog.setAttribute("data-cache", data); + dialog.querySelector(".oinput-title").textContent = config.title; + dialog.querySelector(".oinput-label").textContent = config.label; + dialog.querySelector("#oinput-input").value = inputVal; + dialog.querySelector(".act-save-oinput-file").textContent = config.action; + this.show('oinput'); + dialog.querySelector("#oinput-input").select(); + return dialog; + } +}; + +function handleAuthError() { + if (confirm("Session expired. Go to login page?")) { + window.location.href = "/"; + } else { + Dialog.loading.hide(); + } +} + +async function requestGet(url, data) { + return new Promise(function (resolve, reject) { + var req = new XMLHttpRequest(); + var realUrl = url; + if (IS_DEV) realUrl = "/bruce" + url; + if (data) { + var urlParams = new URLSearchParams(data); + realUrl += "?" + urlParams.toString(); + } + req.open("GET", realUrl, true); + req.onload = function () { + if (req.status >= 200 && req.status < 300) resolve(req.responseText); + else if (req.status === 401) { handleAuthError(); reject(new Error("Unauthorized (401)")); } + else reject(new Error("Request failed: " + req.status)); + }; + req.onerror = function () { reject(new Error("Network error")); }; + req.send(); + }); +} + +async function requestPost(url, data) { + return new Promise(function (resolve, reject) { + var fd = new FormData(); + for (var key in data) { + if (data.hasOwnProperty(key)) fd.append(key, data[key]); + } + var realUrl = url; + if (IS_DEV) realUrl = "/bruce" + url; + var req = new XMLHttpRequest(); + req.open("POST", realUrl, true); + req.onload = function () { + if (req.status >= 200 && req.status < 300) resolve(req.responseText); + else if (req.status === 401) { handleAuthError(); reject(new Error("Unauthorized (401)")); } + else reject(new Error("Request failed: " + req.status)); + }; + req.onerror = function () { reject(new Error("Network error")); }; + req.send(fd); + }); +} + +function stringToId(str) { + var hash = 0, i, chr; + if (str.length === 0) return hash.toString(); + for (i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; + } + return 'id_' + Math.abs(hash); +} + +function calcHash(str) { + var hash = 5381; + str = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + for (var i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) ^ str.charCodeAt(i); + hash = hash >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +function getSerialCommand(fileName) { + var ext = fileName.split('.'); + if (ext.length > 1) { + ext = ext[ext.length - 1].toLowerCase(); + return EXECUTABLE[ext]; + } + return undefined; +} + +function parseStorageBytes(str) { + var parts = str.trim().split(' '); + if (parts.length < 2) return 0; + var num = parseFloat(parts[0]); + var unit = parts[1].toUpperCase(); + var mult = { 'B': 1, 'KB': 1024, 'MB': 1048576, 'GB': 1073741824 }; + return num * (mult[unit] || 1); +} + +// Upload system +var _queueUpload = []; +var _runningUpload = false; +var _uploadItems = []; // {file, id, status:'pending'|'uploading'|'done'|'error', pct:0} +var _uploadPage = 0; +var _uploadPerPage = 8; +var _uploadStartTime = 0; +var _uploadedBytes = 0; +var _totalBytes = 0; +var _uploadedCount = 0; + +function resetUploadState() { + _uploadItems = []; + _uploadPage = 0; + _uploadedBytes = 0; + _totalBytes = 0; + _uploadedCount = 0; + _uploadStartTime = 0; +} + +function addUploadItem(file) { + var filename = file.webkitRelativePath || file.name; + var id = stringToId(filename + '_' + _uploadItems.length); + _uploadItems.push({ file: file, id: id, name: filename, status: 'pending', pct: 0 }); + _totalBytes += file.size; +} + +function renderUploadPage() { + var body = $('#upload-list'); + body.innerHTML = ''; + var totalPages = Math.max(1, Math.ceil(_uploadItems.length / _uploadPerPage)); + if (_uploadPage >= totalPages) _uploadPage = totalPages - 1; + if (_uploadPage < 0) _uploadPage = 0; + + var start = _uploadPage * _uploadPerPage; + var end = Math.min(start + _uploadPerPage, _uploadItems.length); + for (var i = start; i < end; i++) { + var item = _uploadItems[i]; + var el = T.uploadLoading(); + var row = el.querySelector('.upload-loading'); + row.setAttribute('data-uid', item.id); + if (item.status !== 'pending') row.classList.add(item.status === 'uploading' ? 'active' : item.status); + el.querySelector('.upload-name').textContent = item.name; + el.querySelector('.upload-name').setAttribute('title', item.name); + var bar = el.querySelector('.bar'); + bar.setAttribute('id', item.id); + bar.style.width = item.pct + '%'; + var pctLabel = el.querySelector('.upload-pct'); + if (item.status === 'done') pctLabel.textContent = '100%'; + else if (item.status === 'error') pctLabel.textContent = 'ERR'; + else if (item.status === 'uploading') pctLabel.textContent = Math.round(item.pct) + '%'; + else pctLabel.textContent = ''; + body.appendChild(el); + } + + // Auto-scroll to page with active upload + var activeIdx = _uploadItems.findIndex(function(it) { return it.status === 'uploading'; }); + if (activeIdx >= 0) { + var activePage = Math.floor(activeIdx / _uploadPerPage); + if (activePage !== _uploadPage) { + _uploadPage = activePage; + renderUploadPage(); + return; + } + } + + updateUploadPagination(); +} + +function updateUploadPagination() { + var totalPages = Math.max(1, Math.ceil(_uploadItems.length / _uploadPerPage)); + $('#upload-page-info').textContent = (_uploadPage + 1) + ' / ' + totalPages; + $('#upload-prev').disabled = _uploadPage <= 0; + $('#upload-next').disabled = _uploadPage >= totalPages - 1; + // Hide pagination if only one page + var pagesEl = $('#upload-pages'); + if (totalPages <= 1) pagesEl.style.display = 'none'; + else pagesEl.style.display = ''; +} + +function updateUploadStats(currentFilePct, currentFileSize) { + var done = _uploadedCount; + var total = _uploadItems.length; + var partialBytes = (currentFilePct / 100) * currentFileSize; + var transferred = _uploadedBytes + partialBytes; + + // Counter + $('#upload-counter').textContent = done + ' / ' + total; + + // Overall progress + var overallPct = _totalBytes > 0 ? (transferred / _totalBytes) * 100 : 0; + $('#upload-overall-fill').style.width = Math.min(100, overallPct).toFixed(1) + '%'; + + // Speed & ETA + var elapsed = (Date.now() - _uploadStartTime) / 1000; + if (elapsed > 0.5 && transferred > 0) { + var speed = transferred / elapsed; + var remaining = _totalBytes - transferred; + var etaSec = remaining / speed; + + // Format speed + var speedStr; + if (speed >= 1048576) speedStr = (speed / 1048576).toFixed(1) + ' MB/s'; + else if (speed >= 1024) speedStr = (speed / 1024).toFixed(0) + ' KB/s'; + else speedStr = Math.round(speed) + ' B/s'; + $('#upload-speed').textContent = speedStr; + + // Format ETA + var etaStr; + if (etaSec < 1) etaStr = 'ETA: < 1s'; + else if (etaSec < 60) etaStr = 'ETA: ' + Math.ceil(etaSec) + 's'; + else if (etaSec < 3600) etaStr = 'ETA: ' + Math.floor(etaSec / 60) + 'm ' + Math.ceil(etaSec % 60) + 's'; + else etaStr = 'ETA: ' + Math.floor(etaSec / 3600) + 'h ' + Math.floor((etaSec % 3600) / 60) + 'm'; + $('#upload-eta').textContent = etaStr; + } else { + $('#upload-speed').textContent = '--'; + $('#upload-eta').textContent = 'ETA: calculating...'; + } +} + +function appendFileToQueue(files) { + if (_uploadItems.length === 0) resetUploadState(); + Dialog.show('upload'); + for (var i = 0; i < files.length; i++) addUploadItem(files[i]); + updateUploadStats(0, 0); + renderUploadPage(); +} + +async function appendDroppedFiles(entry) { + return new Promise(function (resolve) { + if (entry.isFile) { + entry.file(function (file) { + var fileWithPath = new File([file], entry.fullPath.substring(1), { type: file.type }); + addUploadItem(fileWithPath); + _queueUpload.push(fileWithPath); + resolve(); + }); + } else if (entry.isDirectory) { + var reader = entry.createReader(); + var allEntries = []; + var readBatch = function () { + reader.readEntries(function (entries) { + if (entries.length === 0) { + var proms = []; + for (var i = 0; i < allEntries.length; i++) proms.push(appendDroppedFiles(allEntries[i])); + Promise.all(proms).then(resolve); + } else { + allEntries = allEntries.concat(Array.from(entries)); + readBatch(); + } + }); + }; + readBatch(); + } + }); +} + +function updateItemUI(item) { + var row = document.querySelector('[data-uid="' + item.id + '"]'); + if (!row) return; + row.className = 'upload-loading' + (item.status === 'uploading' ? ' active' : item.status !== 'pending' ? ' ' + item.status : ''); + var bar = row.querySelector('.bar'); + if (bar) bar.style.width = item.pct + '%'; + var pct = row.querySelector('.upload-pct'); + if (pct) { + if (item.status === 'done') pct.textContent = '100%'; + else if (item.status === 'error') pct.textContent = 'ERR'; + else if (item.status === 'uploading') pct.textContent = Math.round(item.pct) + '%'; + else pct.textContent = ''; + } +} + +async function uploadFile() { + if (_queueUpload.length === 0) { + _runningUpload = false; + // Final stats + updateUploadStats(0, 0); + $('#upload-counter').textContent = _uploadedCount + ' / ' + _uploadItems.length; + $('#upload-overall-fill').style.width = '100%'; + $('#upload-eta').textContent = 'Complete!'; + setTimeout(function() { + resetUploadState(); + $('#upload-list').innerHTML = ''; + fetchSystemInfo(); + fetchFiles(currentDrive, currentPath); + Dialog.hide(); + Toast.show("Upload complete", "success"); + }, 800); + return; + } + + return new Promise(function (resolve, reject) { + _runningUpload = true; + if (_uploadStartTime === 0) _uploadStartTime = Date.now(); + + var file = _queueUpload.shift(); + var filename = file.webkitRelativePath || file.name; + + // Find matching upload item + var item = null; + for (var i = 0; i < _uploadItems.length; i++) { + if (_uploadItems[i].status === 'pending' && _uploadItems[i].name === filename) { + item = _uploadItems[i]; break; + } + } + if (!item) { + // Fallback: find first pending + for (var j = 0; j < _uploadItems.length; j++) { + if (_uploadItems[j].status === 'pending') { item = _uploadItems[j]; break; } + } + } + if (item) { + item.status = 'uploading'; + item.pct = 0; + renderUploadPage(); + } + + var fd = new FormData(); + fd.append("file", file, filename); + fd.append("folder", currentPath); + fd.append("fs", currentDrive); + + var realUrl = "/upload"; + if (IS_DEV) realUrl = "/bruce" + realUrl; + var req = new XMLHttpRequest(); + req.upload.onprogress = function (e) { + if (e.lengthComputable) { + var pct = (e.loaded / e.total) * 100; + if (item) { + item.pct = pct; + updateItemUI(item); + } + updateUploadStats(pct, file.size); + } + }; + req.onload = function () { + if (item) { + item.status = (req.status >= 200 && req.status < 300) ? 'done' : 'error'; + item.pct = 100; + updateItemUI(item); + } + _uploadedCount++; + _uploadedBytes += file.size; + updateUploadStats(0, 0); + renderUploadPage(); + uploadFile(); + if (req.status >= 200 && req.status < 300) resolve(req.responseText); + else reject(); + }; + req.onabort = function () { + if (item) { item.status = 'error'; updateItemUI(item); } + reject(); + }; + req.onerror = function () { + if (item) { item.status = 'error'; updateItemUI(item); } + reject(); + }; + req.open("POST", realUrl, true); + req.send(fd); + }); +} + +async function runCommand(cmd) { + Dialog.loading.show('Running command...'); + try { + await requestPost("/cm", { cmnd: cmd }); + Toast.show("Command executed", "success"); + } catch (error) { + Toast.show("Command failed: " + error.message, "error"); + } finally { + Dialog.loading.hide(); + } +} + +// Editor +function updateLineNumbers() { + var textarea = $(".dialog.editor .file-content"); + var lineNumbers = $(".dialog.editor .line-numbers"); + if (!textarea || !lineNumbers) return; + var lines = textarea.value.split('\n'); + var nums = ''; + for (var i = 1; i <= lines.length; i++) nums += i + '\n'; + lineNumbers.textContent = nums; +} + +function syncScrolling() { + var textarea = $(".dialog.editor .file-content"); + var lineNumbers = $(".dialog.editor .line-numbers"); + if (textarea && lineNumbers) lineNumbers.scrollTop = textarea.scrollTop; +} + +function isModified(target) { + return target.getAttribute("data-hash") !== calcHash(target.value); +} + +async function saveEditorFile(runFile) { + Dialog.loading.show('Saving...'); + var editor = $(".dialog.editor .file-content"); + var filename = $(".dialog.editor .editor-file-name").textContent.trim(); + if (isModified(editor)) { + $(".act-save-edit-file").disabled = true; + editor.setAttribute("data-hash", calcHash(editor.value)); + await requestPost("/edit", { fs: currentDrive, name: filename, content: editor.value }); + Toast.show("File saved", "success"); + } + if (runFile) { + var serial = getSerialCommand(filename); + if (serial !== undefined) await runCommand(serial + ' "' + filename + '"'); + } + Dialog.loading.hide(); +} + +// Multi-select system +var selectedFiles = new Set(); + +function updateMultiBar() { + var bar = $('#multi-bar'); + var count = selectedFiles.size; + if (count > 0) { + bar.classList.remove('hidden'); + $('#selected-count').textContent = count + ' selected'; + } else { + bar.classList.add('hidden'); + } + var checkAll = $('#check-all'); + var allChecks = document.querySelectorAll('.file-check'); + if (allChecks.length > 0 && count === allChecks.length) { + checkAll.checked = true; + } else { + checkAll.checked = false; + } +} + +function clearSelection() { + selectedFiles.clear(); + document.querySelectorAll('.file-check').forEach(function (cb) { cb.checked = false; }); + document.querySelectorAll('tr.selected').forEach(function (r) { r.classList.remove('selected'); }); + updateMultiBar(); +} + +async function deleteSelected() { + var files = Array.from(selectedFiles); + if (files.length === 0) return; + if (!confirm("Delete " + files.length + " item(s)?\n\nTHIS CANNOT BE UNDONE!")) return; + + Dialog.loading.show('Deleting ' + files.length + ' items...'); + var errors = 0; + for (var i = 0; i < files.length; i++) { + Dialog.loading.show('Deleting (' + (i + 1) + '/' + files.length + ')...'); + try { + await requestGet("/file", { fs: currentDrive, action: 'delete', name: files[i] }); + } catch (e) { errors++; } + } + Dialog.loading.hide(); + clearSelection(); + fetchSystemInfo(); + fetchFiles(currentDrive, currentPath); + if (errors > 0) Toast.show(errors + " deletion(s) failed", "error"); + else Toast.show(files.length + " item(s) deleted", "success"); +} + +// File list rendering and state +var _rawFileList = ''; +var _parsedFiles = []; +var _sortCol = 'name'; +var _sortDir = 1; // 1 = asc, -1 = desc + +function parseFileList(fileList) { + var parsed = []; + fileList.split("\n").forEach(function (line) { + var parts = line.split(":"); + var type = parts[0]; + var name = parts[1]; + var size = parts.slice(2).join(":"); + if (size === undefined) return; + parsed.push({ type: type, name: name, size: size, rawSize: parseSizeToBytes(size) }); + }); + return parsed; +} + +function parseSizeToBytes(sizeStr) { + if (!sizeStr || sizeStr === '-') return -1; + var num = parseFloat(sizeStr); + if (isNaN(num)) return 0; + var s = sizeStr.toUpperCase(); + if (s.indexOf('GB') >= 0) return num * 1073741824; + if (s.indexOf('MB') >= 0) return num * 1048576; + if (s.indexOf('KB') >= 0) return num * 1024; + return num; +} + +function sortFiles(files) { + return files.slice().sort(function (a, b) { + // Parent dir always first + if (a.type === 'pa') return -1; + if (b.type === 'pa') return 1; + // Folders before files + if (a.type !== b.type) return b.type.localeCompare(a.type); + + if (_sortCol === 'size') { + return (a.rawSize - b.rawSize) * _sortDir; + } + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) * _sortDir; + }); +} + +function renderFileRow(fileList) { + _rawFileList = fileList; + _parsedFiles = parseFileList(fileList); + renderParsedFiles(); +} + +function renderParsedFiles() { + var tbody = $("table.explorer tbody"); + tbody.innerHTML = ""; + selectedFiles.clear(); + updateMultiBar(); + + var search = ($('#search-files') || {}).value || ''; + search = search.toLowerCase().trim(); + + var sorted = sortFiles(_parsedFiles); + var visibleCount = 0; + + sorted.forEach(function (item) { + var e; + var dPath = ((currentPath.endsWith("/") ? currentPath : currentPath + "/") + item.name).replace(/\/\//g, "/"); + + if (item.type === "pa") { + if (dPath === "/") return; + e = T.pathRow(); + var preFolder = currentPath.substring(0, currentPath.lastIndexOf('/')); + if (preFolder === "") preFolder = "/"; + e.querySelector(".path-row").setAttribute("data-path", preFolder); + visibleCount++; + } else if (item.type === "Fi") { + if (search && item.name.toLowerCase().indexOf(search) === -1) return; + e = T.fileRow(); + e.querySelector('.file-row').setAttribute("data-file", dPath); + e.querySelector('.act-rename').setAttribute("data-action", "renameFile"); + e.querySelector(".col-name").classList.add("act-edit-file"); + e.querySelector(".col-name").textContent = item.name; + e.querySelector(".col-name").setAttribute("title", item.name); + e.querySelector(".col-size").textContent = item.size; + e.querySelector(".col-action").classList.add("type-file"); + + var downloadUrl = '/file?fs=' + currentDrive + '&name=' + encodeURIComponent(dPath) + '&action=download'; + if (IS_DEV) downloadUrl = "/bruce" + downloadUrl; + e.querySelector(".act-download").setAttribute("download", item.name); + e.querySelector(".act-download").setAttribute("href", downloadUrl); + + var serialCmd = getSerialCommand(item.name); + if (serialCmd) { + e.querySelector(".act-play").setAttribute("data-cmd", serialCmd + ' "' + dPath + '"'); + e.querySelector(".col-action").classList.add("executable"); + } + visibleCount++; + } else if (item.type === "Fo") { + if (search && item.name.toLowerCase().indexOf(search) === -1) return; + e = T.fileRow(); + e.querySelector(".col-name").classList.add("act-browse"); + e.querySelector('.file-row').setAttribute("data-path", dPath); + e.querySelector('.act-rename').setAttribute("data-action", "renameFolder"); + e.querySelector(".col-name").textContent = item.name; + e.querySelector(".col-name").setAttribute("title", item.name); + e.querySelector(".col-size").textContent = item.size; + e.querySelector(".col-action").classList.add("type-folder"); + visibleCount++; + } + + if (e) tbody.appendChild(e); + }); + + var empty = $('#empty-state'); + if (visibleCount === 0 && search) { + empty.classList.remove('hidden'); + } else { + empty.classList.add('hidden'); + } + + updateSortIcons(); +} + +function updateSortIcons() { + document.querySelectorAll('.sort-icon').forEach(function (el) { el.textContent = ''; }); + var th = $('th[data-sort="' + _sortCol + '"]'); + if (th) { + th.querySelector('.sort-icon').textContent = _sortDir === 1 ? ' \u25B2' : ' \u25BC'; + } +} + +// State +var sdCardAvailable = false; +var currentDrive; +var currentPath; +var btnRefreshFolder = $("#refresh-folder"); + +// URL management +function updateURL(drive, path, editFile) { + var params = new URLSearchParams(); + if (drive) params.set('drive', drive); + if (path && path !== '/') params.set('path', path); + if (editFile) params.set('edit', editFile); + var newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : ''); + window.history.replaceState({ drive: drive, path: path, editFile: editFile }, '', newURL); +} + +function getURLParams() { + var params = new URLSearchParams(window.location.search); + return { + drive: params.get('drive'), + path: params.get('path') || '/', + editFile: params.get('edit') + }; +} + +async function fetchFiles(drive, path) { + btnRefreshFolder.classList.add("reloading"); + $("table.explorer tbody").innerHTML = 'Loading...'; + currentDrive = drive; + currentPath = path; + + var urlParams = getURLParams(); + updateURL(drive, path, urlParams.editFile); + + var prev = document.querySelector('.act-browse.active'); + if (prev) prev.classList.remove("active"); + var card = document.querySelector('.act-browse[data-drive="' + drive + '"]'); + if (card) card.classList.add("active"); + $(".current-path").textContent = drive + ":/" + path; + clearSelection(); + + var req = await requestGet("/listfiles", { fs: drive, folder: path }); + renderFileRow(req); + btnRefreshFolder.classList.remove("reloading"); +} + +async function fetchSystemInfo() { + Dialog.loading.show('Fetching system info...'); + try { + var req = await requestGet("/systeminfo"); + var info = JSON.parse(req); + $(".bruce-version").textContent = info.BRUCE_VERSION; + $(".free-sd span").textContent = info.SD.used + ' / ' + info.SD.total; + $(".free-fs span").textContent = info.LittleFS.used + ' / ' + info.LittleFS.total; + sdCardAvailable = info.SD.total !== '0 B'; + + // Storage fill bars + var sdUsed = parseStorageBytes(info.SD.used); + var sdTotal = parseStorageBytes(info.SD.total); + var fsUsed = parseStorageBytes(info.LittleFS.used); + var fsTotal = parseStorageBytes(info.LittleFS.total); + var sdFill = document.getElementById('sd-fill'); + var fsFill = document.getElementById('fs-fill'); + if (sdFill) sdFill.style.width = (sdTotal > 0 ? Math.min(100, (sdUsed / sdTotal) * 100) : 0) + '%'; + if (fsFill) fsFill.style.width = (fsTotal > 0 ? Math.min(100, (fsUsed / fsTotal) * 100) : 0) + '%'; + } catch (e) { + Toast.show("Failed to fetch system info", "error"); + } + Dialog.loading.hide(); +} + +// Navigator +async function openNavigator() { + Dialog.show('navigator'); + await reloadScreen(); + autoReloadScreen(); +} + +var SCREEN_NAVIGATING = false; +async function runNavigation(direction) { + if (SCREEN_NAVIGATING) return; + SCREEN_NAVIGATING = true; + try { + drawCanvasLoading(); + await requestPost("/cm", { cmnd: "nav " + direction.toLowerCase() }); + await reloadScreen(); + } catch (error) { + Toast.show("Navigation failed: " + error.message, "error"); + } finally { + SCREEN_NAVIGATING = false; + } +} + +var btnForceReload = $("#force-reload"); +var SCREEN_RELOAD = false; +async function reloadScreen() { + if (SCREEN_RELOAD) return; + SCREEN_RELOAD = true; + btnForceReload.classList.add("reloading"); + try { + var binResponse = await fetch((IS_DEV ? "/bruce" : "") + "/getscreen"); + var arrayBuffer = await binResponse.arrayBuffer(); + var screenData = new Uint8Array(arrayBuffer); + await renderTFT(screenData); + } catch (error) { + Toast.show("Screen reload failed", "error"); + } finally { + btnForceReload.classList.remove("reloading"); + SCREEN_RELOAD = false; + } +} + +var eConfigAutoReload = $("#navigator-auto-reload"); +var AUTO_RELOAD_SCREEN = null; +async function taskReloader() { + var timer = parseInt(eConfigAutoReload.value); + var navigatorOpen = $(".dialog.navigator:not(.hidden)"); + if (timer <= 0 || !navigatorOpen) { + if (AUTO_RELOAD_SCREEN) { clearTimeout(AUTO_RELOAD_SCREEN); AUTO_RELOAD_SCREEN = null; } + return; + } + await reloadScreen(); + setTimeout(taskReloader, timer); +} +async function autoReloadScreen() { + var timer = parseInt(eConfigAutoReload.value); + if (AUTO_RELOAD_SCREEN) { clearTimeout(AUTO_RELOAD_SCREEN); AUTO_RELOAD_SCREEN = null; } + if (timer > 0) taskReloader(); +} + +// TFT Rendering — preserved exactly +var loadingDrawn = false; +var imageCache = {}; +async function renderTFT(data) { + loadingDrawn = false; + var canvas = $("#navigator-screen"); + var ctx = canvas.getContext("2d"); + + var loadImage = async function (url) { + if (imageCache[url]) return imageCache[url]; + return new Promise(function (resolve, reject) { + var img = new Image(); + img.onload = function () { imageCache[url] = img; resolve(img); }; + img.onerror = function (err) { reject(err); }; + img.src = url; + }); + }; + + var drawImageCached = async function (img_url, input) { + if (IS_DEV) img_url = "/bruce" + img_url; + var img = await loadImage(img_url); + var drawX = input.x; + var drawY = input.y; + if (input.center === 1) { + drawX += (canvas.width - img.width) / 2; + drawY += (canvas.height - img.height) / 2; + } + ctx.drawImage(img, drawX, drawY); + }; + + var color565toCSS = function (color565) { + var r = ((color565 >> 11) & 0x1F) * 255 / 31; + var g = ((color565 >> 5) & 0x3F) * 255 / 63; + var b = (color565 & 0x1F) * 255 / 31; + return "rgb(" + r + "," + g + "," + b + ")"; + }; + + var drawRoundRect = function (ctx, input, fill) { + var x = input.x, y = input.y, w = input.w, h = input.h, r = input.r; + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + if (fill) ctx.fill(); else ctx.stroke(); + }; + + var startData = 0; + var getByteValue = function (dataType) { + if (dataType === 'int8') { + return data[startData++]; + } else if (dataType === 'int16') { + var value = (data[startData] << 8) | data[startData + 1]; + startData += 2; + return value; + } else if (dataType.startsWith("s")) { + var strLength = parseInt(dataType.substring(1)); + var strBytes = data.slice(startData, startData + strLength); + startData += strLength; + return new TextDecoder().decode(strBytes); + } + }; + + var byteToObject = function (fn, size) { + var keysMap = { + 0: ["fg"], + 1: ["x", "y", "w", "h", "fg"], + 2: ["x", "y", "w", "h", "fg"], + 3: ["x", "y", "w", "h", "r", "fg"], + 4: ["x", "y", "w", "h", "r", "fg"], + 5: ["x", "y", "r", "fg"], + 6: ["x", "y", "r", "fg"], + 7: ["x", "y", "x2", "y2", "x3", "y3", "fg"], + 8: ["x", "y", "x2", "y2", "x3", "y3", "fg"], + 9: ["x", "y", "rx", "ry", "fg"], + 10: ["x", "y", "rx", "ry", "fg"], + 11: ["x", "y", "x1", "y1", "fg"], + 12: ["x", "y", "r", "ir", "startAngle", "endAngle", "fg", "bg"], + 13: ["x", "y", "bx", "by", "wd", "fg", "bg"], + 14: ["x", "y", "size", "fg", "bg", "txt"], + 15: ["x", "y", "size", "fg", "bg", "txt"], + 16: ["x", "y", "size", "fg", "bg", "txt"], + 17: ["x", "y", "size", "fg", "bg", "txt"], + 18: ["x", "y", "center", "ms", "fs", "file"], + 20: ["x", "y", "h", "fg"], + 21: ["x", "y", "w", "fg"], + 99: ["w", "h", "rotation"] + }; + + var r = {}; + var lengthLeft = size - 3; + for (var key of keysMap[fn]) { + if (['txt', 'file'].includes(key)) { + r[key] = getByteValue("s" + lengthLeft); + } else if (['rotation', 'fs'].includes(key)) { + lengthLeft -= 1; + r[key] = getByteValue('int8'); + if (key === 'fs') r[key] = (r[key] === 0) ? "SD" : "FS"; + } else { + lengthLeft -= 2; + r[key] = getByteValue('int16'); + } + } + return r; + }; + + var offset = 0; + ctx.clearRect(0, 0, canvas.width, canvas.height); + while (offset < data.length) { + ctx.beginPath(); + if (data[offset] !== 0xAA) break; + + startData = offset + 1; + var size = getByteValue('int8'); + var fn = getByteValue('int8'); + offset += size; + + var input = byteToObject(fn, size); + ctx.lineWidth = 1; + ctx.fillStyle = "black"; + ctx.strokeStyle = "black"; + switch (fn) { + case 99: + canvas.width = input.w; + canvas.height = input.h; + case 0: + ctx.fillStyle = color565toCSS(input.fg); + ctx.fillRect(0, 0, canvas.width, canvas.height); + break; + case 1: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.strokeRect(input.x, input.y, input.w, input.h); + break; + case 2: + ctx.fillStyle = color565toCSS(input.fg); + ctx.fillRect(input.x, input.y, input.w, input.h); + break; + case 3: + ctx.strokeStyle = color565toCSS(input.fg); + drawRoundRect(ctx, input, false); + break; + case 4: + ctx.fillStyle = color565toCSS(input.fg); + drawRoundRect(ctx, input, true); + break; + case 5: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2); + ctx.stroke(); + break; + case 6: + ctx.fillStyle = color565toCSS(input.fg); + ctx.arc(input.x, input.y, input.r, 0, Math.PI * 2); + ctx.fill(); + break; + case 7: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.beginPath(); + ctx.moveTo(input.x, input.y); + ctx.lineTo(input.x2, input.y2); + ctx.lineTo(input.x3, input.y3); + ctx.closePath(); + ctx.stroke(); + break; + case 8: + ctx.fillStyle = color565toCSS(input.fg); + ctx.beginPath(); + ctx.moveTo(input.x, input.y); + ctx.lineTo(input.x2, input.y2); + ctx.lineTo(input.x3, input.y3); + ctx.closePath(); + ctx.fill(); + break; + case 9: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.beginPath(); + ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2); + ctx.stroke(); + break; + case 10: + ctx.fillStyle = color565toCSS(input.fg); + ctx.beginPath(); + ctx.ellipse(input.x, input.y, input.rx, input.ry, 0, 0, Math.PI * 2); + ctx.fill(); + break; + case 11: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.moveTo(input.x, input.y); + ctx.lineTo(input.x1, input.y1); + ctx.stroke(); + break; + case 12: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.lineWidth = (input.r - input.ir) || 1; + var sa = (input.startAngle + 90 || 0) * Math.PI / 180; + var ea = (input.endAngle + 90 || 0) * Math.PI / 180; + var radius = (input.r + input.ir) / 2; + ctx.beginPath(); + ctx.arc(input.x, input.y, radius, sa, ea); + ctx.stroke(); + break; + case 13: + ctx.strokeStyle = color565toCSS(input.fg); + ctx.lineWidth = input.wd || 1; + ctx.moveTo(input.x, input.y); + ctx.lineTo(input.bx, input.by); + ctx.stroke(); + break; + case 14: case 15: case 16: case 17: + if (input.bg == input.fg) input.bg = 0; + ctx.fillStyle = color565toCSS(input.bg); + input.txt = input.txt.replaceAll("\\n", ""); + var fw = input.size === 3 ? 13.5 : input.size === 2 ? 9 : 4.5; + var o = 0; + if (fn === 15) o = input.txt.length * fw; + if (fn === 14) o = input.txt.length * fw / 2; + ctx.fillRect(input.x - o, input.y, input.txt.length * fw, input.size * 8); + ctx.fillStyle = color565toCSS(input.fg); + ctx.font = (input.size * 8) + "px monospace"; + ctx.textBaseline = "top"; + ctx.textAlign = fn === 14 ? "center" : fn === 15 ? "right" : "left"; + ctx.fillText(input.txt, input.x, input.y); + break; + case 18: + var url = "/file?fs=" + input.fs + "&name=" + encodeURIComponent(input.file) + "&action=image"; + await drawImageCached(url, input); + break; + case 19: + ctx.fillStyle = color565toCSS(input.fg); + ctx.fillRect(input.x, input.y, 1, 1); + break; + case 20: + ctx.fillStyle = color565toCSS(input.fg); + ctx.fillRect(input.x, input.y, 1, input.h); + break; + case 21: + ctx.fillStyle = color565toCSS(input.fg); + ctx.fillRect(input.x, input.y, input.w, 1); + break; + } + } +} + +function drawCanvasLoading() { + if (loadingDrawn || !showNavigating) return; + loadingDrawn = true; + var canvas = $("#navigator-screen"); + var ctx = canvas.getContext("2d"); + ctx.save(); + ctx.globalAlpha = 0.8; + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.globalAlpha = 1.0; + ctx.fillStyle = "#fff"; + ctx.font = "bold 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Navigating...", canvas.width / 2, canvas.height / 2); + ctx.restore(); +} + +// Event setup +var oldTimerSession = sessionStorage.getItem("autoReload") || "0"; +var timerOption = eConfigAutoReload.querySelector('option[value="' + oldTimerSession + '"]'); +if (timerOption) timerOption.selected = true; +eConfigAutoReload.addEventListener("change", function () { + autoReloadScreen(); + sessionStorage.setItem("autoReload", eConfigAutoReload.value); +}); + +btnForceReload.addEventListener("click", function (e) { + e.preventDefault(); + drawCanvasLoading(); + reloadScreen(); +}); + +// Mobile menu toggle +document.getElementById('mobile-menu-toggle').addEventListener('click', function () { + document.getElementById('header-nav').classList.toggle('open'); +}); + +btnRefreshFolder.addEventListener('click', function () { + fetchFiles(currentDrive, currentPath); +}); +// Close mobile menu on any nav button click +document.querySelectorAll('#header-nav .btn').forEach(function (btn) { + btn.addEventListener('click', function () { + document.getElementById('header-nav').classList.remove('open'); + }); +}); + +// Drag and drop +window.ondragenter = function () { $(".upload-area").classList.remove("hidden"); }; +$(".upload-area").ondragleave = function () { $(".upload-area").classList.add("hidden"); }; +$(".upload-area").ondragover = function (e) { e.preventDefault(); }; +$(".upload-area").ondrop = async function (e) { + e.preventDefault(); + $(".upload-area").classList.add("hidden"); + var items = e.dataTransfer.items; + if (!items || items.length === 0) return; + // Collect all entries synchronously — the DataTransferItemList is + // a live object that the browser invalidates after the first await. + var entries = []; + for (var k = 0; k < items.length; k++) { + var entry = items[k].webkitGetAsEntry(); + if (entry) entries.push(entry); + } + if (entries.length === 0) return; + if (_uploadItems.length === 0) resetUploadState(); + Dialog.show('upload'); + for (var j = 0; j < entries.length; j++) { + await appendDroppedFiles(entries[j]); + } + renderUploadPage(); + updateUploadStats(0, 0); + if (!_runningUpload) setTimeout(function () { + if (_queueUpload.length === 0) return; + uploadFile(); + }, 100); +}; + +document.querySelectorAll(".inp-uploader").forEach(function (el) { + el.addEventListener("change", function (e) { + var files = e.target.files; + if (!files || files.length === 0) return; + appendFileToQueue(files); + _queueUpload.push.apply(_queueUpload, Array.from(files)); + if (!_runningUpload) uploadFile(); + e.target.value = ""; + }); +}); + +// File search +$('#search-files').addEventListener('input', function () { renderParsedFiles(); }); + +// Upload pagination +$('#upload-prev').addEventListener('click', function () { + if (_uploadPage > 0) { _uploadPage--; renderUploadPage(); } +}); +$('#upload-next').addEventListener('click', function () { + var totalPages = Math.ceil(_uploadItems.length / _uploadPerPage); + if (_uploadPage < totalPages - 1) { _uploadPage++; renderUploadPage(); } +}); + +// Sort columns +document.querySelectorAll('th.sortable').forEach(function (th) { + th.addEventListener('click', function () { + var col = th.getAttribute('data-sort'); + if (_sortCol === col) _sortDir *= -1; + else { _sortCol = col; _sortDir = 1; } + renderParsedFiles(); + }); +}); + +// Select all checkbox +$('#check-all').addEventListener('change', function () { + var checked = this.checked; + document.querySelectorAll('.file-check').forEach(function (cb) { + cb.checked = checked; + var row = cb.closest('tr'); + var file = row.getAttribute('data-file') || row.getAttribute('data-path'); + if (file) { + if (checked) { selectedFiles.add(file); row.classList.add('selected'); } + else { selectedFiles.delete(file); row.classList.remove('selected'); } + } + }); + updateMultiBar(); +}); + +// Multi-delete +$('#multi-delete').addEventListener('click', function () { deleteSelected(); }); +$('#multi-cancel').addEventListener('click', function () { clearSelection(); }); + +// Main container clicks +$(".app").addEventListener("click", function (e) { + // Storage card / browse + var browseAction = e.target.closest(".act-browse"); + if (browseAction) { + e.preventDefault(); + var drive = browseAction.getAttribute("data-drive") || currentDrive || "LittleFS"; + var path = browseAction.getAttribute("data-path") || browseAction.closest("tr")?.getAttribute('data-path') || "/"; + if (drive === currentDrive && path === currentPath) return; + fetchFiles(drive, path); + return; + } + + // File checkbox + var checkbox = e.target.closest('.file-check'); + if (checkbox) { + var row = checkbox.closest('tr'); + var file = row.getAttribute('data-file') || row.getAttribute('data-path'); + if (file) { + if (checkbox.checked) { selectedFiles.add(file); row.classList.add('selected'); } + else { selectedFiles.delete(file); row.classList.remove('selected'); } + updateMultiBar(); + } + return; + } + + // Edit file + var editFileAction = e.target.closest(".act-edit-file"); + if (editFileAction) { + e.preventDefault(); + var editor = $(".dialog.editor .file-content"); + var file = editFileAction.closest("tr").getAttribute("data-file"); + if (!file) return; + $(".dialog.editor .editor-file-name").textContent = file; + editor.value = ""; + Dialog.loading.show('Loading file...'); + requestGet('/file?fs=' + currentDrive + '&name=' + encodeURIComponent(file) + '&action=edit').then(function (r) { + editor.value = r; + editor.setAttribute("data-hash", calcHash(r)); + updateLineNumbers(); + $(".act-save-edit-file").disabled = true; + var serial = getSerialCommand(file); + if (serial === undefined) $(".act-run-edit-file").classList.add("hidden"); + else $(".act-run-edit-file").classList.remove("hidden"); + Dialog.loading.hide(); + Dialog.show('editor'); + updateURL(currentDrive, currentPath, file); + }); + return; + } + + // One input dialogs (serial, createFile, createFolder, rename) + var oActionOInput = e.target.closest(".act-oinput"); + if (oActionOInput) { + e.preventDefault(); + var action = oActionOInput.getAttribute("data-action"); + if (!action) return; + var value = "", data = ""; + if (action.startsWith("rename")) { + var row = oActionOInput.closest("tr"); + var filePath = row.getAttribute("data-file") || row.getAttribute("data-path"); + if (filePath) { + value = filePath.substring(filePath.lastIndexOf("/") + 1); + data = action + "|" + filePath; + } + } else if (action.startsWith("create")) { + data = action + "|" + currentPath; + } else { + data = action; + } + Dialog.showOneInput(action, value, data); + return; + } + + // Delete single file + var actDeleteFile = e.target.closest(".act-delete"); + if (actDeleteFile) { + e.preventDefault(); + var file = actDeleteFile.closest(".file-row").getAttribute("data-file") + || actDeleteFile.closest(".file-row").getAttribute("data-path"); + if (!file) return; + if (!confirm("Delete " + file + "?\n\nTHIS CANNOT BE UNDONE!")) return; + Dialog.loading.show('Deleting...'); + requestGet("/file", { fs: currentDrive, action: 'delete', name: file }).then(function () { + Dialog.loading.hide(); + Toast.show("Deleted successfully", "success"); + fetchSystemInfo(); + fetchFiles(currentDrive, currentPath); + }); + return; + } + + // Play/Run file + var actPlay = e.target.closest(".act-play"); + if (actPlay) { + e.preventDefault(); + var cmd = actPlay.getAttribute("data-cmd"); + if (cmd) { actPlay.blur(); runCommand(cmd); } + return; + } +}); + +// Dialog background clicks +$(".dialog-background").addEventListener("click", function (e) { + if (e.target.matches(".act-dialog-close")) { + e.preventDefault(); + Dialog.hide(); + } +}); + +// Save one-input dialog +$(".act-save-oinput-file").addEventListener("click", async function () { + var dialog = $(".dialog.oinput"); + var fileInput = $("#oinput-input"); + var fileName = fileInput.value.trim(); + if (!fileName) { Toast.show("Name cannot be empty", "error"); return; } + var action = dialog.getAttribute("data-cache"); + if (!action) return; + + var refreshList = true; + var parts = action.split("|"); + var actionType = parts[0]; + var path = parts[1]; + + if (actionType.startsWith("rename")) { + Dialog.loading.show('Renaming...'); + await requestPost("/rename", { fs: currentDrive, filePath: path, fileName: fileName }); + Toast.show("Renamed successfully", "success"); + } else if (actionType === "createFolder") { + Dialog.loading.show('Creating folder...'); + await requestGet("/file?" + new URLSearchParams({ + fs: currentDrive, action: "create", name: path.replace(/\/+$/, '') + '/' + fileName + }).toString()); + Toast.show("Folder created", "success"); + } else if (actionType === "createFile") { + Dialog.loading.show('Creating file...'); + await requestGet("/file?" + new URLSearchParams({ + fs: currentDrive, action: "createfile", name: path.replace(/\/+$/, '') + '/' + fileName + }).toString()); + Toast.show("File created", "success"); + } else if (actionType === "serial") { + Dialog.loading.show('Running...'); + await runCommand(fileName); + refreshList = false; + } + + if (refreshList) fetchFiles(currentDrive, currentPath); + Dialog.hide(); +}); + +// Credentials +$(".act-save-credential").addEventListener("click", async function () { + var username = $("#cred-username").value.trim(); + var password = $("#cred-password").value.trim(); + if (!username || !password) { Toast.show("Fields cannot be empty", "error"); return; } + Dialog.loading.show('Saving...'); + await requestGet("/wifi", { usr: username, pwd: password }); + Dialog.loading.hide(); + Toast.show("Credentials saved!", "success"); +}); + +// Editor save +$(".act-save-edit-file").addEventListener("click", function () { saveEditorFile(); }); +$(".act-run-edit-file").addEventListener("click", function () { saveEditorFile(true); this.blur(); }); + +// Show/hide navigating overlay +var showNavigating = localStorage.getItem('showNavigating') || false; +updateShowHideNavigatingButton(); +$(".act-hide-show-navigating").addEventListener("click", function (e) { + e.preventDefault(); + showNavigating = !showNavigating; + localStorage.setItem('showNavigating', showNavigating); + updateShowHideNavigatingButton(); +}); +function updateShowHideNavigatingButton() { + $('.act-hide-show-navigating').textContent = "'Navigating...' Overlay: " + (showNavigating ? 'Shown' : 'Hidden'); +} + +// Reboot +$(".act-reboot").addEventListener("click", async function (e) { + e.preventDefault(); + if (!confirm("Reboot the device?")) return; + Dialog.loading.show('Rebooting...'); + await requestGet("/reboot"); + setTimeout(function () { location.reload(); }, 1000); +}); + +// Navigator pad +$(".navigator-canvas").addEventListener("click", async function (e) { + var nav = e.target.matches(".nav") ? e.target : e.target.closest(".nav"); + if (!nav) return; + var direction = nav.getAttribute("data-direction"); + if (direction === "Menu") direction = "Sel 500"; + await runNavigation(direction.toLowerCase()); +}); + +// Keyboard shortcuts +window.addEventListener("keydown", async function (e) { + var key = e.key.toLowerCase(); + + if ($(".dialog.editor:not(.hidden)")) { + if ((e.ctrlKey || e.metaKey) && key === "s") { + e.preventDefault(); e.stopImmediatePropagation(); + await saveEditorFile(); + } else if (e.altKey && key === "enter") { + e.preventDefault(); e.stopImmediatePropagation(); + await saveEditorFile(true); + } + } + + if ($(".dialog.navigator:not(.hidden)")) { + var map = { + "arrowup": "Up", "arrowdown": "Down", "arrowleft": "Prev", + "arrowright": "Next", "enter": "Sel", "backspace": "Esc", + "m": "Menu", "pageup": "NextPage", "pagedown": "PrevPage" + }; + if (key === 'r') { e.preventDefault(); e.stopImmediatePropagation(); reloadScreen(); return; } + if (key in map) { + e.preventDefault(); e.stopImmediatePropagation(); + var navEl = document.querySelector('.navigator-canvas .nav[data-direction="' + map[key] + '"]'); + if (navEl) navEl.click(); + return; + } + } + + // Global shortcuts + if ((e.ctrlKey || e.metaKey) && key === "f" && !$(".dialog:not(.hidden)")) { + e.preventDefault(); + $('#search-files').focus(); + return; + } + + if (key === "escape") { + if ($('#search-files') === document.activeElement) { + $('#search-files').value = ''; + $('#search-files').blur(); + renderParsedFiles(); + return; + } + if (selectedFiles.size > 0) { clearSelection(); return; } + if ($(".dialog-background.visible")) { + if ($(".dialog.editor:not(.hidden)")) { + var editor = $(".dialog.editor .file-content"); + if (isModified(editor)) { + if (!confirm("Discard unsaved changes?")) return; + } + } + var btnEscape = $(".dialog:not(.hidden) .act-escape"); + if (btnEscape) btnEscape.click(); + } + } + + if (key === "delete" && selectedFiles.size > 0 && !$(".dialog-background.visible")) { + e.preventDefault(); + deleteSelected(); + } +}); + +// Editor text area handlers +$(".file-content").addEventListener("keydown", function (e) { + if (!$(".dialog.editor:not(.hidden)")) return; + var textarea = this; + var start = textarea.selectionStart; + var end = textarea.selectionEnd; + var TAB_SIZE = 2; + var tabSpaces = " ".repeat(TAB_SIZE); + var leadingSpacesRegex = /^ */; + var closingCharRegex = /^[\}\)\]]/; + + var insertText = function (text, newStart, newEnd, preserveSelection) { + textarea.setSelectionRange(start, end); + document.execCommand("insertText", false, text); + if (preserveSelection) textarea.setSelectionRange(newStart, newEnd); + else textarea.setSelectionRange(newStart, newStart); + }; + + var getCurrentLine = function (pos) { + var lineStart = textarea.value.lastIndexOf("\n", pos - 1) + 1; + var lineEnd = textarea.value.indexOf("\n", pos); + var line = textarea.value.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); + return { line: line, lineStart: lineStart, lineEnd: lineEnd === -1 ? textarea.value.length : lineEnd }; + }; + + var handleTab = function (shift) { + if (start === end) { + var cl = getCurrentLine(start); + if (shift) { + var remove = Math.min(cl.line.match(leadingSpacesRegex)[0].length, TAB_SIZE); + textarea.setSelectionRange(cl.lineStart, cl.lineEnd); + document.execCommand("insertText", false, cl.line.slice(remove)); + textarea.setSelectionRange(start - remove, start - remove); + } else { + insertText(tabSpaces, start + TAB_SIZE, start + TAB_SIZE, false); + } + return; + } + var first = getCurrentLine(start); + var last = getCurrentLine(end === start ? end : end - 1); + var selectedFullText = textarea.value.slice(first.lineStart, last.lineEnd); + var fullLines = selectedFullText.split("\n"); + var totalChange = 0; + var newTextLines = fullLines.map(function (line, idx) { + if (idx === fullLines.length - 1 && /^\s*$/.test(line)) return line; + var ls = line.match(leadingSpacesRegex)[0].length; + if (shift) { var rm = Math.min(ls, TAB_SIZE); totalChange -= rm; return line.slice(rm); } + else { var add = TAB_SIZE - (ls % TAB_SIZE); totalChange += add; return " ".repeat(add) + line; } + }); + textarea.setSelectionRange(first.lineStart, last.lineEnd); + document.execCommand("insertText", false, newTextLines.join("\n")); + textarea.setSelectionRange(first.lineStart, first.lineStart + newTextLines.join("\n").length); + }; + + var handleEnter = function () { + var cl = getCurrentLine(start); + var indent = cl.line.match(leadingSpacesRegex)[0] || ""; + var nextChar = start < textarea.value.length ? textarea.value[start] : ""; + var prevChar = start > 0 ? textarea.value[start - 1] : ""; + var pairs = { "{": "}", "(": ")", "[": "]" }; + if (pairs[prevChar] === nextChar) { + var extra = " ".repeat(TAB_SIZE); + var ins = "\n" + indent + extra + "\n" + indent; + insertText(ins, start + indent.length + extra.length + 1, start + indent.length + extra.length + 1); + } else { + var closing = closingCharRegex.test(nextChar) ? "\n" + indent : ""; + insertText("\n" + indent + closing, start + indent.length + 1, start + indent.length + 1); + } + }; + + var handleAutoPair = function (key) { + var p = { "(": ")", "{": "}", "[": "]", '"': '"', "'": "'", "`": "`", "<": ">" }; + if (start === end) insertText(key + p[key], start + 1, start + 1, false); + else { + var sel = textarea.value.slice(start, end); + insertText(key + sel + p[key], start + 1, start + 1 + sel.length, true); + } + }; + + var handleComment = function (cs) { + var toggleComment = function (line) { + var ind = line.match(leadingSpacesRegex)[0] || ""; + var content = line.slice(ind.length); + if (content.startsWith(cs + " ")) return { line: ind + content.slice(cs.length + 1), offset: -(cs.length + 1) }; + if (content.startsWith(cs)) return { line: ind + content.slice(cs.length), offset: -cs.length }; + return { line: ind + cs + " " + content, offset: cs.length + 1 }; + }; + var isCommented = function (line) { + var c = line.slice((line.match(leadingSpacesRegex)[0] || "").length); + return c.startsWith(cs + " ") || c.startsWith(cs); + }; + if (start === end) { + var cl = getCurrentLine(start); + var r = toggleComment(cl.line); + textarea.setSelectionRange(cl.lineStart, cl.lineEnd); + document.execCommand("insertText", false, r.line); + textarea.setSelectionRange(start + r.offset, start + r.offset); + return; + } + var first = getCurrentLine(start); + var last = getCurrentLine(end === start ? end : end - 1); + var fullLines = textarea.value.slice(first.lineStart, last.lineEnd).split("\n"); + var nonEmpty = fullLines.filter(function (l) { return l.trim().length > 0; }); + var allCommented = nonEmpty.every(isCommented); + var minInd = Math.min.apply(null, nonEmpty.map(function (l) { return (l.match(leadingSpacesRegex)[0] || "").length; })); + var newLines = fullLines.map(function (line, idx) { + if ((idx === fullLines.length - 1 && /^\s*$/.test(line)) || line.trim().length === 0) return line; + var ind = line.match(leadingSpacesRegex)[0] || ""; + var content = line.slice(ind.length); + if (allCommented) { + if (content.startsWith(cs + " ")) return ind + content.slice(cs.length + 1); + if (content.startsWith(cs)) return ind + content.slice(cs.length); + return line; + } + return " ".repeat(minInd) + cs + " " + line.slice(minInd); + }); + textarea.setSelectionRange(first.lineStart, last.lineEnd); + document.execCommand("insertText", false, newLines.join("\n")); + textarea.setSelectionRange(first.lineStart, first.lineStart + newLines.join("\n").length); + }; + + switch (e.key) { + case "Tab": e.preventDefault(); handleTab(e.shiftKey); return; + case "Enter": e.preventDefault(); handleEnter(); return; + case "/": if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleComment("//"); return; } break; + case "#": if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleComment("#"); return; } break; + } + + var nextChar = start < textarea.value.length ? textarea.value[start] : ""; + var closers = [")", "}", "]", ">", '"', "'", "`"]; + if (closers.includes(e.key) && nextChar === e.key) { + e.preventDefault(); + textarea.setSelectionRange(start + 1, start + 1); + return; + } + var pairs = { "(": ")", "{": "}", "[": "]", '"': '"', "'": "'", "`": "`", "<": ">" }; + if (e.key in pairs) { e.preventDefault(); handleAutoPair(e.key); return; } +}); + +$(".file-content").addEventListener("keyup", function (e) { + if ($(".dialog.editor:not(.hidden)")) { + $(".act-save-edit-file").disabled = !isModified(e.target); + updateLineNumbers(); + } +}); + +$(".file-content").addEventListener("scroll", function () { + if ($(".dialog.editor:not(.hidden)")) syncScrolling(); +}); + +$(".file-content").addEventListener("input", function () { + if ($(".dialog.editor:not(.hidden)")) updateLineNumbers(); +}); + +document.querySelectorAll(".oinput-text-submit").forEach(function (el) { + el.addEventListener("keyup", function (e) { + if (e.key === "Enter" || e.keyCode === 13) { + e.preventDefault(); + var btn = this.closest(".dialog").querySelector(".btn-default"); + if (btn) btn.click(); + } + }); +}); + +// Browser back/forward +window.addEventListener('popstate', function (event) { + if (event.state && event.state.drive && event.state.path) { + fetchFiles(event.state.drive, event.state.path); + if (event.state.editFile) restoreEditor(event.state.drive, event.state.editFile); + } else { + var p = getURLParams(); + var drive = p.drive || (sdCardAvailable ? "SD" : "LittleFS"); + fetchFiles(drive, p.path || "/"); + if (p.editFile) restoreEditor(drive, p.editFile); + } +}); + +async function restoreEditor(drive, editFile) { + setTimeout(async function () { + try { + var editor = $(".dialog.editor .file-content"); + $(".dialog.editor .editor-file-name").textContent = editFile; + editor.value = ""; + Dialog.loading.show('Loading file...'); + var r = await requestGet('/file?fs=' + drive + '&name=' + encodeURIComponent(editFile) + '&action=edit'); + editor.value = r; + editor.setAttribute("data-hash", calcHash(r)); + updateLineNumbers(); + $(".act-save-edit-file").disabled = true; + var serial = getSerialCommand(editFile); + if (serial === undefined) $(".act-run-edit-file").classList.add("hidden"); + else $(".act-run-edit-file").classList.remove("hidden"); + Dialog.loading.hide(); + Dialog.show('editor'); + } catch (err) { + updateURL(currentDrive, currentPath, null); + } + }, 100); +} + +// Init +(async function () { + await fetchSystemInfo(); + var p = getURLParams(); + var initialDrive = p.drive || (sdCardAvailable ? "SD" : "LittleFS"); + var initialPath = p.path || "/"; + await fetchFiles(initialDrive, initialPath); + if (p.editFile) restoreEditor(initialDrive, p.editFile); +})(); diff --git a/embedded_resources/web_interface/login.html b/embedded_resources/web_interface/login.html index f3947c889..49578609e 100644 --- a/embedded_resources/web_interface/login.html +++ b/embedded_resources/web_interface/login.html @@ -1,43 +1,40 @@ - - - - - - - Bruce - - - - - -
-
- -
-
- - - - + + + + + + Bruce - Login + + + + +
+
+
Bruce Login
+
+ + + + + + +
+ +
+
+ + + diff --git a/src/core/display.cpp b/src/core/display.cpp index 341234ee9..d1f3b8b74 100644 --- a/src/core/display.cpp +++ b/src/core/display.cpp @@ -486,7 +486,6 @@ int loopOptions( ); if (index >= options.size()) index = 0; bool firstRender = true; - static unsigned long menuOpenTs = 0; // timestamp when menu was first rendered drawMainBorder(); while (1) { // Check for shutdown before drawing menu to avoid drawing a black bar on the screen @@ -526,7 +525,6 @@ int loopOptions( firstRender ); } - if (firstRender) menuOpenTs = millis(); firstRender = false; redraw = false; } @@ -605,11 +603,7 @@ int loopOptions( /* Select and run function forceMenuOption is set by a SerialCommand to force a selection within the menu */ - // Prevent immediate selection if the SEL button was already being held when the menu opened. - // Allow a short grace period for the user to release the button first. - static const unsigned long MENU_SELECT_IGNORE_MS = 600; // ms to ignore SEL after menu opens - - if (forceMenuOption >= 0 || (millis() - menuOpenTs > MENU_SELECT_IGNORE_MS && check(SelPress))) { + if (check(SelPress) || forceMenuOption >= 0) { uint16_t chosen = index; if (forceMenuOption >= 0) { chosen = forceMenuOption; @@ -774,38 +768,8 @@ void drawSubmenu(int index, std::vector