1361 lines
52 KiB
TypeScript
1361 lines
52 KiB
TypeScript
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<QuizConfig>[];
|
|
userId: string;
|
|
role: string;
|
|
clearSig: Signal<number>;
|
|
setCanStudy: Signal<boolean>;
|
|
tabs: string[];
|
|
tab_index: number;
|
|
}
|
|
|
|
export type ListOfQuizLists = [QuizConfig, Dispatch<StateUpdater<QuizConfig>>];
|
|
|
|
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<Array<{ value: QuizConfig | null | undefined }>>(
|
|
[],
|
|
);
|
|
|
|
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<Map<number, number[]>>(
|
|
(() => {
|
|
const initMap = new Map<number, number[]>();
|
|
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<number, number[]>();
|
|
quizProps.forEach((_group, index) => {
|
|
initMap.set(index, []);
|
|
});
|
|
return initMap;
|
|
})();
|
|
lastClearSigValue = event;
|
|
} else if (event < 0) {
|
|
const newCheckedItems = new Map<number, number[]>();
|
|
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<number, number[]>(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<QuizConfig>[] = [];
|
|
|
|
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 (
|
|
<>
|
|
<div
|
|
className={`theme-container rounded-xl shadow-2xl p-8 max-w-md w-full`}
|
|
>
|
|
<h2 className="text-2xl font-bold mb-4">
|
|
Dashboard
|
|
</h2>
|
|
<p>No quizzes synced.</p>
|
|
</div>
|
|
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-auto">
|
|
{role === "admin"
|
|
? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
globalThis.location.href = "/language/admin";
|
|
}}
|
|
class={`w-[200px] theme-admin-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
|
|
>
|
|
Admin
|
|
</button>
|
|
)
|
|
: <span class={`w-[200px] py-3 px-6`}></span>}
|
|
<span class={`w-[16px]`}></span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
globalThis.location.href = "/language/lists";
|
|
}}
|
|
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
|
|
>
|
|
Add List
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<number, number[]>();
|
|
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<number, number[]> | 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}
|
|
<div
|
|
className={`theme-container rounded-xl shadow-2xl p-8 max-w-md w-full`}
|
|
>
|
|
<button
|
|
id="brain-button"
|
|
type="button"
|
|
onClick={() => {
|
|
const tabIndex = activeTabIndex ?? 0;
|
|
globalThis.location.href =
|
|
`/language/brain?tab_index=${tabIndex}`;
|
|
}}
|
|
className="fixed top-1 right-1 z-50 w-12 h-12 bg-blue-500 rounded-full shadow-lg flex flex-col items-center justify-center hover:bg-blue-600 transition-colors duration-200"
|
|
aria-label="Brain Study"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-4 w-4 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
|
/>
|
|
</svg>
|
|
<span className="text-white text-xs font-semibold">Auto</span>
|
|
</button>
|
|
<h2 className="text-2xl font-bold mb-2">
|
|
Dashboard ({sumOfChecks(checkedItems.value)})
|
|
</h2>
|
|
<div className="flex items-center justify-start mb-4 gap-3">
|
|
{activeTabIndex > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => navigateToTab(activeTabIndex - 1)}
|
|
className="px-3 py-2 text-sm font-medium theme-button-text bg-transparent border theme-border rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
{normalizedTabs[activeTabIndex - 1]}
|
|
</button>
|
|
)}
|
|
<div className="px-4 py-2 text-sm font-semibold bg-blue-600 text-white rounded-lg shadow-md">
|
|
{normalizedTabs[activeTabIndex] ?? "Default"}
|
|
</div>
|
|
{activeTabIndex < normalizedTabs.length - 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => navigateToTab(activeTabIndex + 1)}
|
|
className="px-3 py-2 text-sm font-medium theme-button-text bg-transparent border theme-border rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
{normalizedTabs[activeTabIndex + 1]}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{visibleQuizIndices.length === 0 && <p>No quizzes in this tab.</p>}
|
|
|
|
{(quizStates !== null && visibleQuizIndices.length > 0 &&
|
|
!visibleQuizIndices.some((idx) => {
|
|
const item = quizStates.value[idx];
|
|
return !item?.value || !item.value.questions;
|
|
})) && (
|
|
<ul className="space-y-4">
|
|
{visibleQuizIndices.map((index: number) => {
|
|
const quiz = quizStates.value[index]?.value;
|
|
if (!quiz || !quiz.questions) {
|
|
return (
|
|
<li
|
|
key={quiz?.quiz_config_id || `quiz-${index}`}
|
|
className="border-b border-gray-200 pb-4"
|
|
>
|
|
<p>Please wait for the quiz to load.</p>
|
|
</li>
|
|
);
|
|
} else {
|
|
const editable = quiz?.owner_id === userId;
|
|
return (
|
|
<li
|
|
key={quiz?.quiz_config_id || `quiz-${index}`}
|
|
className={`border-b theme-border pb-4`}
|
|
>
|
|
<div className="flex justify-between items-center w-full">
|
|
<div className="w-[300px] mx-0 px-0">
|
|
<button
|
|
type="button"
|
|
className={`font-semibold hover:underline text-left truncate w-full theme-button-text`}
|
|
title={quiz?.quiz_name + " (" +
|
|
quiz?.questions.length +
|
|
")"}
|
|
onClick={(_e) => {
|
|
quiz?.questions?.forEach(
|
|
(_: Question, qIndex: number) => {
|
|
handleCheck(
|
|
index,
|
|
qIndex,
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
{quiz?.quiz_name + " (" +
|
|
checkedItems.value.get(index)?.length + ")"}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={async (e) => {
|
|
e.preventDefault();
|
|
|
|
const postData = JSON.stringify({
|
|
quiz: quiz!,
|
|
question: null,
|
|
index: index,
|
|
iQuestion: -1,
|
|
quizKey: quizProps[index].key,
|
|
action: "delete",
|
|
props: reassemblePageProps(),
|
|
});
|
|
|
|
await handleDashboardPost(postData, index);
|
|
}}
|
|
className={`pr-1 rounded-full hover:bg-opacity-80 transition-colors duration-200 mr-0 text-red-600 hover:text-red-800 theme-icon-button`}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{editable
|
|
? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
startQuizEditor(quiz!);
|
|
}}
|
|
className={`pr-2 pl-0 rounded-full hover:bg-opacity-80 transition-colors duration-200 mx-0 theme-icon-button`}
|
|
aria-label="Edit quiz"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-5 w-5"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
</svg>
|
|
</button>
|
|
)
|
|
: (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
downloadQuiz(quiz!);
|
|
}}
|
|
className={`pr-2 pl-0 rounded-full hover:bg-opacity-80 transition-colors duration-200 mx-0 theme-icon-button`}
|
|
aria-label="Download quiz"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-5 w-5"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
toggleQuiz(quiz?.quiz_config_id!, quiz!)}
|
|
className="text-2xl w-[31px] bg-linear-to-r from-blue-400 to-blue-600 rounded-full hover:bg-opacity-80 transition-colors focus:outline-none"
|
|
>
|
|
{expandedQuizId.value === quiz?.quiz_config_id
|
|
? "v"
|
|
: "<"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{expandedQuizId.value === quiz?.quiz_config_id && (
|
|
<div id={`quiz-expand-top-${quiz?.quiz_config_id}`}>
|
|
</div>
|
|
)}
|
|
{expandedQuizId.value === quiz?.quiz_config_id && (
|
|
<ul className="mt-2 space-y-2">
|
|
{quiz?.questions?.map((
|
|
question: Question,
|
|
iQuestion: number,
|
|
) => (
|
|
<li key={question.id} className="pl-4 text-black">
|
|
<div
|
|
className="flex justify-between items-center w-full p-0"
|
|
style={{
|
|
backgroundImage:
|
|
`${
|
|
(question?.background_image_data &&
|
|
(question?.background_image_data
|
|
.startsWith("http") ||
|
|
question?.background_image_data
|
|
.startsWith("/")))
|
|
? "url(" +
|
|
CSS.escape(
|
|
decodeKvURL(
|
|
question.background_image_data,
|
|
),
|
|
) + ")"
|
|
: question?.background_image_data
|
|
.length >
|
|
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
|
|
}}
|
|
>
|
|
<div className="flex-grow">
|
|
<div className="flex items-center">
|
|
<div className="absolute pl-5 -left-0 flex flex-col gap-1 mt-6">
|
|
<button
|
|
id="audio1_button"
|
|
type="button"
|
|
onClick={(e) => {
|
|
postAction.value = "audio";
|
|
postMessage.value =
|
|
"Confirm to save the audio to the quiz.";
|
|
e.preventDefault();
|
|
question.quiz_id =
|
|
quiz.quiz_config_id;
|
|
viewAudioPopup1(
|
|
question,
|
|
index,
|
|
iQuestion,
|
|
role,
|
|
).then((blobAudio) => {
|
|
if (blobAudio) {
|
|
question.audio_file_1 = blobAudio;
|
|
}
|
|
});
|
|
}}
|
|
className={`rounded-full hover:bg-opacity-80 transition-colors duration-200 theme-icon-button`}
|
|
aria-label="Record audio for text 1"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={`h-6 w-6 rounded-full ${
|
|
question.audio_file_1
|
|
? "bg-blue-500"
|
|
: "bg-white bg-opacity-60"
|
|
}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
id="audio2_button"
|
|
type="button"
|
|
onClick={(e) => {
|
|
postAction.value = "audio";
|
|
postMessage.value =
|
|
"Confirm to save the audio to the quiz.";
|
|
e.preventDefault();
|
|
question.quiz_id =
|
|
quiz.quiz_config_id;
|
|
viewAudioPopup2(
|
|
question,
|
|
index,
|
|
iQuestion,
|
|
role,
|
|
).then((blobAudio) => {
|
|
if (blobAudio) {
|
|
question.audio_file_2 = blobAudio;
|
|
}
|
|
});
|
|
}}
|
|
className={`rounded-full hover:bg-opacity-80 transition-colors duration-200 theme-icon-button`}
|
|
aria-label="Record audio for text 2"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={`h-6 w-6 rounded-full ${
|
|
question.audio_file_2
|
|
? "bg-blue-500"
|
|
: "bg-white bg-opacity-60"
|
|
}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<span
|
|
className={`strokelittle theme-question-text`}
|
|
>
|
|
{question.text_1}
|
|
</span>
|
|
</div>
|
|
<p>
|
|
<span
|
|
className={`strokelittle theme-question-text`}
|
|
>
|
|
{question.text_2}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex-shrink-0 ml-8">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
postAction.value = "image";
|
|
postMessage.value =
|
|
"Confirm to save the image to the quiz.";
|
|
//console.log("set value");
|
|
e.preventDefault();
|
|
question.quiz_id = quiz.quiz_config_id;
|
|
// viewImagePopup handles all updates and signal reactivity internally
|
|
viewImagePopup(
|
|
question,
|
|
index,
|
|
iQuestion,
|
|
role,
|
|
);
|
|
}}
|
|
className={`p-2 rounded-full hover:bg-opacity-80 transition-colors duration-200 theme-icon-button`}
|
|
aria-label="View question photo"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-6 w-6 rounded-full bg-white bg-opacity-60"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<input
|
|
className="h-6 w-6 rounded-full bg-white bg-opacity-60"
|
|
type="checkbox"
|
|
checked={checkedItems.value?.get(index)
|
|
?.includes(
|
|
iQuestion,
|
|
) || false}
|
|
onChange={(e: any) => {
|
|
handleCheck(
|
|
index,
|
|
iQuestion,
|
|
e.currentTarget.checked,
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
})}
|
|
</ul>
|
|
)}
|
|
<form
|
|
id="studyrun"
|
|
hidden
|
|
action="/language/set-quiz"
|
|
method="POST"
|
|
className="w-[300px] mx-0 px-0"
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="quizConfigData"
|
|
value={(() => {
|
|
const studySet: any[] = [];
|
|
for (let i = 0; i < quizProps.length; ++i) {
|
|
if (checkedItems.value.has(i)) {
|
|
const checkQuestions = checkedItems.value.get(i);
|
|
if (checkQuestions) {
|
|
const quiz = quizStates.value[i].value;
|
|
|
|
checkQuestions.forEach((k: number) => {
|
|
const question = quiz?.questions[k];
|
|
if (question) {
|
|
studySet.push(question);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const newKvObj = {
|
|
value: {
|
|
questions: studySet,
|
|
},
|
|
};
|
|
const str = JSON.stringify({
|
|
theUpdate: -1,
|
|
quiz: newKvObj.value,
|
|
|
|
quizOrder: {
|
|
index: -1,
|
|
slots: quizStates.value.map(
|
|
(quiz: any, index: number) => {
|
|
return quiz.value.quiz_name + " (" +
|
|
quiz.value.questions.length +
|
|
")";
|
|
},
|
|
),
|
|
},
|
|
});
|
|
return str;
|
|
})() as string}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="quizMode"
|
|
value={"photo-see-bisaya"}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
checkedItems.value = (() => {
|
|
const initMap = new Map<number, number[]>();
|
|
quizProps.forEach((group, index) => {
|
|
initMap.set(index, []);
|
|
});
|
|
return initMap;
|
|
})();
|
|
}}
|
|
/>
|
|
</form>
|
|
<form
|
|
id="arrangement"
|
|
hidden
|
|
action="/arrange-quiz"
|
|
method="POST"
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="quizConfigData"
|
|
value={(() => {
|
|
const studySet: any[] = [];
|
|
for (let i = 0; i < quizProps.length; ++i) {
|
|
if (checkedItems.value.has(i)) {
|
|
const checkQuestions = checkedItems.value.get(i);
|
|
if (checkQuestions) {
|
|
const quiz = quizStates.value[i].value;
|
|
|
|
checkQuestions.forEach((k: number) => {
|
|
const question = quiz?.questions[k];
|
|
if (question) {
|
|
studySet.push(question);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const newKvObj = {
|
|
value: {
|
|
questions: studySet,
|
|
},
|
|
};
|
|
const str = JSON.stringify({
|
|
theUpdate: -1,
|
|
quiz: newKvObj.value,
|
|
|
|
quizOrder: {
|
|
index: -1,
|
|
slots: quizStates.value.map(
|
|
(quiz: any, index: number) => {
|
|
return quiz.value.quiz_name + " (" +
|
|
quiz.value.questions.length +
|
|
")";
|
|
},
|
|
),
|
|
},
|
|
});
|
|
return str;
|
|
})() as string}
|
|
/>
|
|
</form>
|
|
<div class="h-16"></div>
|
|
</div>
|
|
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-auto">
|
|
{role === "admin"
|
|
? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
globalThis.location.href = "/language/admin";
|
|
}}
|
|
class={`w-[200px] theme-admin-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
|
|
>
|
|
Admin
|
|
</button>
|
|
)
|
|
: <span class={`w-[200px] py-3 px-6`}></span>}
|
|
<span class={`w-[16px]`}></span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
globalThis.location.href = "/language/lists";
|
|
}}
|
|
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
|
|
>
|
|
Add List
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
} else {
|
|
bale.value = true;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function findObjectById(
|
|
quizStates: Signal<QuizConfig | null | undefined>[],
|
|
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))
|
|
);
|
|
}
|
|
}
|
|
}
|