diff --git a/TODO.md b/TODO.md index 357f870..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,304 +0,0 @@ -# CyberHybridHub TODO - -## High Priority 🔴 - -### Error Handling Improvements - -- [ ] **Replace console.log statements with proper logging** - - routes/api/payments/set_auto_pay.ts (line 80) - - routes/api/payments/cancel_auto_pay.ts (line 65) - - routes/tenant/settings/index.tsx (line 50) - - routes/tenant/payment-methods/index.tsx (lines 33, 43) - - Use utils/logging.ts functions (writeAudioLog, writeImageLog, logBlobFetch) or create new logging utilities - -- [ ] **Standardize error handling across API routes** - - Many catch blocks use generic error handling without proper logging - - Some routes return `JSON.stringify(e)` which may not be safe - - Create proper error classes and error types - - Implement consistent error response format - -- [ ] **Add proper error logging infrastructure** - - Extend utils/logging.ts with general error logging functions - - Add request ID tracking for better debugging - - Implement structured logging with log levels (error, warn, info, debug) - -### TypeScript Type Safety - -- [ ] **Replace `any` types with proper TypeScript types** - - **Utils & Types:** - - utils/request_handler.ts (line 7) - - types/graph/ServicePrincipal.ts (lines 34, 43, 45, 46) - - utils/TextToQuizTranslations.ts (line 33) - - db/interfaces.ts (lines 60, 204, 251) - - **Islands:** - - islands/AsyncImageGenerater.tsx (line 35) - - islands/ClearCheckedButton.tsx (lines 4-5) - - islands/SyncButton.tsx (line 7) - - islands/ArrangeQuiz.tsx (lines 145, 190, 235, 254) - - islands/Card.tsx (line 332) - - islands/StudyButton.tsx (line 7) - - islands/AdminPage.tsx (line 187) - - islands/dialogs/AddRentalDialog.tsx (line 4) - - islands/dialogs/AddLeaseDialog.tsx (line 4) - - islands/TranslationList.tsx (line 273) - - islands/UserDashboard.tsx (lines 1136, 1167, 1196, 1235, 1264, 1349) - - **Routes:** - - routes/auth/validate.ts (line 96) - - routes/landlord/onboarding/profile.tsx (line 111) - - routes/api/update/payment_method.ts (line 51) - - routes/landlord/maintenance/index.tsx (line 68) - - **Services & Modules:** - - services/stateMiddleware.ts (line 104) - - modules/KvCacheStore.ts (lines 635, 636, 839, 850, 858) - - **Tests:** - - tests/e2e/helpers.ts (line 12) - -- [ ] **Add proper type definitions for event handlers** - - Create shared types for form event handlers - - Define proper types for onChange, onSubmit, etc. - - Use Preact's specific event types - -## Medium Priority 🟡 - -### API Endpoints & Services - -- [ ] **Create reminder API endpoints** - - POST /api/create/reminder - Create new reminder - - GET /api/retrieve/reminders - Get reminders for user - - PUT /api/update/reminder - Update reminder - - DELETE /api/delete/reminder - Delete reminder - - PUT /api/update/reminder/complete - Mark reminder as completed - - Service exists: services/reminderService.ts - -- [ ] **Create financial data API endpoints** - - GET /api/retrieve/obligations - Get user obligations - - GET /api/retrieve/incomes - Get user incomes - - Service exists: services/financhalServices.ts - -- [ ] **Verify all API endpoints have proper error handling** - - Review all routes/api/create/* files - - Review all routes/api/update/* files - - Review all routes/api/delete/* files - - Review all routes/api/retrieve/* files - - Ensure consistent error response format - -### Code Quality & Testing - -- [ ] **Run and fix TypeScript compilation errors** - - Run `deno check` to identify issues - - Fix all type mismatches - - Ensure all files pass type checking - -- [ ] **Validate all changed files** - - Run `deno fmt` for formatting - - Run `deno lint` for code quality - - Run `deno check` for type checking - -- [ ] **Add unit tests for services** - - services/paymentService.ts - - services/leaseService.ts - - services/reminderService.ts - - services/financhalServices.ts - - services/tenantInviteService.ts - -- [ ] **Add integration tests for API endpoints** - - Test all create endpoints - - Test all update endpoints - - Test all delete endpoints - - Test all retrieve endpoints - -### Database & Schema - -- [ ] **Verify database schema supports all features** - - Check lease table supports required states (new, signed, active) - - Verify tenant_id column is nullable in lease table - - Confirm all required fields exist for reminders - - Validate financial tables (obligations, incomes) schema - -- [ ] **Add database migration documentation** - - Document existing migrations in drizzle/ directory - - Add migration rollback procedures - - Document schema evolution - -## Low Priority 🟢 - -### Documentation - -- [ ] **Update README.md with setup instructions** - - Add environment variable configuration guide - - Document all required environment variables - - Add troubleshooting section - -- [ ] **Add API documentation** - - Document all API endpoints - - Include request/response examples - - Add authentication requirements - -- [ ] **Create developer onboarding guide** - - Project structure overview - - Development workflow - - Testing guidelines - - Code style guidelines - -### Performance & Optimization - -- [ ] **Optimize database queries** - - Review all service functions for N+1 queries - - Add proper indexes where needed - - Implement query result caching - -- [ ] **Add request/response caching** - - Cache frequently accessed data - - Implement cache invalidation strategy - - Use Deno KV for caching (already configured) - -- [ ] **Optimize bundle size** - - Analyze production bundle - - Remove unused dependencies - - Implement code splitting - -### Security - -- [ ] **Add input validation middleware** - - Validate all incoming request data - - Sanitize user inputs - - Prevent injection attacks - -- [ ] **Add rate limiting** - - Implement rate limiting for API endpoints - - Add rate limiting for authentication endpoints - - Configure rate limits per endpoint type - -- [ ] **Add security headers** - - Content Security Policy (CSP) - - X-Frame-Options - - X-Content-Type-Options - - Strict-Transport-Security (HSTS) - -## Completed Items ✅ - -### Lease Creation Feature Implementation - -- [x] **Implement `getTransactionByStripeId(stripeId: string)`** in `services/paymentService.ts` - - ✅ EXISTS at line 391 - - Used by routes/api/receipt/[id].ts and routes/api/payments/webhook.ts - -- [x] **POST `/api/leases`** - Create new lease endpoint - - ✅ EXISTS at routes/api/create/lease.ts - - Request body validation (rent, deposit, propertyId, dates) - - Database insertion with `new` state - - Returns created lease object - -- [x] **PUT `/api/leases/:id/invite`** - Send tenant invitation - - ✅ EXISTS at routes/api/update/lease_invite.ts - - Generates invitation token - - Creates tenant invite - - Updates lease status - -- [x] **PUT `/api/leases/:id/accept`** - Tenant accepts invitation - - ✅ EXISTS at routes/api/update/lease_accept.ts - - Validates invitation token - - Updates lease tenant field and status to `signed` - -- [x] **Create lease creation form component** - - ✅ AddLeaseDialog.tsx exists - - ✅ PopAddLeaseDialog.tsx exists - - Rent amount input field - - Security deposit amount input field - - Property selection dropdown - - Lease term start/end date pickers - - Form validation and error handling - -- [x] **Add "Create New Lease" button on landlord dashboard** - - ✅ Available at /leased-home route - - ✅ LeasedHome island component exists - -- [x] **Tenant invitation interface** - - ✅ LandlordInviteTenant.tsx exists - - Email input and invitation sending UI - -## Next Steps -2. Replace console.log statements with proper logging -3. Standardize error handling across all API routes -4. Replace TypeScript any types with proper types -5. Create missing API endpoints for reminders and financial data -6. Add comprehensive error logging infrastructure -7. Run deno check, fmt, and lint to fix code quality issues -8. Add unit and integration tests for services and API endpoints -9. Update documentation with setup and API guides -10. Implement performance optimizations and security enhancements - -#### Frontend Components -- [ ] **Create lease creation form component** (`components/LeaseForm.tsx`) - - Rent amount input field - - Security deposit amount input field - - Property selection dropdown - - Lease term start/end date pickers - - Form validation and error handling -- [ ] **Add "Create New Lease" button next to "Add Property" button** on landlord dashboard (`/dashboard/landlord` route) -- [ ] **Create lease creation modal/page** (`routes/landlord/create-lease.tsx`) -- [ ] **Tenant invitation interface** - Email input and invitation sending UI - -#### Backend API Endpoints -- [ ] **POST `/api/leases`** - Create new lease endpoint - - Request body validation (rent, deposit, propertyId, dates) - - Database insertion with `new` state - - Return created lease object -- [ ] **PUT `/api/leases/:id/invite`** - Send tenant invitation - - Generate invitation token - - Send email to tenant - - Update lease status -- [ ] **PUT `/api/leases/:id/accept`** - Tenant accepts invitation - - Validate invitation token - - Update lease tenant field and status to `signed` - -## Medium Priority 🟡 - -### Payment Service Fixes -- [ ] **Implement `getTransactionByStripeId(stripeId: string)`** in `services/paymentService.ts` - - Query database by stripe transaction ID - - Return transaction object or null -- [ ] **Fix TypeScript import errors** in receipt API routes - - Check all `routes/api/receipts/` files - - Fix relative import paths -- [ ] **Add proper TypeScript types** for Fresh route handlers - - Define request/response types for API endpoints - - Update handler function signatures - -### Code Quality & Testing -- [ ] **Fix TypeScript compilation errors** - - Run `deno check` to identify issues - - Fix all type mismatches -- [ ] **Validate all changed files** - - Run `deno fmt` for formatting - - Run `deno lint` for code quality - - Run `deno check` for type checking -- [ ] **Test lease creation workflow end-to-end** - - Manual testing in development environment - - Verify database state changes - - Test tenant invitation flow - -## Low Priority 🟢 - -### Testing Infrastructure -- [ ] **Unit tests for lease creation** (`tests/unit/lease.test.ts`) -- [ ] **Integration tests for tenant invitation flow** (`tests/integration/invitation.test.ts`) -- [ ] **E2E tests for complete lease lifecycle** (`tests/e2e/lease-lifecycle.spec.ts`) -- [ ] **Payment service integration tests** (`tests/integration/payment.test.ts`) - -## Database Schema Verification Needed -- [ ] **Verify lease table supports required states** - - Check `state` column accepts `new` and `signed` - - Verify `tenant_id` column is nullable - - Confirm all required fields exist - -## Current Blockers -- ❌ **TypeScript compilation errors** preventing development -- ❌ **Missing payment service functions** breaking receipt functionality - -## Next Steps -1. Fix immediate TypeScript and payment service issues -2. Implement lease creation API endpoints -3. Create frontend components for lease management -4. Test complete workflow end-to-end - diff --git a/islands/ArrangeQuiz.tsx b/islands/ArrangeQuiz.tsx deleted file mode 100644 index cca5dd6..0000000 --- a/islands/ArrangeQuiz.tsx +++ /dev/null @@ -1,372 +0,0 @@ -// islands/QuizConfigure.tsx -import { useEffect, useState } from "preact/hooks"; -import { QuizConfig } from "../utils/types.ts"; -import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx"; - -export interface ArrangementProps { - quizConfig: QuizConfig; - // kvObj: KvObj; - quizOrder: { index: number; slots: string[] }; - quizMode: string; - tabs: string[]; -} - -const NEW_TAB_VALUE = "__new_tab__"; - -export default function ArrangeQuiz( - { quizConfig, quizOrder, quizMode, tabs }: ArrangementProps, -) { - // Theme classes are defined in styles.css with @media (prefers-color-scheme: dark) - - const [ask, popupElement] = AsyncYesNoPop(); - const [thisQuizConfig, setQuizConfig] = useState(quizConfig); - const [orderedQuizStr, setOrderQuizStr] = useState( - JSON.stringify(thisQuizConfig), - ); - const [thisQuizConfigStr, setQuizConfigStr] = useState( - JSON.stringify({ - theUpdate: null, - quiz: quizConfig, - //kvObj: kvObj, - quizOrder: quizOrder, - }), - ); - const [selectedSlot, setSelectedSlot] = useState(0); - const [thisQuizOder, setThisQuizOrder] = useState(quizOrder); - const [randomOrder, setRandomOrder] = useState(true); - const [modeType, setModeType] = useState(quizMode); - //const [isLoading, setIsLoading] = useState(true); - - // Tab selection state - const [selectedTabIndex, setSelectedTabIndex] = useState< - number | typeof NEW_TAB_VALUE - >(-1); - const [newTabName, setNewTabName] = useState(""); - const isNewTab = selectedTabIndex === NEW_TAB_VALUE; - const isExistingTab = typeof selectedTabIndex === "number" && - selectedTabIndex >= 0; - - const [count, setCount] = useState(0); - - const handleClick = () => { - setCount((prev) => { - const newCount = prev + 1; - return newCount; - }); - }; - - const handleSaveQuiz = async (event: Event) => { - //console.log("Clicked save"); - //if (changeMode && changeMode.mode) { - // setQuizConfigStr(JSON.stringify({ theUpdate:changeMode.mode, quiz: thisQuizConfig, kvObj:kvObj,quizOrder:thisQuizOder })); - // handleClick(); - event.preventDefault(); - //value={JSON.stringify({ theUpdate:null, quiz: quiz , kvObj:quizProps[index] ? cloneObjectExcept(quizProps[index],["value"]) as KvObj: null})} - - const form = event.currentTarget as HTMLFormElement; - - const userConfirmed = await ask( - "Do you want to save this change?", - Date.now(), - ); - - if (userConfirmed) { - form.submit(); - } - //const userConfirmed = window.confirm("Do you wanted to save changes?"); - - // if (userConfirmed) { - // (event.currentTarget as HTMLFormElement).submit(); - // } - //} else { - // event.preventDefault(); - //} - }; - - useEffect(() => { - //console.log("setting mode!!",quizConfig.quiz_mode) - if (quizConfig.quiz_mode) { - setModeType(quizConfig.quiz_mode); - } - }, []); - - useEffect(() => { - // Initialize selectedTabIndex to 0 when tabs are available and no tab is selected - if (tabs.length > 0 && selectedTabIndex === -1) { - setSelectedTabIndex(0); - } - }, [tabs, selectedTabIndex]); - - return ( - <> - {popupElement} -
- - -

