Files
bitvid/js/ui/loginModalController.js
T

351 lines
9.4 KiB
JavaScript

const DEFAULT_SLOW_EXTENSION_DELAY_MS = 8000;
const noop = () => {};
function isElement(value) {
return value instanceof HTMLElement;
}
function toArray(value) {
return Array.isArray(value) ? value.slice() : [];
}
function getTimerHost() {
if (typeof window !== "undefined" && window && window.setTimeout) {
return window;
}
if (typeof globalThis !== "undefined" && globalThis.setTimeout) {
return globalThis;
}
return {
setTimeout: () => null,
clearTimeout: () => {},
};
}
export default class LoginModalController {
constructor({
modalElement,
optionsContainer,
closeButton,
providers = [],
authService = null,
callbacks = {},
slowExtensionDelayMs = DEFAULT_SLOW_EXTENSION_DELAY_MS,
} = {}) {
this.modalElement = isElement(modalElement) ? modalElement : null;
this.optionsContainer = isElement(optionsContainer)
? optionsContainer
: this.modalElement?.querySelector("[data-login-options]") || null;
this.closeButton = isElement(closeButton) ? closeButton : null;
this.authService = authService && typeof authService === "object"
? authService
: null;
this.callbacks = {
onOpen: noop,
onClose: noop,
onSuccess: noop,
onError: noop,
...(callbacks && typeof callbacks === "object" ? callbacks : {}),
};
this.providers = toArray(providers);
this.slowExtensionDelayMs =
Number.isFinite(slowExtensionDelayMs) && slowExtensionDelayMs >= 0
? slowExtensionDelayMs
: DEFAULT_SLOW_EXTENSION_DELAY_MS;
this.providerStates = new Map();
this.timerHost = getTimerHost();
this.boundCloseHandler = () => this.hide();
if (this.closeButton) {
this.closeButton.addEventListener("click", this.boundCloseHandler);
}
this.syncAriaHidden();
this.render();
}
destroy() {
if (this.closeButton && this.boundCloseHandler) {
this.closeButton.removeEventListener("click", this.boundCloseHandler);
}
for (const state of this.providerStates.values()) {
this.teardownProviderState(state);
}
this.providerStates.clear();
}
syncAriaHidden() {
if (!this.modalElement) {
return;
}
const isHidden = this.modalElement.classList.contains("hidden");
this.modalElement.setAttribute("aria-hidden", isHidden ? "true" : "false");
}
setProviders(providers) {
this.providers = toArray(providers);
this.render();
}
render() {
if (!this.optionsContainer) {
return;
}
for (const state of this.providerStates.values()) {
this.teardownProviderState(state);
}
this.providerStates.clear();
this.optionsContainer.innerHTML = "";
if (!this.providers.length) {
const fallbackMessage = document.createElement("p");
fallbackMessage.className = "text-sm text-gray-400";
fallbackMessage.textContent = "No login options are available right now.";
this.optionsContainer.appendChild(fallbackMessage);
return;
}
const fragment = document.createDocumentFragment();
for (const provider of this.providers) {
const button = this.createProviderButton(provider);
if (button) {
fragment.appendChild(button);
}
}
this.optionsContainer.appendChild(fragment);
}
createProviderButton(provider) {
if (!provider || typeof provider.id !== "string") {
return null;
}
const providerId = provider.id.trim();
if (!providerId) {
return null;
}
const ui = provider.ui && typeof provider.ui === "object" ? provider.ui : {};
const button = document.createElement("button");
button.type = "button";
button.dataset.providerId = providerId;
button.className =
typeof ui.buttonClass === "string" && ui.buttonClass.trim()
? ui.buttonClass
: "w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors";
const defaultLabel =
typeof ui.buttonLabel === "string" && ui.buttonLabel.trim()
? ui.buttonLabel.trim()
: provider.label || providerId;
button.textContent = defaultLabel;
button.dataset.loading = "false";
button.setAttribute("aria-busy", "false");
const disabledLabel =
typeof ui.disabledLabel === "string" && ui.disabledLabel.trim()
? ui.disabledLabel.trim()
: "";
const isDisabled = ui.disabled === true;
if (isDisabled) {
button.disabled = true;
button.setAttribute("aria-disabled", "true");
if (disabledLabel) {
button.textContent = disabledLabel;
}
}
const state = {
provider,
button,
defaultLabel: defaultLabel,
disabledLabel,
isDisabled,
loadingLabel:
typeof ui.loadingLabel === "string" && ui.loadingLabel.trim()
? ui.loadingLabel.trim()
: `Connecting to ${provider.label || providerId}...`,
slowHint:
typeof ui.slowHint === "string" && ui.slowHint.trim()
? ui.slowHint.trim()
: "",
slowTimer: null,
clickHandler: null,
};
if (!isDisabled) {
state.clickHandler = (event) => {
event.preventDefault();
this.handleProviderClick(providerId);
};
button.addEventListener("click", state.clickHandler);
}
this.providerStates.set(providerId, state);
return button;
}
teardownProviderState(state) {
if (!state) {
return;
}
if (state.clickHandler && state.button) {
state.button.removeEventListener("click", state.clickHandler);
}
this.clearSlowTimer(state);
}
invokeCallback(name, ...args) {
const callback = this.callbacks?.[name];
if (typeof callback !== "function") {
return;
}
try {
callback(...args);
} catch (error) {
console.error(`[LoginModalController] ${name} callback threw:`, error);
}
}
clearSlowTimer(state) {
if (!state || !state.slowTimer) {
return;
}
this.timerHost.clearTimeout(state.slowTimer);
state.slowTimer = null;
}
setLoadingState(providerId, isLoading) {
const state = this.providerStates.get(providerId);
if (!state || !state.button) {
return;
}
const { button } = state;
if (isLoading) {
button.disabled = true;
button.dataset.loading = "true";
button.setAttribute("aria-busy", "true");
button.setAttribute("aria-disabled", "true");
button.textContent = state.loadingLabel;
this.clearSlowTimer(state);
if (state.slowHint && this.slowExtensionDelayMs > 0) {
state.slowTimer = this.timerHost.setTimeout(() => {
if (button.dataset.loading === "true") {
button.textContent = state.slowHint;
}
}, this.slowExtensionDelayMs);
}
return;
}
button.dataset.loading = "false";
button.setAttribute("aria-busy", "false");
this.clearSlowTimer(state);
if (state.isDisabled) {
button.disabled = true;
button.setAttribute("aria-disabled", "true");
button.textContent = state.disabledLabel || state.defaultLabel;
} else {
button.disabled = false;
button.removeAttribute("aria-disabled");
button.textContent = state.defaultLabel;
}
}
resetLoadingStates() {
for (const providerId of this.providerStates.keys()) {
this.setLoadingState(providerId, false);
}
}
describeError(error, provider) {
if (error && typeof error.message === "string" && error.message.trim()) {
return error.message.trim();
}
const ui = provider && typeof provider.ui === "object" ? provider.ui : {};
if (ui && typeof ui.errorMessage === "string" && ui.errorMessage.trim()) {
return ui.errorMessage.trim();
}
if (provider && typeof provider.label === "string" && provider.label) {
return `Failed to login with ${provider.label}. Please try again.`;
}
return "Failed to login. Please try again.";
}
async handleProviderClick(providerId) {
const provider = this.providers.find(
(entry) => entry && entry.id === providerId,
);
if (!provider) {
return;
}
const state = this.providerStates.get(providerId);
if (!state || state.button.dataset.loading === "true") {
return;
}
if (!this.authService || typeof this.authService.requestLogin !== "function") {
const message = this.describeError(
new Error("Login is not available."),
provider,
);
this.invokeCallback("onError", message, { provider, error: null });
return;
}
this.setLoadingState(providerId, true);
try {
const result = await this.authService.requestLogin({ providerId });
this.hide();
this.invokeCallback("onSuccess", result, { provider });
} catch (error) {
console.error(
`[LoginModalController] Failed to login with provider "${providerId}":`,
error,
);
const message = this.describeError(error, provider);
this.invokeCallback("onError", message, { provider, error });
} finally {
this.setLoadingState(providerId, false);
}
}
show() {
if (!this.modalElement) {
return;
}
this.modalElement.classList.remove("hidden");
this.syncAriaHidden();
this.invokeCallback("onOpen");
}
hide() {
if (!this.modalElement) {
return;
}
this.modalElement.classList.add("hidden");
this.resetLoadingStates();
this.syncAriaHidden();
this.invokeCallback("onClose");
}
}