diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 361752d5f2..aa5da8cf2d 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -7,5 +7,5 @@ import("./not-found"); import("./screencast"); import("./beta-badges"); import("./detail-page-title"); -import("./notification-stack"); +import("./toast-stack"); import("./verified-badge"); diff --git a/frontend/src/components/notification-stack.ts b/frontend/src/components/toast-stack.ts similarity index 94% rename from frontend/src/components/notification-stack.ts rename to frontend/src/components/toast-stack.ts index 731b0e6c99..54e2f3da2c 100644 --- a/frontend/src/components/notification-stack.ts +++ b/frontend/src/components/toast-stack.ts @@ -19,7 +19,7 @@ import { import { tw } from "@/utils/tailwind"; /** - * Global notifications to stack in bottom end of the viewport. + * Global toast notifications to stack in bottom end of the viewport. * * This component reuses `.sl-toast-stack` styles instead of using Shoelace's * `SlAlert.toast()` to reactively render the toast state of a notification @@ -27,7 +27,7 @@ import { tw } from "@/utils/tailwind"; * * @fires btrix-remove-notification */ -@customElement("btrix-notification-stack") +@customElement("btrix-toast-stack") export class NotificationStack extends BtrixElement { @consume({ context: notificationsContext, subscribe: true }) @state() @@ -57,7 +57,7 @@ export class NotificationStack extends BtrixElement {
${repeat(this.notifications, ({ id }) => id, this.renderNotification)} @@ -72,7 +72,6 @@ export class NotificationStack extends BtrixElement { return html`) => { e.stopPropagation(); @@ -88,8 +92,4 @@ export class NotificationsContextController implements ReactiveController { console.debug("no notification with id"), e.detail.id; } }; - - private addNotification(notification: AppNotification) { - this.#context.setValue([notification, ...this.#context.value]); - } } diff --git a/frontend/src/context/org-uploads/OrgUploadsContextController.ts b/frontend/src/context/org-uploads/OrgUploadsContextController.ts new file mode 100644 index 0000000000..501d4ea992 --- /dev/null +++ b/frontend/src/context/org-uploads/OrgUploadsContextController.ts @@ -0,0 +1,196 @@ +import { ContextProvider } from "@lit/context"; +import { msg } from "@lit/localize"; +import { type ReactiveController } from "lit"; + +import { + orgUploadsContext, + orgUploadsInitialValue, + type OrgUploadsContext, +} from "./org-uploads"; +import type { + OrgUpload, + OrgUploadCancelRemoveEventDetail, + OrgUploadEventDetail, +} from "./types"; + +import type { BtrixElement } from "@/classes/BtrixElement"; +import { AbortReason } from "@/controllers/api"; + +/** + * Provides data on org uploads to subscribed descendents of a component. + * + * @example Usage: + * ```ts + * class Component extends BtrixElement { + * readonly [orgUploadsContextKey] = new OrgUploadsContextController(this); + * } + * ``` + */ +export class OrgUploadsContextController implements ReactiveController { + readonly #host: BtrixElement; + readonly #context: ContextProvider<{ __context__: OrgUploadsContext }>; + readonly #uploadRequests = new Map(); + + static uploadsByStatus(context: OrgUploadsContext) { + const uploads = Object.entries(context); + const all: (OrgUpload & { uploadId: string })[] = []; + const canceled: (OrgUpload & { uploadId: string })[] = []; + const inProgress: (OrgUpload & { uploadId: string })[] = []; + const uploaded: (OrgUpload & { uploadId: string })[] = []; + + uploads.forEach(([uploadId, upload]) => { + const item = { uploadId, ...upload }; + + all.push(item); + + if (upload.canceled) { + canceled.push(item); + } else if (upload.itemId) { + uploaded.push(item); + } else { + inProgress.push(item); + } + }); + + return { all, canceled, inProgress, uploaded }; + } + + constructor(host: BtrixElement) { + this.#host = host; + this.#context = new ContextProvider(this.#host, { + context: orgUploadsContext, + initialValue: orgUploadsInitialValue, + }); + + host.addController(this); + } + + hostConnected(): void { + this.#host.addEventListener("btrix-org-upload", this.onUpload); + this.#host.addEventListener("btrix-org-upload-cancel", this.onCancel); + this.#host.addEventListener("btrix-org-upload-remove", this.onRemove); + } + hostDisconnected(): void { + this.#host.removeEventListener("btrix-org-upload", this.onUpload); + this.#host.removeEventListener("btrix-org-upload-cancel", this.onCancel); + this.#host.removeEventListener("btrix-org-upload-remove", this.onRemove); + } + + setUpload(uploadId: string, upload: Partial) { + this.#context.setValue({ + ...this.#context.value, + [uploadId]: { + ...this.#context.value[uploadId], + ...upload, + }, + }); + } + + private readonly onUpload = async (e: CustomEvent) => { + e.stopPropagation(); + + const { apiPath, file, itemName, uploadId: eventUploadId } = e.detail; + + if (eventUploadId && this.#uploadRequests.has(eventUploadId)) { + this.abort(eventUploadId); + } + + const onUploadProgress = (e: ProgressEvent) => { + this.setUpload(uploadId, { + itemName, + filename: file.name, + loaded: e.loaded, + total: e.total, + }); + }; + + const uploadId = eventUploadId ?? window.crypto.randomUUID(); + const uploadComplete = this.#host.api.upload( + apiPath, + file, + undefined, + onUploadProgress, + ); + const request = uploadComplete.request; + + if (request) { + this.#uploadRequests.set(uploadId, request); + } + + try { + const { id } = await uploadComplete; + + this.setUpload(uploadId, { + ...this.#context.value[uploadId], + itemId: id, + }); + + this.setUpload(uploadId, { + itemId: id, + }); + } catch (err) { + console.debug(err); + + if (err === AbortReason.UserCancel) { + console.debug("Upload aborted to user cancel"); + + this.setUpload(uploadId, { + canceled: true, + }); + } else { + let message = msg("Sorry, couldn't upload file at this time."); + console.debug(err); + if (err === AbortReason.QuotaReached) { + message = msg( + "Your org does not have enough storage to upload this file.", + ); + this.#host.dispatchEvent( + new CustomEvent("btrix-storage-quota-update", { + detail: { reached: true }, + bubbles: true, + }), + ); + } + this.#host.notify.toast({ + message: message, + variant: "danger", + icon: "exclamation-octagon", + id: "file-upload-status", + }); + } + } + + this.#uploadRequests.delete(uploadId); + }; + + private readonly onCancel = ( + e: CustomEvent, + ) => { + e.stopPropagation(); + + e.detail.uploadIds.forEach(this.abort); + }; + + private readonly onRemove = ( + e: CustomEvent, + ) => { + e.stopPropagation(); + + e.detail.uploadIds.forEach(this.remove); + }; + + private readonly abort = (uploadId: string) => { + const request = this.#uploadRequests.get(uploadId); + + if (request) { + request.abort(); + } else { + console.debug("no request for uploadId", uploadId); + } + }; + + private readonly remove = (uploadId: string) => { + const { [uploadId]: _canceled, ...context } = this.#context.value; + this.#context.setValue(context); + }; +} diff --git a/frontend/src/context/org-uploads/index.ts b/frontend/src/context/org-uploads/index.ts new file mode 100644 index 0000000000..0046026b03 --- /dev/null +++ b/frontend/src/context/org-uploads/index.ts @@ -0,0 +1,5 @@ +import { orgUploadsContext, type OrgUploadsContext } from "./org-uploads"; + +export type { OrgUploadsContext }; + +export default orgUploadsContext; diff --git a/frontend/src/context/org-uploads/org-uploads.ts b/frontend/src/context/org-uploads/org-uploads.ts new file mode 100644 index 0000000000..df6d46a9bc --- /dev/null +++ b/frontend/src/context/org-uploads/org-uploads.ts @@ -0,0 +1,10 @@ +import { createContext } from "@lit/context"; + +import { orgUploadsContextKey, type OrgUpload } from "./types"; + +export type OrgUploadsContext = Record; + +export const orgUploadsInitialValue = {} satisfies OrgUploadsContext; + +export const orgUploadsContext = + createContext(orgUploadsContextKey); diff --git a/frontend/src/context/org-uploads/types.ts b/frontend/src/context/org-uploads/types.ts new file mode 100644 index 0000000000..accb6d437f --- /dev/null +++ b/frontend/src/context/org-uploads/types.ts @@ -0,0 +1,29 @@ +export const orgUploadsContextKey = Symbol("org-uploads"); + +export type OrgUpload = { + itemId?: string; + canceled?: boolean; + itemName: string; + filename: string; + loaded: number; + total: number; +}; + +export type OrgUploadEventDetail = { + uploadId?: string; + itemName: string; + apiPath: string; + file: File; +}; + +export type OrgUploadCancelRemoveEventDetail = { + uploadIds: string[]; +}; + +declare global { + interface GlobalEventHandlersEventMap { + "btrix-org-upload": CustomEvent; + "btrix-org-upload-cancel": CustomEvent; + "btrix-org-upload-remove": CustomEvent; + } +} diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts index ab50bbc6a8..3626e003d4 100644 --- a/frontend/src/controllers/api.ts +++ b/frontend/src/controllers/api.ts @@ -4,6 +4,7 @@ import throttle from "lodash/fp/throttle"; import { APIError } from "@/utils/api"; import AuthService from "@/utils/AuthService"; +import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; import appState from "@/utils/state"; export type QuotaUpdateDetail = { reached: boolean }; @@ -20,6 +21,12 @@ export enum AbortReason { RequestTimeout = "request-timeout", } +type UploadResponseBody = { + id: string; + added: boolean; + storageQuotaReached: boolean; +}; + /** * Utilities for interacting with the Browsertrix backend API * @@ -37,8 +44,6 @@ export enum AbortReason { export class APIController implements ReactiveController { host: ReactiveControllerHost & EventTarget; - uploadProgress = 0; - private uploadRequest: XMLHttpRequest | null = null; constructor(host: APIController["host"]) { @@ -52,7 +57,10 @@ export class APIController implements ReactiveController { this.cancelUpload(); } - async fetch(path: string, options?: RequestInit): Promise { + async fetch( + path: string, + options?: RequestInit, + ): Promise { const auth = appState.auth; if (!auth) throw new Error("auth not in state"); @@ -68,9 +76,122 @@ export class APIController implements ReactiveController { }); if (resp.ok) { - const body = await resp.json(); + const body = (await resp.json()) as NonNullable; + return this.handleOk>(body); + } + + let errorDetail; + try { + errorDetail = (await resp.json()).detail; + } catch { + /* empty */ + } + + const error = this.handleError(resp.status, errorDetail); + throw new APIError(error); + } + + upload( + path: string, + file: File, + abortSignal?: AbortSignal, + /** + * Custom XMLHttpRequest['upload'] loadstart and progress event callback, + * which need to be attached before `send`. + */ + uploadCallback?: (e: ProgressEvent) => void, + ): Promise & { request?: XMLHttpRequest } { + const auth = appState.auth; + + if (!auth) throw new Error("auth not in state"); + + let request: XMLHttpRequest | undefined; + + const promise: Promise & { request?: XMLHttpRequest } = + new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(AbortReason.UserCancel); + } + const xhr = new XMLHttpRequest(); + + xhr.open("PUT", `/api${path}`); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + Object.entries(auth.headers).forEach(([k, v]) => { + xhr.setRequestHeader(k, v); + }); + xhr.addEventListener("load", () => { + if (xhr.status === 200) { + resolve( + JSON.parse(xhr.response as string) as { + id: string; + added: boolean; + storageQuotaReached: boolean; + }, + ); + } + if (xhr.status >= 401) { + reject( + new APIError( + this.handleError( + xhr.status, + xhr.responseType === "json" && xhr.response, + ), + ), + ); + } + }); + xhr.addEventListener("error", () => { + reject(AbortReason.NetworkError); + }); + xhr.addEventListener("timeout", () => { + reject(AbortReason.RequestTimeout); + }); + xhr.addEventListener("abort", () => { + reject(AbortReason.UserCancel); + }); + + if (uploadCallback) { + const onUploadProgress = throttle( + file.size > BYTES_PER_GB + ? 800 + : file.size > BYTES_PER_MB + ? 400 + : 200, + )(uploadCallback); + + xhr.upload.addEventListener("loadstart", onUploadProgress); + xhr.upload.addEventListener("progress", onUploadProgress); + xhr.upload.addEventListener("load", (e: ProgressEvent) => { + onUploadProgress.cancel(); + uploadCallback(e); + }); + xhr.upload.addEventListener("abort", () => onUploadProgress.cancel()); + xhr.upload.addEventListener("error", () => onUploadProgress.cancel()); + xhr.upload.addEventListener("timeout", () => + onUploadProgress.cancel(), + ); + } + + xhr.send(file); + + abortSignal?.addEventListener("abort", () => { + xhr.abort(); + reject(AbortReason.UserCancel); + }); + + request = xhr; + }); + + promise.request = request; + this.uploadRequest = request ?? null; + + return promise; + } + + private readonly handleOk = (body: T) => { + if ("storageQuotaReached" in body) { const storageQuotaReached = body.storageQuotaReached; - const executionMinutesQuotaReached = body.execMinutesQuotaReached; + if (typeof storageQuotaReached === "boolean") { if (storageQuotaReached !== appState.org?.storageQuotaReached) { this.host.dispatchEvent( @@ -82,6 +203,11 @@ export class APIController implements ReactiveController { ); } } + } + + if ("execMinutesQuotaReached" in body) { + const executionMinutesQuotaReached = body.execMinutesQuotaReached; + if (typeof executionMinutesQuotaReached === "boolean") { if ( executionMinutesQuotaReached != appState.org?.execMinutesQuotaReached @@ -98,21 +224,19 @@ export class APIController implements ReactiveController { ); } } - - return body as T; } - let errorDetail; - let errorDetails = null; - try { - errorDetail = (await resp.json()).detail; - } catch { - /* empty */ - } + return body; + }; + private readonly handleError = ( + status: Response["status"], + errorDetail?: unknown, + ) => { + let errorDetails = null; let errorMessage: string = msg("Unknown API error"); - switch (resp.status) { + switch (status) { case 401: { this.host.dispatchEvent(AuthService.createNeedLoginEvent()); errorMessage = msg("Need login"); @@ -169,96 +293,18 @@ export class APIController implements ReactiveController { } } - throw new APIError({ + return { message: errorMessage, - status: resp.status, + status: status, details: errorDetails, - errorCode: errorDetail, - }); - } - - async upload( - path: string, - file: File, - abortSignal?: AbortSignal, - ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { - const auth = appState.auth; - - if (!auth) throw new Error("auth not in state"); - - // TODO handle multiple uploads - if (this.uploadRequest) { - console.debug("upload request exists"); - this.cancelUpload(); - } - - return new Promise((resolve, reject) => { - if (abortSignal?.aborted) { - reject(AbortReason.UserCancel); - } - const xhr = new XMLHttpRequest(); - - xhr.open("PUT", `/api${path}`); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - Object.entries(auth.headers).forEach(([k, v]) => { - xhr.setRequestHeader(k, v); - }); - xhr.addEventListener("load", () => { - if (xhr.status === 200) { - resolve( - JSON.parse(xhr.response as string) as { - id: string; - added: boolean; - storageQuotaReached: boolean; - }, - ); - } - if (xhr.status === 403) { - reject(AbortReason.QuotaReached); - } - if (xhr.status >= 404) { - reject( - new APIError({ - message: xhr.statusText, - status: xhr.status, - }), - ); - } - }); - xhr.addEventListener("error", () => { - reject(AbortReason.NetworkError); - }); - xhr.addEventListener("timeout", () => { - reject(AbortReason.RequestTimeout); - }); - xhr.addEventListener("abort", () => { - reject(AbortReason.UserCancel); - }); - xhr.upload.addEventListener("progress", this.onUploadProgress); - - xhr.send(file); - - abortSignal?.addEventListener("abort", () => { - xhr.abort(); - reject(AbortReason.UserCancel); - }); - - this.uploadRequest = xhr; - }); - } - - readonly onUploadProgress = throttle(100)((e: ProgressEvent) => { - this.uploadProgress = (e.loaded / e.total) * 100; - - this.host.requestUpdate(); - }); + errorCode: errorDetail as APIError["errorCode"], + }; + }; private cancelUpload() { if (this.uploadRequest) { this.uploadRequest.abort(); this.uploadRequest = null; } - - this.onUploadProgress.cancel(); } } diff --git a/frontend/src/controllers/notify.ts b/frontend/src/controllers/notify.ts index 3e370eab52..f5788eb2bd 100644 --- a/frontend/src/controllers/notify.ts +++ b/frontend/src/controllers/notify.ts @@ -40,7 +40,8 @@ export interface NotifyEventMap { const NOTIFY_EVENT_NAME: keyof NotifyEventMap = "btrix-notify"; -const iconMap = { +export const notifyIconFor = { + neutral: "info-circle", info: "info-circle", primary: "info-circle", success: "check2-circle", @@ -73,7 +74,7 @@ export class NotifyController implements ReactiveController { ...detail, variant, type: "toast", - icon: detail.icon ?? iconMap[variant], + icon: detail.icon ?? notifyIconFor[variant], duration: detail.duration ?? (variant === "danger" ? 10000 : 5000), }, }), diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts index 1ea724839d..8b14d03ef8 100644 --- a/frontend/src/features/archived-items/file-uploader.ts +++ b/frontend/src/features/archived-items/file-uploader.ts @@ -1,20 +1,28 @@ +import { ContextConsumer } from "@lit/context"; import { localized, msg } from "@lit/localize"; import type { SlButton } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html, type PropertyValues } from "lit"; -import { customElement, property, queryAsync, state } from "lit/decorators.js"; +import { + customElement, + property, + query, + queryAsync, + state, +} from "lit/decorators.js"; import { when } from "lit/directives/when.js"; -import throttle from "lodash/fp/throttle"; import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; import type { FileRemoveEvent } from "@/components/ui/file-list"; import type { BtrixFileChangeEvent } from "@/components/ui/file-list/events"; import type { Tags } from "@/components/ui/tag-input"; +import orgUploadsContext from "@/context/org-uploads"; +import type { OrgUploadEventDetail } from "@/context/org-uploads/types"; import type { BtrixTagsChangeEvent } from "@/features/archived-items/item-tags-input"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; import { DESCRIPTION_MAX_LENGTH } from "@/types/archivedItems"; -import { APIError } from "@/utils/api"; import { maxLengthValidator } from "@/utils/form"; export type FileUploaderRequestCloseEvent = CustomEvent>; @@ -27,42 +35,40 @@ export type FileUploaderUploadedEvent = CustomEvent<{ fileSize: number; }>; -enum AbortReason { - UserCancel = "user-canceled", - QuotaReached = "storage_quota_reached", -} - /** * Usage: * ```ts * * ``` * - * @TODO Refactor to use this.api.upload - * - * @event request-close - * @event upload-start - * @event uploaded + * @event request-CollectionSavedEvent */ @customElement("btrix-file-uploader") @localized() export class FileUploader extends BtrixElement { + readonly #orgUploads = new ContextConsumer(this, { + context: orgUploadsContext, + subscribe: true, + callback: (value) => { + if (this.uploadingId && this.uploadingId in value) { + // Finish with dialog once upload has begun + this.uploadingId = undefined; + this.close(); + } + }, + }); + @property({ type: Boolean }) open = false; @state() - private isUploading = false; + private uploadingId?: string; @state() private isDialogVisible = false; - @state() - private isConfirmingCancel = false; - @state() private collectionIds: string[] = []; @@ -72,8 +78,8 @@ export class FileUploader extends BtrixElement { @state() private fileList: File[] = []; - @state() - private progress = 0; + @query("btrix-dialog") + private readonly dialog?: Dialog | null; @queryAsync("#fileUploadForm") private readonly form!: Promise; @@ -82,9 +88,6 @@ export class FileUploader extends BtrixElement { DESCRIPTION_MAX_LENGTH, ); - // Use to cancel requests - private uploadRequest: XMLHttpRequest | null = null; - willUpdate(changedProperties: PropertyValues & Map) { if (changedProperties.has("open") && this.open) { if (changedProperties.get("open") === undefined) { @@ -97,30 +100,24 @@ export class FileUploader extends BtrixElement { } render() { - const uploadInProgress = this.isUploading || this.isConfirmingCancel; return html` (this.isDialogVisible = true)} @sl-after-hide=${() => (this.isDialogVisible = false)} - @sl-request-close=${this.tryRequestClose} - style="--width: ${uploadInProgress ? 40 : 60}rem;" > - ${when(this.isDialogVisible, () => - uploadInProgress ? this.renderUploading() : this.renderForm(), - )} + ${when(this.isDialogVisible, () => this.renderForm())} `; } private renderForm() { + const loading = Boolean(this.uploadingId); + return html` -
+

@@ -152,8 +149,8 @@ export class FileUploader extends BtrixElement { { // Using submit method instead of type="submit" fixes // incorrect getRootNode in Chrome @@ -232,77 +229,7 @@ export class FileUploader extends BtrixElement { `; } - private renderUploading() { - if (this.isConfirmingCancel) { - return html` -
-

- ${msg("Cancel this upload?")} -

-
- - ${Array.from(this.fileList).map( - (file) => - html``, - )} - -
-
- (this.isConfirmingCancel = false)} - > - ${msg("No")} - - { - this.cancelUpload(); - this.requestClose(); - }} - > - ${msg("Yes")} - -
-
- `; - } - return html` -
-

- ${msg("Uploading...")} -

-

- ${msg("Keep this window open until your upload finishes.")} -

-
- - ${Array.from(this.fileList).map( - (file) => - html``, - )} - -
-
-
- this.tryRequestClose()}> - ${msg("Cancel")} - -
- `; - } - private readonly handleRemoveFile = (e: FileRemoveEvent) => { - this.cancelUpload(); const idx = this.fileList.indexOf(e.detail.item as File); if (idx === -1) return; this.fileList = [ @@ -311,33 +238,15 @@ export class FileUploader extends BtrixElement { ]; }; - private cancelUpload() { - this.uploadRequest?.abort(); - this.onUploadProgress.cancel(); - } - private resetState() { this.fileList = []; this.tagsToSave = []; - this.isUploading = false; - this.isConfirmingCancel = false; - this.progress = 0; - } - - private tryRequestClose(e?: CustomEvent) { - if (this.isUploading) { - e?.preventDefault(); - this.isConfirmingCancel = true; - } else { - this.requestClose(); - } + this.uploadingId = undefined; } - private requestClose() { - this.dispatchEvent( - new CustomEvent("request-close") as FileUploaderRequestCloseEvent, - ); - } + private readonly close = () => { + void this.dialog?.hide(); + }; private async onSubmit(e: SubmitEvent) { e.preventDefault(); @@ -348,148 +257,32 @@ export class FileUploader extends BtrixElement { const file = this.fileList[0] as File | undefined; if (!file) return; - this.isUploading = true; + this.uploadingId = window.crypto.randomUUID(); + + const { name, description } = serialize(formEl); + const query = queryString.stringify({ + filename: file.name, + name, + description: description, + collections: this.collectionIds, + tags: this.tagsToSave, + }); + + // Dispatch information for upload to be handled on the org level this.dispatchEvent( - new CustomEvent("upload-start", { + new CustomEvent("btrix-org-upload", { detail: { - fileName: file.name, - fileSize: file.size, + uploadId: this.uploadingId, + itemName: name as string, + apiPath: `/orgs/${this.orgId}/uploads/stream?${query}`, + file, }, - }) as FileUploaderUploadedEvent, + bubbles: true, + composed: true, + }), ); - - const { name, description } = serialize(formEl); - try { - const query = queryString.stringify({ - filename: file.name, - name, - description: description, - collections: this.collectionIds, - tags: this.tagsToSave, - }); - - const data = await this.upload( - `orgs/${this.orgId}/uploads/stream?${query}`, - file, - ); - - this.uploadRequest = null; - - // Dispatch event here because we're not using apiFetch() for uploads - if (data.storageQuotaReached) { - this.dispatchEvent( - new CustomEvent("btrix-storage-quota-update", { - detail: { reached: true }, - bubbles: true, - }), - ); - } - - if (data.id && data.added) { - this.dispatchEvent( - new CustomEvent("uploaded", { - detail: { - fileName: file.name, - fileSize: file.size, - }, - }) as FileUploaderUploadedEvent, - ); - this.requestClose(); - this.notify.toast({ - message: msg( - html`Successfully uploaded ${name}.
- View Item `, - ), - variant: "success", - icon: "check2-circle", - id: "file-upload-status", - }); - } else { - throw data; - } - } catch (err) { - if (err === AbortReason.UserCancel) { - console.debug("Upload aborted to user cancel"); - } else { - let message = msg("Sorry, couldn't upload file at this time."); - console.debug(err); - if (err === AbortReason.QuotaReached) { - message = msg( - "Your org does not have enough storage to upload this file.", - ); - this.dispatchEvent( - new CustomEvent("btrix-storage-quota-update", { - detail: { reached: true }, - bubbles: true, - }), - ); - } - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "file-upload-status", - }); - } - } - this.isUploading = false; } - // Use XHR to get upload progress - private async upload( - url: string, - file: File, - ): Promise<{ id: string; added: boolean; storageQuotaReached: boolean }> { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.open("PUT", `/api/${url}`); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - Object.entries(this.authState!.headers).forEach(([k, v]) => { - xhr.setRequestHeader(k, v); - }); - xhr.addEventListener("load", () => { - if (xhr.status === 200) { - resolve( - JSON.parse(xhr.response as string) as { - id: string; - added: boolean; - storageQuotaReached: boolean; - }, - ); - } - if (xhr.status === 403) { - reject(AbortReason.QuotaReached); - } - }); - xhr.addEventListener("error", () => { - reject( - new APIError({ - message: xhr.statusText, - status: xhr.status, - }), - ); - }); - xhr.addEventListener("abort", () => { - reject(AbortReason.UserCancel); - }); - xhr.upload.addEventListener("progress", this.onUploadProgress); - - xhr.send(file); - - this.uploadRequest = xhr; - }); - } - - private readonly onUploadProgress = throttle(100)((e: ProgressEvent) => { - this.progress = (e.loaded / e.total) * 100; - }); - private async checkFormValidity(formEl: HTMLFormElement) { await this.updateComplete; return !formEl.querySelector("[data-invalid]"); diff --git a/frontend/src/features/org/index.ts b/frontend/src/features/org/index.ts index e697ac84fe..2201f7d215 100644 --- a/frontend/src/features/org/index.ts +++ b/frontend/src/features/org/index.ts @@ -1,2 +1,3 @@ import("./org-status-banner"); +import("./org-uploads-dialog"); import("./usage-history-table"); diff --git a/frontend/src/features/org/org-uploads-dialog.ts b/frontend/src/features/org/org-uploads-dialog.ts new file mode 100644 index 0000000000..fc17797eb4 --- /dev/null +++ b/frontend/src/features/org/org-uploads-dialog.ts @@ -0,0 +1,426 @@ +import { ContextConsumer } from "@lit/context"; +import { localized, msg, str } from "@lit/localize"; +import type { SlAlert, SlIconButton } from "@shoelace-style/shoelace"; +import clsx from "clsx"; +import { html, nothing, type PropertyValues } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import sum from "lodash/fp/sum"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import notificationsContext from "@/context/notifications"; +import orgUploadsContext from "@/context/org-uploads"; +import { orgUploadsInitialValue } from "@/context/org-uploads/org-uploads"; +import { OrgUploadsContextController } from "@/context/org-uploads/OrgUploadsContextController"; +import type { + OrgUpload, + OrgUploadCancelRemoveEventDetail, +} from "@/context/org-uploads/types"; +import { notifyIconFor } from "@/controllers/notify"; +import { OrgTab } from "@/routes"; +import { stopProp } from "@/utils/events"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; + +/** + * Displays status of org-wide uploads in a non-modal dialog. + */ +@customElement("btrix-org-uploads-dialog") +@localized() +export class OrgUploadsDialog extends BtrixElement { + readonly #notifications = new ContextConsumer(this, { + context: notificationsContext, + subscribe: true, + callback: () => this.updateToastStackOffset(), + }); + + readonly #orgUploads = new ContextConsumer(this, { + context: orgUploadsContext, + subscribe: true, + callback: (value) => { + this.uploadsByStatus = OrgUploadsContextController.uploadsByStatus(value); + + if (this.cancelIds.length) { + // Remove IDs that have been removed + const cancelIds: string[] = []; + + this.cancelIds.forEach((id) => { + if (id in value && !value[id].canceled) { + cancelIds.push(id); + } + }); + + this.cancelIds = cancelIds; + } + }, + }); + + @state() + private uploadsByStatus = OrgUploadsContextController.uploadsByStatus( + orgUploadsInitialValue, + ); + + @state() + private open = false; + + @state() + private minimized = false; + + @state() + private cancelIds: string[] = []; + + @query("sl-alert") + private readonly alert?: SlAlert; + + get uploadIds() { + return this.uploadsByStatus.all.map(({ uploadId }) => uploadId); + } + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("beforeunload", this.onBeforeUnload); + } + + disconnectedCallback(): void { + window.removeEventListener("beforeunload", this.onBeforeUnload); + super.disconnectedCallback(); + } + + private readonly onBeforeUnload = (e: BeforeUnloadEvent) => { + if (this.open && this.uploadsByStatus.inProgress.length) { + e.preventDefault(); + } + }; + + protected updated(changedProperties: PropertyValues): void { + if ( + changedProperties.has("uploadsByStatus") || + changedProperties.has("minimized") + ) { + this.updateToastStackOffset(); + } + + if (changedProperties.has("uploadsByStatus")) { + void this.updateAlertVisibility(); + } + } + + private async updateAlertVisibility() { + await this.alert?.updateComplete; + + const uploadIds = this.uploadIds; + + if (uploadIds.length) { + this.open = true; + } else { + this.open = false; + } + } + + /** + * Offset app notification stack so that org uploads are always pinned to the bottom. + */ + private readonly updateToastStackOffset = () => { + const uploadIds = this.uploadIds; + + if (uploadIds.length && this.#notifications.value?.length) { + document.body.style.setProperty( + "--btrix-toast-stack-offset", + this.minimized + ? "5.375rem" + : `calc(${(1 + uploadIds.length) * 2.625}rem + 2.75rem)`, + ); + } else { + document.body.style.removeProperty("--btrix-toast-stack-offset"); + } + }; + + render() { + const { all, canceled, inProgress, uploaded } = this.uploadsByStatus; + const totalCount = all.length; + const inProgressCount = inProgress.length; + const canceledCount = canceled.length; + const uploadedCount = uploaded.length; + const allDone = inProgressCount === 0; + const allCanceled = canceledCount === totalCount; + + const sumLoaded = sum(all.map(({ loaded }) => loaded)); + const sumTotal = sum(all.map(({ total }) => total)); + + const number_of_files_in_progress = this.localize.number(inProgressCount); + const plural_of_files_in_progress = pluralOf("files", inProgressCount); + const number_of_uploaded_files = this.localize.number(uploadedCount); + const plural_of_uploaded_files = pluralOf("files", uploadedCount); + const plural_of_uploads = pluralOf("uploads", canceledCount); + + const variant = allDone && uploadedCount ? "success" : "primary"; + + return html` +
+ { + this.dispatchEvent( + new CustomEvent( + "btrix-org-upload-remove", + { + detail: { uploadIds: this.uploadIds }, + bubbles: true, + composed: true, + }, + ), + ); + }} + > + + +
+
+ ${allCanceled + ? msg(str`Canceled file ${plural_of_uploads}`) + : allDone + ? msg( + str`Uploaded ${number_of_uploaded_files} ${plural_of_uploaded_files}`, + ) + : msg( + str`Uploading ${number_of_files_in_progress} ${plural_of_files_in_progress}`, + )} +
+ + ${this.minimized && !allCanceled + ? html`` + : nothing} + (this.minimized = !this.minimized)} + > + { + if (inProgressCount) { + this.cancelIds = inProgress.map(({ uploadId }) => uploadId); + } else { + this.open = false; + } + }} + > +
+ +
+ ${repeat(all, ({ uploadId }) => uploadId, this.renderUpload)} +
+
+
+ + ${this.renderDialog()} + `; + } + + private readonly renderUpload = ( + upload: OrgUpload & { uploadId: string }, + ) => { + const progress = (upload.loaded / upload.total) * 100; + const uploaded = !upload.canceled && upload.loaded === upload.total; + const isItem = Boolean(upload.itemId); + const linkBtn = html` { + if ((e.target as SlIconButton).disabled) { + e.preventDefault(); + return; + } + removeOrHide(); + this.navigate.link(e); + }} + >`; + + const removeOrHide = () => { + if (this.uploadIds.length > 1) { + this.dispatchEvent( + new CustomEvent( + "btrix-org-upload-remove", + { + detail: { uploadIds: [upload.uploadId] }, + bubbles: true, + composed: true, + }, + ), + ); + } else { + this.open = false; + } + }; + + return html` +
+
+
+
+ ${upload.itemName} +
+
+ ${isItem + ? msg("Uploaded") + : upload.canceled + ? msg("Canceled") + : uploaded + ? msg("Finishing") + : html`${this.localize.bytes(upload.loaded)} / + ${this.localize.bytes(upload.total)}`} +
+
+ +
+ ${uploaded + ? isItem + ? html` + ${linkBtn} + ` + : html` + ${linkBtn} + ` + : html` + { + if (upload.canceled) { + removeOrHide(); + } else { + this.cancelIds = [upload.uploadId]; + } + }} + > + `} +
+ `; + }; + + private renderDialog() { + const cancelCount = this.cancelIds.length; + + const message = () => { + if (cancelCount === 1) { + const uploadId = this.cancelIds.values().next().value; + const orgUploads = this.#orgUploads.value; + + if (uploadId && orgUploads?.[uploadId]) { + const upload_name = orgUploads[uploadId].itemName; + return msg( + str`Are you sure you want to cancel uploading “${upload_name}”?`, + ); + } + } + + const number_of_files = this.localize.number(cancelCount); + const plural_of_files = pluralOf("files", cancelCount); + + return msg( + str`Are you sure you want to cancel uploading ${number_of_files} ${plural_of_files}?`, + ); + }; + + return html` +