- Arrange Quizzes -

- -
-

- Dashboard Quiz List -

-
- - - {/* Step 1: Select a quiz */} -
- - -
- - {/* Step 2: Select a tab (only shown after quiz is selected) */} - {thisQuizOder.index != -1 && ( -
- - -
- )} - - {/* Step 3a: New tab name input (only shown if "New Tab" is selected) */} - {thisQuizOder.index != -1 && isNewTab && ( -
- - setNewTabName(e.target.value)} - placeholder="Enter new tab name" - className="w-full px-3 py-2 rounded-md theme-input border theme-border" - /> -
- )} - - {/* Step 3b: Position selector (only shown if existing tab is selected) */} - {thisQuizOder.index != -1 && isExistingTab && ( -
- - -
- )} - - {/* Save button for moving to new tab */} - {thisQuizOder.index != -1 && isNewTab && - newTabName.trim().length > 0 && ( -
- -
- )} - - {/* Save button for reordering position within tab */} - {thisQuizOder.index != -1 && isExistingTab && - thisQuizOder.index !== selectedSlot && ( -
- -
- )} - - {/* Save button for moving to different existing tab (same position) */} - {thisQuizOder.index != -1 && isExistingTab && - thisQuizOder.index === selectedSlot && ( -
- -
- )} -
-
-
-
-
- - - -
-
- - ); -} diff --git a/islands/AsyncVoiceRecorder.tsx b/islands/AsyncVoiceRecorder.tsx deleted file mode 100644 index 9a29771..0000000 --- a/islands/AsyncVoiceRecorder.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// islands/AsyncVoiceRecorder.ts -import { useRef, useState } from "preact/hooks"; -import VoiceRecorder from "./VoiceRecorder.tsx"; -import { AudioFile } from "../utils/types.ts"; - -export function AsyncVoiceRecorder() { - const queueRef = useRef< - { - audioFile: AudioFile; - role: string; - resolve: (value: Blob | null) => void; - }[] - >([]); - const [popup, setPopup] = useState< - { - audioFile: AudioFile; - role: string; - resolve: (value: Blob | null) => void; - } | null - >(null); - - // Show the popup and return a Promise for the answer - const record = ( - audioFile: AudioFile, - role: string, - ): Promise => { - return new Promise((resolve) => { - const req = { audioFile, role, resolve }; - - setPopup((current) => { - if (current) { - queueRef.current.push(req); - return current; - } - return req; - }); - }); - }; - - // Render the popup if needed - const popupRecorder = popup - ? ( - { - setPopup((current) => { - if (current) { - current.resolve(answer); - } - const next = queueRef.current.shift() ?? null; - return next; - }); - }} - /> - ) - : null; - - return [record, popupRecorder] as const; -} diff --git a/islands/BrainStudy.tsx b/islands/BrainStudy.tsx deleted file mode 100644 index b466cd0..0000000 --- a/islands/BrainStudy.tsx +++ /dev/null @@ -1,770 +0,0 @@ -// islands/BrainStudy.tsx -// Brain study component with locked-set mastery behavior -// Cards must be mastered through all 6 modes before new cards are introduced - -import { useEffect, useRef, useState } from "preact/hooks"; -import { signal } from "@preact/signals"; -import Card from "./Card.tsx"; -import { BrainQueueItem } from "../utils/types.ts"; - -// Import mode utilities (these will be inlined for client-side use) -const QUIZ_MODES = [ - "photo-see-bisaya", - "photo-see-english", - "photo-hide-bisaya", - "photo-hide-english", - "blank-see-bisaya", - "blank-see-english", -] as const; - -const MODE_LABELS: Record = { - "photo-see-bisaya": "Photo, See Bisaya", - "photo-see-english": "Photo, See English", - "photo-hide-bisaya": "Blank, Hide Bisaya", - "photo-hide-english": "Photo, Hide English", - "blank-see-bisaya": "Blank, See Bisaya", - "blank-see-english": "Blank, See English", -}; - -function getModeDisplayInfo(mode: string) { - const index = QUIZ_MODES.indexOf(mode as typeof QUIZ_MODES[number]); - const safeIndex = index >= 0 ? index : 0; - return { - index: safeIndex + 1, - total: QUIZ_MODES.length, - label: MODE_LABELS[mode] || mode, - isFinal: safeIndex === QUIZ_MODES.length - 1, - }; -} - -function getNextMode(currentMode: string): string | null { - const index = QUIZ_MODES.indexOf(currentMode as typeof QUIZ_MODES[number]); - if (index >= 0 && index < QUIZ_MODES.length - 1) { - return QUIZ_MODES[index + 1]; - } - return null; // Already at final mode -} - -interface BrainStudyProps { - userId: string; - role: string; - initialMode: string; - tab_index?: number; -} - -interface QueueResponse { - success: boolean; - queue: BrainQueueItem[]; - stats: { - totalReviewed: number; - totalCorrect: number; - dueNow: number; - accuracy: number; - reviewOnlyCount?: number; - }; - mode: string; - totalInQueue: number; - dueCount: number; - newCount: number; - activeSetCount?: number; -} - -interface SessionStats { - reviewed: number; - correctFirstTry: number; - sessionStart: number; - modeAdvances: number; // Track how many cards advanced to next mode - cardsRetired: number; // Cards that completed all 6 modes this session -} - -// Extended queue item for client-side tracking -interface ActiveQueueItem extends BrainQueueItem { - // Client-side tracking - clientModeIndex: number; // Current mode being studied (client-side, may differ from server after correct answer) - clientDisplayMode: string; // Current mode for display - hasRetired: boolean; // Has completed all 6 modes -} - -const useInputHint = signal(false); - -export default function BrainStudy( - { userId, role, initialMode, tab_index }: BrainStudyProps, -) { - const [loading, setLoading] = useState(true); - const [hasStarted, setHasStarted] = useState(false); - // Active set: the locked set of cards being mastered - const [activeSet, setActiveSet] = useState([]); - // Pool of available new cards (from server) that haven't been added to active set yet - const [newCardPool, setNewCardPool] = useState([]); - // Current position in active set rotation - const [currentIndex, setCurrentIndex] = useState(0); - const [sessionStats, setSessionStats] = useState({ - reviewed: 0, - correctFirstTry: 0, - sessionStart: Date.now(), - modeAdvances: 0, - cardsRetired: 0, - }); - const [overallStats, setOverallStats] = useState({ - totalReviewed: 0, - totalCorrect: 0, - dueNow: 0, - accuracy: 0, - reviewOnlyCount: 0, - }); - const [isComplete, setIsComplete] = useState(false); - const [error, setError] = useState(null); - const [isFirstAttempt, setIsFirstAttempt] = useState(true); - const [cardKey, setCardKey] = useState(0); - - // Card state - const [cardInputParams, setCardInputParams] = useState({ - setFullAuto: (_auto: boolean) => {}, - runFullAuto: false, - setTogglePause: (_fn: () => void) => {}, - setIsPausedDisplay: (_paused: boolean) => {}, - questionPhrase: "", - answerPhrase: "", - nextButton: () => {}, - audio1: "", - audio2: "", - onHelpUsed: () => {}, - flipped: false, - }); - const [backgroundUrl, setBackgroundUrl] = useState(""); - - // Track first attempt per question - const firstAttemptRef = useRef(true); - - // Maximum active set size - const MAX_ACTIVE_SET_SIZE = 10; - - // Fetch queue on mount - useEffect(() => { - fetchQueue(); - }, []); - - // Convert server queue items to active queue items with client-side tracking - const initializeActiveSet = (items: BrainQueueItem[]): ActiveQueueItem[] => { - return items.map((item) => ({ - ...item, - clientModeIndex: item.mode_index, - clientDisplayMode: item.display_mode, - hasRetired: false, - })); - }; - - const fetchQueue = async () => { - setLoading(true); - setError(null); - try { - // Request more cards than we need for the active set so we have a pool to draw from - // Include tab_index if provided to filter quiz configs by tab - const tabParam = tab_index !== undefined ? `&tab_index=${tab_index}` : ""; - const response = await fetch( - `/api/brain/queue?limit=100&newLimit=100${tabParam}`, - ); - if (!response.ok) { - throw new Error("Failed to fetch queue"); - } - const data: QueueResponse = await response.json(); - if (data.success) { - // Separate the queue into active set (up to MAX_ACTIVE_SET_SIZE) and pool - - const allCards = data.queue.filter((q) => !q.is_review_only); - - const initialActiveCards = allCards.slice(0, MAX_ACTIVE_SET_SIZE); - const remainingPool = allCards.slice(MAX_ACTIVE_SET_SIZE); - - setActiveSet(initializeActiveSet(initialActiveCards)); - setNewCardPool(remainingPool); - setOverallStats({ - ...data.stats, - reviewOnlyCount: data.stats.reviewOnlyCount ?? 0, - }); - setCurrentIndex(0); - setIsComplete(allCards.length === 0); - } else { - throw new Error("Failed to build queue"); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); - } finally { - setLoading(false); - } - }; - - /** - * Configure card display based on mode - * Each mode determines: - * - What text is shown (questionPhrase) - * - What the user types (answerPhrase) - * - Whether to show the photo (backgroundUrl) - * - Whether to hide certain text (done via questionPhrase) - * - * Both audio tracks always play for every mode. - */ - const loadCard = (item: ActiveQueueItem) => { - const question = item.question_data; - // Use client-side mode tracking for display - const mode = item.clientDisplayMode; - firstAttemptRef.current = true; - setIsFirstAttempt(true); - - let questionPhrase = ""; - let answerPhrase = ""; - let showPhoto = true; - let flipped = false; - - // Configure based on mode - switch (mode) { - case "photo-see-bisaya": - // Show photo, display Bisaya text, user types English - questionPhrase = question.text_1; // Bisaya - answerPhrase = question.text_2; // English - showPhoto = true; - flipped = false; - break; - - case "photo-see-english": - // Show photo, display English text, user types Bisaya - questionPhrase = question.text_2; // English - answerPhrase = question.text_1; // Bisaya - showPhoto = true; - flipped = true; - break; - - case "photo-hide-bisaya": - // Show photo, hide Bisaya (rely on audio), user types Bisaya - questionPhrase = ""; - answerPhrase = question.text_2; // Bisaya - showPhoto = false; - flipped = false; - break; - - case "photo-hide-english": - // Show photo, hide English, user types English - questionPhrase = ""; - answerPhrase = question.text_1; // English - showPhoto = true; - flipped = true; - break; - - case "blank-see-bisaya": - // No photo, display Bisaya text, user types English - questionPhrase = question.text_2; // Bisaya - answerPhrase = question.text_1; // English - showPhoto = false; - flipped = true; - break; - - case "blank-see-english": - // No photo, display English text, user types Bisaya - questionPhrase = question.text_1; // English - answerPhrase = question.text_2; // Bisaya - showPhoto = false; - flipped = false; - break; - - default: - // Fallback to photo-see-english behavior - questionPhrase = question.text_2; - answerPhrase = question.text_1; - showPhoto = true; - } - - setCardInputParams({ - setFullAuto: () => {}, - runFullAuto: false, - setTogglePause: () => {}, - setIsPausedDisplay: () => {}, - questionPhrase, - answerPhrase, - nextButton: () => handleNextCard(true), - // Both audio tracks always provided for every mode - audio1: question.audio_file_1, - audio2: "", - onHelpUsed: handleWrongAttempt, - flipped, - }); - - // Set background only if mode requires photo - setBackgroundUrl(showPhoto ? (question.background_image_data || "") : ""); - setCardKey((prev) => prev + 1); - }; - - // Load the current card whenever the active set or index changes - useEffect(() => { - if (!hasStarted) return; - if (!activeSet.length) return; - if (isComplete) return; - // Wrap around if we've gone past the end (for rotation) - const safeIndex = currentIndex % activeSet.length; - if (safeIndex !== currentIndex) { - setCurrentIndex(safeIndex); - return; - } - loadCard(activeSet[safeIndex]); - }, [activeSet, currentIndex, isComplete, hasStarted]); - - const recordProgress = async ( - wasCorrectFirstTry: boolean, - ): Promise<{ modeAdvanced: boolean; isReviewOnly: boolean } | null> => { - const safeIndex = currentIndex % activeSet.length; - const currentItem = activeSet[safeIndex]; - if (!currentItem) return null; - - try { - const response = await fetch("/api/brain/progress", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - questionId: currentItem.question_id, - quizId: currentItem.quiz_id, - wasCorrectFirstTry, - }), - }); - - if (response.ok) { - const data = await response.json(); - return { - modeAdvanced: data.modeAdvanced ?? false, - isReviewOnly: data.isReviewOnly ?? false, - }; - } - } catch (err) { - console.error("Failed to record progress:", err); - } - return null; - }; - - // Pull cards from the pool to fill the active set - const refillActiveSet = () => { - if (newCardPool.length === 0) return; - - const currentActiveCount = - activeSet.filter((item) => !item.hasRetired).length; - const slotsAvailable = MAX_ACTIVE_SET_SIZE - currentActiveCount; - - if (slotsAvailable > 0 && newCardPool.length > 0) { - const cardsToAdd = newCardPool.slice(0, slotsAvailable); - const remainingPool = newCardPool.slice(slotsAvailable); - - setNewCardPool(remainingPool); - setActiveSet((prev) => { - // Filter out retired cards and add new ones - const activeCards = prev.filter((item) => !item.hasRetired); - const newActiveItems = initializeActiveSet(cardsToAdd); - return [...activeCards, ...newActiveItems]; - }); - } - }; - - const handleNextCard = async (wasCorrect: boolean) => { - // Only count first attempt - const wasFirstAttempt = firstAttemptRef.current; - firstAttemptRef.current = false; - - const safeIndex = currentIndex % activeSet.length; - const currentItem = activeSet[safeIndex]; - - if (!currentItem) { - setIsComplete(true); - return; - } - - let cardRetired = false; - - if (wasFirstAttempt) { - // Record progress to server - await recordProgress(wasCorrect); - - // Update session stats - setSessionStats((prev) => ({ - ...prev, - reviewed: prev.reviewed + 1, - correctFirstTry: prev.correctFirstTry + (wasCorrect ? 1 : 0), - })); - - if (wasCorrect) { - // Correct on first try: advance mode - const nextMode = getNextMode(currentItem.clientDisplayMode); - - if (nextMode !== null) { - // Advance to next mode in the progression - setSessionStats((prev) => ({ - ...prev, - modeAdvances: prev.modeAdvances + 1, - })); - - // Update the card's client-side mode - setActiveSet((prev) => { - const updated = [...prev]; - const idx = safeIndex; - if (updated[idx]) { - updated[idx] = { - ...updated[idx], - clientModeIndex: updated[idx].clientModeIndex + 1, - clientDisplayMode: nextMode, - }; - } - return updated; - }); - } else { - // Already at final mode and correct on first try - card is RETIRED - cardRetired = true; - setSessionStats((prev) => ({ - ...prev, - cardsRetired: prev.cardsRetired + 1, - })); - - // Mark the card as retired and remove from active set - setActiveSet((prev) => { - const updated = prev.filter((_, idx) => idx !== safeIndex); - return updated; - }); - } - } - // If not correct on first try: card stays in rotation at same mode (no state change needed) - } - - // Move to next card in the rotation - setActiveSet((prevActiveSet) => { - if (prevActiveSet.length === 0) { - // All cards retired, try to refill - if (newCardPool.length > 0) { - // Will be handled by refillActiveSet - return prevActiveSet; - } - setIsComplete(true); - return prevActiveSet; - } - return prevActiveSet; - }); - - // Adjust index for next card - setCurrentIndex((prevIndex) => { - // Get current active set length (accounting for possible removal) - const currentSetLength = cardRetired - ? activeSet.length - 1 - : activeSet.length; - - if (currentSetLength === 0) { - return 0; - } - - // Move to next card, wrapping around - const nextIndex = cardRetired - ? prevIndex % currentSetLength // Stay at same index (next card shifted into position) - : (prevIndex + 1) % currentSetLength; - - return nextIndex; - }); - - // Check if we need to refill the active set - if (cardRetired) { - // Use setTimeout to ensure state has updated - setTimeout(() => { - refillActiveSet(); - }, 0); - } - - // Check if session is complete - setTimeout(() => { - setActiveSet((currentActiveSet) => { - if (currentActiveSet.length === 0 && newCardPool.length === 0) { - setIsComplete(true); - } - return currentActiveSet; - }); - }, 100); - }; - - const handleWrongAttempt = () => { - // Mark that this is no longer a first attempt - if (firstAttemptRef.current) { - firstAttemptRef.current = false; - setIsFirstAttempt(false); - } - }; - - const handleReturnToDashboard = () => { - const baseUrl = "/language/dashboard"; - const returnUrl = tab_index !== undefined - ? `${baseUrl}?tab_index=${tab_index}` - : baseUrl; - globalThis.location.href = returnUrl; - }; - - const handleRestartSession = async () => { - setLoading(true); - setError(null); - try { - // Reset all quiz progress on server (back to first study mode) - const response = await fetch("/api/brain/reset-session", { - method: "DELETE", - }); - if (!response.ok) { - throw new Error("Failed to reset session"); - } - - // Reset local state - setSessionStats({ - reviewed: 0, - correctFirstTry: 0, - sessionStart: Date.now(), - modeAdvances: 0, - cardsRetired: 0, - }); - setHasStarted(false); - setActiveSet([]); - setNewCardPool([]); - setCurrentIndex(0); - setIsComplete(false); - await fetchQueue(); - } catch (err) { - setError( - err instanceof Error - ? err.message - : "Failed to reset session. Please try again.", - ); - } finally { - setLoading(false); - } - }; - - // Loading state - if (loading) { - return ( -
-

