mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
351 lines
9.4 KiB
JavaScript
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");
|
|
}
|
|
}
|