Skip to content
2 changes: 1 addition & 1 deletion frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ 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
* instead of relocating it in the DOM.
*
* @fires btrix-remove-notification
*/
@customElement("btrix-notification-stack")
@customElement("btrix-toast-stack")
export class NotificationStack extends BtrixElement {
@consume({ context: notificationsContext, subscribe: true })
@state()
Expand Down Expand Up @@ -57,7 +57,7 @@ export class NotificationStack extends BtrixElement {
<div
class=${clsx(
"btrix-toast-stack",
this.notifications.length && tw`min-h-24`,
this.notifications.length && tw`min-h-20`,
)}
>
${repeat(this.notifications, ({ id }) => id, this.renderNotification)}
Expand All @@ -72,7 +72,6 @@ export class NotificationStack extends BtrixElement {
return html`<sl-alert
data-id=${notification.id}
class=${clsx(
tw`[--sl-spacing-large:var(--sl-spacing-medium)]`,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- will add new notification types
notification.type === "toast" &&
(variant === "danger" || variant === "warning") &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export class NotificationsContextController implements ReactiveController {
);
}

addNotification(notification: AppNotification) {
this.#context.setValue([notification, ...this.#context.value]);
}

private readonly onNotify = (e: CustomEvent<NotifyEventDetail>) => {
e.stopPropagation();

Expand Down Expand Up @@ -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]);
}
}
196 changes: 196 additions & 0 deletions frontend/src/context/org-uploads/OrgUploadsContextController.ts
Original file line number Diff line number Diff line change
@@ -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<string, XMLHttpRequest>();

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<OrgUpload>) {
this.#context.setValue({
...this.#context.value,
[uploadId]: {
...this.#context.value[uploadId],
...upload,
},
});
}

private readonly onUpload = async (e: CustomEvent<OrgUploadEventDetail>) => {
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<OrgUploadCancelRemoveEventDetail>,
) => {
e.stopPropagation();

e.detail.uploadIds.forEach(this.abort);
};

private readonly onRemove = (
e: CustomEvent<OrgUploadCancelRemoveEventDetail>,
) => {
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);
};
}
5 changes: 5 additions & 0 deletions frontend/src/context/org-uploads/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { orgUploadsContext, type OrgUploadsContext } from "./org-uploads";

export type { OrgUploadsContext };

export default orgUploadsContext;
10 changes: 10 additions & 0 deletions frontend/src/context/org-uploads/org-uploads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createContext } from "@lit/context";

import { orgUploadsContextKey, type OrgUpload } from "./types";

export type OrgUploadsContext = Record<string, OrgUpload>;

export const orgUploadsInitialValue = {} satisfies OrgUploadsContext;

export const orgUploadsContext =
createContext<OrgUploadsContext>(orgUploadsContextKey);
29 changes: 29 additions & 0 deletions frontend/src/context/org-uploads/types.ts
Original file line number Diff line number Diff line change
@@ -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<OrgUploadEventDetail>;
"btrix-org-upload-cancel": CustomEvent<OrgUploadCancelRemoveEventDetail>;
"btrix-org-upload-remove": CustomEvent<OrgUploadCancelRemoveEventDetail>;
}
}
Loading
Loading