Brain Study

-
-
-

Loading study queue...

-
- ); - } - - // Error state - if (error) { - return ( -
-

Error

-

{error}

- -
- ); - } - - // Complete state - if (isComplete) { - const accuracy = sessionStats.reviewed > 0 - ? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100) - : 0; - - return ( -
-

- Session Complete! -

- -
-
-

- {sessionStats.reviewed} -

-

Cards Reviewed

-
- -
-

- {accuracy}% -

-

First-Try Accuracy

-
- -
-

- {sessionStats.correctFirstTry} / {sessionStats.reviewed} -

-

Correct on First Try

-
- - {sessionStats.modeAdvances > 0 && ( -
-

- {sessionStats.modeAdvances} -

-

Mode Advances

-
- )} - - {sessionStats.cardsRetired > 0 && ( -
-

- {sessionStats.cardsRetired} -

-

- Cards Fully Mastered (All 6 Modes) -

-
- )} -
- -
- - -
-
- ); - } - - if (!hasStarted) { - return ( -
-

Brain Study

-

- Press Start to begin. This enables audio playback for the first card. -

- -
- ); - } - - // Study state - const safeIndex = activeSet.length > 0 ? currentIndex % activeSet.length : 0; - const currentItem = activeSet[safeIndex]; - // Show progress as "X cards remaining in active set" - const progress = `${safeIndex + 1} / ${activeSet.length}`; - const modeInfo = getModeDisplayInfo( - currentItem?.clientDisplayMode || QUIZ_MODES[0], - ); - - return ( - <> -
- {/* Header with progress and mode indicator */} -
- {/* Mode indicator row */} - - {/* Progress row */} -
-
-
-
- - Step {modeInfo.index}/{modeInfo.total} - -
- {currentItem?.is_review_only && ( - - Review - - )} -
-
-
-
- {progress} - - ({currentItem?.is_new ? "New" : "Old"}) - -
-
-
- -
-
-
-
- {/* Card */} - - - {/* Skip button */} -
- -
-
-
- - ); -} diff --git a/islands/Card.tsx b/islands/Card.tsx deleted file mode 100644 index f3bf4bb..0000000 --- a/islands/Card.tsx +++ /dev/null @@ -1,856 +0,0 @@ -// components/Card.tsx -import { useEffect, useRef, useState } from "preact/hooks"; -import { TargetedKeyboardEvent } from "preact"; -import { Signal } from "@preact/signals"; -import { decodeData } from "../utils/urlEncoder.ts"; -//import { useSpeechSynthesis } from 'react-speech-kit'; - -interface CardProps { - cardInputParams: { - setFullAuto: (auto: boolean) => void; - runFullAuto: boolean; - setTogglePause: (fn: () => void) => void; - setIsPausedDisplay: (paused: boolean) => void; - questionPhrase: string; - answerPhrase: string; - nextButton: () => void; - audio1: string; - audio2: string; - onHelpUsed: () => void; - flipped: boolean; - }; - backgroundUrl: string; - hideText: boolean; - useInputHint: Signal; -} - -export default function Card( - { cardInputParams, backgroundUrl, hideText, useInputHint }: CardProps, -) { - // const [currentBackgroundImage, setCurrentBackgroundImage] = useState(() => { - // //console.log("imageData"); - // // if (backgroundUrl) { - // // console.log(backgroundUrl.substring(0, 100)); - // // } - - // return backgroundUrl; - // }); - const audioRef_happy = useRef(null); - const audioRef_fail = useRef(null); - const inputRef = useRef(null); - const [inputValue, setInputValue] = useState(""); - const [message, setMessage] = useState(""); - const [helpClick, setHelpClick] = useState(false); - const [fullAutoInterval, setFullAutoInterval] = useState(0); - const [isPaused, setIsPaused] = useState(false); - const isPausedRef = useRef(false); - const audioRef1 = useRef(null); - const audioRef2 = useRef(null); - - const [initialAudioUrl1, setInitialAudioUrl1] = useState(null); - const [initialAudioUrl2, setInitialAudioUrl2] = useState(null); - const playSequenceId = useRef(0); - const [playSequenceComplete, setPlaySequenceComplete] = useState(true); - - const [hint, setHintDisplay] = useState(""); - - // Register toggle pause function - useEffect(() => { - cardInputParams.setTogglePause(() => { - setIsPaused((prev) => { - const newVal = !prev; - isPausedRef.current = newVal; - cardInputParams.setIsPausedDisplay(newVal); - return newVal; - }); - }); - }, [cardInputParams]); - - // Fetch audio files from API when audio1/audio2 props change - useEffect(() => { - let url1: string | null = null; - let url2: string | null = null; - - if (cardInputParams.audio1 || cardInputParams.audio2) { - setPlaySequenceComplete(false); - } - - const fetchAudio = async ( - audioFile: string, - setter: (url: string | null) => void, - ) => { - if (!audioFile) { - setter(null); - return null; - } - try { - const response = await fetch( - `/api/audio-blob/${encodeURIComponent(audioFile)}`, - { credentials: "include" }, - ); - if (!response.ok) { - console.error("Failed to fetch audio file:", response.statusText); - return null; - } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - setter(url); - return url; - } catch (err) { - console.error("Error fetching audio file:", err); - return null; - } - }; - - const loadAudios = async () => { - url1 = cardInputParams.audio1 - ? await fetchAudio(cardInputParams.audio1, setInitialAudioUrl1) - : ""; - url2 = cardInputParams.audio2 - ? await fetchAudio(cardInputParams.audio2, setInitialAudioUrl2) - : ""; - }; - - loadAudios(); - - // Cleanup blob URLs on unmount or when audio props change - return () => { - if (url1) URL.revokeObjectURL(url1); - if (url2) URL.revokeObjectURL(url2); - }; - }, [cardInputParams.audio1, cardInputParams.audio2]); - - const stopAudio = (audio: HTMLAudioElement | null) => { - if (!audio) return; - try { - audio.pause(); - audio.currentTime = 0; - } catch (_err) { - // Ignore - } - }; - - const waitForCanPlayThrough = (audio: HTMLAudioElement) => { - if (audio.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { - return Promise.resolve(); - } - - return new Promise((resolve) => { - const onReady = () => { - audio.removeEventListener("canplaythrough", onReady); - resolve(); - }; - audio.addEventListener("canplaythrough", onReady); - }); - }; - - const playAndWaitDone = async ( - audio: HTMLAudioElement, - requestId: number, - ): Promise => { - await waitForCanPlayThrough(audio); - if (requestId !== playSequenceId.current) return false; - - audio.currentTime = 0; - - try { - await audio.play(); - } catch (err: unknown) { - const errName = (err && typeof err === "object" && "name" in err && - typeof (err as { name: unknown }).name === "string") - ? (err as { name: string }).name - : ""; - - if (errName === "NotAllowedError") { - console.log("Audio autoplay blocked"); - return true; - } - console.error("Audio play error:", err); - return true; - } - - await new Promise((resolve) => { - const onDone = () => { - cleanup(); - resolve(); - }; - - const cleanup = () => { - audio.removeEventListener("ended", onDone); - audio.removeEventListener("pause", onDone); - audio.removeEventListener("error", onDone); - }; - - audio.addEventListener("ended", onDone); - audio.addEventListener("pause", onDone); - audio.addEventListener("error", onDone); - }); - return true; - }; - - const playSequence = async () => { - const requestId = ++playSequenceId.current; - stopAudio(audioRef1.current); - stopAudio(audioRef2.current); - - const audio1 = initialAudioUrl1 ? audioRef1.current : null; - const audio2 = initialAudioUrl2 ? audioRef2.current : null; - - if (audio1) { - const shouldContinue = await playAndWaitDone(audio1, requestId); - if (!shouldContinue || requestId !== playSequenceId.current) { - return; - } - } - - if (audio2) { - await playAndWaitDone(audio2, requestId); - } - - if (cardInputParams.runFullAuto) { - let firstCouple = 0; - - const answerLength = Math.max( - 1, - (cardInputParams.answerPhrase ?? "").length, - ); - const autoIntervalMs = Math.max(1, Math.floor(3000 / answerLength)); - - const autoInterval = setInterval(() => { - // Skip if paused - if (!isPausedRef.current) { - if (firstCouple > 3) { - let canClickHelp = false; - setHelpClick((prev) => { - //if (!prev){ - canClickHelp = prev; - //return true; - //} - - return prev; - }); - if (!canClickHelp) { - let inputNow = ""; - setInputValue((prev) => { - inputNow = prev; - return prev; - }); - //console.log("auto!!!",cardInputParams.answerPhrase, inputNow); - if (inputNow !== cardInputParams.answerPhrase) { - helpNext(inputNow); - } else { - clearInterval(autoInterval); - return; - } - } - } else { - firstCouple += 1; - } - } - }, autoIntervalMs); - - setFullAutoInterval(autoInterval); - } - - if (requestId === playSequenceId.current) { - setPlaySequenceComplete(true); - } - }; - - // Auto-play audio1 when loaded, then sequence audio2 after audio1 completes - useEffect(() => { - if (!initialAudioUrl1 && !initialAudioUrl2) return; - - playSequence(); - - return () => { - playSequenceId.current += 1; - stopAudio(audioRef1.current); - stopAudio(audioRef2.current); - setPlaySequenceComplete(true); - }; - }, [initialAudioUrl1, initialAudioUrl2]); - - const effectSetFullAuto = () => { - setInputValue(defaultInputValue); - return 0; - }; - - //let lastInputValue = ""; - //let lastInputValueLength = -1; - const defaultInputValue = ""; - - const setHint = (quizInput: string, specialInput = "") => { - const asciitestWord = toAscii(cardInputParams.answerPhrase).toLowerCase(); - const hintChars = new Array(asciitestWord.length); - let answerInput = ""; - - if (specialInput === "") { - if (quizInput) { - answerInput = toAscii(quizInput.trim().toLowerCase()); - } - } else { - answerInput = specialInput.toLowerCase(); - } - - for (let i = 0; i < asciitestWord.length; ++i) { - const testCh = asciitestWord[i]; - - if (/\s/.test(testCh)) { - hintChars[i] = " "; - } else { - if (answerInput && answerInput.length > i) { - const answerCh = answerInput[i]; - - if (!/[a-zA-Z0-9]/.test(answerCh) || answerCh === testCh) { - hintChars[i] = cardInputParams.answerPhrase[i]; - } else { - hintChars[i] = "-"; - } - } else { - if (!/[a-zA-Z0-9]/.test(testCh)) { - hintChars[i] = cardInputParams.answerPhrase[i]; - } else { - hintChars[i] = "-"; - } - } - } - } - - return hintChars.join(""); - }; - - // const loadVoices = () => { - // const voices = window.speechSynthesis.getVoices(); - // console.log("voices:") - // voices.forEach(voice => { - // console.log("-> " + voice.name + " " + voice.lang); - // }); - // } - - //const { speak } = useSpeechSynthesis(); - const speak = (text: any) => { - //loadVoices(); - // console.log("speech start"); - // const utterance = new SpeechSynthesisUtterance("hello world"); - // //utterance.lang = 'fil-PH'; // Set language to Filipino - // window.speechSynthesis.speak(utterance); - // console.log("speech done"); - }; - - // Play the initial audio_file_1 automatically when the component mounts - // useEffect(() => { - // if (audio_file.startsWith("http") && initialAudioRef.current) { - // // Attempt to play programmatically for better browser support - // initialAudioRef.current.play().catch(() => { - // // Autoplay might be blocked, user must interact - // }); - // } - // }, [audio_file]); - - useEffect(() => { - //console.log("intitial url"); - //const decoded = decodeKvURL(initialQuizData.questions[0].background_image_data); - //console.log(backgroundImage.substring(0,100)); - //setCurrentBackgroundImage(backgroundUrl); - if (inputRef.current) { - inputRef.current.focus(); - } - - //console.log("background ", backgroundImage); - setHintDisplay(setHint("")); - }, [cardInputParams]); - - const handleInputChange = (e: TargetedKeyboardEvent) => { - setInputValue((prev) => { - // console.log("keypress", - // e.currentTarget.value); - - return e.currentTarget.value.trim(); - }); - }; - - const waitForAudioEnd = (audio: HTMLAudioElement): Promise => { - return new Promise((resolve) => { - const onDone = () => { - audio.removeEventListener("ended", onDone); - audio.removeEventListener("pause", onDone); - audio.removeEventListener("error", onDone); - resolve(); - }; - audio.addEventListener("ended", onDone); - audio.addEventListener("pause", onDone); - audio.addEventListener("error", onDone); - }); - }; - - const playFinalAudio = async () => { - if (cardInputParams.audio1 && audioRef1.current) { - try { - await audioRef1.current.play(); - await waitForAudioEnd(audioRef1.current); - } catch { - // Ignore play errors - } - } - - if (audioRef_happy.current) { - try { - audioRef_happy.current.volume = 0.6; - await audioRef_happy.current.play(); - await waitForAudioEnd(audioRef_happy.current); - } catch { - // Ignore play errors - } - } - - setTimeout(() => { - setInputValue(""); - setMessage(""); - cardInputParams.nextButton(); - setHelpClick(false); - }, 1000); - }; - - const checkAnswer = (value: string) => { - const processedInput: boolean = handleValidSubmit(value); - - if (processedInput) { //}.toLowerCase() === answerPhrase.toLowerCase()) { - setMessage("Correct! Great job!"); - playFinalAudio(); - } else { - if (audioRef_fail.current) { - audioRef_fail.current.volume = 0.6; - audioRef_fail.current.play().catch(() => {}); - } - setMessage(""); - } - }; - - const handleKeyPress = (e: TargetedKeyboardEvent) => { - if (e.key === "Enter") { - if (e.shiftKey) { - e.preventDefault(); - setFullAutoInterval((prev) => { - if (prev) { - clearInterval(prev); - cardInputParams.setFullAuto(false); - } - return 0; - }); - if (!helpClick) { - cardInputParams.onHelpUsed(); // Mark as not first attempt - setHelpClick(true); - helpNext(inputValue); - } - } else { - const timValue = e.currentTarget.value.trim(); - //console.log(timValue); - - setInputValue(timValue); - checkAnswer(timValue); - } - } - }; - - const handleValidSubmit = (quizInput: string) => { - let answerInput = ""; - - if (quizInput) { - answerInput = toAscii(quizInput.trim()); - //console.log("ascii",answerInput); - } else { - return false; - } - - const asciitestWord = toAscii(cardInputParams.answerPhrase); - - if (asciitestWord[0] === "?") { - if (answerInput.length === 0) { - answerInput = "?"; - } else if (answerInput[0] !== "?") { - answerInput = "?" + answerInput; - } - } - - if (asciitestWord[0] === "!") { - if (answerInput.length === 0) { - answerInput = "!"; - } else if (answerInput[0] !== "!") { - answerInput = "!" + answerInput; - } - } - - if ( - asciitestWord[asciitestWord.length - 1] === "?" && - (asciitestWord.length - 1) === answerInput.length - ) { - answerInput = answerInput + "?"; - } - - if ( - asciitestWord[asciitestWord.length - 1] === "!" && - (asciitestWord.length - 1) === answerInput.length - ) { - answerInput = answerInput + "!"; - } - - if ( - asciitestWord[asciitestWord.length - 1] === "." && - (asciitestWord.length - 1) === answerInput.length - ) { - answerInput = answerInput + "."; - } - - if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) { - setInputValue(answerInput); - setHintDisplay(setHint(answerInput)); - return true; - } - - let outputStr = ""; - let i = 0; - for (; i < cardInputParams.answerPhrase.length; ++i) { - const asciiCh = asciitestWord[i]; - if (!/[a-zA-Z0-9]/.test(asciiCh)) { - outputStr += cardInputParams.answerPhrase[i]; - continue; - } - - if (i >= answerInput.length) { - break; - } - - const ch = answerInput[i]; - - if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) { - break; - } - - outputStr += cardInputParams.answerPhrase[i]; - } - - if (outputStr === cardInputParams.answerPhrase) { - setInputValue(outputStr); - setHintDisplay(setHint(outputStr)); - return true; - } - - setHintDisplay(setHint(outputStr)); - setInputValue((prev) => { - //console.log("set input end",prev,outputStr); - return outputStr; - }); - return false; - }; - - const helpNext = (value: string) => { - const processedInput: string = handleValidHelp(value.trim()); - - if ( - trimJunk(toAscii(processedInput)).toLowerCase() === - trimJunk(toAscii(cardInputParams.answerPhrase)).toLowerCase() - ) { - setFullAutoInterval((prev) => { - if (prev) { - clearInterval(prev); - } - return 0; - }); - setMessage("Correct! Great job!"); - playFinalAudio(); - } else { - setMessage(""); - setHelpClick(false); - } - }; - - const handleValidHelp = (quizInput: string) => { - let answerInput = ""; - - if (quizInput) { - answerInput = toAscii(quizInput.trim()); - } - - const asciitestWord = toAscii(cardInputParams.answerPhrase); - - if (asciitestWord[0] === "?") { - if (answerInput.length === 0) { - answerInput = "?"; - } else if (answerInput[0] !== "?") { - answerInput = "?" + answerInput; - } - } - - if (asciitestWord[0] === "!") { - if (answerInput.length === 0) { - answerInput = "!"; - } else if (answerInput[0] !== "!") { - answerInput = "!" + answerInput; - } - } - - if ( - asciitestWord[asciitestWord.length - 1] === "?" && - (asciitestWord.length - 1) === answerInput.length - ) { - answerInput = answerInput + "?"; - } - - if ( - asciitestWord[asciitestWord.length - 1] === "!" && - (asciitestWord.length - 1) === answerInput.length - ) { - answerInput = answerInput + "!"; - } - - if ( - asciitestWord[asciitestWord.length - 1] === "." && - (asciitestWord.length - 1) === answerInput.length - ) { - answerInput = answerInput + "."; - } - - if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) { - setInputValue(answerInput); - setHintDisplay(setHint(answerInput)); - return answerInput; - } - - let outputStr = ""; - let i = 0; - for (; i < cardInputParams.answerPhrase.length; ++i) { - const asciiCh = asciitestWord[i]; - if (!/[a-zA-Z0-9]/.test(asciiCh)) { - outputStr += cardInputParams.answerPhrase[i]; - continue; - } - - if (i >= answerInput.length) { - break; - } - - const ch = answerInput[i]; - - if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) { - break; - } - - outputStr += cardInputParams.answerPhrase[i]; - } - - if (outputStr === cardInputParams.answerPhrase) { - setInputValue(outputStr); - setHintDisplay(setHint(outputStr)); - return outputStr; - } - - if (asciitestWord.length > outputStr.length) { - outputStr = asciitestWord.substring(0, outputStr.length + 1); - - if (outputStr[outputStr.length - 1] === " ") { - outputStr = asciitestWord.substring(0, outputStr.length + 1); - } - - setHintDisplay(setHint(quizInput, outputStr)); - setInputValue(outputStr); - } - - return outputStr; - }; - // const divStyle = { - // width: "320px", - // height: "auto", - // backgroundImage: `url(${currentBackgroundImage})`, - // backgroundSize: "cover", - // backgroundPosition: "center", - // // - const questionPhrase = cardInputParams.questionPhrase || ""; - const trimmedQuestionPhrase = questionPhrase.trim(); - const shouldShowPlaceholder = hideText || trimmedQuestionPhrase === ""; - const answerPhraseLength = cardInputParams.answerPhrase - ? cardInputParams.answerPhrase.length - : 0; - const placeholderCharCount = Math.min( - Math.max(questionPhrase.length, answerPhraseLength, 20), - 120, - ); - const placeholderText = "\u00A0".repeat(placeholderCharCount); - - if (!shouldShowPlaceholder && !questionPhrase) { - return null; - } - - if (cardInputParams.answerPhrase) { - return ( -
{ - if (inputRef.current) { - inputRef.current.focus(); - } - }} - > -
500 - ? "url(" + backgroundUrl + ")" - : "" - : "" - }`, - - backgroundSize: "cover", - backgroundPosition: "center", - }} - className="p-4 rounded-lg theme-card w-80 button-padding" - > -
-
- {shouldShowPlaceholder - ? ( - - ) - : ( -

- {cardInputParams.questionPhrase} -

- )} -
-
-
- {useInputHint.value && - ( -

- {cardInputParams.answerPhrase ? hint : ""} -

- )} -
-
- - {cardInputParams.answerPhrase && - ( - , - ) => { - if (!helpClick) { - handleKeyPress(e); - } - }} - ref={inputRef} - placeholder={cardInputParams.flipped - ? "Type study language" - : "Type meaning language"} - className="theme-card-input strokeme w-full px-2 -pt-1 py-2 border rounded-md focus:outline-none focus:ring-2" - /> - )} -
- {cardInputParams.answerPhrase && - ( -
- - - - -
- )} -
-
-
-
- ); - } else { - return null; - } -} - -function trimJunk(inputStr: string) { - if (!inputStr) { - return ""; - } - - let i = inputStr.length - 1; - - while (i > 0 && !/[a-zA-Z0-9]/.test(inputStr[i])) { - --i; - } - - return inputStr.substring(0, i + 1).toLowerCase(); -} - -function toAscii(input: string) { - if (!input) return ""; - - return input.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); -} diff --git a/islands/QuizConfigure.tsx b/islands/QuizConfigure.tsx deleted file mode 100644 index 15a7466..0000000 --- a/islands/QuizConfigure.tsx +++ /dev/null @@ -1,229 +0,0 @@ -// islands/QuizConfigure.tsx -import { useEffect, useState } from "preact/hooks"; -import { QuizConfig } from "../utils/types.ts"; -import { User } from "../db/interfaces.ts"; - -export interface QuizConfigProps { - quizConfig: QuizConfig; - // kvObj: KvObj; - quizOrder: { index: number; slots: string[] }; - quizMode: string; -} - -export default function QuizConfigure( - { quizConfig, quizOrder, quizMode }: QuizConfigProps, -) { - // Theme classes are defined in styles.css with @media (prefers-color-scheme: dark) - - const [thisQuizConfig, setQuizConfig] = useState(quizConfig); - const [orderedQuizStr, setOrderQuizStr] = useState( - JSON.stringify(thisQuizConfig), - ); - const [thisQuizConfigStr, setQuizConfigStr] = useState( - JSON.stringify({ - theUpdate: null, - quiz: quizConfig, - //kvObj: kvObj, - quizOrder: quizOrder, - }), - ); - const [selectedSlot, setSelectedSlot] = useState(0); - //const [changeMode, setChangeMode] = useState<{ mode: string } | null>(null); - const [nameError, setNameError] = useState(false); - const [thisQuizOder, setThisQuizOrder] = useState(quizOrder); - const [randomOrder, setRandomOrder] = useState(true); - const [modeType, setModeType] = useState(quizMode); - //const [isLoading, setIsLoading] = useState(true); - - const [count, setCount] = useState(0); - - const handleClick = () => { - setCount((prev) => { - const newCount = prev + 1; - return newCount; - }); - }; - - // const handleQuizNameChange = (e: Event) => { - // const target = e.target as HTMLInputElement; - // if (!target.value) { - // setNameError(true); - // } else { - // thisQuizConfig.quiz_name = target.value; - // setQuizConfig(thisQuizConfig); - // setChangeMode((prev) => { - // if (!prev) { - // return { mode: "quiz" }; - // } - // - // - // - // return prev; - // }); - // } - // setQuizConfigStr( - // JSON.stringify({ - // theUpdate: changeMode?.mode, - // quiz: thisQuizConfig, - // kvObj: kvObj, - // quizOrder: thisQuizOder, - // }), - // ); - // }; - - const handleQuizModeChange = (e: Event) => { - const target = e.target as HTMLSelectElement; - // setQuizConfig((prev) => { - // if (!prev) return null; - // const updated = { ...prev, quiz_mode: target.value }; - // //saveQuizConfig(updated); // Save the updated config - // return updated; - // }); - }; - - if (!thisQuizConfig) { - return
Error: Unable to load quiz configuration
; - } - - function handleOwnerChange( - event: Event, - selectedUser: User | undefined, - ): void { - if (selectedUser) { - //("selected ", selectedUser.id); - thisQuizConfig.owner_id = selectedUser.id; - setQuizConfig(thisQuizConfig); - //console.log("thisQuizConfig ", thisQuizConfig.owner_id); - } - //console.log("on user change ", JSON.stringify(event)); - //throw new Error("Function not implemented."); - } - - useEffect(() => { - //console.log("setting mode!!",quizConfig.quiz_mode) - if (quizConfig.quiz_mode) { - setModeType(quizConfig.quiz_mode); - } - }, []); - - return ( - <> -
- -
-
- - - - - -
-
-
- - - - setRandomOrder((e.target as HTMLInputElement).checked)} - className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" - /> -
-
-
-
- - -
-
-
-

Study Quiz

-
- -
-
-
- -
- - ); -} diff --git a/islands/Quizzing.tsx b/islands/Quizzing.tsx deleted file mode 100644 index 437ef8a..0000000 --- a/islands/Quizzing.tsx +++ /dev/null @@ -1,416 +0,0 @@ -// islands/Quizzing.tsx -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; -//import { QuizData } from "../utils/database.ts"; -import { QuizConfig } from "../utils/types.ts"; -import { Signal, signal } from "@preact/signals"; -import Card from "./Card.tsx"; -import { stringify } from "node:querystring"; - -interface QuizzingIProps { - initialQuizData: QuizConfig; - runData: string; -} - -export const STORAGE_KEY = "QUIZ_STATE"; - -const useAudio1 = signal(true); -const useAudio2 = signal(true); -const useInputHint = signal(false); - -export default function Quizzing({ initialQuizData, runData }: QuizzingIProps) { - const hideText = initialQuizData && - (initialQuizData.quiz_mode === "photo-hide-english" || - initialQuizData.quiz_mode === "photo-hide-bisaya"); - - const [quizData, setQuizData] = useState(initialQuizData); - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [questionText, setQuextionText] = useState( - quizData ? quizData.questions[0].text_1 : "", - ); - const [meaningText, setMeaningText] = useState(""); - const [backgroundUrl, setCurrentBackgroundUrl] = useState(""); - const [quizCompleted, setQuizCompleted] = useState(false); - const [runFullAuto, setRunFullAuto] = useState(false); - const [isPausedDisplay, setIsPausedDisplay] = useState(false); - const togglePauseRef = useRef<() => void>(() => {}); - const [countdown, setCountdown] = useState(3); - const postButtonRef = useRef(null); - const [count, setCount] = useState(0); - const [cardKey, setCardKey] = useState(0); - - const runFullAutoRef = useRef(runFullAuto); - const meaningTextRef = useRef(meaningText); - const quizDataRef = useRef(quizData); - - useEffect(() => { - runFullAutoRef.current = runFullAuto; - }, [runFullAuto]); - - useEffect(() => { - meaningTextRef.current = meaningText; - }, [meaningText]); - - useEffect(() => { - quizDataRef.current = quizData; - }, [quizData]); - - const [cardInputParams, seCardInputParams] = useState({ - setFullAuto: setRunFullAuto, - runFullAuto: false, - setTogglePause: (fn: () => void) => { - togglePauseRef.current = fn; - }, - setIsPausedDisplay: setIsPausedDisplay, - questionPhrase: quizData ? quizData.questions[0].text_1 : "", - answerPhrase: "", - nextButton: () => {}, - audio1: "", - audio2: "", - onHelpUsed: () => {}, - flipped: false, - }); - - const handleClick = () => { - setCount((prev) => { - const newCount = prev + 1; - //eventEmitter.emit("countUpdated", newCount); - return newCount; - }); - }; - - // Ref to track current index synchronously (state updates are async) - const currentIndexRef = useRef(0); - - const incTheIndex = (auto: boolean): number => { - let nextIndex = currentIndexRef.current + 1; - if (auto && nextIndex >= initialQuizData.questions.length) { - nextIndex = 0; - } - currentIndexRef.current = nextIndex; - setCurrentQuestionIndex(nextIndex); - return nextIndex; - }; - - useEffect(() => { - if (quizCompleted && countdown > 0) { - const timer = setTimeout(() => { - setCountdown(countdown - 1); - }, 1000); - return () => clearTimeout(timer); - } else if (quizCompleted && countdown === 0) { - if (postButtonRef.current) { - //console.log(runData); - postButtonRef.current.click(); - } - //wind ow.location.href = "/language/dashboard"; - } - }, [quizCompleted, countdown]); - - const handleNextQuestionInternal = useCallback((forceAuto?: boolean) => { - if (forceAuto === true && !runFullAutoRef.current) { - runFullAutoRef.current = true; - setRunFullAuto(true); - } - - const thisAuto = typeof forceAuto === "boolean" - ? forceAuto - : runFullAutoRef.current; - const quizDataNow = quizDataRef.current; - const meaningTextNow = meaningTextRef.current; - - // Determine the next index synchronously - // On first call (meaningText === ""), stay at index 0 - // On subsequent calls, increment - const isFirstCall = meaningTextNow === ""; - const thisIndex = isFirstCall ? 0 : incTheIndex(thisAuto); - - if (thisIndex < quizDataNow.questions.length) { - // Get ALL data from the SAME question object - const thisQuestion = quizDataNow.questions[thisIndex]; - const text_1 = thisQuestion.text_1; - const text_2 = thisQuestion.text_2; - const audio1 = useAudio1.value ? thisQuestion.audio_file_1 : ""; - const audio2 = useAudio2.value ? thisQuestion.audio_file_2 : ""; - const thisDataUrl = thisQuestion.background_image_data || ""; - - // Update all state together - setQuextionText(text_1); - setMeaningText(text_2); - meaningTextRef.current = text_2; - setCurrentBackgroundUrl(thisDataUrl); - - const cardParams = { - setFullAuto: setRunFullAuto, - runFullAuto: thisAuto, - setTogglePause: (fn: () => void) => { - togglePauseRef.current = fn; - }, - setIsPausedDisplay: setIsPausedDisplay, - questionPhrase: text_1, - answerPhrase: text_2, - nextButton: handleNextQuestion, - audio1: audio1, - audio2: audio2, - onHelpUsed: () => {}, - flipped: initialQuizData.quiz_mode === "photo-hide-english" || - initialQuizData.quiz_mode === "photo-see-english" || - initialQuizData.quiz_mode === "blank-see-english", - }; - - seCardInputParams(cardParams); - setCardKey((prev) => prev + 1); // Force Card remount - handleClick(); - } else { - //localStorage.removeItem("QUIZ_STATE"); - setQuizCompleted(true); - } - }, [initialQuizData]); - - const handleNextQuestion = useCallback(() => { - handleNextQuestionInternal(); - }, [handleNextQuestionInternal]); - - if (!initialQuizData || !runData) { - return
Unable to load quiz
; - } - - if (!quizData) { - return
Loading...
; - } - - if (quizCompleted) { - return ( -
-

Quiz Completed!

-
- - - - - - - - {countdown} - -
-

Redirecting ...

- - -
- ); - } - - //const currentQuestion = quizData.questions[currentQuestionIndex]; - //if (quizStarted){ - - return ( -
- - {meaningText !== "" - ? ( - - ) - : ( -
-

Ready to start quiz

-

- Questions: {quizData.questions.length} -

-
- )} - {meaningText === "" - ? ( -
-
-
- Keyboard: shift + return = help -
- -
- - - - - -
- -
- - -
-
-
- ) - : runFullAuto - ? ( - - ) - : ( - - )} -
- ); - // } - // else{ - // return ( - //
- - // - //
- // ); - // } - - // Placeholder function for fetching quiz data - // async function fetchQuizData(quiz_id: number): Promise<{}> { - // // Implement the actual data fetching logic here - // // This is just a placeholder - // return { quiz_id: quiz_id, questions: [] }; - // } -} diff --git a/islands/StudyButton.tsx b/islands/StudyButton.tsx deleted file mode 100644 index 627b2d1..0000000 --- a/islands/StudyButton.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// islands/StudyButton.tsx - -import { useState } from "preact/hooks"; -import { JSX } from "preact/jsx-runtime"; - -interface StudyButtonProps { - setCanStudy: any; -} - -export default function StudyButton( - { setCanStudy }: StudyButtonProps, -) { - //const [clicked, setClicked] = useState(true); - - const handleClick = () => { - //setClicked(true); - //onClick(); - }; - const handleStudyClick = ( - event: JSX.TargetedMouseEvent, - ) => { - //console.log("clicked"); - if (!setCanStudy.value) { - event.preventDefault(); - } - }; - return ( - - ); -} diff --git a/islands/SyncButton.tsx b/islands/SyncButton.tsx deleted file mode 100644 index f0991e3..0000000 --- a/islands/SyncButton.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// islands/StudyButton.tsx - -import { useState } from "preact/hooks"; -import { JSX } from "preact/jsx-runtime"; - -interface SyncButtonProps { - setCanStudy: any; -} - -export default function SyncButton( - { setCanStudy }: SyncButtonProps, -) { - //const [clicked, setClicked] = useState(true); - - const handleClick = () => { - //setClicked(true); - //onClick(); - }; - const handleStudyClick = ( - event: JSX.TargetedMouseEvent, - ) => { - //console.log("clicked"); - if (!setCanStudy.value) { - event.preventDefault(); - } - }; - return ( - - - - ); -} diff --git a/islands/TranslationList.tsx b/islands/TranslationList.tsx deleted file mode 100644 index 72a7e81..0000000 --- a/islands/TranslationList.tsx +++ /dev/null @@ -1,407 +0,0 @@ -// islands/TranslationList.tsx -import { useEffect, useState } from "preact/hooks"; -import { QuizToTextFile } from "../utils/QuizToTextFile.ts"; -import { TextToQuizTranslations } from "../utils/TextToQuizTranslations.ts"; -import { QuizConfig } from "../utils/types.ts"; -//import { QuizData } from "../utils/database.ts"; - -import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx"; - -export interface Translation { - id: number; - quiz_id: number; - owner_id: string; - question_text: string; - meaning_text: string; - order_num: number; -} - -interface TranslationListProps { - quiz: QuizConfig; - userName: string; -} - -export default function TranslationList( - { quiz, userName }: TranslationListProps, -) { - // Theme classes are defined in styles.css with @media (prefers-color-scheme: dark) - - const [ask, popupElement] = AsyncYesNoPop(); - const [quizData, setQuizData] = useState(quiz); - const [quizConfigDataStr, setQuizConfigDataStr] = useState( - JSON.stringify(quiz), - ); - const [quizNameStr, setQuizNameStr] = useState(quiz.quiz_name); - const [text1Str, setText1Str] = useState(""); - const [text2Str, setText2Str] = useState(""); - const [deleteIndexStr, setDeleteIndexStr] = useState(""); - - //const [fileContent, setFileContent] = useState(""); - const [quiz_nameError, setquiz_nameError] = useState(false); - const [questionsError, setQuestionsError] = useState(false); - useEffect(() => { - const lockRoute = localStorage.getItem("lock-route"); - if (lockRoute) { - globalThis.location.href = lockRoute; - } - //fetchQuizData(); - }, []); - - const justClear = async () => { - const clearQuiz = { - active_date: Date.now(), - quiz_mode: "", - owner_id: quiz.owner_id, - quiz_config_id: crypto.randomUUID(), - quiz_name: "", - questions: [], - results: [], - }; - setQuizData(clearQuiz); - setQuizConfigDataStr(JSON.stringify(clearQuiz)); - setQuizNameStr(""); - setText1Str(""); - setText2Str(""); - setDeleteIndexStr(""); - }; - - const handleSaveNewQuiz = async () => { - }; - - const handleUpdateTranslation = async (translation: Translation) => { - }; - const handleAddTranslation = async () => { - }; - const handleDeleteTranslation = async (translationId: number) => { - }; - - const uploadFile = () => { - setquiz_nameError(false); - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".txt"; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (file) { - const newQuizName = (file.name as string)?.split(".txt")[0] - ?.trim(); - if (!quizNameStr && !newQuizName) { - setquiz_nameError(true); - return; - } - - if ( - !quizNameStr && newQuizName && newQuizName.length > 0 - ) { - setQuizNameStr(newQuizName); - } - - const text = (await file.text())?.trim(); - - if (text && text.length > 0) { - const textToQuiz = new TextToQuizTranslations(quizData); - const parsedQuizData = textToQuiz.parseText(text); - setQuizData(parsedQuizData); - setQuizConfigDataStr(JSON.stringify(parsedQuizData)); - } - } - }; - input.click(); - }; - - const downloadFile = async () => { - if (quizData.questions.length === 0) { - setQuestionsError(true); - return; - } - if (!quizNameStr || quizNameStr.length === 0) { - setquiz_nameError(true); - return; - } - setQuestionsError(false); - const quizToText = new QuizToTextFile(quizData); - 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 = `${quizNameStr}.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const handleSaveAddQuiz = async (event: Event) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - - if (deleteIndexStr) { - const userConfirmed = await ask( - "Are you sure you want to delete?", - Date.now(), - ); - - if (userConfirmed) { - form.submit(); - } - } else { - if (!quizNameStr || quizNameStr.length === 0) { - setquiz_nameError(true); - } else { - form.submit(); - } - } - }; - - return ( - <> - {popupElement} -
-
- - - -
-
- - - - - -
- { - const target = (e.target as HTMLInputElement).value.trim(); - if (!target) { - setQuizNameStr(quizNameStr); - } else { - setQuizNameStr(target); - } - }} - onKeyDown={() => setquiz_nameError(false)} - placeholder="Quiz Name" - className={`w-full p-2 border rounded theme-input ${ - quiz_nameError ? "border-red-500" : "" - }`} - /> -
-
-

