import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks"; import { AudioFile, cloneObjectExcept, ImageFile, KvObj, QuizConfig, } from "../utils/types.ts"; type Question = { id: string; question_index?: number; quiz_id: string; owner_id: string; language_1: string; language_2: string; text_1: string; audio_file_1: string; text_2: string; audio_file_2: string; order_num: number; background_file: string; background_image_data: string; video_file: string; video_file_data: string; }; import { decodeData as _decodeData, decodeKvURL } from "../utils/urlEncoder.ts"; import { QuizToTextFile } from "../utils/QuizToTextFile.ts"; import { Signal, useSignal } from "@preact/signals"; import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx"; import { AsyncVoiceRecorder } from "./AsyncVoiceRecorder.tsx"; import { AsyncImageGenerater } from "./AsyncImageGenerater.tsx"; export interface ImageCheck { i: number; j: number; file: string; } interface BrowserDashboardProps { imageFileChecks: ImageCheck[]; quizProps: KvObj[]; userId: string; role: string; clearSig: Signal; setCanStudy: Signal; tabs: string[]; tab_index: number; } export type ListOfQuizLists = [QuizConfig, Dispatch>]; let lastClearSigValue = 0; export default function UserDashboard( { imageFileChecks: _imageFileChecks, quizProps, userId, role, clearSig, setCanStudy, tabs, tab_index, }: BrowserDashboardProps, ) { const [popupKey, setPopupKey] = useState(0); const [ask, popupElement] = AsyncYesNoPop(); const [audioRrecord, popupAudioRecorder] = AsyncVoiceRecorder(); const [imageGenerate, popupImageGenerater] = AsyncImageGenerater(); const normalizedTabs = (() => { const list = Array.isArray(tabs) ? [...tabs] : []; if (list.length === 0) { list.push("Default"); } if (list[0] !== "Default") { if (list.includes("Default")) { const withoutDefault = list.filter((t) => t !== "Default"); return ["Default", ...withoutDefault]; } return ["Default", ...list]; } return list; })(); const [activeTabIndex, setActiveTabIndex] = useState( typeof tab_index === "number" && Number.isFinite(tab_index) ? tab_index : 0, ); useEffect(() => { const safeIndex = typeof tab_index === "number" && Number.isFinite(tab_index) ? tab_index : 0; setActiveTabIndex((prev) => (prev === safeIndex ? prev : safeIndex)); }, [tab_index]); useEffect(() => { setActiveTabIndex((prev) => { const max = Math.max(0, normalizedTabs.length - 1); if (prev < 0) return 0; if (prev > max) return max; return prev; }); }, [normalizedTabs.length]); const navigateToTab = (nextIndex: number) => { const max = Math.max(0, normalizedTabs.length - 1); const clamped = Math.max(0, Math.min(max, nextIndex)); try { const url = new URL(globalThis.location.href); url.searchParams.set("tab_index", String(clamped)); globalThis.location.href = url.toString(); } catch (_e) { void _e; } }; const quizStates = useSignal>( [], ); useEffect(() => { quizStates.value = quizProps.map((quiz) => ({ value: quiz.value })); }, [quizProps]); const isInitialized = useSignal(false); const bale = useSignal(false); const expandedQuizId = useSignal("0"); const postAction = useSignal("delete"); const postMessage = useSignal( "You might want to download your list in the edit mode first.\nDo you want to delete the quiz?", ); const checkedItems = useSignal>( (() => { const initMap = new Map(); quizProps.forEach((_group, index) => { initMap.set(index, []); }); return initMap; })(), ); useEffect(() => { setTimeout(() => { const lockRoute = localStorage.getItem("lock-route"); const url = new URL(globalThis.location.href); const currentRoute = url.pathname + url.search; if (lockRoute && lockRoute === currentRoute) { localStorage.removeItem("lock-route"); } }, 100); clearSig.subscribe((event: number) => { if (event === lastClearSigValue) { return; } if (event > 0) { checkedItems.value = (() => { const initMap = new Map(); quizProps.forEach((_group, index) => { initMap.set(index, []); }); return initMap; })(); lastClearSigValue = event; } else if (event < 0) { const newCheckedItems = new Map(); quizProps.forEach((group, index) => { if (group && group.value) { const config = group.value as QuizConfig; newCheckedItems.set( index, Array.from({ length: config.questions.length }, (_, i) => i), ); } }); checkedItems.value = newCheckedItems; lastClearSigValue = event; } }); }, []); const handleCheck = ( groupIndex: number, itemIndex: number, isChecked: boolean, ) => { checkedItems.value = (() => { const prevCheckedItems = checkedItems.value; const newCheckedItems = new Map(prevCheckedItems); if (!newCheckedItems.has(groupIndex)) { newCheckedItems.set(groupIndex, []); } if (isChecked) { const items = newCheckedItems.get(groupIndex); if (items) { if (!items.includes(itemIndex)) { items.push(itemIndex); newCheckedItems.set(groupIndex, items); } } } else { const groupItems = newCheckedItems.get(groupIndex); if (groupItems) { const updatedGroupItems = groupItems.filter((index: number) => index !== itemIndex ); newCheckedItems.set(groupIndex, updatedGroupItems); } } return newCheckedItems; })(); }; const reassemblePageProps = () => { const builtQuizArray: KvObj[] = []; for (let i = 0; i < quizStates.value.length; ++i) { const quizStatesRef = quizStates.value.at(i); if (quizStatesRef) { const quiz = quizStatesRef.value; const inputQuiz = quizProps[i].value; if (quiz && inputQuiz) { builtQuizArray.push( cloneObjectExcept(quizProps[i], ["value"], [quiz]) as KvObj< QuizConfig >, ); } } } return builtQuizArray; }; useEffect(() => { if (bale.value) { const baseUrl = "/"; const url = new URL(baseUrl, globalThis.location.origin); globalThis.location.href = url.toString(); } if (!isInitialized.value) { isInitialized.value = true; } }, [isInitialized, bale]); if (!quizStates || quizStates.value.length === 0) { return ( <>

Dashboard

No quizzes synced.

{role === "admin" ? ( ) : }
); } const toggleQuiz = ( quiz_id: string, _q: QuizConfig, ) => { const nextId = expandedQuizId.value === quiz_id ? "0" : quiz_id; expandedQuizId.value = nextId; if (nextId !== "0") { const scrollToExpandedQuiz = () => { let attempts = 0; const maxAttempts = 20; let lastTop = -1; let stableCount = 0; const checkAndScroll = () => { const el = document.getElementById(`quiz-expand-top-${nextId}`); if (!el) { if (attempts < maxAttempts) { attempts++; requestAnimationFrame(checkAndScroll); } return; } const rect = el.getBoundingClientRect(); const currentTop = rect.top; if (Math.abs(currentTop - lastTop) < 2) { stableCount++; } else { stableCount = 0; } lastTop = currentTop; if (stableCount >= 3) { el.scrollIntoView({ behavior: "smooth", block: "center" }); } else if (attempts < maxAttempts) { attempts++; requestAnimationFrame(checkAndScroll); } else { el.scrollIntoView({ behavior: "smooth", block: "center" }); } }; requestAnimationFrame(checkAndScroll); }; scrollToExpandedQuiz(); } }; function startQuizEditor(quiz: QuizConfig) { try { const baseUrl = "/language/lists"; const url = new URL(baseUrl, globalThis.location.origin); if (quiz.quiz_config_id) { url.searchParams.set("quiz_id", quiz.quiz_config_id.toString()); } globalThis.location.href = url.toString(); } catch (_e) { void _e; } } const handleDashboardPost = async ( pageConfigData: string, quizIndex: number, ) => { const userConfirmed = await ask( "You might want to download your list in the edit mode first.\nDo you want to delete the quiz?", popupKey, ); setPopupKey((k) => k + 1); if (userConfirmed) { try { const formData = new FormData(); formData.append("pageConfigData", pageConfigData); const response = await fetch("/api/update/dashboard", { method: "POST", body: formData, }); if (response.ok) { quizStates.value = quizStates.value.filter((_, index) => index !== quizIndex ); checkedItems.value = (() => { const newMap = new Map(); let newIndex = 0; for (let i = 0; i < quizProps.length; i++) { if (i !== quizIndex) { const items = checkedItems.value.get(i) || []; newMap.set(newIndex, items); newIndex++; } } return newMap; })(); quizProps.splice(quizIndex, 1); console.log("Quiz deleted successfully"); } else { console.error("Failed to delete quiz:", response.status); } } catch (error) { console.error("Error deleting quiz:", error); } } }; function downloadQuiz(quiz: QuizConfig) { const quizToText = new QuizToTextFile(quiz); const content = quizToText.generateTextContent(); const blob = new Blob([content], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${quiz.quiz_name}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } const viewAudioPopup1 = async ( question: { id: string; quiz_id: string; owner_id: string; language_1: string; language_2: string; text_1: string; audio_file_1: string; text_2: string; audio_file_2: string; order_num: number; background_file: string; background_image_data: string; video_file: string; video_file_data: string; }, index: number, iQuestion: number, role: string, ) => { const oldAudioFile: AudioFile = { text: question.text_1.replace(/[^a-zA-Z0-9_-]/g, " "), audio_file: question.audio_file_1, }; const newAudioFile: Blob | null = await audioRrecord( oldAudioFile, role, ); if (newAudioFile) { const arrayBuffer = await newAudioFile.arrayBuffer(); const audioBase64 = btoa( String.fromCharCode(...new Uint8Array(arrayBuffer)), ); const apiResponse: Response = await fetch( `/api/audio-blob/${ encodeURIComponent(question.text_1.replace(/[^a-zA-Z0-9_-]/g, " ")) }`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ quizId: question.quiz_id, questionIndex: iQuestion, audioField: "audio_file_1", blobData: audioBase64, contentType: newAudioFile.type || "audio/webm", }), }, ); const jsonRes = await apiResponse.json(); if (jsonRes && jsonRes.success) { const blobUrl = jsonRes.url; const quizItem = quizStates.value[index].value; if (quizItem) { quizItem.questions[iQuestion].audio_file_1 = blobUrl; } return blobUrl; } else { const blobUrl = URL.createObjectURL(newAudioFile); const quizItem = quizStates.value[index].value; if (quizItem) { quizItem.questions[iQuestion].audio_file_1 = blobUrl; } return blobUrl; } } return null; }; const viewAudioPopup2 = async ( question: { id: string; quiz_id: string; owner_id: string; language_1: string; language_2: string; text_1: string; audio_file_1: string; text_2: string; audio_file_2: string; order_num: number; background_file: string; background_image_data: string; video_file: string; video_file_data: string; }, index: number, iQuestion: number, role: string, ) => { const oldAudioFile: AudioFile = { text: question.text_2.replace(/[^a-zA-Z0-9_-]/g, " "), audio_file: question.audio_file_2, }; console.log("oldAudioFile", oldAudioFile); const newAudioFile: Blob | null = await audioRrecord( oldAudioFile, role, ); console.log("recordedAudioFile", newAudioFile); if (newAudioFile) { const arrayBuffer = await newAudioFile.arrayBuffer(); const audioBase64 = btoa( String.fromCharCode(...new Uint8Array(arrayBuffer)), ); const apiResponse: Response = await fetch( `/api/audio-blob/${ encodeURIComponent(question.text_2.replace(/[^a-zA-Z0-9_-]/g, " ")) }`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ quizId: question.quiz_id, questionIndex: iQuestion, audioField: "audio_file_2", blobData: audioBase64, contentType: newAudioFile.type || "audio/webm", }), }, ); console.log("apiResponse", apiResponse); const jsonRes = await apiResponse.json(); console.log("jsonRes", jsonRes); if (jsonRes && jsonRes.success) { const blobUrl = jsonRes.url; const quizItem = quizStates.value[index].value; if (quizItem) { quizItem.questions[iQuestion].audio_file_2 = blobUrl; } return blobUrl; } else { const blobUrl = URL.createObjectURL(newAudioFile); console.log("blobUrl", blobUrl); const quizItem = quizStates.value[index].value; if (quizItem) { quizItem.questions[iQuestion].audio_file_2 = blobUrl; } return blobUrl; } } return null; }; const viewImagePopup = async ( question: { id: string; quiz_id: string; owner_id: string; language_1: string; language_2: string; text_1: string; audio_file_1: string; text_2: string; audio_file_2: string; order_num: number; background_file: string; background_image_data: string; video_file: string; video_file_data: string; }, index: number, iQuestion: number, role: string, ) => { const oldimageFile: ImageFile = { image_text: question.background_file || question.text_2.replace(/[^a-zA-Z0-9_-]/g, " "), image_file: question.background_file, image_data: question.background_image_data, }; if ( !oldimageFile.image_file || oldimageFile.image_file.trim().length === 0 ) { oldimageFile.image_file = question.text_2.replace(/[^a-zA-Z0-9_-]/g, " ") .trim(); oldimageFile.image_text = question.text_2.replace(/[^a-zA-Z0-9_-]/g, " ") .trim(); } const newImageData: ImageFile | null = await imageGenerate( oldimageFile, role, ); if (newImageData && newImageData.image_data) { const rawImageData = (newImageData.image_data ?? "").trim(); const isAlreadyUrl = rawImageData.startsWith("/") || rawImageData.startsWith("http"); const normalizedBase64 = rawImageData.startsWith("data:") ? (rawImageData.split(",")[1] ?? "") : rawImageData; const looksLikeBase64 = normalizedBase64.length > 0 && normalizedBase64.length % 4 === 0 && /^[A-Za-z0-9+/]+=*$/.test(normalizedBase64); if ( !isAlreadyUrl && oldimageFile.image_data !== newImageData.image_data ) { if (!looksLikeBase64) { console.error( "Refusing to upload non-base64 image_data to /api/image-blob", rawImageData.slice(0, 120), ); } else { const apiResponse: Response = await fetch( `/api/image-blob/${newImageData.image_file}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quizId: question.quiz_id, questionIndex: iQuestion, fileName: newImageData.image_file, blobData: normalizedBase64, }), }, ); if (apiResponse.ok) { const jsonRes = await apiResponse.json(); if (jsonRes && jsonRes.success && typeof jsonRes.url === "string") { const blobUrl = jsonRes.url as string; question.background_image_data = blobUrl; const quizItem = quizStates.value[index].value; if (quizItem) { quizItem.questions[iQuestion].background_image_data = blobUrl; quizStates.value = [...quizStates.value]; } return blobUrl; } } } } if (isAlreadyUrl) { const userDescription = newImageData.image_text || newImageData.image_file; question.background_file = userDescription; const quizItem = quizStates.value[index].value; if (quizItem) { quizItem.questions[iQuestion].background_file = userDescription; quizItem.questions[iQuestion].background_image_data = newImageData.image_data; question.background_image_data = newImageData.image_data; quizStates.value = [...quizStates.value]; try { const postData = JSON.stringify({ quiz: quizItem, question: quizItem.questions[iQuestion], index: index, iQuestion: iQuestion, quizKey: quizProps[index].key, action: "image", userId: userId, props: reassemblePageProps(), }); const formData = new FormData(); formData.append("pageConfigData", postData); await fetch("/api/update/dashboard", { method: "POST", body: formData, }); } catch (e) { console.error("Failed to persist image change", e); } return newImageData.image_data; } } } else { console.log("no image saved"); } return question.background_image_data; }; const sumOfChecks = (numberMap: Map | null) => { if (!numberMap) { return 0; } let totalCount = 0; for (const array of numberMap.values()) { totalCount += array.length; } setCanStudy.value = totalCount > 0; return totalCount; }; if (quizStates && quizStates.value.length > 0) { // Quiz configs are already filtered by tab_index from the server const visibleQuizIndices = quizStates.value.map((_, idx) => idx); return ( <> {popupElement} {popupAudioRecorder} {popupImageGenerater}

Dashboard ({sumOfChecks(checkedItems.value)})

{activeTabIndex > 0 && ( )}
{normalizedTabs[activeTabIndex] ?? "Default"}
{activeTabIndex < normalizedTabs.length - 1 && ( )}
{visibleQuizIndices.length === 0 &&

No quizzes in this tab.

} {(quizStates !== null && visibleQuizIndices.length > 0 && !visibleQuizIndices.some((idx) => { const item = quizStates.value[idx]; return !item?.value || !item.value.questions; })) && (
    {visibleQuizIndices.map((index: number) => { const quiz = quizStates.value[index]?.value; if (!quiz || !quiz.questions) { return (
  • Please wait for the quiz to load.

  • ); } else { const editable = quiz?.owner_id === userId; return (
  • {editable ? ( ) : ( )}
    {expandedQuizId.value === quiz?.quiz_config_id && (
    )} {expandedQuizId.value === quiz?.quiz_config_id && (
      {quiz?.questions?.map(( question: Question, iQuestion: number, ) => (
    • 500 ? "url(" + question?.background_image_data + ")" : "" }, ` + `${ question?.background_image_data && "url(data:image/gif;base64,R0lGODlhyAAoAIEAAAAAAP///wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJBQAAACwAAAAAyAAoAAAInwABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTKl3KtKnTp1CjSp1KtSrLAFitar2ItevWrxK7ZgVLtqHYAGXTLvSqtq3bt3Djyp1Lt67du3jz6t3Lt6/fv4ADCx5MuLDhw4gTKyYcEAAh+QQJBQAAACxkABMABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxmABQABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxpABUABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxsABUABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxwABYABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx0ABYABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx4ABYABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx8ABUABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx+ABQABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACyAABIABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx+AA8ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx8AA4ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx4AA0ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACx0AA0ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxwAA0ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxsAA4ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxpAA4ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxmAA8ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxkABAABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxiABIABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxfABMABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxdABQABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxaABUABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxXABUABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxTABYABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxPABYABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxLABYABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxHABUABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxFABQABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxEABIABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxFAA8ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxHAA4ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxLAA0ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxPAA0ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxTAA0ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxXAA4ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxaAA4ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxdAA8ABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAAh+QQJBQAAACxfABAABQAFAIEAAAD///8AAAAAAAAIDwABBBgocKDBgwgLBgAQEAA7)" }`, backgroundSize: "cover", backgroundPosition: "center", //minHeight: "100px", // Adjust this value as needed }} >
      {question.text_1}

      {question.text_2}

      { handleCheck( index, iQuestion, e.currentTarget.checked, ); }} />
    • ))}
    )}
  • ); } })}
)}
{role === "admin" ? ( ) : }
); } else { bale.value = true; return null; } } export function findObjectById( quizStates: Signal[], idField: string, idValue: string, ) { for (let index = 0; index < quizStates.length; index++) { const quiz = quizStates[index]; if (quiz.value && (quiz.value as any)[idField] === idValue) { return { quiz, index }; } } return null; } export async function fetchWithRetry( url: string, options = {}, maxRetries = 3, timeout = 30000, ) { let retries = 0; while (retries < maxRetries) { try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); const response = await fetch(url, { ...options, signal: controller.signal, }); clearTimeout(id); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response; } catch (error: any) { retries++; console.warn(`Attempt ${retries} failed. Retrying...`); if (retries >= maxRetries) { throw new Error(`Max retries reached. Last error: ${error.message}`); } await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retries)) ); } } }