Browse Source

Revamp file explorer transfer and toast UI

Replaced the old upload progress widget with a full transfer panel (minimize/collapse, per-task actions, summary, clear/cancel-all, active-row follow, and improved progress/state handling for uploads and zip downloads). Added a new bottom toast component with explicit show/hide behavior and error styling, plus new transfer icons and theme tokens. Also removed selection checkmark badges in list/grid rendering, refined thumbnail/card styling, and updated locale strings for new transfer/toast labels across languages.
Toby Chui 1 month ago
parent
commit
388aa4b15e

+ 423 - 163
src/web/SystemAO/file_system/file_explorer.css

@@ -72,10 +72,12 @@ body.whiteTheme {
     --contextmenu_hover: #f9f9f9;
     --contextmenu_textcolor: rgb(78, 78, 78);
 
-    --upload_progressbar: #92cfe7;
+    /* Transfer panel bars. The pale legacy greens/reds washed out against the
+       new card surface, so these are the saturated status colours. */
+    --upload_progressbar: var(--fs-accent);
     --upload_progresstext: rgb(255, 255, 255);
-    --upload_progress_succ: #8cd68f;
-    --upload_progress_failed: #d68c8c;
+    --upload_progress_succ: #1e8e3e;
+    --upload_progress_failed: #d93025;
 
     --open_with_selected: #51aded;
     --open_with_selected_text: white;
@@ -166,7 +168,7 @@ body.darkTheme{
     --contextmenu_hover: #2a3039;
     --contextmenu_textcolor: #e6e9ee;
 
-    --upload_progressbar: #4a9eff;
+    --upload_progressbar: var(--fs-accent);
     --upload_progresstext: #0d1014;
     --upload_progress_succ: #3ecf6d;
     --upload_progress_failed: #e5484d;
@@ -744,10 +746,14 @@ body.darkTheme .ui.icon.button{
     Icons use contain so they are never distorted; a real thumbnail (marked by
     .hasThumbnail from thumbnail.js) uses cover so it fills the square instead
     of letterboxing.
+
+    display:block matters as much as the sizing here: an inline <img> sits on
+    the text baseline, which leaves a few pixels of the container's background
+    showing as a grey strip under every thumbnail.
 */
 .fileviewList .fileObject.card img{
+    display: block;
     width: 100%;
-    max-width: 148px;
     aspect-ratio: 1 / 1;
     height: auto;
     object-fit: contain;
@@ -755,7 +761,16 @@ body.darkTheme .ui.icon.button{
 
 .fileviewList .fileObject.card.hasThumbnail img{
     object-fit: cover;
-    object-position: center top;
+    object-position: center;
+}
+
+/*
+    The tinted plate behind an icon is there to give a bare glyph a surface. A
+    real thumbnail covers the whole square, so keeping the plate only risks it
+    peeking out along an edge.
+*/
+.fileviewList .fileObject.card.hasThumbnail .image{
+    background: transparent;
 }
 
 .fileviewList .fileObject.card .content{
@@ -970,26 +985,99 @@ body.darkTheme .ui.icon.button{
     Message box and popups
 */
 
-.msgbox{
-    position: absolute;
-    bottom: 0;
-    left: 3em;
-    width: 20em;
-    padding: 1.2em;
-    padding-right: 2em;
-    background-color: var(--navi_bgcolor);
-    border-top-right-radius: 0.4em;
-    border-top-left-radius: 0.4em;
-    border: 1px solid var(--divider) !important;
-    color: var(--text_color);
+/*
+    Toast
+
+    A dark pill centred along the bottom edge. It auto dismisses, and the
+    trailing button closes it early.
+
+    Colours are hardcoded to the toast tokens rather than the theme surfaces:
+    the pill floats above the content instead of belonging to it, so it stays
+    dark in the light theme too. That also means #msgbox has to win over the
+    blanket ao.css rule that paints every span and <i> under a themed body,
+    which the id in the selector does without needing !important.
+*/
+#msgbox.msgbox{
+    position: fixed;
+    bottom: 24px;
+    left: 50%;
+    transform: translateX(-50%) translateY(10px);
+    z-index: 999;
+    display: flex;
+    align-items: center;
+    gap: 11px;
+    max-width: min(560px, calc(100% - 32px));
+    padding: 11px 10px 11px 18px;
+    border-radius: 999px;
+    background-color: var(--fs-toast-bg);
+    color: var(--fs-toast-fg);
+    box-shadow: 0px 6px 22px rgba(0, 0, 0, 0.35);
+    opacity: 0;
+    transition: opacity 0.18s ease, transform 0.18s ease;
+    /*
+        Belt and braces with the display:none that hideMsgBox sets after the
+        fade: while faded out the pill must never swallow clicks aimed at the
+        content beneath it, and that must not depend on a timer firing.
+    */
+    pointer-events: none;
 }
-.closeMsgButton{
-    position:absolute;
-    top:8px;
-    right:12px;
+
+#msgbox.msgbox.visible{
+    opacity: 1;
+    transform: translateX(-50%) translateY(0px);
+    pointer-events: auto;
 }
-.closeMsgButton{
-    cursor:pointer;
+
+#msgbox.msgbox.error{
+    background-color: var(--fs-toast-error-bg);
+    color: var(--fs-toast-error-fg);
+}
+
+#msgbox .msgboxText{
+    flex: 1 1 auto;
+    min-width: 0px;
+    color: inherit;
+    font-size: 13.5px;
+    line-height: 1.45;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+#msgbox > i.icon{
+    flex: 0 0 auto;
+    width: 16px;
+    height: auto;
+    margin: 0px;
+    font-size: 15px;
+    text-align: center;
+    color: inherit;
+    opacity: 0.9;
+}
+
+#msgbox .closeMsgButton{
+    flex: 0 0 auto;
+    width: 26px;
+    height: 26px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 50%;
+    cursor: pointer;
+    color: var(--fs-toast-dim);
+}
+#msgbox.msgbox.error .closeMsgButton{
+    color: var(--fs-toast-error-fg);
+    opacity: 0.85;
+}
+#msgbox .closeMsgButton:hover{
+    background-color: rgba(255, 255, 255, 0.14);
+    color: var(--fs-toast-fg);
+}
+#msgbox .closeMsgButton svg{
+    width: 15px;
+    height: 15px;
+    display: block;
 }
 .popup{
     position:fixed;
@@ -1168,113 +1256,318 @@ body.darkTheme .ui.icon.button{
 
 
 /*
-    Upload Related
+    Transfer panel
+
+    The floating upload / zip task card at the bottom right. Markup shell lives
+    in file_explorer.html, rows are drawn by js/explorer/uploadui.js.
 */
 #uploadTab{
-    position:fixed;
-    right:0px;
-    bottom: 0px;
-    width:300px;
-    margin-bottom:0px !important;
-    padding:0px !important;
-    background-color: var(--upload_bg) !important;
-    overflow-x: hidden;
+    position: fixed;
+    right: 16px;
+    bottom: 16px;
+    width: 360px;
+    max-width: calc(100% - 32px);
+    background-color: var(--upload_bg);
+    border: 1px solid var(--divider);
+    border-radius: 12px;
+    box-shadow: 0px 8px 28px var(--fs-shadow);
+    overflow: hidden;
+    z-index: 500;
 }
 
-.statusBar{
-    height:32px;
-    background-color:var(--uploadtab_bg);
-    padding:8px;
+/* ---------- Header ---------- */
+#uploadTab .uploadHeader{
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    padding: 12px 10px 12px 16px;
+    background-color: var(--uploadtab_bg);
 }
 
-.statusBar{
-    color:var(--uploadtab_txt);
+#uploadTab .uploadHeaderTitle{
+    flex: 1 1 auto;
+    min-width: 0px;
+    font-size: 15px;
+    color: var(--uploadtab_txt);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
 }
 
+#uploadTab .uploadHeaderBtn{
+    flex: 0 0 auto;
+    width: 28px;
+    height: 28px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 6px;
+    cursor: pointer;
+    color: var(--fs-icon);
+}
+#uploadTab .uploadHeaderBtn:hover{
+    background-color: var(--fs-fill-hover);
+}
+#uploadTab .uploadHeaderBtn svg{
+    width: 17px;
+    height: 17px;
+    display: block;
+}
 
-.hideUploadButton{
-    float: right;
-    cursor:pointer;
+/* ---------- Task list ---------- */
+#uploadTab .uploadList{
+    padding: 0px 16px;
+    max-height: 300px;
+    overflow-y: auto;
+    overflow-x: hidden;
+    scrollbar-width: thin;
 }
 
-.uploadList{
-    margin:8px;
-    padding-bottom:3px;
-    margin-bottom:0px;
-    max-height: 250px;
-    overflow-y:auto;
+/*
+    Three rows: name, byte counter + status, progress bar. The action button
+    spans the first two so it centres against the text block while the bar
+    still runs the full width of the card.
+*/
+#uploadTab .uploadTask{
+    display: grid;
+    grid-template-columns: 1fr auto;
+    column-gap: 10px;
+    padding: 14px 0px 2px 0px;
 }
 
-.uploadTask{
-    margin-top:6px;
-    position: relative;
+#uploadTab .uploadTaskName{
+    grid-column: 1;
+    grid-row: 1;
+    font-size: 13.5px;
+    line-height: 1.45;
+    color: var(--normal_object_txt);
+    word-break: break-word;
+    overflow: hidden;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
 }
 
-.uploadTaskRemoveIcon{
-    position:absolute;
-    top:0px;
-    right:0px;
-    cursor:pointer;
+#uploadTab .uploadTaskMeta{
+    grid-column: 1;
+    grid-row: 2;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    margin-top: 7px;
+    font-size: 12px;
+    color: var(--fs-text-dim);
 }
 
-.saving {
-    -moz-animation: saving 3s ease-out;
-    -moz-animation-iteration-count: infinite;
+#uploadTab .uploadTaskSize,
+#uploadTab .uploadTaskStatus{
+    color: var(--fs-text-dim);
+}
 
-    -webkit-animation: saving 3s ease-out;
-    -webkit-animation-iteration-count: infinite;
+/*
+    Byte counters update several times a second, so every glyph has to keep its
+    x position or the text is unreadable. Two things are needed: tabular figures
+    so "1" occupies the same advance as "8", and a fixed cell per part so a
+    changing digit count cannot push the neighbouring parts around.
+
+    Widths are in ch (the width of a digit at the current size), so they track
+    the font rather than assuming one.
+*/
+#uploadTab .uploadTaskSize,
+#uploadTab .uploadSummaryText{
+    font-variant-numeric: tabular-nums;
+    -moz-font-feature-settings: "tnum";
+    -webkit-font-feature-settings: "tnum";
+    font-feature-settings: "tnum";
+}
 
-    -ms-animation: saving 3s ease-out;
-    -ms-animation-iteration-count: infinite;
+/* Right aligned: the ones column stays put as the value grows into 4 digits */
+#uploadTab .uploadNum{
+    display: inline-block;
+    text-align: right;
 }
 
-@keyframes saving {
-    0% { opacity: 0.6; }
-    50% { opacity: 1; }
-    100% { opacity: 0.6; }
+/*
+    Left aligned and padded off the number, so the unit starts at a fixed x.
+
+    This one is a hard width, not a min-width: unit letters are not digit width
+    and differ from each other ("MB" is wider than "KB", which is wider than
+    "B"), so a min-width still lets the cell grow and shove everything after it
+    when a transfer crosses a unit boundary. The value is sized for the widest
+    unit rather than measured per string.
+*/
+#uploadTab .uploadUnit{
+    display: inline-block;
+    width: 4.4ch;
+    text-align: left;
+    padding-left: 4px;
 }
 
