Improve the UI of uploaded attachments
authorAlexander Ebert <ebert@woltlab.com>
Fri, 3 May 2024 13:02:47 +0000 (15:02 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 8 Jun 2024 10:19:39 +0000 (12:19 +0200)
ts/WoltLabSuite/Core/Api/Error.ts
ts/WoltLabSuite/Core/Component/Attachment/List.ts
ts/WoltLabSuite/Core/Component/File/Upload.ts
ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Error.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js
wcfsetup/install/files/lib/data/file/File.class.php
wcfsetup/install/files/style/ui/attachment.scss

index 7ea2b4e81db5c268a0b02d2b0a0f044fc3750d26..663bdf7e0a2d6b40f41ab9ee80fa36b17b16d5e2 100644 (file)
@@ -27,7 +27,7 @@ export class ApiError {
   }
 }
 
-class ValidationError {
+export class ValidationError {
   constructor(
     public readonly code: string,
     public readonly message: string,
index 9ec565cd1c53265f85c1d50b90f847cb03988794..3ad3bb30844d32067a80e72f6b0a9e046e2d7c48 100644 (file)
@@ -4,6 +4,7 @@ import WoltlabCoreFileElement from "../File/woltlab-core-file";
 
 import "../File/woltlab-core-file";
 import { CkeditorDropEvent } from "../File/Upload";
+import { formatFilesize } from "WoltLabSuite/Core/FileUtil";
 
 type FileProcessorData = {
   attachmentID: number;
@@ -19,49 +20,63 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: s
 
   const filename = document.createElement("div");
   filename.classList.add("attachment__item__filename");
-  filename.textContent = file.filename!;
+  filename.textContent = file.filename || file.dataset.filename!;
 
-  element.append(fileWrapper, filename);
+  const fileSize = document.createElement("div");
+  fileSize.classList.add("attachment__item__fileSize");
+  fileSize.textContent = formatFilesize(file.fileSize || parseInt(file.dataset.fileSize!));
+
+  element.append(fileWrapper, filename, fileSize);
   fileList.append(element);
 
-  void file.ready.then(() => {
-    const data = file.data;
-    if (data === undefined) {
-      // TODO: error handling
-      return;
-    }
+  void file.ready
+    .then(() => {
+      const data = file.data;
+      if (data === undefined) {
+        // TODO: error handling
+        return;
+      }
 
-    const fileId = file.fileId;
-    if (fileId === undefined) {
-      // TODO: error handling
-      return;
-    }
+      const fileId = file.fileId;
+      if (fileId === undefined) {
+        // TODO: error handling
+        return;
+      }
 
-    const buttonList = document.createElement("div");
-    buttonList.classList.add("attachment__item__buttons");
-    buttonList.append(
-      getDeleteAttachButton(fileId, (data as FileProcessorData).attachmentID, editorId, element),
-      getInsertAttachBbcodeButton(
-        (data as FileProcessorData).attachmentID,
-        file.isImage() && file.link ? file.link : "",
-        editorId,
-      ),
-    );
-
-    if (file.isImage()) {
-      const thumbnail = file.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny");
-      if (thumbnail !== undefined) {
-        file.thumbnail = thumbnail;
+      const buttonList = document.createElement("div");
+      buttonList.classList.add("attachment__item__buttons");
+      buttonList.append(
+        getDeleteAttachButton(fileId, (data as FileProcessorData).attachmentID, editorId, element),
+        getInsertAttachBbcodeButton(
+          (data as FileProcessorData).attachmentID,
+          file.isImage() && file.link ? file.link : "",
+          editorId,
+        ),
+      );
+
+      if (file.isImage()) {
+        const thumbnail = file.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny");
+        if (thumbnail !== undefined) {
+          file.thumbnail = thumbnail;
+        }
+
+        const url = file.thumbnails.find((thumbnail) => thumbnail.identifier === "")?.link;
+        if (url !== undefined) {
+          buttonList.append(getInsertThumbnailButton((data as FileProcessorData).attachmentID, url, editorId));
+        }
       }
 
-      const url = file.thumbnails.find((thumbnail) => thumbnail.identifier === "")?.link;
-      if (url !== undefined) {
-        buttonList.append(getInsertThumbnailButton((data as FileProcessorData).attachmentID, url, editorId));
+      element.append(buttonList);
+    })
+    .catch(() => {
+      if (file.validationError === undefined) {
+        return;
       }
-    }
 
-    element.append(buttonList);
-  });
+      // TODO: Add a proper error message, this is for development purposes only.
+      element.append(JSON.stringify(file.validationError));
+      element.classList.add("attachment__item--error");
+    });
 }
 
 function getDeleteAttachButton(
index d90246a556aeaf89ed792386095331323bcd1380..03479303766d03ec3d6789dd94d96d035e5665b0 100644 (file)
@@ -41,6 +41,7 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis
 
   const fileElement = document.createElement("woltlab-core-file");
   fileElement.dataset.filename = file.name;
+  fileElement.dataset.fileSize = file.size.toString();
 
   const event = new CustomEvent<WoltlabCoreFileElement>("uploadStart", { detail: fileElement });
   element.dispatchEvent(event);
@@ -49,12 +50,12 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis
   if (!response.ok) {
     const validationError = response.error.getValidationError();
     if (validationError === undefined) {
-      fileElement.uploadFailed();
+      fileElement.uploadFailed(undefined);
 
       throw response.error;
     }
 
-    console.log(validationError);
+    fileElement.uploadFailed(validationError);
     return undefined;
   }
 
@@ -73,7 +74,7 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis
 
     const response = await uploadChunk(identifier, i, checksum, chunk);
     if (!response.ok) {
-      fileElement.uploadFailed();
+      fileElement.uploadFailed(undefined);
 
       throw response.error;
     }
index 200893b4b61dc791674325cca3344fa41997805f..c8dbeb48e1a81b82904835a83c07df458662e8d4 100644 (file)
@@ -1,3 +1,4 @@
+import type { ValidationError } from "WoltLabSuite/Core/Api/Error";
 import { getExtensionByMimeType, getIconNameByFilename } from "WoltLabSuite/Core/FileUtil";
 
 const enum State {
@@ -35,9 +36,11 @@ export class WoltlabCoreFileElement extends HTMLElement {
   #data: Record<string, unknown> | undefined = undefined;
   #filename: string = "";
   #fileId: number | undefined = undefined;
+  #fileSize: number | undefined = undefined;
   #link: string | undefined = undefined;
   #mimeType: string | undefined = undefined;
   #state: State = State.Initial;
+  #validationError: ValidationError | undefined = undefined;
   readonly #thumbnails: Thumbnail[] = [];
 
   #readyReject!: () => void;
@@ -70,9 +73,12 @@ export class WoltlabCoreFileElement extends HTMLElement {
     // Files that exist at page load have a valid file id, otherwise a new
     // file element can only be the result of an upload attempt.
     if (this.#fileId === undefined) {
-      this.#filename = this.dataset.filename || "bogus.bin";
+      this.#filename = this.dataset.filename || "unknown.bin";
       delete this.dataset.filename;
 
+      this.#fileSize = parseInt(this.dataset.fileSize || "0");
+      delete this.dataset.fileSize;
+
       this.#mimeType = this.dataset.mimeType || "application/octet-stream";
       delete this.dataset.mimeType;
 
@@ -129,7 +135,7 @@ export class WoltlabCoreFileElement extends HTMLElement {
         break;
 
       case State.Failed:
-        this.#replaceWithIcon("times");
+        this.#replaceWithIcon("triangle-exclamation");
         break;
 
       default:
@@ -223,6 +229,10 @@ export class WoltlabCoreFileElement extends HTMLElement {
     return this.#filename;
   }
 
+  get fileSize(): number | undefined {
+    return this.#fileSize;
+  }
+
   get mimeType(): string | undefined {
     return this.#mimeType;
   }
@@ -252,12 +262,13 @@ export class WoltlabCoreFileElement extends HTMLElement {
     }
   }
 
-  uploadFailed(): void {
+  uploadFailed(validationError: ValidationError | undefined): void {
     if (this.#state !== State.Uploading) {
       return;
     }
 
     this.#state = State.Failed;
+    this.#validationError = validationError;
     this.#rebuildElement();
 
     this.#readyReject();
@@ -319,6 +330,10 @@ export class WoltlabCoreFileElement extends HTMLElement {
   get ready(): Promise<void> {
     return this.#readyPromise;
   }
+
+  get validationError(): ValidationError | undefined {
+    return this.#validationError;
+  }
 }
 
 export default WoltlabCoreFileElement;
index 640e2de721b3d2bbf57974943a92c457b646609b..9eddeae1749a11c906a020bfef41e9c355dc9705 100644 (file)
@@ -9,7 +9,7 @@
 define(["require", "exports"], function (require, exports) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.ApiError = void 0;
+    exports.ValidationError = exports.ApiError = void 0;
     class ApiError {
         type;
         code;
@@ -41,4 +41,5 @@ define(["require", "exports"], function (require, exports) {
             this.param = param;
         }
     }
+    exports.ValidationError = ValidationError;
 });
index e6c59bf81522aea1b4161031655c72a92f8153fb..4c341005533d7cecb19f690743090afc10a17243 100644 (file)
@@ -1,4 +1,4 @@
-define(["require", "exports", "WoltLabSuite/Core/Api/Files/DeleteFile", "../Ckeditor/Event", "../File/woltlab-core-file"], function (require, exports, DeleteFile_1, Event_1) {
+define(["require", "exports", "WoltLabSuite/Core/Api/Files/DeleteFile", "../Ckeditor/Event", "WoltLabSuite/Core/FileUtil", "../File/woltlab-core-file"], function (require, exports, DeleteFile_1, Event_1, FileUtil_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.setup = void 0;
@@ -10,10 +10,14 @@ define(["require", "exports", "WoltLabSuite/Core/Api/Files/DeleteFile", "../Cked
         fileWrapper.append(file);
         const filename = document.createElement("div");
         filename.classList.add("attachment__item__filename");
-        filename.textContent = file.filename;
-        element.append(fileWrapper, filename);
+        filename.textContent = file.filename || file.dataset.filename;
+        const fileSize = document.createElement("div");
+        fileSize.classList.add("attachment__item__fileSize");
+        fileSize.textContent = (0, FileUtil_1.formatFilesize)(file.fileSize || parseInt(file.dataset.fileSize));
+        element.append(fileWrapper, filename, fileSize);
         fileList.append(element);
-        void file.ready.then(() => {
+        void file.ready
+            .then(() => {
             const data = file.data;
             if (data === undefined) {
                 // TODO: error handling
@@ -38,6 +42,14 @@ define(["require", "exports", "WoltLabSuite/Core/Api/Files/DeleteFile", "../Cked
                 }
             }
             element.append(buttonList);
+        })
+            .catch(() => {
+            if (file.validationError === undefined) {
+                return;
+            }
+            // TODO: Add a proper error message, this is for development purposes only.
+            element.append(JSON.stringify(file.validationError));
+            element.classList.add("attachment__item--error");
         });
     }
     function getDeleteAttachButton(fileId, attachmentId, editorId, element) {
index d3baeb81a640900aaff485e01891ffe23390bc19..2c1395fd687ca8f3d1add74df6ddceebc6a4f914 100644 (file)
@@ -8,16 +8,17 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
         const fileHash = await getSha256Hash(await file.arrayBuffer());
         const fileElement = document.createElement("woltlab-core-file");
         fileElement.dataset.filename = file.name;
+        fileElement.dataset.fileSize = file.size.toString();
         const event = new CustomEvent("uploadStart", { detail: fileElement });
         element.dispatchEvent(event);
         const response = await (0, Upload_1.upload)(file.name, file.size, fileHash, typeName, element.dataset.context || "");
         if (!response.ok) {
             const validationError = response.error.getValidationError();
             if (validationError === undefined) {
-                fileElement.uploadFailed();
+                fileElement.uploadFailed(undefined);
                 throw response.error;
             }
-            console.log(validationError);
+            fileElement.uploadFailed(validationError);
             return undefined;
         }
         const { identifier, numberOfChunks } = response.value;
@@ -30,7 +31,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
             const checksum = await getSha256Hash(await chunk.arrayBuffer());
             const response = await (0, Chunk_1.uploadChunk)(identifier, i, checksum, chunk);
             if (!response.ok) {
-                fileElement.uploadFailed();
+                fileElement.uploadFailed(undefined);
                 throw response.error;
             }
             await chunkUploadCompleted(fileElement, response.value);
index 21a4cb7fb9123b6f6df5741722396e6889bfe24e..87a725759bfef5d3f247da3299ca6f307eeaca33 100644 (file)
@@ -21,9 +21,11 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
         #data = undefined;
         #filename = "";
         #fileId = undefined;
+        #fileSize = undefined;
         #link = undefined;
         #mimeType = undefined;
         #state = 0 /* State.Initial */;
+        #validationError = undefined;
         #thumbnails = [];
         #readyReject;
         #readyResolve;
@@ -49,8 +51,10 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
             // Files that exist at page load have a valid file id, otherwise a new
             // file element can only be the result of an upload attempt.
             if (this.#fileId === undefined) {
-                this.#filename = this.dataset.filename || "bogus.bin";
+                this.#filename = this.dataset.filename || "unknown.bin";
                 delete this.dataset.filename;
+                this.#fileSize = parseInt(this.dataset.fileSize || "0");
+                delete this.dataset.fileSize;
                 this.#mimeType = this.dataset.mimeType || "application/octet-stream";
                 delete this.dataset.mimeType;
                 const fileId = parseInt(this.getAttribute("file-id") || "0");
@@ -97,7 +101,7 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
                     }
                     break;
                 case 4 /* State.Failed */:
-                    this.#replaceWithIcon("times");
+                    this.#replaceWithIcon("triangle-exclamation");
                     break;
                 default:
                     throw new Error("Unreachable", {
@@ -174,6 +178,9 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
         get filename() {
             return this.#filename;
         }
+        get fileSize() {
+            return this.#fileSize;
+        }
         get mimeType() {
             return this.#mimeType;
         }
@@ -197,11 +204,12 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
                     return false;
             }
         }
-        uploadFailed() {
+        uploadFailed(validationError) {
             if (this.#state !== 1 /* State.Uploading */) {
                 return;
             }
             this.#state = 4 /* State.Failed */;
+            this.#validationError = validationError;
             this.#rebuildElement();
             this.#readyReject();
         }
@@ -246,6 +254,9 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
         get ready() {
             return this.#readyPromise;
         }
+        get validationError() {
+            return this.#validationError;
+        }
     }
     exports.WoltlabCoreFileElement = WoltlabCoreFileElement;
     exports.default = WoltlabCoreFileElement;
index c77b2b02620ad9dcae34b8dbff55631e4719f48c..a6df5d4684a496d2aa5a663386feda80346f44fc 100644 (file)
@@ -148,6 +148,7 @@ class File extends DatabaseObject
                 <woltlab-core-file
                     file-id="%d"
                     data-filename="%s"
+                    data-file-size="%s"
                     data-mime-type="%s"
                     data-thumbnails="%s"
                     data-meta-data="%s"
@@ -156,6 +157,7 @@ class File extends DatabaseObject
                 EOT,
             $this->fileID,
             StringUtil::encodeHTML($this->filename),
+            $this->fileSize,
             StringUtil::encodeHTML($this->mimeType),
             StringUtil::encodeHTML(\json_encode($thumbnails)),
             StringUtil::encodeHTML(\json_encode($metaData)),
index c7ed608a63a5076d5572c862c8afe5f47c54fcd3..8ee0de940d2117bd856203ef75fdadfdc94a8b28 100644 (file)
@@ -139,7 +139,7 @@ html[data-color-scheme="dark"] {
 .attachment__list {
        display: grid;
        gap: 10px;
-       grid-auto-flow: column;
+       grid-auto-flow: row;
        // TODO: use container queries to make this more dynamic?
        grid-template-columns: repeat(3, 1fr);
 }
@@ -153,16 +153,27 @@ html[data-color-scheme="dark"] {
        border-radius: var(--wcfBorderRadius);
        box-shadow: var(--wcfBoxShadowCard);
        display: grid;
-       gap: 10px;
        grid-template-areas:
                "file filename"
+               "file fileSize"
                "file buttons";
-       grid-template-columns: 64px auto;
+       grid-template-columns: 80px auto;
        padding: 10px;
 }
 
+.attachment__item--error {
+       border-color: var(--wcfStatusErrorBorder);
+}
+
+.attachment__item--error .attachment__item__file {
+       color: var(--wcfStatusErrorText);
+}
+
 .attachment__item__file {
+       display: flex;
        grid-area: file;
+       justify-content: center;
+       margin-right: 10px;
 }
 
 .attachment__item__filename {
@@ -173,7 +184,17 @@ html[data-color-scheme="dark"] {
        white-space: nowrap;
 }
 
+.attachment__item__fileSize {
+       color: var(--wcfContentDimmedText);
+       font-size: 12px;
+       grid-area: fileSize;
+       overflow: hidden;
+       text-overflow: ellipsis;
+       white-space: nowrap;
+}
+
 .attachment__item__buttons {
+       align-items: end;
        column-gap: 5px;
        display: flex;
        grid-area: buttons;