- Add New Translation -

-
- { - const target = (e.target as HTMLInputElement).value.trim(); - setText1Str(target); - }} - placeholder="Bisaya" - onKeyDown={() => setQuestionsError(false)} - className={`flex-1 p-2 border rounded theme-input ${ - questionsError ? "border-red-500" : "" - }`} - /> - { - const target = (e.target as HTMLInputElement).value.trim(); - setText2Str(target); - }} - placeholder="English" - onKeyDown={() => setQuestionsError(false)} - className={`flex-1 p-2 border rounded theme-input ${ - questionsError ? "border-red-500" : "" - }`} - /> - -
-
-
    - {quizData.questions.map((translation: any, index: number) => ( -
  • -
    -
    - {translation.text_1} -
    -
    - {translation.text_2} -
    -
    - {!(!!translation.audio_file_1 || !!translation.audio_file_2 || - !!translation.video_file || !!translation.video_file_data || - !!translation.background_file || - !!translation.background_image_data) - ? ( - - ) - : ( - - )} -
  • - ))} -
-
-
-
-
- - - -
-
- - ); -} -async function makeQuizWithName( - owner_id: string, - quiz_name: string, -): Promise { - try { - const response = await fetch("/api/quiz", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - owner_id, - quiz_name, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error("Error creating quiz:", errorData.error); - return 0; - } - - const result = await response.json(); - return result.quiz_id; - } catch (error) { - console.error("Network error when creating quiz:", error); - return 0; - } -} diff --git a/islands/UserDashboard.tsx b/islands/UserDashboard.tsx deleted file mode 100644 index 8dafc11..0000000 --- a/islands/UserDashboard.tsx +++ /dev/null @@ -1,1360 +0,0 @@ -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)) - ); - } - } -} diff --git a/islands/VoiceRecorder.tsx b/islands/VoiceRecorder.tsx deleted file mode 100644 index da1c776..0000000 --- a/islands/VoiceRecorder.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { useEffect, useRef, useState } from "preact/hooks"; - -interface VoiceRecorderProps { - text: string; - audio_file: string; - role: string; - recordingCallback: (data: Blob | null) => void; -} - -export default function VoiceRecorder( - { text, audio_file, role, recordingCallback }: VoiceRecorderProps, -) { - const [recording, setRecording] = useState(false); - const [audioUrl, setAudioUrl] = useState(null); - const [initialAudioUrl, setInitialAudioUrl] = useState(null); - const [audioBlob, setAudioBlob] = useState(null); - const mediaRecorderRef = useRef(null); - const chunksRef = useRef([]); - const selectedMimeTypeRef = useRef(undefined); - const initialAudioRef = useRef(null); - - const pickBestAudioMimeType = (): string | undefined => { - if (typeof MediaRecorder === "undefined") return undefined; - if (typeof MediaRecorder.isTypeSupported !== "function") return undefined; - - const candidates = [ - "audio/mp4", - "audio/mp4;codecs=mp4a.40.2", - "audio/webm;codecs=opus", - "audio/webm", - "audio/ogg;codecs=opus", - "audio/ogg", - ]; - - return candidates.find((t) => MediaRecorder.isTypeSupported(t)); - }; - - // Fetch and play the initial audio_file when the component mounts - useEffect(() => { - if (!audio_file) return; - - const fetchAudio = async () => { - try { - const isDirectUrl = audio_file.startsWith("blob:") || - audio_file.startsWith("http") || - audio_file.startsWith("/"); - if (isDirectUrl) { - setInitialAudioUrl(audio_file); - return; - } - - const response = await fetch( - `/api/audio-blob/${encodeURIComponent(audio_file)}`, - { credentials: "include" }, - ); - if (!response.ok) { - console.error("Failed to fetch audio file:", response.statusText); - return; - } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - setInitialAudioUrl(url); - } catch (err) { - console.error("Error fetching audio file:", err); - } - }; - - fetchAudio(); - - // Cleanup blob URL on unmount - return () => { - if (initialAudioUrl) { - URL.revokeObjectURL(initialAudioUrl); - } - }; - }, [audio_file]); - - // Auto-play when initial audio is loaded - useEffect(() => { - if (initialAudioUrl && initialAudioRef.current) { - initialAudioRef.current.play().catch(() => { - // Autoplay might be blocked, user must interact - }); - } - }, [initialAudioUrl]); - - const startRecording = async () => { - if (audioUrl) { - URL.revokeObjectURL(audioUrl); - } - setAudioUrl(null); - setAudioBlob(null); - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - - if (typeof MediaRecorder === "undefined") { - alert("Audio recording is not supported in this browser."); - stream.getTracks().forEach((t) => t.stop()); - return; - } - - const bestMimeType = pickBestAudioMimeType(); - const mimeTypeCandidates = [ - bestMimeType, - "audio/mp4", - "audio/mp4;codecs=mp4a.40.2", - "audio/webm;codecs=opus", - "audio/webm", - "audio/ogg;codecs=opus", - "audio/ogg", - undefined, - ].filter((t, i, arr) => arr.indexOf(t) === i); - - let mediaRecorder: MediaRecorder | null = null; - let lastError: unknown = undefined; - - for (const candidate of mimeTypeCandidates) { - try { - mediaRecorder = candidate - ? new MediaRecorder(stream, { mimeType: candidate }) - : new MediaRecorder(stream); - selectedMimeTypeRef.current = candidate; - break; - } catch (err) { - lastError = err; - mediaRecorder = null; - continue; - } - } - - if (!mediaRecorder) { - console.error("Failed to create MediaRecorder", lastError); - alert("Audio recording is not supported in this browser."); - stream.getTracks().forEach((t) => t.stop()); - return; - } - - mediaRecorderRef.current = mediaRecorder; - - mediaRecorder.onerror = (e) => { - const recErr = (e as ErrorEvent | Event) as unknown as { - error?: DOMException; - }; - const err = recErr?.error; - console.error("MediaRecorder error", err || e); - stream.getTracks().forEach((t) => t.stop()); - setRecording(false); - alert( - "Recording failed. This Safari version may not support MediaRecorder with audio-only streams.", - ); - }; - - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) { - chunksRef.current.push(e.data); - } - }; - - mediaRecorder.onstop = () => { - const fallbackType = chunksRef.current[0]?.type || - mediaRecorder.mimeType || - selectedMimeTypeRef.current || - ""; - - const blob = new Blob( - chunksRef.current, - fallbackType ? { type: fallbackType } : undefined, - ); - chunksRef.current = []; - setAudioBlob(blob); - setAudioUrl(URL.createObjectURL(blob)); - - stream.getTracks().forEach((t) => t.stop()); - }; - - mediaRecorder.start(); - setRecording(true); - } catch (err) { - alert("Microphone access denied or not supported."); - } - }; - - const stopRecording = () => { - mediaRecorderRef.current?.stop(); - setRecording(false); - }; - - const handleSave = () => { - if (audioBlob) { - recordingCallback(audioBlob); - } - }; - - const handleClose = () => { - recordingCallback(null); - }; - - return ( -
-
e.stopPropagation()} - > - -
-
{text}
-
- {/* Initial audio playback */} -
- {initialAudioUrl && ( -
- {role === "admin" && ( -
- - - -
- )} -
- {audioUrl && } -
-
-
- ); -} diff --git a/routes/api/brain/debug.ts b/routes/api/brain/debug.ts deleted file mode 100644 index f66cb42..0000000 --- a/routes/api/brain/debug.ts +++ /dev/null @@ -1,101 +0,0 @@ -// routes/api/brain/debug.ts -// Debug endpoint to verify brain progress persistence across restarts - -import { define, State } from "../../../utils.ts"; -import { SessionCookie } from "../../auth/callback.ts"; -import { brainDataClient } from "../../../utils/BrainDataClient.ts"; -import { - sqlGetAllBrainProgress, - sqlGetBrainProgressByUser, -} from "../../../db/sqlUserRepository.ts"; - -export const handler = define.handlers({ - // GET: Debug info about brain progress persistence - async GET(ctx) { - const state = ctx.state as State; - const stateData: SessionCookie = state.data ?? { - started_on: 0, - user_name: "", - user_id: "", - role: "", - user_encrypted: false, - }; - - if (!state.stateId || !stateData.user_id) { - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ); - } - - try { - // Get progress from BrainDataClient (uses KV + SQL via KvCacheStore) - const kvProgress = await brainDataClient.getUserProgress( - stateData.user_id, - ); - - // Get progress directly from SQL (bypasses KV cache) - const sqlProgress = await sqlGetBrainProgressByUser(stateData.user_id); - - // Get all SQL progress (for admin debugging) - const allSqlProgress = await sqlGetAllBrainProgress(); - - // Count records with is_review_only = true - const kvMasteredCount = kvProgress.filter((p) => p.is_review_only).length; - const sqlMasteredCount = sqlProgress.filter((p) => - p.is_review_only - ).length; - - return new Response( - JSON.stringify({ - success: true, - user_id: stateData.user_id, - persistence_status: { - kv_record_count: kvProgress.length, - sql_record_count: sqlProgress.length, - all_sql_record_count: allSqlProgress.length, - kv_mastered_count: kvMasteredCount, - sql_mastered_count: sqlMasteredCount, - records_match: kvProgress.length === sqlProgress.length, - }, - // Include first few records for debugging - kv_sample: kvProgress.slice(0, 3).map((p) => ({ - id: p.id, - question_id: p.question_id, - current_mode: p.current_mode, - mode_index: p.mode_index, - is_review_only: p.is_review_only, - attempts: p.attempts, - })), - sql_sample: sqlProgress.slice(0, 3).map((p) => ({ - id: p.id, - question_id: p.question_id, - current_mode: p.current_mode, - mode_index: p.mode_index, - is_review_only: p.is_review_only, - attempts: p.attempts, - })), - // Diagnostic info - diagnostics: { - kv_mastered_ids: kvProgress - .filter((p) => p.is_review_only) - .map((p) => p.question_id), - sql_mastered_ids: sqlProgress - .filter((p) => p.is_review_only) - .map((p) => p.question_id), - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error in brain debug endpoint:", error); - return new Response( - JSON.stringify({ - error: "Failed to fetch debug info", - details: error instanceof Error ? error.message : String(error), - }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - }, -}); diff --git a/routes/api/brain/progress.ts b/routes/api/brain/progress.ts deleted file mode 100644 index e5c67a8..0000000 --- a/routes/api/brain/progress.ts +++ /dev/null @@ -1,160 +0,0 @@ -// routes/api/brain/progress.ts -// API endpoint for Brain study progress with mode progression - -import { define, State } from "../../../utils.ts"; -import { SessionCookie } from "../../auth/callback.ts"; -import { brainDataClient } from "../../../utils/BrainDataClient.ts"; - -export const handler = define.handlers({ - // GET: Fetch user's brain progress stats - async GET(ctx) { - const state = ctx.state as State; - const stateData: SessionCookie = state.data ?? { - started_on: 0, - user_name: "", - user_id: "", - role: "", - user_encrypted: false, - }; - - if (!state.stateId || !stateData.user_id) { - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ); - } - - try { - const stats = await brainDataClient.getStats(stateData.user_id); - const dueCount = await brainDataClient.getDueCount(stateData.user_id); - - return new Response( - JSON.stringify({ - success: true, - stats, - dueCount, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error fetching brain progress:", error); - return new Response( - JSON.stringify({ error: "Failed to fetch progress" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - }, - - // POST: Record answer result with mode progression - async POST(ctx) { - const state = ctx.state as State; - const stateData: SessionCookie = state.data ?? { - started_on: 0, - user_name: "", - user_id: "", - role: "", - user_encrypted: false, - }; - - if (!state.stateId || !stateData.user_id) { - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ); - } - - try { - const body = await ctx.req.json(); - const { questionId, quizId, wasCorrectFirstTry } = body; - - // Mode is no longer required - it's tracked per-card by the backend - if (!questionId || !quizId || typeof wasCorrectFirstTry !== "boolean") { - return new Response( - JSON.stringify({ - error: - "Missing required fields: questionId, quizId, wasCorrectFirstTry", - }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - - const progress = await brainDataClient.recordAnswer( - stateData.user_id, - questionId, - quizId, - wasCorrectFirstTry, - ); - - return new Response( - JSON.stringify({ - success: true, - progress, - // Include mode advancement info for UI feedback - modeAdvanced: wasCorrectFirstTry && progress.mode_index > 0, - isReviewOnly: progress.is_review_only, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error recording brain progress:", error); - return new Response( - JSON.stringify({ error: "Failed to record progress" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - }, - - // DELETE: Reset progress - async DELETE(ctx) { - const state = ctx.state as State; - const stateData: SessionCookie = state.data ?? { - started_on: 0, - user_name: "", - user_id: "", - role: "", - user_encrypted: false, - }; - - if (!state.stateId || !stateData.user_id) { - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ); - } - - const url = new URL(ctx.req.url); - const questionId = url.searchParams.get("questionId"); - const quizId = url.searchParams.get("quizId"); - const resetAll = url.searchParams.get("all") === "true"; - - try { - if (resetAll) { - await brainDataClient.resetAllProgress(stateData.user_id); - } else if (quizId) { - await brainDataClient.resetQuizProgress(stateData.user_id, quizId); - } else if (questionId) { - // Mode is no longer needed - progress is per-question now - await brainDataClient.resetQuestionProgress( - stateData.user_id, - questionId, - ); - } else { - return new Response( - JSON.stringify({ error: "Missing reset parameters" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - - return new Response( - JSON.stringify({ success: true }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error resetting brain progress:", error); - return new Response( - JSON.stringify({ error: "Failed to reset progress" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - }, -}); diff --git a/routes/api/brain/queue.ts b/routes/api/brain/queue.ts deleted file mode 100644 index ed64474..0000000 --- a/routes/api/brain/queue.ts +++ /dev/null @@ -1,108 +0,0 @@ -// routes/api/brain/queue.ts -// API endpoint for building Brain study queue with mode progression - -import { define, State } from "../../../utils.ts"; -import { SessionCookie } from "../../auth/callback.ts"; -import { brainDataClient } from "../../../utils/BrainDataClient.ts"; -import { getAllKvQuizConfigs } from "../../../utils/QuizDataClient.ts"; -import { DEFAULT_QUIZ_MODE, QUIZ_MODES } from "../../../utils/quizModes.ts"; - -export const handler = define.handlers({ - // GET: Build and return study queue for user - async GET(ctx) { - const state = ctx.state as State; - const stateData: SessionCookie = state.data ?? { - started_on: 0, - user_name: "", - user_id: "", - role: "", - user_encrypted: false, - }; - - if (!state.stateId || !stateData.user_id) { - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ); - } - - const url = new URL(ctx.req.url); - // Mode parameter is now deprecated - each card has its own mode based on progression - // We still accept it for backwards compatibility but it's ignored - const newCardsLimit = parseInt( - url.searchParams.get("newLimit") || "10", - 10, - ); - const totalLimit = parseInt(url.searchParams.get("limit") || "50", 10); - - // Parse tab_index from query parameters for filtering - const rawTabIndex = url.searchParams.get("tab_index"); - let tabIndex: number | undefined = undefined; - if (rawTabIndex !== null) { - const parsed = Number.parseInt(rawTabIndex, 10); - if (Number.isFinite(parsed) && parsed >= 0) { - tabIndex = parsed; - } - } - - try { - // Get all quiz configs for the user - let quizConfigs = await getAllKvQuizConfigs(stateData.user_id); - - // Filter by tab_index if provided - if (tabIndex !== undefined) { - quizConfigs = quizConfigs.filter((kvObj) => { - const quizTabIndex = kvObj.value?.tab_index ?? 0; - return quizTabIndex === tabIndex; - }); - } - - // Build the study queue (mode is now per-card based on progression) - const queue = await brainDataClient.buildStudyQueue( - stateData.user_id, - quizConfigs, - DEFAULT_QUIZ_MODE, // This is now ignored internally but kept for API compatibility - newCardsLimit, - totalLimit, - ); - - // Get stats - const stats = await brainDataClient.getStats(stateData.user_id); - - // Calculate counts based on new locked-set behavior: - // - newCount: cards still in active progression (either truly new or in-progress through modes) - // - dueCount: cards that have completed all modes and are now in spaced repetition review - // - reviewOnlyCount: same as dueCount (review-only cards) - const activeSetCards = queue.filter((q) => q.is_new && !q.is_review_only); - const reviewCards = queue.filter((q) => q.is_review_only); - - return new Response( - JSON.stringify({ - success: true, - queue, - stats, - // Return default mode for backwards compatibility - mode: DEFAULT_QUIZ_MODE, - // Include the full mode list so client knows the progression order - modeOrder: QUIZ_MODES, - totalInQueue: queue.length, - // Active set count: cards currently being mastered through 6 modes - activeSetCount: activeSetCards.length, - // New count: for UI, same as active set (cards not yet fully mastered) - newCount: activeSetCards.length, - // Review-only count: cards that have completed all 6 modes - reviewOnlyCount: reviewCards.length, - // Due count: for backwards compatibility, set to review-only count - dueCount: reviewCards.length, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error building brain queue:", error); - return new Response( - JSON.stringify({ error: "Failed to build queue" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - }, -}); diff --git a/routes/api/brain/reset-session.ts b/routes/api/brain/reset-session.ts deleted file mode 100644 index c4c673f..0000000 --- a/routes/api/brain/reset-session.ts +++ /dev/null @@ -1,46 +0,0 @@ -// routes/api/brain/reset-session.ts -// API endpoint for resetting all Brain study progress for a user - -import { define, State } from "../../../utils.ts"; -import { SessionCookie } from "../../auth/callback.ts"; -import { brainDataClient } from "../../../utils/BrainDataClient.ts"; - -export const handler = define.handlers({ - // DELETE: Reset all quiz progress for the current user - async DELETE(ctx) { - const state = ctx.state as State; - const stateData: SessionCookie = state.data ?? { - started_on: 0, - user_name: "", - user_id: "", - role: "", - user_encrypted: false, - }; - - if (!state.stateId || !stateData.user_id) { - return new Response( - JSON.stringify({ error: "Unauthorized" }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ); - } - - try { - // Reset all progress for this user - await brainDataClient.resetAllProgress(stateData.user_id); - - return new Response( - JSON.stringify({ - success: true, - message: "All quiz progress has been reset", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error resetting brain session:", error); - return new Response( - JSON.stringify({ error: "Failed to reset session" }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - }, -}); diff --git a/utils/BrainDataClient.ts b/utils/BrainDataClient.ts deleted file mode 100644 index cb84bfd..0000000 --- a/utils/BrainDataClient.ts +++ /dev/null @@ -1,425 +0,0 @@ -// utils/BrainDataClient.ts -// Client for managing Brain study progress data with mode progression - -import { kvStore } from "../modules/KvCacheStore.ts"; -import { - BrainQuestionProgress, - BrainQueueItem, - KvObj, - QuizConfig, -} from "./types.ts"; -import { calculateNextReview, isDue } from "./spacedRepetition.ts"; -import { - DEFAULT_QUIZ_MODE, - getModeIndex, - getNextMode, - QUIZ_MODES, -} from "./quizModes.ts"; - -// How many days to push out review-only cards -const REVIEW_ONLY_INTERVAL_DAYS = 30; - -const REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW = 30; - -function shuffleInPlace(arr: T[]) { - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]]; - } -} - -/** - * Generate composite ID for brain progress record (mode-agnostic) - */ -export function generateBrainProgressId( - userId: string, - questionId: string, -): string { - return `${userId}_${questionId}`; -} - -/** - * Client for managing Brain study progress with mode progression - */ -export class BrainDataClient { - /** - * Get all progress records for a user - */ - async getUserProgress(userId: string): Promise { - const progress: BrainQuestionProgress[] = []; - - for await ( - const entry of kvStore.list({ - prefix: ["brain_question_progress", userId], - }) - ) { - if (entry.value) { - progress.push(entry.value); - } - } - - return progress; - } - - /** - * Get a specific progress record for a question (mode-agnostic) - */ - async getProgress( - userId: string, - questionId: string, - ): Promise { - const id = generateBrainProgressId(userId, questionId); - const key = ["brain_question_progress", userId, id]; - return await kvStore.get(key); - } - - /** - * Get all questions due for review - */ - async getDueQuestions(userId: string): Promise { - const allProgress = await this.getUserProgress(userId); - return allProgress.filter((p) => isDue(p.next_due_at)); - } - - /** - * Record an answer and update progress with mode progression logic - * - * Mode progression rules: - * - When wasCorrectFirstTry is true and not in final mode: advance to next mode - * - When wasCorrectFirstTry is true and in final mode: mark as review-only - * - When wasCorrectFirstTry is false: stay in current mode, reset consecutive streak - */ - async recordAnswer( - userId: string, - questionId: string, - quizId: string, - wasCorrectFirstTry: boolean, - ): Promise { - const id = generateBrainProgressId(userId, questionId); - const key = ["brain_question_progress", userId, id]; - const now = Date.now(); - - // Get existing progress or create new - const existing = await kvStore.get(key); - - // Determine current mode and index - let currentMode = existing?.current_mode ?? DEFAULT_QUIZ_MODE; - let modeIndex = existing?.mode_index ?? 0; - let consecutiveFirstTry = existing?.consecutive_first_try ?? 0; - let isReviewOnly = existing?.is_review_only ?? false; - let currentInterval = existing?.interval_days ?? 1; - const currentEase = existing?.ease_factor ?? 2.5; - - // Mode progression logic - if (wasCorrectFirstTry) { - consecutiveFirstTry++; - - // Advance to next mode if correct on first try - const nextMode = getNextMode(currentMode); - if (nextMode !== null) { - // Move to next harder mode - currentMode = nextMode; - modeIndex = getModeIndex(nextMode); - // Reset interval for the new mode (card needs to be reviewed soon in new mode) - currentInterval = 1; - } else { - // Already at final mode and correct on first try - mark as review-only - isReviewOnly = true; - // Push due date far out for review-only cards - currentInterval = REVIEW_ONLY_INTERVAL_DAYS; - } - } else { - // Incorrect: reset consecutive streak, stay in current mode - consecutiveFirstTry = 0; - // Don't reset mode - keep in current mode until they get it right - } - - // Calculate new review schedule - const { nextDueAt, newInterval, newEaseFactor } = calculateNextReview( - wasCorrectFirstTry, - currentInterval, - currentEase, - ); - - // For review-only cards, use longer interval - const finalInterval = isReviewOnly - ? Math.max(newInterval, REVIEW_ONLY_INTERVAL_DAYS) - : newInterval; - const finalNextDueAt = isReviewOnly - ? now + (finalInterval * 24 * 60 * 60 * 1000) - : nextDueAt; - - const progress: BrainQuestionProgress = { - id, - user_id: userId, - quiz_id: quizId, - question_id: questionId, - current_mode: currentMode, - mode_index: modeIndex, - attempts: (existing?.attempts ?? 0) + 1, - first_try_successes: (existing?.first_try_successes ?? 0) + - (wasCorrectFirstTry ? 1 : 0), - consecutive_first_try: consecutiveFirstTry, - last_result: wasCorrectFirstTry, - next_due_at: finalNextDueAt, - interval_days: finalInterval, - ease_factor: newEaseFactor, - is_review_only: isReviewOnly, - created_on: existing?.created_on ?? now, - updated_on: now, - }; - - await kvStore.set(key, progress); - return progress; - } - - /** - * Build study queue from all user's quizzes - * Returns cards sorted by mode (easier modes first), then by due status - * Review-only cards are deprioritized unless there are too few active cards - */ - async buildStudyQueue( - userId: string, - quizConfigs: KvObj[], - _mode: string, // Deprecated - mode is now per-card based on progression - newCardsLimit: number = 10, - totalLimit: number = 50, - ): Promise { - const now = Date.now(); - // Get all existing progress for this user - const allProgress = await this.getUserProgress(userId); - const progressMap = new Map(); - - for (const p of allProgress) { - progressMap.set(p.question_id, p); - } - - // Build queue items from all questions - const allItems: { - item: BrainQueueItem; - nextDueAt: number | null; - isNew: boolean; // Truly new (never seen) - isInProgression: boolean; // Has started but not completed all modes - modeIndex: number; - isReviewOnly: boolean; - isDue: boolean; - isReviewLongEnough: boolean; - }[] = []; - - for (const kvObj of quizConfigs) { - if (!kvObj.value?.questions) continue; - - for (const question of kvObj.value.questions) { - const progress = progressMap.get(question.id) ?? null; - // "Truly new" = never seen before (no progress record) - const isTrulyNew = progress === null; - // "In progression" = has started but hasn't completed all 6 modes yet - const isInProgression = progress !== null && !progress.is_review_only; - // For UI: "new" means either truly new or still in active progression - const isNew = isTrulyNew || isInProgression; - - // Determine the mode for this card - const displayMode = progress?.current_mode ?? DEFAULT_QUIZ_MODE; - const modeIndex = progress?.mode_index ?? 0; - const isReviewOnly = progress?.is_review_only ?? false; - const nextDueAt = progress?.next_due_at ?? null; - const cardIsDue = isDue(nextDueAt); - const lastReviewedAt = progress?.updated_on ?? progress?.created_on ?? - null; - const isReviewLongEnough = !isReviewOnly || lastReviewedAt === null - ? true - : (now - lastReviewedAt) >= - (REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW * 24 * 60 * 60 * 1000); - - allItems.push({ - item: { - question_id: question.id, - quiz_id: kvObj.value.quiz_config_id, - question_data: question, - progress, - is_new: isNew, - display_mode: displayMode, - mode_index: modeIndex, - is_review_only: isReviewOnly, - }, - nextDueAt, - isNew: isTrulyNew, // For queue building, only count truly new cards toward newLimit - isInProgression, - modeIndex, - isReviewOnly, - isDue: cardIsDue, - isReviewLongEnough, - }); - } - } - - // Separate cards into categories for the locked-set study behavior: - // 1. Cards currently in progression (started but not finished all 6 modes) - highest priority - // IMPORTANT: Do NOT gate on isDue - active-learning cards should remain eligible - // even if next_due_at is in the future, so the user can complete all 6 modes in one session - // 2. Truly new cards (never seen) - these fill the active set up to newLimit - // 3. Review-only cards (completed all modes, now in spaced repetition) - only if due - const inProgressionCards = allItems.filter( - (item) => item.isInProgression, // No isDue check - always include in-progression cards - ); - const trulyNewCards = allItems.filter((item) => item.isNew); - const reviewOnlyCards = allItems.filter( - (item) => item.isReviewOnly && item.isDue && item.isReviewLongEnough, // Review-only respects due and minimum time since last review - ); - - // Sort each category by mode index (easier modes first for learning progression) - const sortByModeAndDue = ( - a: typeof allItems[0], - b: typeof allItems[0], - ) => { - // Primary: mode index (lower = easier = first) - if (a.modeIndex !== b.modeIndex) { - return a.modeIndex - b.modeIndex; - } - // Secondary: due status (more overdue first) - const aDue = a.nextDueAt ?? Date.now(); - const bDue = b.nextDueAt ?? Date.now(); - return aDue - bDue; - }; - - inProgressionCards.sort(sortByModeAndDue); - trulyNewCards.sort(sortByModeAndDue); - reviewOnlyCards.sort(sortByModeAndDue); - - // Randomize the pool of new cards so large queue requests don't return the same order - // Keep in-progression cards sorted/stable so users can reliably finish what they've started. - shuffleInPlace(trulyNewCards); - shuffleInPlace(reviewOnlyCards); - - // Build final queue using locked-set behavior: - // - First, include all cards currently in progression (they're part of the active set) - // - Then, fill up to newCardsLimit with truly new cards - // - Review-only cards are only added if specifically requested (not mixed into active set) - const queue: BrainQueueItem[] = []; - - // 1. Add all cards currently in progression (these are the "active set" that must be completed) - for (const { item } of inProgressionCards) { - if (queue.length >= totalLimit) break; - queue.push(item); - } - - // 2. Calculate how many slots are available for new cards - // The active set should be up to newCardsLimit (default 20) - const activeSetCount = queue.length; - const slotsForNew = Math.max(0, newCardsLimit - activeSetCount); - - // 3. Add truly new cards to fill the active set up to the limit - let newCardsAdded = 0; - for (const { item } of trulyNewCards) { - if (queue.length >= totalLimit || newCardsAdded >= slotsForNew) break; - queue.push(item); - newCardsAdded++; - } - - // 4. Only add review-only cards if there are no active new/progression cards - // This ensures the user focuses on completing the active set before reviews - if (queue.length === 0) { - for (const { item } of reviewOnlyCards) { - if (queue.length >= totalLimit) break; - queue.push(item); - } - } - - return queue; - } - - /** - * Get count of questions due for review - */ - async getDueCount(userId: string, _mode?: string): Promise { - const allProgress = await this.getUserProgress(userId); - // Mode parameter is deprecated - count all due cards - return allProgress.filter((p) => isDue(p.next_due_at)).length; - } - - /** - * Get statistics for a user - */ - async getStats(userId: string, _mode?: string): Promise<{ - totalReviewed: number; - totalCorrect: number; - dueNow: number; - accuracy: number; - reviewOnlyCount: number; - modeDistribution: Record; - }> { - const allProgress = await this.getUserProgress(userId); - - const totalReviewed = allProgress.reduce( - (sum, p) => sum + (p.attempts ?? 0), - 0, - ); - const totalCorrect = allProgress.reduce( - (sum, p) => sum + (p.first_try_successes ?? 0), - 0, - ); - const dueNow = allProgress.filter((p) => isDue(p.next_due_at)).length; - const accuracy = totalReviewed > 0 - ? (totalCorrect / totalReviewed) * 100 - : 0; - const reviewOnlyCount = allProgress.filter((p) => p.is_review_only).length; - - // Count cards by current mode - const modeDistribution: Record = {}; - for (const mode of QUIZ_MODES) { - modeDistribution[mode] = 0; - } - for (const p of allProgress) { - const mode = p.current_mode ?? DEFAULT_QUIZ_MODE; - modeDistribution[mode] = (modeDistribution[mode] ?? 0) + 1; - } - - return { - totalReviewed, - totalCorrect, - dueNow, - accuracy: Math.round(accuracy * 10) / 10, - reviewOnlyCount, - modeDistribution, - }; - } - - /** - * Reset progress for a specific question - */ - async resetQuestionProgress( - userId: string, - questionId: string, - ): Promise { - const id = generateBrainProgressId(userId, questionId); - const key = ["brain_question_progress", userId, id]; - await kvStore.delete(key); - } - - /** - * Reset all progress for a quiz - */ - async resetQuizProgress(userId: string, quizId: string): Promise { - const allProgress = await this.getUserProgress(userId); - for (const p of allProgress) { - if (p.quiz_id === quizId) { - const key = ["brain_question_progress", userId, p.id]; - await kvStore.delete(key); - } - } - } - - /** - * Reset all progress for a user - */ - async resetAllProgress(userId: string): Promise { - const allProgress = await this.getUserProgress(userId); - for (const p of allProgress) { - const key = ["brain_question_progress", userId, p.id]; - await kvStore.delete(key); - } - } -} - -// Export singleton instance -export const brainDataClient = new BrainDataClient(); diff --git a/utils/QuizToTextFile.ts b/utils/QuizToTextFile.ts deleted file mode 100644 index b4a005b..0000000 --- a/utils/QuizToTextFile.ts +++ /dev/null @@ -1,46 +0,0 @@ -//import { QuizData } from "./database.ts"; - -import { QuizConfig } from "./types.ts"; - -// Class to handle quiz to text file conversion -export class QuizToTextFile { - quizData: QuizConfig; - constructor(quizData: QuizConfig) { - this.quizData = quizData; - } - - generateTextContent() { - // let content = `QUIZ_ID: ${this.quizData.quiz_id}\n`; - // content += `QUIZ_NAME: ${this.quizData.quiz_name}\n`; - // content += `OWNER_ID: ${this.quizData.owner_id}\n\n`; - let content = ""; - this.quizData.questions.forEach((question, index) => { - // content += `Q${index + 1}_ID: ${question.id}\n`; - // content += `Q${index + 1}_LANGUAGE: ${question.language_id}\n`; - content += `${question.text_1}\n`; - content += `${question.text_2}\n\n`; - // content += `Q${index + 1}_ORDER: ${question.order_num}\n\n`; - }); - - return content; - } - - async saveToFile(fileName: string) { - const content = this.generateTextContent(); - try { - await Deno.writeTextFile(fileName, content); - //console.log(`Quiz saved to ${fileName}`); - } catch (error) { - //console.error(`Error saving quiz to file: ${error}`); - } - } -} - -// Example usage: -// const quizData = { /* ... */ }; -// const quizToText = new QuizToTextFile(quizData); -// await quizToText.saveToFile('quiz_translations.txt'); - -// const textToQuiz = new TextToQuizTranslations(); -// const parsedQuizData = await textToQuiz.parseFile('quiz_translations.txt'); -// console.log(parsedQuizData); diff --git a/utils/TextToQuizTranslations.ts b/utils/TextToQuizTranslations.ts deleted file mode 100644 index c5102de..0000000 --- a/utils/TextToQuizTranslations.ts +++ /dev/null @@ -1,105 +0,0 @@ -//import { QuizData } from "./database.ts"; - -import { QuizConfig } from "./types.ts"; - -// Class to parse text and add translations -export class TextToQuizTranslations { - quizData: QuizConfig; - constructor(quiz: QuizConfig) { - this.quizData = { - quiz_config_id: quiz.quiz_config_id, - owner_id: quiz.owner_id, - quiz_name: quiz.quiz_name, - active_date: quiz.active_date, - quiz_mode: quiz.quiz_mode, - questions: quiz.questions, - results: quiz.results, - }; - } - - parseText(text: string) { - const lines = text.split("\n"); - let question = ""; - let meaning = ""; - const languageLines: string[] = []; - - lines.forEach((value: string) => { - const thisLine = value.trim(); - if (thisLine !== "") { - languageLines.push(thisLine); - } - }); - - const newQuestions: any = []; - let nextQuestionIndex = this.quizData.questions.length; - languageLines.forEach((value: string, index: number) => { - const sequence = index % 2; - - if (sequence == 0) { - question = value; - } else { - meaning = value; - const thisQuestion: { - 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; - } = { - quiz_id: this.quizData.quiz_config_id, - owner_id: this.quizData.owner_id, - question_index: nextQuestionIndex, - language_1: "bis-ph", - language_2: "en-us", - text_1: question, - text_2: meaning, - audio_file_1: "", - audio_file_2: "", - background_file: "", - background_image_data: "", - id: crypto.randomUUID(), - order_num: 1, - video_file: "", - video_file_data: "", - }; - - nextQuestionIndex++; - - newQuestions.push(thisQuestion); - } - }); - - this.quizData.questions = [...newQuestions, ...this.quizData.questions]; - - return this.quizData; - } - - async parseFile(fileName: string) { - try { - const text = await Deno.readTextFile(fileName); - return this.parseText(text); - } catch (error) { - console.error(`Error reading file: ${error}`); - return null; - } - } -} - -// Example usage: -// const quizData = { /* ... */ }; -// const quizToText = new QuizToTextFile(quizData); -// await quizToText.saveToFile('quiz_translations.txt'); - -// const textToQuiz = new TextToQuizTranslations(); -// const parsedQuizData = await textToQuiz.parseFile('quiz_translations.txt'); -// console.log(parsedQuizData); diff --git a/utils/quizModes.ts b/utils/quizModes.ts deleted file mode 100644 index af839ff..0000000 --- a/utils/quizModes.ts +++ /dev/null @@ -1,92 +0,0 @@ -// utils/quizModes.ts -// Shared canonical quiz mode definitions matching QuizConfigure.tsx - -/** - * Ordered list of quiz modes from easiest to hardest. - * This order is used for mode progression in BrainStudy. - * - * Mode meanings: - * - photo-see-bisaya: Show photo, display Bisaya text, user types English - * - photo-see-english: Show photo, display English text, user types Bisaya - * - photo-hide-bisaya: Show photo, hide Bisaya (audio only), user types Bisaya - * - photo-hide-english: Show photo, hide English, user types English - * - blank-see-bisaya: No photo, display Bisaya text, user types English - * - blank-see-english: No photo, display English text, user types Bisaya - */ -export const QUIZ_MODES = [ - "photo-see-bisaya", - "photo-see-english", - "photo-hide-bisaya", - "photo-hide-english", - "blank-see-bisaya", - "blank-see-english", -] as const; - -export type QuizMode = typeof QUIZ_MODES[number]; - -/** - * Default/starting mode for new cards - */ -export const DEFAULT_QUIZ_MODE: QuizMode = QUIZ_MODES[0]; - -/** - * Get the index of a mode in the progression order - */ -export function getModeIndex(mode: string): number { - const index = QUIZ_MODES.indexOf(mode as QuizMode); - return index >= 0 ? index : 0; -} - -/** - * Get the next mode in the progression - * Returns null if already at the final mode - */ -export function getNextMode(currentMode: string): QuizMode | null { - const currentIndex = getModeIndex(currentMode); - if (currentIndex >= QUIZ_MODES.length - 1) { - return null; // Already at final mode - } - return QUIZ_MODES[currentIndex + 1]; -} - -/** - * Check if a mode is the final mode in the progression - */ -export function isFinalMode(mode: string): boolean { - return getModeIndex(mode) === QUIZ_MODES.length - 1; -} - -/** - * Get display label for a mode - */ -export function getModeLabel(mode: string): string { - const labels: Record = { - "photo-see-bisaya": "Photo, See Bisaya", - "photo-see-english": "Photo, See English", - "photo-hide-bisaya": "Blank, Hide Bisaya", - "photo-hide-english": "Photo, Hide English", - "blank-see-bisaya": "Blank, See Bisaya", - "blank-see-english": "Blank, See English", - }; - return labels[mode] || mode; -} - -/** - * Get mode display info for UI - */ -export function getModeDisplayInfo(mode: string): { - index: number; - total: number; - label: string; - isFirst: boolean; - isFinal: boolean; -} { - const index = getModeIndex(mode); - return { - index: index + 1, // 1-based for display - total: QUIZ_MODES.length, - label: getModeLabel(mode), - isFirst: index === 0, - isFinal: index === QUIZ_MODES.length - 1, - }; -}