-@-webkit-keyframes saving {
-    0% { opacity: 0.6; }
-    50% { opacity: 1; }
-    100% { opacity: 0.6; }
+#uploadTab .uploadSep{
+    display: inline-block;
+    width: 16px;
+    text-align: center;
 }
 
-@-moz-keyframes saving {
-    0% { opacity: 0.6; }
-    50% { opacity: 1; }
-    100% { opacity: 0.6; }
+#uploadTab .uploadTaskStatus{
+    flex: 0 0 auto;
+    white-space: nowrap;
 }
 
-@-ms-keyframes saving {
-    0% { opacity: 0.3; }
-    50% { opacity: 1; }
-    100% { opacity: 0.3; }
+#uploadTab .uploadTaskAction{
+    grid-column: 2;
+    grid-row: 1 / span 2;
+    align-self: center;
+    width: 30px;
+    height: 30px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 50%;
+    cursor: pointer;
+    color: var(--upload_progressbar);
+}
+#uploadTab .uploadTaskAction svg{
+    width: 26px;
+    height: 26px;
+    display: block;
+}
+#uploadTab .uploadTask.done .uploadTaskAction{
+    color: var(--upload_progress_succ);
+    cursor: default;
+}
+#uploadTab .uploadTask.failed .uploadTaskAction{
+    color: var(--upload_progress_failed);
+}
+#uploadTab .uploadTaskAction:hover{
+    background-color: var(--fs-fill-hover);
+}
+#uploadTab .uploadTask.done .uploadTaskAction:hover{
+    background-color: transparent;
 }
 
-.clearallButton{
-    padding:8px;
-    cursor:pointer;
+/* ---------- Progress bar ---------- */
+#uploadTab .uploadTaskBar{
+    grid-column: 1 / -1;
+    grid-row: 3;
+    height: 4px;
+    margin-top: 10px;
+    border-radius: 999px;
+    background-color: var(--fs-line);
+    overflow: hidden;
 }
 
-.themed.progress .bar{
+#uploadTab .uploadTaskBarFill{
+    width: 0%;
+    height: 100%;
+    border-radius: 999px;
     background-color: var(--upload_progressbar);
+    transition: width 0.2s linear;
+}
+#uploadTab .uploadTask.done .uploadTaskBarFill{
+    background-color: var(--upload_progress_succ);
+}
+#uploadTab .uploadTask.failed .uploadTaskBarFill{
+    background-color: var(--upload_progress_failed);
+}
+#uploadTab .uploadTask.paused .uploadTaskBarFill{
+    background-color: var(--fs-text-faint);
 }
 
-.themed.progress .percentage{
-    color: var(--upload_progresstext) !important;
+/* A task with no announced total sweeps instead of filling */
+#uploadTab .uploadTask.indeterminate .uploadTaskBarFill{
+    width: 40%;
+    transition: none;
+    animation: uploadSweep 1.4s ease-in-out infinite;
 }
 
-.success.progress .bar{
-    background-color: var(--upload_progress_succ);
+@keyframes uploadSweep {
+    0%   { transform: translateX(-105%); }
+    100% { transform: translateX(255%); }
 }
 
-.error.progress .bar{
-    background-color: var(--upload_progress_failed);
+/* ---------- Summary row ---------- */
+#uploadTab .uploadSummary{
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 12px 16px;
+    margin-top: 6px;
+    border-top: 1px solid var(--divider);
 }
 
-.ui.progress{
-    margin-bottom: 1em !important;
+#uploadTab .uploadSummaryIcon{
+    flex: 0 0 auto;
+    color: var(--fs-icon);
+}
+#uploadTab .uploadSummaryIcon svg{
+    width: 19px;
+    height: 19px;
+    display: block;
+}
+
+#uploadTab .uploadSummaryText{
+    flex: 1 1 auto;
+    min-width: 0px;
+    font-size: 12.5px;
+    color: var(--fs-text-dim);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+/* ---------- Footer buttons ---------- */
+#uploadTab .uploadActions{
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 4px 16px 16px 16px;
+}
+
+#uploadTab .uploadActionBtn{
+    display: flex;
+    align-items: center;
+    gap: 7px;
+    padding: 8px 14px;
+    border: 1px solid var(--popup_btn_border);
+    border-radius: 8px;
+    cursor: pointer;
+    font-size: 12.5px;
+    white-space: nowrap;
+    color: var(--normal_object_txt);
+}
+#uploadTab .uploadActionBtn:hover{
+    background-color: var(--fs-fill-hover);
+}
+#uploadTab .uploadActionBtn svg{
+    width: 15px;
+    height: 15px;
+    display: block;
+    color: var(--fs-icon);
+}
+
+#uploadTab .uploadActionBtn.primary{
+    margin-left: auto;
+    border-color: var(--upload_progressbar);
+    color: var(--upload_progressbar);
+}
+#uploadTab .uploadActionBtn.primary:hover{
+    background-color: var(--fs-accent-soft);
+}
+
+
+@media screen and (max-width: 500px) {
+    #uploadTab{
+        right: 12px;
+        left: 12px;
+        bottom: 12px;
+        width: auto;
+        max-width: none;
+    }
+    .uploadList{
+        max-height: 40vh;
+    }
 }
 
 /*
@@ -1414,11 +1707,6 @@ i.blue{
         max-width: 148px;
     }
     
-    /* aspect-ratio above already keeps this square; only the cap needs lifting */
-    .fileviewList .fileObject.card img{
-        max-width: none;
-    }
-
     /* Properties view, disable function in small display size*/
     #propertiesView{
         display:none !important;
@@ -1436,11 +1724,6 @@ i.blue{
         height: auto;
     }
 
-    /* msgbox */
-    .msgbox{
-        left: 0.4em;
-        border: 0px solid transparent;
-    }
 }
 
 @media (max-width:450px) {
@@ -1808,45 +2091,13 @@ i.blue{
 .detailstable td.fmColType,
 .detailstable td.fmColSize { color: var(--fs-text-dim); }
 
-/* Checkbox column, shown on hover or when the row is selected */
-.fmCheck {
-    display: inline-block;
-    width: 16px;
-    height: 16px;
-    border: 1.5px solid var(--fs-line);
-    border-radius: 4px;
-    background: var(--fs-surface);
-    opacity: 0;
-    transition: opacity 0.12s ease;
-    vertical-align: middle;
-}
 /*
-    Check marks belong to multi-select, not to selection: a single click only
-    highlights. #folderView gains .fmMultiSelect once ctrl/shift is held, mobile
-    multi-select is on, or more than one item is already picked.
-*/
-.fmCheck { display: none; }
-#folderView.fmMultiSelect .fmCheck { display: inline-block; }
-#folderView.fmMultiSelect .detailstable tr.fileObject:hover .fmCheck { opacity: 1; }
-#folderView.fmMultiSelect .detailstable tr.fileObject.selected .fmCheck {
-    opacity: 1;
-    background: var(--fs-accent);
-    border-color: var(--fs-accent);
-    position: relative;
-}
-#folderView.fmMultiSelect .detailstable tr.fileObject.selected .fmCheck::after {
-    content: "";
-    position: absolute;
-    left: 4.5px;
-    top: 1px;
-    width: 4px;
-    height: 8px;
-    border: solid var(--fs-primary-fg);
-    border-width: 0 2px 2px 0;
-    transform: rotate(45deg);
-}
+    ---------- Selection highlight, all three modes ----------
 
-/* ---------- Selection highlight, all three modes ---------- */
+    Selection is shown by the row/tile fill alone - there is no check badge in
+    any view mode. #folderView still gains .fmMultiSelect while ctrl/shift is
+    held or mobile multi-select is on, but nothing keys off it visually.
+*/
 .fileviewList .fileObject.item:hover,
 .detailstable tr.fileObject.details:hover { background: var(--fs-row-hover); }
 
@@ -1907,30 +2158,6 @@ i.blue{
     margin-top: 2px;
 }
 