${message()}

+
+ (this.cancelIds = [])} + >${msg("Continue Upload")} + { + this.dispatchEvent( + new CustomEvent( + "btrix-org-upload-cancel", + { + detail: { uploadIds: [...this.cancelIds] }, + bubbles: true, + composed: true, + }, + ), + ); + }} + > + ${msg("Cancel Upload")} + +
+
`; + } +} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 0d50bbd0a7..158d271e43 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -359,7 +359,7 @@ export class App extends BtrixElement { ${this.renderUserGuide()} - + `; } diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index b97af43ebd..b28de838a3 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -1514,13 +1514,26 @@ export class ArchivedItemDetail extends BtrixElement { private async fetchCrawl(): Promise { try { this.item = await this.getCrawl(); - } catch { - this.notify.toast({ - message: msg("Sorry, couldn't retrieve crawl at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "archived-item-retrieve-error", - }); + } catch (err) { + console.debug(err); + + this.navigate.to(`${this.navigate.orgBasePath}/${OrgTab.Items}`); + + if (isApiError(err) && err.statusCode === 404) { + this.notify.toast({ + message: msg("Archived item does not exist."), + variant: "danger", + icon: "exclamation-octagon", + id: "archived-item-retrieve-error", + }); + } else { + this.notify.toast({ + message: msg("Sorry, couldn't retrieve item at this time."), + variant: "danger", + icon: "exclamation-octagon", + id: "archived-item-retrieve-error", + }); + } } } diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index ba2c6f42b2..2d4e132ca3 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -523,14 +523,9 @@ export class CrawlsList extends BtrixElement { () => html` (this.isUploadingArchive = false)} - @uploaded=${() => { - if (this.itemType !== "crawl") { - this.pagination = { - ...this.pagination, - page: 1, - }; - } + @sl-after-hide=${(e: CustomEvent) => { + e.stopPropagation(); + this.isUploadingArchive = false; }} > `, diff --git a/frontend/src/pages/org/collection-detail/dedupe.ts b/frontend/src/pages/org/collection-detail/dedupe.ts index 241ab269c5..cf5bb01ed9 100644 --- a/frontend/src/pages/org/collection-detail/dedupe.ts +++ b/frontend/src/pages/org/collection-detail/dedupe.ts @@ -25,11 +25,11 @@ import type { Collection } from "@/types/collection"; import type { ArchivedItem, Workflow } from "@/types/crawler"; import type { DedupeIndexStats } from "@/types/dedupe"; import { SortDirection } from "@/types/utils"; +import { BYTES_PER_MB } from "@/utils/bytes"; import { finishedCrawlStates } from "@/utils/crawler"; import { indexAvailable, indexUpdating } from "@/utils/dedupe"; import { tw } from "@/utils/tailwind"; -const BYTES_PER_MB = 1e6; const INITIAL_PAGE_SIZE = 10; enum ItemsView { diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 71f1f3b881..142c089494 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -25,6 +25,11 @@ import { orgProxiesContext, type OrgProxiesContext, } from "@/context/org-proxies"; +import orgUploadsContext, { + type OrgUploadsContext, +} from "@/context/org-uploads"; +import { OrgUploadsContextController } from "@/context/org-uploads/OrgUploadsContextController"; +import { orgUploadsContextKey } from "@/context/org-uploads/types"; import { SearchOrgContextController } from "@/context/search-org/SearchOrgContextController"; import { searchOrgContextKey } from "@/context/search-org/types"; import type { QuotaUpdateDetail } from "@/controllers/api"; @@ -114,6 +119,9 @@ export class Org extends BtrixElement { @provide({ context: orgCrawlerChannelsContext }) crawlerChannels: OrgCrawlerChannelsContext = null; + @provide({ context: orgUploadsContext }) + orgUploads: OrgUploadsContext = {}; + @property({ type: Object }) viewStateData?: ViewState["data"]; @@ -137,6 +145,9 @@ export class Org extends BtrixElement { private isCreateDialogVisible = false; private readonly [searchOrgContextKey] = new SearchOrgContextController(this); + private readonly [orgUploadsContextKey] = new OrgUploadsContextController( + this, + ); private readonly proxiesTask = new Task(this, { task: async ([id], { signal }) => { @@ -402,6 +413,8 @@ export class Org extends BtrixElement { ${this.renderNewResourceDialogs()}

+ + `; } @@ -496,11 +509,9 @@ export class Org extends BtrixElement { > (this.openDialogName = undefined)} - @uploaded=${() => { - if (this.orgTab === OrgTab.Dashboard) { - this.navigate.to(`${this.navigate.orgBasePath}/items/upload`); - } + @sl-after-hide=${(e: CustomEvent) => { + e.stopPropagation(); + this.openDialogName = undefined; }} > diff --git a/frontend/src/pages/public/org.ts b/frontend/src/pages/public/org.ts index a86912418b..59cca3d66a 100644 --- a/frontend/src/pages/public/org.ts +++ b/frontend/src/pages/public/org.ts @@ -14,6 +14,7 @@ import { type PublicCollection, } from "@/types/collection"; import type { OrgData, PublicOrgCollections } from "@/types/org"; +import type { UserInfo } from "@/types/user"; import { SortDirection } from "@/types/utils"; import { richText } from "@/utils/rich-text"; import { toShortUrl } from "@/utils/url-helpers"; @@ -298,7 +299,9 @@ export class PublicOrg extends BtrixElement { private async getUserOrg(): Promise { try { - const userInfo = this.userInfo || (await this.api.fetch("/users/me")); + const userInfo = + this.userInfo || + (await this.api.fetch("/users/me")); const userOrg = userInfo?.orgs.find((org) => org.slug === this.orgSlug); if (!userOrg) { diff --git a/frontend/src/stories/decorators/notificationsDecorator.ts b/frontend/src/stories/decorators/notificationsDecorator.ts new file mode 100644 index 0000000000..88c7aa401d --- /dev/null +++ b/frontend/src/stories/decorators/notificationsDecorator.ts @@ -0,0 +1,47 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { NotificationsContext } from "@/context/notifications"; +import { notificationsInitialValue } from "@/context/notifications/notifications"; +import { NotificationsContextController } from "@/context/notifications/NotificationsContextController"; +import { notificationsContextKey } from "@/context/notifications/types"; + +import "@/components/toast-stack"; + +export type StorybookNotificationsProps = { + notifications?: NotificationsContext; +}; + +@customElement("btrix-storybook-notifications") +export class StorybookOrg extends BtrixElement { + @property({ type: Array, attribute: false }) + notifications: NotificationsContext = notificationsInitialValue; + + private readonly [notificationsContextKey] = + new NotificationsContextController(this); + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("notifications")) { + this.notifications.forEach((notification) => { + this[notificationsContextKey].addNotification(notification); + }); + } + } + + render() { + return html` `; + } +} + +export function notificationsDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + const { notifications } = args as StorybookNotificationsProps; + + return html` + ${story(args, context)} + `; +} diff --git a/frontend/src/stories/decorators/orgUploadsDecorator.ts b/frontend/src/stories/decorators/orgUploadsDecorator.ts new file mode 100644 index 0000000000..6f37f6d239 --- /dev/null +++ b/frontend/src/stories/decorators/orgUploadsDecorator.ts @@ -0,0 +1,44 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { OrgUploadsContext } from "@/context/org-uploads"; +import { orgUploadsInitialValue } from "@/context/org-uploads/org-uploads"; +import { OrgUploadsContextController } from "@/context/org-uploads/OrgUploadsContextController"; +import { orgUploadsContextKey } from "@/context/org-uploads/types"; + +export type StorybookOrgUploadsProps = { + orgUploads?: OrgUploadsContext; +}; + +@customElement("btrix-storybook-org-uploads") +export class StorybookOrg extends BtrixElement { + @property({ type: Object, attribute: false }) + orgUploads: OrgUploadsContext = orgUploadsInitialValue; + + private readonly [orgUploadsContextKey] = new OrgUploadsContextController( + this, + ); + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("orgUploads")) { + Object.entries(this.orgUploads).forEach(([uploadId, upload]) => { + this[orgUploadsContextKey].setUpload(uploadId, upload); + }); + } + } + + render() { + return html``; + } +} + +export function orgUploadsDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + const { orgUploads } = args as StorybookOrgUploadsProps; + + return html` + ${story(args, context)} + `; +} diff --git a/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts new file mode 100644 index 0000000000..99bc3a6c27 --- /dev/null +++ b/frontend/src/stories/features/org/OrgUploadsDialog.stories.ts @@ -0,0 +1,292 @@ +import type { + Meta, + StoryContext, + StoryFn, + StoryObj, +} from "@storybook/web-components"; +import { html } from "lit"; +import type { DecoratorFunction } from "storybook/internal/types"; + +import { argTypes } from "../excludeContainerProperties"; + +import type { OrgUploadsDialog } from "@/features/org/org-uploads-dialog"; +import { + notificationsDecorator, + type StorybookNotificationsProps, +} from "@/stories/decorators/notificationsDecorator"; +import { + orgDecorator, + type StorybookOrgProps, +} from "@/stories/decorators/orgDecorator"; +import { + orgUploadsDecorator, + type StorybookOrgUploadsProps, +} from "@/stories/decorators/orgUploadsDecorator"; +import { BYTES_PER_GB, BYTES_PER_MB } from "@/utils/bytes"; + +import "@/features/org/org-uploads-dialog"; + +type RenderProps = OrgUploadsDialog & + StorybookOrgProps & + StorybookOrgUploadsProps & + StorybookNotificationsProps; + +function containerDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + return html`
${story(args, context)}
`; +} + +const meta = { + title: "Features/Org/Org Uploads Dialog", + component: "btrix-org-uploads-dialog", + tags: ["autodocs"], + decorators: [ + notificationsDecorator as DecoratorFunction, + orgDecorator as DecoratorFunction, + orgUploadsDecorator as DecoratorFunction, + containerDecorator as DecoratorFunction, + ], + render: () => html``, + argTypes: { + ...argTypes, + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithUploadInProgress: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + }, + }, +}; + +export const Minimized: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + }, + }, + render: () => + html``, +}; + +export const Finishing: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + }, + }, +}; + +export const Complete: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + itemId: "upload-item-id-1-item-id", + }, + }, + }, +}; + +export const Canceled: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + canceled: true, + }, + }, + }, +}; + +export const MultipleInProgress: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, +}; + +export const SomeFinishing: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, +}; + +export const SomeComplete: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + itemId: "upload-item-id-1", + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, +}; + +export const SomeCanceled: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + canceled: true, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 310 * BYTES_PER_MB, + total: 4.85 * BYTES_PER_GB, + }, + }, + }, +}; + +export const MultipleDone: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + canceled: true, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 4.85 * BYTES_PER_GB, + total: 4.85 * BYTES_PER_GB, + itemId: "upload-item-id-2", + }, + }, + }, +}; + +export const MultipleComplete: Story = { + args: { + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 50.15 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + itemId: "upload-item-id-1", + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 4.85 * BYTES_PER_GB, + total: 4.85 * BYTES_PER_GB, + itemId: "upload-item-id-2", + }, + }, + }, +}; + +export const WithToastStack: Story = { + args: { + notifications: [ + { + id: "notification-1", + type: "toast", + message: "Success!", + variant: "success", + closable: true, + duration: Infinity, + }, + ], + orgUploads: { + "upload-1": { + itemName: "Test WACZ File", + filename: "test_file.wacz", + loaded: 9.005 * BYTES_PER_MB, + total: 50.15 * BYTES_PER_MB, + }, + "upload-2": { + itemName: + "Test WACZ file with longer file name for testing long file name", + filename: + "test_file_with_longer_file_name_for_testing_long_file_name.wacz", + loaded: 4.85 * BYTES_PER_GB, + total: 4.85 * BYTES_PER_GB, + itemId: "upload-item-id-2", + }, + }, + }, +}; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 47bdb9547b..3aeba00746 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -26,6 +26,9 @@ /* Custom screen widths */ --btrix-screen-desktop: 82.5rem; /* Should match tailwind.config.screens.desktop */ + /* Offset toast notification stack */ + --btrix-toast-stack-offset: 0; + /* * Shoelace Theme Tokens */ @@ -171,6 +174,7 @@ --height: 0.5rem; --track-color: var(--sl-color-neutral-100); --indicator-color: var(--sl-color-primary-300); + --btrix-indicator-border-color: var(--sl-color-primary-500); } sl-progress-bar::part(base) { @@ -179,7 +183,7 @@ } sl-progress-bar::part(indicator) { - box-shadow: inset 0 0 0 1px var(--sl-color-primary-500); + box-shadow: inset 0 0 0 1px var(--btrix-indicator-border-color); @apply rounded-md; } @@ -590,22 +594,37 @@ .btrix-toast-stack { position: fixed; top: auto; - bottom: 0; + bottom: var(--btrix-toast-stack-offset, 0); inset-inline-end: 0; z-index: var(--sl-z-index-toast); width: 28rem; max-width: 100%; max-height: 100%; overflow: auto; + pointer-events: none; } .btrix-toast-stack sl-alert { margin: var(--sl-spacing-medium); + pointer-events: auto; } .btrix-toast-stack sl-alert::part(base) { box-shadow: var(--sl-shadow-large); } + + sl-alert::part(message) { + /* Decrease padding */ + padding: var(--sl-spacing-medium); + } + + sl-alert::part(icon) { + padding-inline-start: var(--sl-spacing-medium); + } + + sl-alert::part(close-button) { + padding-inline-end: var(--sl-spacing-medium); + } } /* Following styles won't work with layers */ diff --git a/frontend/src/utils/LiteElement.ts b/frontend/src/utils/LiteElement.ts index d1c65131bd..3f508eb1d0 100644 --- a/frontend/src/utils/LiteElement.ts +++ b/frontend/src/utils/LiteElement.ts @@ -81,6 +81,7 @@ export default class LiteElement extends LitElement { /** * @deprecated New components should use APIController directly */ - apiFetch = async (...args: Parameters) => - this.apiController.fetch(...args); + apiFetch = async ( + ...args: Parameters + ) => this.apiController.fetch(...args); } diff --git a/frontend/src/utils/bytes.ts b/frontend/src/utils/bytes.ts new file mode 100644 index 0000000000..4c99b8875b --- /dev/null +++ b/frontend/src/utils/bytes.ts @@ -0,0 +1,2 @@ +export const BYTES_PER_MB = 1e6; +export const BYTES_PER_GB = 1e9; diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index e5eddd9b57..052e922ed4 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -39,6 +39,32 @@ const plurals = { id: "crawls.plural.other", }), }, + uploads: { + zero: msg("uploads", { + desc: 'plural form of "upload" for zero uploads', + id: "uploads.plural.zero", + }), + one: msg("upload", { + desc: 'singular form for "upload"', + id: "uploads.plural.one", + }), + two: msg("uploads", { + desc: 'plural form of "upload" for two uploads', + id: "uploads.plural.two", + }), + few: msg("uploads", { + desc: 'plural form of "upload" for few uploads', + id: "uploads.plural.few", + }), + many: msg("uploads", { + desc: 'plural form of "upload" for many uploads', + id: "uploads.plural.many", + }), + other: msg("uploads", { + desc: 'plural form of "upload" for multiple/other uploads', + id: "uploads.plural.other", + }), + }, items: { zero: msg("items", { desc: 'plural form of "item" for zero items', @@ -455,6 +481,32 @@ const plurals = { id: "organizations.plural.other", }), }, + files: { + zero: msg("files", { + desc: 'plural form of "file" for zero files', + id: "files.plural.zero", + }), + one: msg("file", { + desc: 'singular form for "file"', + id: "files.plural.one", + }), + two: msg("files", { + desc: 'plural form of "file" for two files', + id: "files.plural.two", + }), + few: msg("files", { + desc: 'plural form of "file" for few files', + id: "files.plural.few", + }), + many: msg("files", { + desc: 'plural form of "file" for many files', + id: "files.plural.many", + }), + other: msg("files", { + desc: 'plural form of "file" for multiple/other files', + id: "files.plural.other", + }), + }, }; /** diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts index 1fb275a9e7..343688c3ae 100644 --- a/frontend/src/utils/workflow.ts +++ b/frontend/src/utils/workflow.ts @@ -19,12 +19,13 @@ import { } from "@/types/crawler"; import type { OrgData } from "@/types/org"; import { NewWorkflowOnlyScopeType, WorkflowScopeType } from "@/types/workflow"; +import { BYTES_PER_GB } from "@/utils/bytes"; import { unescapeCustomPrefix } from "@/utils/crawl-workflows/unescapeCustomPrefix"; import { DEFAULT_MAX_SCALE, isPageScopeType } from "@/utils/crawler"; import { getNextDate, getScheduleInterval } from "@/utils/cron"; import localize, { getDefaultLang } from "@/utils/localize"; -export const BYTES_PER_GB = 1e9; +export { BYTES_PER_GB }; export const DEFAULT_SELECT_LINKS = ["a[href]->href" as const]; export const DEFAULT_AUTOCLICK_SELECTOR = "a"; export const SEED_LIST_FILE_EXT = "txt";