integratedhub/islands/UserDashboard.tsx
2026-01-20 09:02:26 -06:00

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))
);
}
}
}