-/* Selected tick badge on a tile, like the mockup */
-.fmTileCheck {
-    position: absolute;
-    top: 6px;
-    right: 6px;
-    width: 20px;
-    height: 20px;
-    border-radius: 50%;
-    background: var(--fs-accent);
-    display: none;
-}
-.fmTileCheck::after {
-    content: "";
-    position: absolute;
-    left: 7px;
-    top: 3.5px;
-    width: 5px;
-    height: 10px;
-    border: solid var(--fs-primary-fg);
-    border-width: 0 2px 2px 0;
-    transform: rotate(45deg);
-}
-#folderView.fmMultiSelect .fileObject.card.selected .fmTileCheck { display: block; }
-
 /* Date group headers */
 .fmGroupHeader {
     font-size: 13px;
@@ -1987,6 +2214,40 @@ i.blue{
 .fmStatusBtn:hover { background: var(--fs-fill); }
 .fmStatusBtn svg { display: block; width: 100%; height: 100%; }
 
+/*
+    Collapsed transfer panel
+
+    Scoped to the id rather than .fmStatusBtn.active so the properties toggle,
+    which also carries an .active class, keeps its current look.
+*/
+#fmUploadListBtn.active{
+    background: var(--fs-fill-active);
+    color: var(--fs-accent);
+}
+
+/*
+    Slow pulse while something is still transferring, so background progress is
+    noticeable without being a distraction. Animating the colour rather than the
+    whole button keeps the hit area and the hover fill steady.
+*/
+#fmUploadListBtn.transferring{
+    animation: uploadBtnPulse 2.2s ease-in-out infinite;
+}
+
+@keyframes uploadBtnPulse {
+    0%   { color: var(--fs-icon); }
+    50%  { color: var(--fs-accent); }
+    100% { color: var(--fs-icon); }
+}
+
+/* A blinking control is exactly what this setting asks to be spared */
+@media (prefers-reduced-motion: reduce) {
+    #fmUploadListBtn.transferring{
+        animation: none;
+        color: var(--fs-accent);
+    }
+}
+
 @media only screen and (max-width: 720px) {
     /*
         updateZoomControlVisibility() shows this with an inline style, which
@@ -2006,7 +2267,6 @@ i.blue{
    With table-layout:fixed the first row defines the columns, so these widths
    apply to the header table and both row tables identically. Hiding a cell
    collapses its column, which a <colgroup> could not do.                    */
-.detailstable .fmColCheck { width: 38px; }
 .detailstable .fmColDate  { width: 170px; }
 .detailstable .fmColType  { width: 120px; }
 .detailstable .fmColSize  { width: 90px; }

+ 40 - 11
src/web/SystemAO/file_system/file_explorer.html

@@ -145,10 +145,6 @@
                 </div>
             </div>
 
-            <div class="msgbox" style="z-index:999; display:none;">
-                <i class="checkmark icon"></i> <span>No Message</span>
-                <div class="closeMsgButton" onclick="$(this).parent().stop().slideUp('fast');"><i class="caret down icon"></i></div>
-            </div>
         </div>
        
         <div id="mainWindow">
@@ -209,6 +205,9 @@
                 <button class="videmode" mode="list" title="List View" onclick="changeViewMode(this);"><span data-fsicon="viewList"></span></button>
                 <button class="videmode" mode="details" title="Detail View" onclick="changeViewMode(this);"><span data-fsicon="viewDetails"></span></button>
             </div>
+            <!-- Collapsed form of the transfer panel. Shown only while the list
+                 has tasks; uploadui.js drives its state. -->
+            <button id="fmUploadListBtn" class="fmStatusBtn" title="Upload List" onclick="toggleUploadPanel();" style="display:none;"><span data-fsicon="cloudUpload"></span></button>
             <button id="togglePropertiesViewBtn" class="fmStatusBtn" title="Show Properties" onclick="togglePropertiesView(this);"><span data-fsicon="sidePanel"></span></button>
         </div>
 
@@ -507,16 +506,46 @@
             </div>
         </div>
 
-        <!-- Upload Progress Bars-->
-        <div id="uploadTab" class="ui segment" style="display:none;">
-            <div class="statusBar">
-                <i class="upload icon"></i><span id="uploadCount">0</span> <span locale="upload/count/uploading">Files</span> / <i class="wait icon"></i> <span id="waitingCount">0</span> <span locale="upload/count/pending">Files</span>
-                <div class="hideUploadButton" onclick="toggleUploadList();">
-                    <i class="caret down icon"></i>
+        <!--
+            Toast. Lives at body level so its position:fixed is resolved against
+            the viewport and cannot be captured by a transformed ancestor.
+        -->
+        <div id="msgbox" class="msgbox" style="display:none;">
+            <i class="checkmark icon"></i>
+            <span class="msgboxText">No Message</span>
+            <div class="closeMsgButton" onclick="hideMsgBox();" data-fsicon="close"></div>
+        </div>
+
+        <!--
+            Transfer panel. Shows one row per upload (and per zip-preparation
+            task started from download.js). Rendering lives in
+            js/explorer/uploadui.js - the markup below is only the shell.
+        -->
+        <div id="uploadTab" style="display:none;">
+            <div class="uploadHeader">
+                <span class="uploadHeaderTitle" id="uploadHeaderTitle">Uploading</span>
+                <div class="uploadHeaderBtn" onclick="toggleUploadMinimize();" title="Minimize">
+                    <span data-fsicon="minus"></span>
+                </div>
+                <div class="uploadHeaderBtn" onclick="closeUploadTab();" title="Close">
+                    <span data-fsicon="close"></span>
                 </div>
             </div>
             <div class="uploadList" id="uploadProgressList">
-              
+
+            </div>
+            <div class="uploadSummary">
+                <span class="uploadSummaryIcon" data-fsicon="cloudUpload"></span>
+                <span class="uploadSummaryText" id="uploadSummaryText"></span>
+            </div>
+            <div class="uploadActions">
+                <div class="uploadActionBtn" onclick="clearCompletedUploads();">
+                    <span data-fsicon="trash"></span>
+                    <span locale="upload/clearCompleted">Clear completed</span>
+                </div>
+                <div class="uploadActionBtn primary" onclick="cancelAllUploads();">
+                    <span locale="upload/cancelAll">Cancel all</span>
+                </div>
             </div>
         </div>
 

+ 3 - 0
src/web/SystemAO/file_system/js/explorer/boot.js

@@ -26,6 +26,9 @@ $(document).ready(function(){
         //Delegated drag/drop/dblclick for the file list. Bound once, not per render.
         bindFileListDelegates();
 
+        //Lets the transfer panel tell a manual scroll from its own
+        bindUploadListScroll();
+
         //Restore the operation toolbar preference (shown unless turned off)
         loadPreference("file_explorer/oprbar", function(value){
             showOprBar = (value !== "false");

+ 7 - 34
src/web/SystemAO/file_system/js/explorer/download.js

@@ -31,12 +31,10 @@ function downloadFile(){
             displayString = applocale.getString("opr/zip/zipping", "Zipping ") + $(".fileObject.selected").attr("filename");
         }
 
+        //The zip size is not known until the server has built it, so this task
+        //rides the transfer panel in its indeterminate state
         var taskUUID = appendUploadFileItem(displayString, -1);
-        $("#uploadTab").show();
-
-        //Set the progress bar to intermediate mode
-        let targetTaskDisplayObject =  getUploadTaskByID(taskUUID);
-        targetTaskDisplayObject.find(".progress").addClass("preparing");
+        setUploadTaskState(taskUUID, "processing");
 
         //Zip the file or folder
         $.ajax({
@@ -46,44 +44,19 @@ function downloadFile(){
             success: function(data){
                 if (data.error !== undefined){
                     //Error
-                    targetTaskDisplayObject.find(".progress").removeClass("preparing").removeClass("primary").addClass("error");
+                    setUploadTaskState(taskUUID, "failed");
                     msgbox("red remove",applocale.getString("message/" + data.error,data.error));
                 }else{
                     //Zip completed.
-                    targetTaskDisplayObject.find(".progress").removeClass("preparing").removeClass("primary").addClass("positive");
+                    setUploadTaskState(taskUUID, "done");
 
                     //Open the zip file
                     window.open("../../media/download?file=" + data);
                 }
-
-                
-                targetTaskDisplayObject.find(".bar").css("width", "100%");
-
-                //Hide the taskbar after 5 secs
-                setTimeout(function(){
-                    //Fade out this task
-                    $(targetTaskDisplayObject).fadeOut('fast',
-                        function(){
-                            $(this).remove();
-                            if ($(".uploadTask").length == 0){
-                                $("#uploadTab").hide();
-                            }
-                        }
-                    );
-                    
-                }, 3000);
-                
-            }, 
+            },
             error: function(){
-                targetTaskDisplayObject.find(".progress").removeClass("preparing").removeClass("primary").addClass("error");
-                targetTaskDisplayObject.find(".bar").css("width", "100%");
+                setUploadTaskState(taskUUID, "failed");
                 msgbox("red remove",applocale.getString("message/zip/fail", "Zipping failed due to unknown reason"));
-
-                setTimeout(function(){
-                    if ($(".uploadTask").length == 0){
-                        $("#uploadTab").hide();
-                    }
-                }, 3000);
             }
         });
         

+ 1 - 3
src/web/SystemAO/file_system/js/explorer/render.js

@@ -198,7 +198,6 @@ function renderGridItem(d){
                 <div class="image">
                     <img draggable="false" src="${d.imagePath}">
                     <div class="shareOverlay ${d.isShared ? "visible" : ""}">${d.shareIcon}</div>
-                    <span class="fmTileCheck"></span>
                 </div>
                 <div class="content">
                     <div class="header normal object" title="${d.filename}"><span class="filename">${shown}</span></div>
@@ -215,7 +214,6 @@ function renderDetailsRow(d){
     let size = d.isDir ? "--" : d.displaysize;
 
     return `<tr class="fileObject details" ${fileObjectAttributes(d)}>
-                <td class="fmColCheck"><span class="fmCheck"></span></td>
                 <td class="fmColName"><span class="fmNameCell"><span class="fmRowIcon">${FileThumb.smallGlyph(d.filename, d.isDir)}</span><span class="filename">${d.displayName}</span>${sharedMark}</span></td>
                 <td class="fmColDate light-text">${d.modTimeText}</td>
                 <td class="fmColType light-text">${typeName}</td>
@@ -252,7 +250,7 @@ function renderDetailsHeader(){
     }).join("");
 
     return `<table class="ui very basic unstackable table detailstable fmHeaderTable">
-                <thead><tr><th class="fmColCheck"></th>${cells}</tr></thead>
+                <thead><tr>${cells}</tr></thead>
             </table>`;
 }
 

+ 37 - 0
src/web/SystemAO/file_system/js/explorer/state.js

@@ -58,6 +58,43 @@ let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will up
 const CHUNK_TIMEOUT_MS = 30000; //30s timeout waiting for server "next" ack before retrying a chunk
 const MAX_CHUNK_RETRIES = 3;    //Maximum retries per individual chunk before failing the upload
 
+/*
+    Transfer panel state (see js/explorer/uploadui.js)
+
+    uploadTaskInfo holds everything the panel needs to draw a row that the DOM
+    cannot cheaply answer for us - byte counts, the running speed estimate the
+    "time left" label is derived from, and the task state. Keyed by task UUID.
+
+    uploadTransferMap holds the live transfer handles so the per-row button and
+    "Cancel all" can actually stop a transfer: {abort, pause, resume, pausable}.
+    Only tasks currently in flight have an entry.
+*/
+let uploadTaskInfo = new Map();
+let uploadTransferMap = new Map();
+//Panel collapsed to the #fmUploadListBtn in the status bar; a new task expands it
+let uploadPanelCollapsed = false;
+const MAX_FINISHED_UPLOAD_ROWS = 60; //Trim completed rows past this to keep mass uploads responsive
+
+/*
+    The task list follows the transfer that is currently in flight, so a long
+    queue does not leave the user staring at finished rows. Scrolling by hand
+    wins for a while - yanking the list back under someone who is reading it is
+    worse than letting the active row drift off screen.
+*/
+let uploadListUserScrolledAt = 0;
+/*
+    The scroll offset this code last set itself, so the scroll handler can tell
+    its own scrolling from the user's. Deliberately a position rather than a
+    "the next event is mine" flag: scrollTop can be assigned without producing
+    any movement (it is already clamped at the end of the list), which fires no
+    event and would leave such a flag stuck - swallowing the user's next scroll.
+*/
+let uploadListAutoScrollTop = -1;
+const UPLOAD_FOLLOW_PAUSE_MS = 5000; //How long a manual scroll suspends following
+
+//Pending auto-dismiss for the toast, so a new message replaces the old timer
+let msgboxTimer = null;
+
 //Module-registered extension icons (ext without dot → web-root-relative path)
 var extIconRegistry = {};
 

+ 39 - 5
src/web/SystemAO/file_system/js/explorer/ui.js

@@ -161,12 +161,45 @@ function toggleDarkTheme(){
 
 }
 
+/*
+    Toast
+
+    Call sites pass a semantic-ui icon class as the first argument, and that
+    string is the only signal available for whether something went wrong - the
+    failure paths all use a red or warning glyph. Rather than touch 43 call
+    sites, the class is matched here.
+*/
+function msgboxIsError(icon){
+    return /(^|\s)(red|remove|caution|exclamation|warning|ban)(\s|$)/.test(icon);
+}
+
 function msgbox(icon, text, delay=3000){
-    $($(".msgbox").find("span")[0]).text(text);
-    $($(".msgbox").find("i")[0]).attr("class", icon + " icon");
-    $(".msgbox").stop().finish().slideDown('fast').delay(delay).slideUp('fast', function(){
-        initWindowSizes(false);
-    });
+    let box = $("#msgbox");
+    box.find(".msgboxText").text(text);
+    box.find("i").attr("class", icon + " icon");
+    box.toggleClass("error", msgboxIsError(icon));
+
+    clearTimeout(msgboxTimer);
+    box.css("display", "flex");
+    //Force a reflow between display and the class, otherwise the browser
+    //collapses both into one style resolution and the transition never runs
+    void box[0].offsetWidth;
+    box.addClass("visible");
+
+    msgboxTimer = setTimeout(hideMsgBox, delay);
+}
+
+function hideMsgBox(){
+    clearTimeout(msgboxTimer);
+    let box = $("#msgbox");
+    box.removeClass("visible");
+    //Take it out of the layer once faded. Driven by a timer rather than
+    //transitionend, which never fires if the element is hidden mid-fade.
+    msgboxTimer = setTimeout(function(){
+        if (!box.hasClass("visible")){
+            box.css("display", "none");
+        }
+    }, 200);
 }
 
     
@@ -196,6 +229,7 @@ function showPopupWrapper(){
     of these means updating those call sites too.
 */
 window.hideAllPopupWindows = hideAllPopupWindows;
+window.hideMsgBox = hideMsgBox;     // the toast's close button
 window.toggleDarkTheme = toggleDarkTheme;
 window.toggleMobileSidebar = toggleMobileSidebar;
 window.toggleSidebar = toggleSidebar;

+ 98 - 132
src/web/SystemAO/file_system/js/explorer/upload.js

@@ -52,7 +52,6 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
             Low Memory Upload Mode
         */
         var filename = encodeURIComponent(file.name);
-        var filesize = file.size;
 
         //Generate a new file item
         let taskUUID = uuid;    //For queueing objects
@@ -80,21 +79,6 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
             return
         }
 
-        function updateProgressForWebSocketUpload(uuid, progress){
-            $(".uploadTask").each(function(){
-                if ($(this).attr("taskID") == uuid){
-                    //Update this progress bar
-                    $(this).find(".bar").css("width",progress + "%");
-                    $(this).find(".progress.percentage").html(`<i class="ui upload icon"></i> ${progress.toFixed(1)}%`);
-                    if (progress == 100){
-                        //When progress = 100 and the server is not response with 200,
-                        //That means the upload has finish and server is processing the upload
-                        $(this).find(".progress").addClass("indicating");
-                    }
-                }
-            });
-        }
-
         //Open the websocket
         let path = currentPath;
         let protocol = "wss://";
@@ -150,19 +134,26 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
         // Store file reference in retry map so the user can retry on failure
         uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadDir))});
 
+        /*
+            Pause / resume state.
+
+            Chunks are pulled by the server: every "next" acknowledgement asks
+            for one more. Pausing therefore just means not answering that ask
+            and remembering that one is outstanding, so nothing has to be
+            unwound and the transfer resumes exactly where it stopped.
+        */
+        let paused = false;
+        let resumeWaiting = false;
+        let aborted = false;
+
         // Mark an upload task as failed and reveal the retry button
         function markUploadFailed(tUUID) {
             clearTimeout(chunkTimeoutTimer);
-            $(".uploadTask").each(function(){
-                if ($(this).attr("taskID") == tUUID){
-                    $(this).find(".bar").css("width","100%");
-                    $(this).find(".progress:not(.percentage)").attr("class","ui tiny error progress");
-                    $(this).find(".uploadTaskRemoveIcon").show();
-                    $(this).find(".uploadTaskRetryBtn").show();
-                    $(this).addClass("ended");
-                    $(this).find(".progress.percentage").hide();
-                }
-            });
+            if (aborted) {
+                return;
+            }
+            unregisterUploadTransfer(tUUID);
+            setUploadTaskState(tUUID, "failed");
         }
 
         // Send a specific chunk by index.
@@ -198,10 +189,8 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
             socket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
             socket.send(arrayBuffer);
 
-            // Update progress (cap at 95% to leave room for the merge phase)
-            var progress = chunks <= 1 ? 50 : (id / (chunks - 1) * 95.0);
-            if (progress > 95) progress = 95;
-            updateProgressForWebSocketUpload(taskUUID, progress);
+            // Report the bytes actually handed to the socket
+            setUploadTaskProgress(taskUUID, Math.min(file.size, offsetEnd), file.size);
 
             // (Re)start the per-chunk acknowledgement timeout
             clearTimeout(chunkTimeoutTimer);
@@ -218,26 +207,53 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
             }, CHUNK_TIMEOUT_MS);
         }
 
+        // Send whatever the server asked for next, or park the request if the
+        // user has paused. Every send path goes through here so pause only has
+        // to be handled once.
+        async function sendNext() {
+            if (paused || aborted) {
+                resumeWaiting = true;
+                return;
+            }
+            if (currentSendingIndex >= chunks){
+                // All chunks sent and acknowledged - send done + full-file checksum
+                let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+                socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
+                setUploadTaskState(taskUUID, "processing");
+            }else{
+                await sendChunk(currentSendingIndex);
+                currentSendingIndex++;
+            }
+        }
+
+        registerUploadTransfer(taskUUID, {
+            pausable: true,
+            pause: function(){
+                paused = true;
+                clearTimeout(chunkTimeoutTimer);
+            },
+            resume: function(){
+                paused = false;
+                if (resumeWaiting){
+                    resumeWaiting = false;
+                    sendNext();
+                }
+            },
+            abort: function(){
+                aborted = true;
+                clearTimeout(chunkTimeoutTimer);
+                try { socket.close(); } catch(e) {}
+            }
+        });
+
         //Update all UI elements
         updateUploadFileCount();
         uploadingFileCount++;
-        $(".uploadTask").each(function(){
-            if ($(this).attr("taskID") == taskUUID){
-                //This is the target upload task object. Hide its close button
-                $(this).find(".uploadTaskRemoveIcon").hide();
-                $(this).find(".uploadTaskRetryBtn").hide();
-            }
-        });
 
         //Start sending
         socket.onopen = async function(e) {
-            if (filesize < uploadFileChunkSize){
-                //This file is smaller than chunk size, set it to somewhere within 10% - 20% so it doesn't look like it is stuck
-                updateProgressForWebSocketUpload(taskUUID, 10 + Math.floor(Math.random() * 10));
-            }
-            //Send the first chunk
-            await sendChunk(0);
-            currentSendingIndex = 1;
+            currentSendingIndex = 0;
+            await sendNext();
         };
 
         socket.onmessage = async function(event) {
@@ -247,35 +263,13 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
                 // Server acknowledged the last chunk; clear timeout and reset retry counter
                 clearTimeout(chunkTimeoutTimer);
                 chunkRetryCount = 0;
-
-                if (currentSendingIndex >= chunks){
-                    // All chunks sent and acknowledged – send done + full-file checksum
-                    let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
-                    socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
-                }else{
-                    //Send next chunk
-                    await sendChunk(currentSendingIndex);
-                    currentSendingIndex++;
-                }
+                await sendNext();
 
             }else if (incomingValue == "OK"){
                 //Merge completed successfully
                 uploadRetryMap.delete(taskUUID);
-                $(".uploadTask").each(function(){
-                    if ($(this).attr("taskID") == taskUUID){
-                        //Update this progress bar to completed
-                        $(this).find(".bar").css("width","100%");
-                        $(this).find(".progress:not(.percentage)").attr("class", "ui tiny success progress");
-                        $(this).find(".progress.percentage").hide();
-                        $(this).find(".uploadTaskRemoveIcon").show();
-                        $(this).find(".uploadTaskRetryBtn").hide();
-                        $(this).addClass("ended");
-                        $.when($(this).delay(1000).fadeOut("fast")).then(function(){
-                            $(this).remove();
-                            updateUploadFileCount();
-                        });
-                    }
-                });
+                unregisterUploadTransfer(taskUUID);
+                setUploadTaskState(taskUUID, "done");
             }else{
                 //Try to parse it as JSON
                 try{
@@ -298,13 +292,8 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
                         }
                     }else if (resp.move !== undefined){
                         //File move from tmp to archive – show progress
-                        $(".uploadTask").each(function(){
-                            if ($(this).attr("taskID") == taskUUID){
-                                $(this).find(".bar").css("width","100%");
-                                $(this).find(".progress:not(.percentage)").attr("class","ui small indicating themed saving progress");
-                                $(this).find(".progress.percentage").html(`<i class="ui save icon"></i> ${resp.move}`);
-                            }
-                        });
+                        setUploadTaskState(taskUUID, "processing");
+                        setUploadTaskStatusText(taskUUID, resp.move);
                     }
                 }catch(ex){
                     //Something else
@@ -316,6 +305,7 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
 
         socket.onclose = function(event) {
             clearTimeout(chunkTimeoutTimer);
+            unregisterUploadTransfer(taskUUID);
             uploadingFileCount--;
             updateUploadFileCount();
             //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
@@ -380,67 +370,46 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
         formData.append('file', file);
         formData.append('path', uploadCurrentPath);
 
-        //Hide the cancel upload task button
-        $(".uploadTask").each(function(){
-            if ($(this).attr("taskID") == taskUUID){
-                //This is the target upload task object. Hide its close button
-                $(this).find(".uploadTaskRemoveIcon").hide();
+        //Let the user retry this task if it fails
+        uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadCurrentPath))});
+
+        /*
+            A POST body cannot be suspended once it is in flight, so this mode
+            offers cancel rather than pause. pausable:false is what makes the
+            row draw a cancel button instead of a pause button.
+        */
+        let xhrAborted = false;
+        registerUploadTransfer(taskUUID, {
+            pausable: false,
+            abort: function(){
+                xhrAborted = true;
+                xhr.abort();
             }
         });
 
         xhr.open('POST', url, true)
         xhr.upload.addEventListener("progress", function(e) {
-            var progress = (e.loaded * 100.0 / e.total) || 100;
-            $(".uploadTask").each(function(){
-                if ($(this).attr("taskID") == taskUUID){
-                    //Update this progress bar
-                    $(this).find(".bar").css("width",progress + "%");
-                    $(this).find(".progress.percentage").text(progress.toFixed(1) + "%");
-                    if (progress == 100){
-                        //When progress = 100 and the server is not response with 200,
-                        //That means the upload has finish and server is processing the upload
-                        $(this).find(".progress").addClass("active");
-                        $(this).find(".progress.percentage").hide();
-
-                    }
-                }
-            });
+            setUploadTaskProgress(taskUUID, e.loaded, e.total);
+            if (e.total > 0 && e.loaded >= e.total){
+                //Bytes are all sent but the server has not answered yet - it is
+                //still writing the file out
+                setUploadTaskState(taskUUID, "processing");
+            }
         })
 
         xhr.addEventListener('readystatechange', function(e) {
             if (xhr.readyState == 4 && xhr.status == 200) {
                 //Upload process ended
-                //Update task status
-                $(".uploadTask").each(function(){
-                    if ($(this).attr("taskID") == taskUUID){
-                        //Update this progress bar to completed
-                        $(this).find(".bar").css("width","100%");
-                        $(this).find(".progress").attr("class", "ui tiny success progress");
-                        $(this).find(".uploadTaskRemoveIcon").show();
-                        $(this).addClass("ended");
-                        //Update 15-11-2020
-                        //Remove this taskbar after 1 second to prevent lags during > 2000 uploads
-                        $.when($(this).delay(1000).fadeOut("fast")).then(function(){
-                            $(this).remove();
-                            updateUploadFileCount();
-                        });
-                        
-                    }
-                });
+                unregisterUploadTransfer(taskUUID);
+                setUploadTaskState(taskUUID, "done");
 
                 var resp = JSON.parse(e.target.response);
                 if (resp.error !== undefined){
                     msgbox("caution",resp.error);
                     //Something went wrong. Set the color to red
-                    $(".uploadTask").each(function(){
-                        if ($(this).attr("taskID") == taskUUID){
-                            //Update this progress bar to completed
-                            $(this).find(".bar").css("width","100%");
-                            $(this).find(".progress").attr("class", "ui tiny error progress");
-                            $(this).find(".uploadTaskRemoveIcon").show();
-                            $(this).addClass("ended");
-                        }
-                    });
+                    setUploadTaskState(taskUUID, "failed");
+                }else{
+                    uploadRetryMap.delete(taskUUID);
                 }
                 uploadingFileCount--;
 
@@ -453,16 +422,13 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
                 }, 100)
                 
             }else if (xhr.readyState == 4 && xhr.status != 200) {
-                msgbox("red remove",applocale.getString( "message/uploadFailed", "File too big or the target disk is fulled"));
-                console.log(xhr);
-                $(".uploadTask").each(function(){
-                    if ($(this).attr("taskID") == taskUUID){
-                        //Upload screwed up. Show error
-                        $(this).find(".progress").attr("class","ui tiny error progress");
-                        $(this).find(".uploadTaskRemoveIcon").show();
-                        $(this).addClass("ended");
-                    }
-                });
+                unregisterUploadTransfer(taskUUID);
+                if (!xhrAborted){
+                    //An abort is the user's own doing - the row is already gone
+                    msgbox("red remove",applocale.getString( "message/uploadFailed", "File too big or the target disk is fulled"));
+                    console.log(xhr);
+                    setUploadTaskState(taskUUID, "failed");
+                }
                 uploadingFileCount--;
 
                 //After the previous file has uploaded / errored, check if there are another file needed to be uploaded

+ 535 - 62
src/web/SystemAO/file_system/js/explorer/uploadui.js

@@ -1,7 +1,18 @@
 /*
     uploadui.js
 
-    Upload task list rendering inside #uploadTab.
+    The transfer panel (#uploadTab): one row per upload task, a running total
+    and the panel level controls.
+
+    upload.js and download.js drive this file through a small API rather than
+    reaching into the row markup themselves:
+
+        appendUploadFileItem(name, size)   -> taskUUID, adds a pending row
+        registerUploadTransfer(uuid, h)    -> hand over {abort, pause, resume}
+        setUploadTaskProgress(uuid, l, t)  -> bytes moved, drives bar/ETA/total
+        setUploadTaskState(uuid, state)    -> pending|uploading|processing|
+                                              paused|done|failed
+        setUploadTaskStatusText(uuid, s)   -> override the right hand label
 
     Part of the ArozOS File Manager. Loaded as a plain script from
     file_explorer.html - see the <script> block at the end of that file.
@@ -18,94 +29,551 @@ function getUploadTaskByID(taskUUID){
     return task;
 }
 
+/*
+    Row creation
+*/
+
 function appendUploadFileItem(filename, filesize){
     var newuuid = uuidv4();
-    var humanReadableFilesize = 0;
-    if (filesize < 0){
-        humanReadableFilesize = applocale.getString("message/unknownSize", "Unknown Size")
-    }else{
-        humanReadableFilesize = bytesToSize(filesize);
-    }
-    
-    $("#uploadProgressList").append(`<div class="uploadTask" taskID="${newuuid}">
-        <p style="width: calc(100% - 15px);">${filename} (${humanReadableFilesize})</p>
-        <div class="ui small themed progress" style="margin-top:-12px;">
-            <div class="bar" style="width: 0%; min-width: 0px;">
-                <div class="progress percentage"></div>
-            </div>
-        </div>
-        <div class="uploadTaskRetryBtn" onclick="retryUploadFile('${newuuid}');" style="display:none; cursor:pointer; float:left; margin-right:4px;" title="Retry upload">
-            <i class="redo icon"></i>
-        </div>
-        <div class="uploadTaskRemoveIcon" onclick="removeThisTask(this, '${newuuid}');" style="">
-            <i class="remove icon"></i>
+
+    //A negative size means the total is not known up front (zip preparation,
+    //where the server streams without announcing a length)
+    var knownSize = !(filesize < 0 || isNaN(filesize));
+
+    uploadTaskInfo.set(newuuid, {
+        name: filename,
+        size: knownSize ? filesize : -1,
+        loaded: 0,
+        state: "pending",
+        speed: 0,
+        lastLoaded: 0,
+        lastTime: Date.now(),
+        statusOverride: null
+    });
+
+    $("#uploadProgressList").append(`<div class="uploadTask pending" taskID="${newuuid}">
+        <div class="uploadTaskName"></div>
+        <div class="uploadTaskMeta">
+            <span class="uploadTaskSize"></span>
+            <span class="uploadTaskStatus"></span>
         </div>
+        <div class="uploadTaskAction" onclick="onUploadTaskButton('${newuuid}');"></div>
+        <div class="uploadTaskBar"><div class="uploadTaskBarFill"></div></div>
     </div>`);
+
+    //Set as text, not html: a filename may legitimately contain angle brackets
+    let row = getUploadTaskByID(newuuid);
+    row.find(".uploadTaskName").text(filename).attr("title", filename);
+
+    //A new task always brings the panel back, even if it was collapsed
+    uploadPanelCollapsed = false;
+    renderUploadTask(newuuid);
+    updateUploadFileCount();
     return newuuid;
 }
 
-function retryUploadFile(taskUUID){
-    let retryInfo = uploadRetryMap.get(taskUUID);
-    if (!retryInfo){
-        console.warn("[Upload] No retry info found for task " + taskUUID);
+/*
+    Transfer handles
+
+    A handle is {abort, pause, resume, pausable}. pause/resume are optional -
+    the plain POST upload mode cannot suspend an in-flight XHR, so its rows
+    offer cancel instead of pause.
+*/
+
+function registerUploadTransfer(taskUUID, handle){
+    uploadTransferMap.set(taskUUID, handle);
+    renderUploadTask(taskUUID);
+}
+
+function unregisterUploadTransfer(taskUUID){
+    uploadTransferMap.delete(taskUUID);
+}
+
+/*
+    Progress and state
+*/
+
+function setUploadTaskProgress(taskUUID, loaded, total){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined){
+        return;
+    }
+
+    if (total !== undefined && total > 0){
+        info.size = total;
+    }
+    info.loaded = loaded;
+
+    //Exponentially smoothed throughput. A raw delta jumps around far too much
+    //for a "time left" label that is meant to be readable.
+    let now = Date.now();
+    let dt = (now - info.lastTime) / 1000;
+    if (dt >= 0.4){
+        let instant = (loaded - info.lastLoaded) / dt;
+        if (instant < 0){
+            instant = 0;
+        }
+        info.speed = info.speed > 0 ? (info.speed * 0.7 + instant * 0.3) : instant;
+        info.lastLoaded = loaded;
+        info.lastTime = now;
+    }
+
+    if (info.state == "pending"){
+        info.state = "uploading";
+    }
+
+    renderUploadTask(taskUUID);
+    updateUploadSummary();
+}
+
+function setUploadTaskState(taskUUID, state){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined){
+        return;
+    }
+    info.state = state;
+    if (state == "done" && info.size > 0){
+        info.loaded = info.size;
+    }
+    if (state != "processing"){
+        info.statusOverride = null;
+    }
+    renderUploadTask(taskUUID);
+    updateUploadFileCount();
+}
+
+function setUploadTaskStatusText(taskUUID, text){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined){
         return;
     }
-    // Reset the task UI back to pending state
-    $(".uploadTask").each(function(){
-        if ($(this).attr("taskID") == taskUUID){
-            $(this).removeClass("ended");
-            $(this).find(".bar").css("width","0%");
-            $(this).find(".progress:not(.percentage)").attr("class","ui small themed progress");
-            $(this).find(".progress.percentage").text("").show();
-            $(this).find(".uploadTaskRetryBtn").hide();
-            $(this).find(".uploadTaskRemoveIcon").hide();
+    info.statusOverride = text;
+    renderUploadTask(taskUUID);
+}
+
+/*
+    Rendering
+*/
+
+/*
+    The shared bytesToSize() drops trailing zeros, so a panel showing several
+    transfers ends up mixing "2 MB", "121.2 MB" and "52.32 MB". Every number in
+    this panel is fixed at one decimal place instead so the columns line up.
+
+    Units are the short forms ("MB", not "Megabytes") because each one is boxed
+    at a fixed width below and a five character "Bytes" would reserve a visible
+    gap on every row.
+*/
+function splitUploadBytes(bytes){
+    var sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
+    if (!(bytes > 0)){
+        return {value: "0.0", unit: sizes[0]};
+    }
+    var i = Math.floor(Math.log(bytes) / Math.log(1024));
+    if (i >= sizes.length){
+        i = sizes.length - 1;
+    }
+    return {value: (bytes / Math.pow(1024, i)).toFixed(1), unit: sizes[i]};
+}
+
+/*
+    A counter that reflows on every progress tick is unreadable, and a single
+    fixed width box around the whole "121.2 MB" string does not fix it: the
+    number still moves inside the box as it grows, and it drags the unit with
+    it. So the number, the unit and the "/" each get their own fixed width cell
+    - the number right aligned so its ones column never moves, the unit left
+    aligned so it starts at the same x whatever the number did. Combined with
+    tabular figures (in a proportional font "1" is narrower than "8"), every
+    glyph position is then stable across updates.
+*/
+function formatUploadBytesHTML(bytes){
+    let parts = splitUploadBytes(bytes);
+    return '<span class="uploadNum">' + parts.value + '</span>' +
+           '<span class="uploadUnit">' + parts.unit + '</span>';
+}
+
+function formatUploadEta(seconds){
+    if (!isFinite(seconds) || seconds <= 0){
+        return "";
+    }
+    if (seconds < 60){
+        return applocale.getString("upload/eta/seconds", "%d sec left").replace("%d", Math.max(1, Math.round(seconds)));
+    }
+    if (seconds < 3600){
+        return applocale.getString("upload/eta/minutes", "%d min left").replace("%d", Math.round(seconds / 60));
+    }
+    return applocale.getString("upload/eta/hours", "%d hr left").replace("%d", Math.round(seconds / 3600));
+}
+
+function renderUploadTask(taskUUID){
+    let info = uploadTaskInfo.get(taskUUID);
+    let row = getUploadTaskByID(taskUUID);
+    if (info === undefined || row === undefined){
+        return;
+    }
+
+    let knownSize = info.size > 0;
+    let percentage = 0;
+    if (info.state == "done"){
+        percentage = 100;
+    }else if (knownSize){
+        percentage = Math.min(100, info.loaded / info.size * 100);
+    }
+
+    //A task with no announced total (zip preparation) has no percentage to
+    //show, so its bar sweeps instead of filling
+    let indeterminate = !knownSize && info.state != "done" && info.state != "failed";
+    row.attr("class", "uploadTask " + info.state + (indeterminate ? " indeterminate" : ""));
+    row.find(".uploadTaskBarFill").css("width", indeterminate ? "" : percentage + "%");
+
+    //Byte counter. Without a known total there is nothing meaningful to divide by.
+    let sizeHTML = "";
+    if (knownSize){
+        sizeHTML = formatUploadBytesHTML(info.loaded) +
+                   '<span class="uploadSep">/</span>' +
+                   formatUploadBytesHTML(info.size);
+    }else if (info.loaded > 0){
+        sizeHTML = formatUploadBytesHTML(info.loaded);
+    }
+    row.find(".uploadTaskSize").html(sizeHTML);
+
+    //Right hand label
+    let statusText = "";
+    if (info.statusOverride){
+        statusText = info.statusOverride;
+    }else if (info.state == "done"){
+        statusText = applocale.getString("upload/completed", "Completed");
+    }else if (info.state == "failed"){
+        statusText = applocale.getString("upload/failed", "Failed");
+    }else if (info.state == "paused"){
+        statusText = applocale.getString("upload/paused", "Paused");
+    }else if (info.state == "pending"){
+        statusText = applocale.getString("upload/waiting", "Waiting");
+    }else if (info.state == "processing"){
+        statusText = applocale.getString("upload/processing", "Processing");
+    }else if (knownSize && info.speed > 0){
+        statusText = formatUploadEta((info.size - info.loaded) / info.speed);
+    }
+    row.find(".uploadTaskStatus").text(statusText);
+
+    //Round button on the right. Completed rows show a status glyph instead of
+    //a control, which is why the icon is chosen from the state, not the handle.
+    let handle = uploadTransferMap.get(taskUUID);
+    let iconName = "closeCircle";
+    let btnTitle = applocale.getString("upload/cancel", "Cancel");
+    if (info.state == "done"){
+        iconName = "checkCircle";
+        btnTitle = applocale.getString("upload/completed", "Completed");
+    }else if (info.state == "failed"){
+        iconName = "refresh";
+        btnTitle = applocale.getString("upload/retry", "Retry");
+    }else if (info.state == "paused"){
+        iconName = "playCircle";
+        btnTitle = applocale.getString("upload/resume", "Resume");
+    }else if (handle && handle.pausable){
+        iconName = "pauseCircle";
+        btnTitle = applocale.getString("upload/pause", "Pause");
+    }
+    row.find(".uploadTaskAction").html(FSIcons[iconName]).attr("title", btnTitle);
+}
+
+/*
+    Panel level chrome
+*/
+
+function updateUploadSummary(){
+    let loaded = 0;
+    let total = 0;
+    let hasUnknown = false;
+    uploadTaskInfo.forEach(function(info){
+        loaded += info.loaded;
+        if (info.size > 0){
+            total += info.size;
+        }else{
+            hasUnknown = true;
         }
     });
-    // Re-queue the file for upload using the same task UUID
-    uploadFile(retryInfo.file, taskUUID, retryInfo.targetDir);
+
+    //The %s slots get the same boxed number markup as the rows. The literal
+    //text around them comes from the locale file and never changes width.
+    let html = "";
+    if (total > 0 && !hasUnknown){
+        html = applocale.getString("upload/total", "Total: %s / %s")
+                .replace("%s", formatUploadBytesHTML(loaded))
+                .replace("%s", formatUploadBytesHTML(total));
+    }else if (loaded > 0){
+        html = formatUploadBytesHTML(loaded);
+    }
+    $("#uploadSummaryText").html(html);
 }
 
-function removeThisTask(object, taskUUID){
-    //Remove item from uploadPendingList
-    let removeId = -1;
+function updateUploadFileCount(){
+    let active = 0;
+    uploadTaskInfo.forEach(function(info){
+        if (info.state != "done" && info.state != "failed"){
+            active++;
+        }
+    });
+
+    let title = "";
+    if (active > 0){
+        title = applocale.getString("upload/title", "Uploading %d items").replace("%d", active);
+    }else{
+        title = applocale.getString("upload/titleDone", "%d items completed").replace("%d", $(".uploadTask").length);
+    }
+    $("#uploadHeaderTitle").text(title);
+
+    updateUploadSummary();
+    trimFinishedUploadRows();
+    applyUploadPanelVisibility();
+
+    //Fired from here rather than from setUploadTaskProgress: this runs when a
+    //task is added or changes state, which is exactly when the active row moves
+    scrollActiveUploadIntoView();
+}
+
+/*
+    Panel and its collapsed stand-in
+
+    The panel and #fmUploadListBtn are two views of one thing, so both are
+    resolved here from the task list rather than toggled at each call site.
+    The button exists whenever there is anything to show - collapsing does not
+    throw the list away, it just parks it in the status bar - and it pulses
+    while a transfer is still moving so background progress stays noticeable.
+*/
+function applyUploadPanelVisibility(){
+    let taskCount = $(".uploadTask").length;
+    let transferring = false;
+    uploadTaskInfo.forEach(function(info){
+        if (info.state == "uploading" || info.state == "processing" || info.state == "pending"){
+            transferring = true;
+        }
+    });
+
+    if (taskCount == 0){
+        //Nothing left to show: the panel and its button both go away
+        $("#uploadTab").hide();
+        $("#fmUploadListBtn").hide();
+        uploadPanelCollapsed = false;
+        return;
+    }
+
+    let expanded = !uploadPanelCollapsed;
+    $("#uploadTab").toggle(expanded);
+    $("#fmUploadListBtn").css("display", "");
+    $("#fmUploadListBtn").toggleClass("active", expanded);
+    $("#fmUploadListBtn").toggleClass("transferring", transferring);
+}
+
+/*
+    A few thousand queued files used to be handled by fading each row out one
+    second after it completed. The panel now keeps completed rows so they can
+    be reviewed, so cap how many of them accumulate instead.
+*/
+function trimFinishedUploadRows(){
+    let finished = $("#uploadProgressList").find(".uploadTask.done");
+    let excess = finished.length - MAX_FINISHED_UPLOAD_ROWS;
+    for (var i = 0; i < excess; i++){
+        let row = $(finished[i]);
+        uploadTaskInfo.delete(row.attr("taskid"));
+        row.remove();
+    }
+}
+
+function onUploadTaskButton(taskUUID){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined){
+        return;
+    }
+    let handle = uploadTransferMap.get(taskUUID);
+
+    if (info.state == "done"){
+        //The glyph is a status indicator, not a button
+        return;
+    }else if (info.state == "failed"){
+        retryUploadFile(taskUUID);
+    }else if (info.state == "paused"){
+        if (handle && handle.resume){
+            handle.resume();
+            setUploadTaskState(taskUUID, "uploading");
+        }
+    }else if (handle && handle.pausable && handle.pause){
+        handle.pause();
+        setUploadTaskState(taskUUID, "paused");
+    }else{
+        cancelUploadTask(taskUUID);
+    }
+}
+
+function cancelUploadTask(taskUUID){
+    let handle = uploadTransferMap.get(taskUUID);
+    if (handle && handle.abort){
+        handle.abort();
+    }
+    uploadTransferMap.delete(taskUUID);
+
+    //Drop it from the pending queue if it never started
     for (var i = 0; i < uploadPendingList.length; i++){
         if (uploadPendingList[i].UUID == taskUUID){
-            removeId = i;
+            uploadPendingList.splice(i, 1);
             break;
         }
     }
 
-    if (removeId >= 0){
-        uploadPendingList.splice(removeId, 1);
-    }
-
-    //Remove the DOM Element
-    $(object).parent().fadeOut('fast',
-        function(){
+    uploadRetryMap.delete(taskUUID);
+    uploadTaskInfo.delete(taskUUID);
+    let row = getUploadTaskByID(taskUUID);
+    if (row !== undefined){
+        row.fadeOut("fast", function(){
             $(this).remove();
             updateUploadFileCount();
+        });
+    }else{
+        updateUploadFileCount();
+    }
+}
+
+function retryUploadFile(taskUUID){
+    let retryInfo = uploadRetryMap.get(taskUUID);
+    if (!retryInfo){
+        console.warn("[Upload] No retry info found for task " + taskUUID);
+        return;
+    }
+
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info !== undefined){
+        info.loaded = 0;
+        info.speed = 0;
+        info.lastLoaded = 0;
+        info.lastTime = Date.now();
+    }
+    setUploadTaskState(taskUUID, "pending");
+
+    //Re-queue the file for upload using the same task UUID
+    uploadFile(retryInfo.file, taskUUID, retryInfo.targetDir);
+}
+
+function clearCompletedUploads(){
+    $("#uploadProgressList").find(".uploadTask.done, .uploadTask.failed").each(function(){
+        uploadTaskInfo.delete($(this).attr("taskid"));
+        $(this).remove();
+    });
+    updateUploadFileCount();
+}
+
+function cancelAllUploads(){
+    //Snapshot first: cancelUploadTask mutates both the map and the queue
+    let ids = [];
+    uploadTaskInfo.forEach(function(info, uuid){
+        if (info.state != "done"){
+            ids.push(uuid);
         }
-    );
-    
+    });
+    uploadPendingList = [];
+    ids.forEach(function(uuid){
+        cancelUploadTask(uuid);
+    });
 }
 
-function toggleUploadList(){
-    $("#uploadProgressList").toggle();
-    if ($("#uploadProgressList").is(":visible")){
-        $(".hideUploadButton").html('<i class="caret down icon"></i>');
-    }else{
-        $(".hideUploadButton").html('<i class="caret up icon"></i>');
+/*
+    Keep the in-flight transfer on screen
+
+    Completed rows stay in the list, so in a long queue the active row keeps
+    drifting downwards out of view. Called on state changes rather than on every
+    progress tick: the active row only moves when a task starts or finishes, and
+    scrolling on each tick would fight the user constantly.
+*/
+function bindUploadListScroll(){
+    let list = document.getElementById("uploadProgressList");
+    if (list == null){
+        return;
     }
+    list.addEventListener("scroll", function(){
+        if (Math.abs(list.scrollTop - uploadListAutoScrollTop) < 1){
+            //Where this file just scrolled to, so this event is its own
+            return;
+        }
+        uploadListUserScrolledAt = Date.now();
+    });
 }
 
-function updateUploadFileCount(){
-    $("#uploadCount").text(uploadingFileCount);
-    $("#waitingCount").text(uploadPendingList.length);
-    if (uploadingFileCount == 0 && $(".uploadTask").length == 0){
-        $("#uploadTab").hide();
-    }else{
-        $("#uploadTab").show();
+function scrollActiveUploadIntoView(){
+    let list = document.getElementById("uploadProgressList");
+    let panel = document.getElementById("uploadTab");
+    if (list == null || panel == null){
+        return;
+    }
+    //Nothing to bring into view while the panel is collapsed to the status bar
+    if (getComputedStyle(panel).display == "none" || getComputedStyle(list).display == "none"){
+        return;
+    }
+    if (Date.now() - uploadListUserScrolledAt < UPLOAD_FOLLOW_PAUSE_MS){
+        //The user is reading the list - leave it alone
+        return;
     }
+
+    //The first row that has not finished is the one worth watching
+    let active = null;
+    let rows = list.querySelectorAll(".uploadTask");
+    for (var i = 0; i < rows.length; i++){
+        if (!rows[i].classList.contains("done") && !rows[i].classList.contains("failed")){
+            active = rows[i];
+            break;
+        }
+    }
+    if (active == null){
+        return;
+    }
+
+    let listBox = list.getBoundingClientRect();
+    let rowBox = active.getBoundingClientRect();
+    let delta = 0;
+    if (rowBox.top < listBox.top || rowBox.height > listBox.height){
+        delta = rowBox.top - listBox.top;
+    }else if (rowBox.bottom > listBox.bottom){
+        delta = rowBox.bottom - listBox.bottom;
+    }
+    if (delta == 0){
+        return;
+    }
+
+    /*
+        scrollTop rather than scrollIntoView: the latter also scrolls every
+        scrollable ancestor, which would drag the file list behind the panel.
+    */
+    list.scrollTop += delta;
+    //Read back rather than reusing the requested value - the browser clamps it
+    uploadListAutoScrollTop = list.scrollTop;
+}
+
+//Collapse the panel down to #fmUploadListBtn. The tasks keep running.
+function toggleUploadMinimize(){
+    uploadPanelCollapsed = true;
+    applyUploadPanelVisibility();
+}
+
+//The status bar button: expands the parked panel, or collapses it again
+function toggleUploadPanel(){
+    uploadPanelCollapsed = !uploadPanelCollapsed;
+    applyUploadPanelVisibility();
+    if (!uploadPanelCollapsed){
+        //Re-expanded from the status bar - put the in-flight row back in sight
+        uploadListUserScrolledAt = 0;
+        scrollActiveUploadIntoView();
+    }
+}
+
+function closeUploadTab(){
+    uploadPanelCollapsed = true;
+    applyUploadPanelVisibility();
+}
+
+/*
+    Kept for compatibility: older call sites remove a row by passing the clicked
+    element. The task UUID is the part that actually matters.
+*/
+function removeThisTask(object, taskUUID){
+    cancelUploadTask(taskUUID);
 }
 
 
@@ -116,4 +584,9 @@ function updateUploadFileCount(){
 */
 window.removeThisTask = removeThisTask;
 window.retryUploadFile = retryUploadFile;
-window.toggleUploadList = toggleUploadList;
+window.toggleUploadMinimize = toggleUploadMinimize;
+window.toggleUploadPanel = toggleUploadPanel;   // the status bar button
+window.closeUploadTab = closeUploadTab;
+window.clearCompletedUploads = clearCompletedUploads;
+window.cancelAllUploads = cancelAllUploads;
+window.onUploadTaskButton = onUploadTaskButton;

+ 15 - 0
src/web/SystemAO/file_system/shared/fs-theme.css

@@ -51,6 +51,14 @@
     /* Effects */
     --fs-shadow: rgba(15, 20, 30, 0.16);
     --fs-overlay: rgba(255, 255, 255, 0.86);
+
+    /* Toast. Dark in both themes - it sits over the content rather than in it,
+       so it reads as an overlay instead of another panel. */
+    --fs-toast-bg: #32363c;
+    --fs-toast-fg: #f2f3f5;
+    --fs-toast-dim: #b9bec6;
+    --fs-toast-error-bg: #c5221f;
+    --fs-toast-error-fg: #ffffff;
 }
 
 body.darkTheme {
@@ -98,4 +106,11 @@ body.darkTheme {
     /* Effects */
     --fs-shadow: rgba(0, 0, 0, 0.6);
     --fs-overlay: rgba(16, 19, 23, 0.9);
+
+    /* A step lighter than the surfaces, so the toast still lifts off a dark UI */
+    --fs-toast-bg: #333b47;
+    --fs-toast-fg: #eef1f5;
+    --fs-toast-dim: #a8b1bd;
+    --fs-toast-error-bg: #b3252a;
+    --fs-toast-error-fg: #ffffff;
 }

+ 13 - 1
src/web/SystemAO/file_system/shared/fsicons.js

@@ -74,7 +74,19 @@
         image:   '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="2.5" y="4" width="19" height="16" rx="3"/><circle cx="8.6" cy="9.4" r="1.8" fill="#ffffff"/><path d="M4.2 18.6l4.6-5.1 3.1 3.3 3-3.4 4.9 5.2z" fill="#ffffff"/></svg>',
         video:   '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="2.5" y="4" width="19" height="16" rx="3"/><path d="M6.4 4v16M17.6 4v16" stroke="#ffffff" stroke-width="1.4"/><path d="M2.5 12h19" stroke="#ffffff" stroke-width="1.4"/></svg>',
         music:   '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3.4v11.2a3.4 3.4 0 1 1-2-3.1V7.3l-7 1.5v8.8a3.4 3.4 0 1 1-2-3.1V6.4z"/></svg>',
-        download_filled: S + '<path d="M12 3v12M7 11l5 5 5-5"/><path d="M4 20h16"/></svg>'
+        download_filled: S + '<path d="M12 3v12M7 11l5 5 5-5"/><path d="M4 20h16"/></svg>',
+
+        /* Transfer panel */
+        minus:       S + '<path d="M5 12h14"/></svg>',
+        chevronDown: S + '<path d="M6 9.5l6 6 6-6"/></svg>',
+        chevronUp:   S + '<path d="M6 14.5l6-6 6 6"/></svg>',
+        cloudUpload: S + '<path d="M6.5 19a4.5 4.5 0 0 1-.6-8.96A6 6 0 0 1 17.7 9.2 3.9 3.9 0 0 1 17.5 19z"/><path d="M12 18v-7M9.2 13.3L12 10.5l2.8 2.8"/></svg>',
+        pause:       S + '<path d="M9.5 5.5v13M14.5 5.5v13"/></svg>',
+        play:        S + '<path d="M8 5.4l10 6.6-10 6.6z"/></svg>',
+        pauseCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M10.1 9.2v5.6M13.9 9.2v5.6"/></svg>',
+        playCircle:  S + '<circle cx="12" cy="12" r="9"/><path d="M10.2 8.6l5.2 3.4-5.2 3.4z"/></svg>',
+        checkCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M8 12.3l2.7 2.7L16 9.7"/></svg>',
+        closeCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6"/></svg>'
     };
 
     //Fill every element carrying data-fsicon="<name>" with its glyph

+ 138 - 36
src/web/SystemAO/locale/file_explorer.json

@@ -22,7 +22,7 @@
                 "status/selected": "個已選",
                 "sidebar/quickaccess": "快速存取",
                 "sidebar/devices": "裝置",
-                "fileopr/Dark Theme": "深色主題",
+                "fileopr/Dark Theme": "切換主題配色",
                 "title/title": "檔案管理員",
                 "menu/button/download": "下載所選",
                 "menu/sort/asc": "檔名順序",
@@ -57,7 +57,6 @@
                 "fileopr/Home": "根目錄",
                 "fileopr/File Info": "檔案資訊",
                 "fileopr/Toggle Folder List": "檢視根目錄列表",
-                "fileopr/Dark Theme": "切換主題配色",
                 "fileopr/Edit Path": "編輯路徑",
                 "fileopr/Sorting Method": "檔案排序",
                 "fileopr/Search": "搜尋",
@@ -186,8 +185,6 @@
                 "contextmenu/download": "下載",
                 "contextmenu/sharemanager": "分享管理員",
                 "contextmenu/properties": "內容",
-                "upload/count/uploading": " 上載中",
-                "upload/count/pending": " 等待中",
                 "message/rename/success": "重新命名成功",
                 "message/copy/success": " 個檔案已複製",
                 "message/move/success": " 個檔案已移動",
@@ -237,7 +234,24 @@
                 "message/decodeFilelistFail": "載案置入失敗:無法讀取檔案列表",
                 "message/uploadFailed": "載案上載失敗:檔案太大或目標儲存裝置已滿",
                 "message/newFilenameIdentical": "重新命名失敗:新舊檔案名稱相同",
-                "message/illegalCharacters": "名稱包含非法字元(不可使用:/ \\ ? % * : | \" < >)"
+                "message/illegalCharacters": "名稱包含非法字元(不可使用:/ \\ ? % * : | \" < >)",
+                "upload/title": "上傳中 %d 個項目",
+                "upload/titleDone": "已完成 %d 個項目",
+                "upload/completed": "已完成",
+                "upload/failed": "上傳失敗",
+                "upload/waiting": "等待中",
+                "upload/processing": "處理中",
+                "upload/paused": "已暫停",
+                "upload/total": "總計:%s / %s",
+                "upload/clearCompleted": "清除已完成項目",
+                "upload/cancelAll": "全部取消",
+                "upload/eta/seconds": "剩餘 %d 秒",
+                "upload/eta/minutes": "剩餘 %d 分鐘",
+                "upload/eta/hours": "剩餘 %d 小時",
+                "upload/pause": "暫停",
+                "upload/resume": "繼續",
+                "upload/cancel": "取消",
+                "upload/retry": "重試"
             },
             "titles": {
                 "Back": "上一頁",
@@ -274,7 +288,10 @@
                 "Clear Search": "清除搜尋結果",
                 "Expand Properties Sidebar": "放大資訊側欄",
                 "Shrink Properties Sidebar": "縮小資訊側欄",
-                "Show Properties": "顯示資訊側欄"
+                "Show Properties": "顯示資訊側欄",
+                "Minimize": "最小化",
+                "Close": "關閉",
+                "Upload List": "上傳清單"
             },
             "placeholder": {
                 "New Filename": "在此輸入新檔案名稱",
@@ -302,7 +319,7 @@
                 "status/selected": "個已選",
                 "sidebar/quickaccess": "快速存取",
                 "sidebar/devices": "裝置",
-                "fileopr/Dark Theme": "深色主題",
+                "fileopr/Dark Theme": "切換主題配色",
                 "title/title": "檔案管理員",
                 "menu/button/download": "下載所選",
                 "menu/sort/asc": "檔名順序",
@@ -337,7 +354,6 @@
                 "fileopr/Home": "根目錄",
                 "fileopr/File Info": "檔案資訊",
                 "fileopr/Toggle Folder List": "檢視根目錄列表",
-                "fileopr/Dark Theme": "切換主題配色",
                 "fileopr/Edit Path": "編輯路徑",
                 "fileopr/Sorting Method": "檔案排序",
                 "fileopr/Search": "搜尋",
@@ -466,8 +482,6 @@
                 "contextmenu/download": "下載",
                 "contextmenu/sharemanager": "分享管理員",
                 "contextmenu/properties": "內容",
-                "upload/count/uploading": " 上載中",
-                "upload/count/pending": " 等待中",
                 "message/rename/success": "重新命名成功",
                 "message/copy/success": " 個檔案已複製",
                 "message/move/success": " 個檔案已移動",
@@ -517,7 +531,24 @@
                 "message/newFilenameIdentical": "重新命名失敗:新舊檔案名稱相同",
                 "message/illegalCharacters": "名稱包含非法字元(不可使用:/ \\ ? % * : | \" < >)",
                 "message/folderCannotOpen": "無法開啟此資料夾",
-                "message/folderCannotOpen/codedesc": "伺服器端回傳以下錯誤訊息: "
+                "message/folderCannotOpen/codedesc": "伺服器端回傳以下錯誤訊息: ",
+                "upload/title": "上傳中 %d 個項目",
+                "upload/titleDone": "已完成 %d 個項目",
+                "upload/completed": "已完成",
+                "upload/failed": "上傳失敗",
+                "upload/waiting": "等待中",
+                "upload/processing": "處理中",
+                "upload/paused": "已暫停",
+                "upload/total": "總計:%s / %s",
+                "upload/clearCompleted": "清除已完成項目",
+                "upload/cancelAll": "全部取消",
+                "upload/eta/seconds": "剩餘 %d 秒",
+                "upload/eta/minutes": "剩餘 %d 分鐘",
+                "upload/eta/hours": "剩餘 %d 小時",
+                "upload/pause": "暫停",
+                "upload/resume": "繼續",
+                "upload/cancel": "取消",
+                "upload/retry": "重試"
             },
             "titles": {
                 "Back": "上一頁",
@@ -554,7 +585,10 @@
                 "Clear Search": "清除搜尋結果",
                 "Expand Properties Sidebar": "放大資訊側欄",
                 "Shrink Properties Sidebar": "縮小資訊側欄",
-                "Show Properties": "顯示資訊側欄"
+                "Show Properties": "顯示資訊側欄",
+                "Minimize": "最小化",
+                "Close": "關閉",
+                "Upload List": "上傳清單"
             },
             "placeholder": {
                 "New Filename": "在此輸入新檔案名稱",
@@ -582,7 +616,7 @@
                 "status/selected": "个已选",
                 "sidebar/quickaccess": "快速访问",
                 "sidebar/devices": "设备",
-                "fileopr/Dark Theme": "深色主题",
+                "fileopr/Dark Theme": "切换主题配色",
                 "title/title": "文件管理器",
                 "menu/button/download": "下载所选",
                 "menu/sort/asc": "文件名顺序排列",
@@ -617,7 +651,6 @@
                 "fileopr/Home": "根目录",
                 "fileopr/File Info": "文件信息",
                 "fileopr/Toggle Folder List": "查看根目录列表",
-                "fileopr/Dark Theme": "切换主题配色",
                 "fileopr/Edit Path": "编辑路径",
                 "fileopr/Sorting Method": "文件排序",
                 "fileopr/Search": "搜索",
@@ -746,8 +779,6 @@
                 "contextmenu/download": "下载",
                 "contextmenu/sharemanager": "分享管理器",
                 "contextmenu/properties": "属性",
-                "upload/count/uploading": " 上传中",
-                "upload/count/pending": " 等待中",
                 "message/rename/success": "重命名成功",
                 "message/copy/success": " 个文件已复制",
                 "message/move/success": " 个文件已移动",
@@ -798,7 +829,24 @@
                 "opr/shortcut/error": "桌面创建快捷方式失败",
                 "message/No file selected": "没有选择文件",
                 "message/folderCannotOpen": "无法打开此文件夹",
-                "message/folderCannotOpen/codedesc": "服务器端返回以下错误信息: "
+                "message/folderCannotOpen/codedesc": "服务器端返回以下错误信息: ",
+                "upload/title": "上传中 %d 个项目",
+                "upload/titleDone": "已完成 %d 个项目",
+                "upload/completed": "已完成",
+                "upload/failed": "上传失败",
+                "upload/waiting": "等待中",
+                "upload/processing": "处理中",
+                "upload/paused": "已暂停",
+                "upload/total": "总计:%s / %s",
+                "upload/clearCompleted": "清除已完成项目",
+                "upload/cancelAll": "全部取消",
+                "upload/eta/seconds": "剩余 %d 秒",
+                "upload/eta/minutes": "剩余 %d 分钟",
+                "upload/eta/hours": "剩余 %d 小时",
+                "upload/pause": "暂停",
+                "upload/resume": "继续",
+                "upload/cancel": "取消",
+                "upload/retry": "重试"
             },
             "titles": {
                 "Back": "上一页",
@@ -835,7 +883,10 @@
                 "Clear Search": "清除搜索结果",
                 "Expand Properties Sidebar": "放大属性侧栏",
                 "Shrink Properties Sidebar": "缩小属性侧栏",
-                "Show Properties": "显示属性侧栏"
+                "Show Properties": "显示属性侧栏",
+                "Minimize": "最小化",
+                "Close": "关闭",
+                "Upload List": "上传列表"
             },
             "placeholder": {
                 "New Filename": "在此输入新文件名称",
@@ -863,7 +914,7 @@
                 "status/selected": "selected",
                 "sidebar/quickaccess": "Quick Access",
                 "sidebar/devices": "Devices",
-                "fileopr/Dark Theme": "Dark Theme",
+                "fileopr/Dark Theme": "Toggle Dark Theme",
                 "title/title": "File Manager",
                 "menu/button/download": "Download Selected",
                 "menu/sort/asc": "Filename (Asc)",
@@ -898,7 +949,6 @@
                 "fileopr/Home": "Home",
                 "fileopr/File Info": "File Info",
                 "fileopr/Toggle Folder List": "Toggle Folder List",
-                "fileopr/Dark Theme": "Toggle Dark Theme",
                 "fileopr/Edit Path": "Edit Path",
                 "fileopr/Sorting Method": "Sorting Method",
                 "fileopr/Search": "Search",
@@ -1027,8 +1077,6 @@
                 "contextmenu/download": "Download",
                 "contextmenu/sharemanager": "Share Manager",
                 "contextmenu/properties": "Properties",
-                "upload/count/uploading": " Uploading",
-                "upload/count/pending": " Waiting",
                 "message/rename/success": "File renamed successfully",
                 "message/copy/success": " files copied successfully",
                 "message/move/success": " files moved successfully",
@@ -1080,7 +1128,24 @@
                 "opr/shortcut/error": "Failed to create desktop shortcut",
                 "message/No file selected": "No file selected",
                 "message/folderCannotOpen": "Cannot open this folder",
-                "message/folderCannotOpen/codedesc": "The server returned the following error message: "
+                "message/folderCannotOpen/codedesc": "The server returned the following error message: ",
+                "upload/title": "Uploading %d items",
+                "upload/titleDone": "%d items completed",
+                "upload/completed": "Completed",
+                "upload/failed": "Failed",
+                "upload/waiting": "Waiting",
+                "upload/processing": "Processing",
+                "upload/paused": "Paused",
+                "upload/total": "Total: %s / %s",
+                "upload/clearCompleted": "Clear completed",
+                "upload/cancelAll": "Cancel all",
+                "upload/eta/seconds": "%d sec left",
+                "upload/eta/minutes": "%d min left",
+                "upload/eta/hours": "%d hr left",
+                "upload/pause": "Pause",
+                "upload/resume": "Resume",
+                "upload/cancel": "Cancel",
+                "upload/retry": "Retry"
             },
             "titles": {
                 "Back": "Back",
@@ -1117,7 +1182,10 @@
                 "Clear Search": "Clear search results",
                 "Expand Properties Sidebar": "Expand properties sidebar",
                 "Shrink Properties Sidebar": "Minify properties sidebar",
-                "Show Properties": "Show Properties"
+                "Show Properties": "Show Properties",
+                "Minimize": "Minimize",
+                "Close": "Close",
+                "Upload List": "Upload List"
             },
             "placeholder": {
                 "New Filename": "Enter new file name here",
@@ -1145,7 +1213,7 @@
                 "status/selected": "個を選択",
                 "sidebar/quickaccess": "クイックアクセス",
                 "sidebar/devices": "デバイス",
-                "fileopr/Dark Theme": "ダークテーマ",
+                "fileopr/Dark Theme": "ダークテーマの切り替え",
                 "title/title": "ファイルマネージャー",
                 "menu/button/download": "選択したものをダウンロード",
                 "menu/sort/asc": "ファイル名(昇順)",
@@ -1180,7 +1248,6 @@
                 "fileopr/Home": "ホーム",
                 "fileopr/File Info": "情報",
                 "fileopr/Toggle Folder List": "フォルダリストの切り替え",
-                "fileopr/Dark Theme": "ダークテーマの切り替え",
                 "fileopr/Edit Path": "パスの編集",
                 "fileopr/Sorting Method": "ソート方法",
                 "fileopr/Search": "検索",
@@ -1309,8 +1376,6 @@
                 "contextmenu/download": "ダウンロード",
                 "contextmenu/sharemanager": "共有管理",
                 "contextmenu/properties": "プロパティ",
-                "upload/count/uploading": " アップロード中",
-                "upload/count/pending": " 待機中",
                 "message/rename/success": "ファイルの名前が正常に変更されました",
                 "message/copy/success": "個のファイルが正常にコピーされました",
                 "message/move/success": "個のファイルが正常に移動されました",
@@ -1362,7 +1427,24 @@
                 "opr/shortcut/error": "デスクトップショートカットの作成に失敗しました",
                 "message/No file selected": "ファイルが選択されていません",
                 "message/folderCannotOpen": "このフォルダを開けません",
-                "message/folderCannotOpen/codedesc": "サーバーから次のエラーメッセージが返されました: "
+                "message/folderCannotOpen/codedesc": "サーバーから次のエラーメッセージが返されました: ",
+                "upload/title": "%d 件をアップロード中",
+                "upload/titleDone": "%d 件完了",
+                "upload/completed": "完了",
+                "upload/failed": "失敗",
+                "upload/waiting": "待機中",
+                "upload/processing": "処理中",
+                "upload/paused": "一時停止中",
+                "upload/total": "合計: %s / %s",
+                "upload/clearCompleted": "完了した項目を消去",
+                "upload/cancelAll": "すべてキャンセル",
+                "upload/eta/seconds": "残り %d 秒",
+                "upload/eta/minutes": "残り %d 分",
+                "upload/eta/hours": "残り %d 時間",
+                "upload/pause": "一時停止",
+                "upload/resume": "再開",
+                "upload/cancel": "キャンセル",
+                "upload/retry": "再試行"
             },
             "titles": {
                 "Back": "戻る",
@@ -1399,7 +1481,10 @@
                 "Clear Search": "検索結果をクリア",
                 "Expand Properties Sidebar": "プロパティサイドバーを開く",
                 "Shrink Properties Sidebar": "プロパティサイドバーを縮小する",
-                "Show Properties": "プロパティサイドバーを表示"
+                "Show Properties": "プロパティサイドバーを表示",
+                "Minimize": "最小化",
+                "Close": "閉じる",
+                "Upload List": "アップロード一覧"
             },
             "placeholder": {
                 "New Filename": "ここに新しいファイル名を入力してください",
@@ -1428,7 +1513,7 @@
                 "status/selected": "개 선택됨",
                 "sidebar/quickaccess": "즐겨찾기",
                 "sidebar/devices": "장치",
-                "fileopr/Dark Theme": "다크 테마",
+                "fileopr/Dark Theme": "다크 테마 전환",
                 "title/title": "파일 관리자",
                 "menu/button/download": "선택 항목 다운로드",
                 "menu/sort/asc": "파일 이름 정렬",
@@ -1463,7 +1548,6 @@
                 "fileopr/Home": "홈 폴더",
                 "fileopr/File Info": "파일 정보",
                 "fileopr/Toggle Folder List": "홈 폴더 목록 토글",
-                "fileopr/Dark Theme": "다크 테마 전환",
                 "fileopr/Edit Path": "경로 편집",
                 "fileopr/Sorting Method": "파일 정렬",
                 "fileopr/Search": "검색",
@@ -1592,8 +1676,6 @@
                 "contextmenu/download": "다운로드",
                 "contextmenu/sharemanager": "공유 관리자",
                 "contextmenu/properties": "내용",
-                "upload/count/uploading": " 업로드 중",
-                "upload/count/pending": " 대기 중",
                 "message/rename/success": "이름 변경 성공",
                 "message/copy/success": " 파일이 복사되었습니다",
                 "message/move/success": " 파일이 이동되었습니다",
@@ -1646,7 +1728,24 @@
                 "message/illegalCharacters": "이름에 사용할 수 없는 문자가 포함되어 있습니다 (사용 불가: / \\ ? % * : | \" < >)",
                 "opr/shortcut/error": "바탕화면 바로가기 생성에 실패했습니다",
                 "opr/shortcut/title": "바탕 화면에 바로 가기 만들기",
-                "opr/shortcut/ok": "바로 가기가 생성되었습니다"
+                "opr/shortcut/ok": "바로 가기가 생성되었습니다",
+                "upload/title": "%d개 항목 업로드 중",
+                "upload/titleDone": "%d개 항목 완료",
+                "upload/completed": "완료됨",
+                "upload/failed": "실패",
+                "upload/waiting": "대기 중",
+                "upload/processing": "처리 중",
+                "upload/paused": "일시중지됨",
+                "upload/total": "총 %s / %s",
+                "upload/clearCompleted": "완료된 항목 지우기",
+                "upload/cancelAll": "모두 취소",
+                "upload/eta/seconds": "%d초 남음",
+                "upload/eta/minutes": "%d분 남음",
+                "upload/eta/hours": "%d시간 남음",
+                "upload/pause": "일시중지",
+                "upload/resume": "다시 시작",
+                "upload/cancel": "취소",
+                "upload/retry": "다시 시도"
             },
             "titles": {
                 "Back": "뒤로",
@@ -1683,7 +1782,10 @@
                 "Clear Search": "검색 결과 지우기",
                 "Expand Properties Sidebar": "속성 사이드바 확장",
                 "Shrink Properties Sidebar": "속성 사이드바 축소",
-                "Show Properties": "속성 표시"
+                "Show Properties": "속성 표시",
+                "Minimize": "최소화",
+                "Close": "닫기",
+                "Upload List": "업로드 목록"
             },
             "placeholder": {
                 "New Filename": "새 파일 이름 입력",