clean up
This commit is contained in:
parent
e22c7e49a2
commit
1df84f59a4
304
TODO.md
304
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
|
||||
|
||||
@ -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<QuizConfig>;
|
||||
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>(quizConfig);
|
||||
const [orderedQuizStr, setOrderQuizStr] = useState<string>(
|
||||
JSON.stringify(thisQuizConfig),
|
||||
);
|
||||
const [thisQuizConfigStr, setQuizConfigStr] = useState<string>(
|
||||
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<QuizConfig>: 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}
|
||||
<div
|
||||
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
|
||||
>
|
||||
<div hidden>
|
||||
<button onClick={handleClick}>Increment</button>
|
||||
<p>Count: {count}</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">
|
||||
Arrange Quizzes
|
||||
</h2>
|
||||
|
||||
<div className="theme-secondary-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto">
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">
|
||||
Dashboard Quiz List
|
||||
</h2>
|
||||
<form
|
||||
action="/language/arrange"
|
||||
method="POST"
|
||||
onSubmit={handleSaveQuiz}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizConfigData"
|
||||
value={thisQuizConfigStr}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizMode"
|
||||
value={quizMode}
|
||||
/>
|
||||
{/* Step 1: Select a quiz */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="quizSelect"
|
||||
className="block mb-2 theme-text"
|
||||
>
|
||||
Select Quiz
|
||||
</label>
|
||||
<select
|
||||
id="quizSelect"
|
||||
value={thisQuizOder.index}
|
||||
onChange={(e: any) => {
|
||||
const newIndex = parseInt(e.target.value, 10);
|
||||
setSelectedSlot(newIndex);
|
||||
thisQuizOder.index = newIndex;
|
||||
setThisQuizOrder({ ...thisQuizOder });
|
||||
// Reset tab selection when quiz changes
|
||||
setSelectedTabIndex(-1);
|
||||
setNewTabName("");
|
||||
setQuizConfigStr(
|
||||
JSON.stringify({
|
||||
theUpdate: newIndex,
|
||||
quiz: thisQuizConfig,
|
||||
quizOrder: thisQuizOder,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-md theme-select"
|
||||
>
|
||||
<option value={-1}>Select a quiz to move</option>
|
||||
{thisQuizOder.slots.map((name: string, i: number) => (
|
||||
<option
|
||||
key={i}
|
||||
className="text-black"
|
||||
value={i}
|
||||
>
|
||||
{`${i} - ${name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Select a tab (only shown after quiz is selected) */}
|
||||
{thisQuizOder.index != -1 && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="tabSelect"
|
||||
className="block mb-2 theme-text"
|
||||
>
|
||||
Select Tab
|
||||
</label>
|
||||
<select
|
||||
id="tabSelect"
|
||||
value={selectedTabIndex === NEW_TAB_VALUE
|
||||
? NEW_TAB_VALUE
|
||||
: selectedTabIndex}
|
||||
onChange={(e: any) => {
|
||||
const value = e.target.value;
|
||||
if (value === NEW_TAB_VALUE) {
|
||||
setSelectedTabIndex(NEW_TAB_VALUE);
|
||||
setNewTabName("");
|
||||
} else {
|
||||
setSelectedTabIndex(parseInt(value, 10));
|
||||
setNewTabName("");
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-md theme-select"
|
||||
>
|
||||
<option value={-1}>Select a tab</option>
|
||||
{tabs.map((tabName: string, i: number) => (
|
||||
<option
|
||||
key={i}
|
||||
className="text-black"
|
||||
value={i}
|
||||
>
|
||||
{tabName}
|
||||
</option>
|
||||
))}
|
||||
<option
|
||||
value={NEW_TAB_VALUE}
|
||||
className="text-black font-semibold"
|
||||
>
|
||||
+ New Tab
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3a: New tab name input (only shown if "New Tab" is selected) */}
|
||||
{thisQuizOder.index != -1 && isNewTab && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="newTabName"
|
||||
className="block mb-2 theme-text"
|
||||
>
|
||||
New Tab Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newTabName"
|
||||
value={newTabName}
|
||||
onChange={(e: any) => setNewTabName(e.target.value)}
|
||||
placeholder="Enter new tab name"
|
||||
className="w-full px-3 py-2 rounded-md theme-input border theme-border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3b: Position selector (only shown if existing tab is selected) */}
|
||||
{thisQuizOder.index != -1 && isExistingTab && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="quizOrder"
|
||||
className="block mb-2 theme-text"
|
||||
>
|
||||
Select Position in Tab
|
||||
</label>
|
||||
<select
|
||||
id="quizOrder"
|
||||
value={selectedSlot}
|
||||
onChange={(e: any) => {
|
||||
setSelectedSlot(parseInt(e.target.value, 10));
|
||||
setQuizConfigStr(
|
||||
JSON.stringify({
|
||||
theUpdate: parseInt(e.target.value, 10),
|
||||
quiz: thisQuizConfig,
|
||||
quizOrder: thisQuizOder,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-md theme-select"
|
||||
>
|
||||
{thisQuizOder.slots.map((name: string, i: number) => (
|
||||
<option
|
||||
key={i}
|
||||
className="text-black"
|
||||
value={i}
|
||||
>
|
||||
{`Position ${i + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button for moving to new tab */}
|
||||
{thisQuizOder.index != -1 && isNewTab &&
|
||||
newTabName.trim().length > 0 && (
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="theme-button px-4 py-2 rounded w-full"
|
||||
onClick={() => {
|
||||
setQuizConfigStr(
|
||||
JSON.stringify({
|
||||
theUpdate: thisQuizOder.index,
|
||||
quiz: thisQuizConfig,
|
||||
quizOrder: thisQuizOder,
|
||||
tabAction: "new",
|
||||
newTabName: newTabName.trim(),
|
||||
newTabIndex: tabs.length,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Move to New Tab
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button for reordering position within tab */}
|
||||
{thisQuizOder.index != -1 && isExistingTab &&
|
||||
thisQuizOder.index !== selectedSlot && (
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="theme-button px-4 py-2 rounded w-full"
|
||||
onClick={() => {
|
||||
setQuizConfigStr(
|
||||
JSON.stringify({
|
||||
theUpdate: selectedSlot,
|
||||
quiz: thisQuizConfig,
|
||||
quizOrder: thisQuizOder,
|
||||
tabAction: "reorder",
|
||||
targetTabIndex: selectedTabIndex,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Save Position Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button for moving to different existing tab (same position) */}
|
||||
{thisQuizOder.index != -1 && isExistingTab &&
|
||||
thisQuizOder.index === selectedSlot && (
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="theme-button px-4 py-2 rounded w-full"
|
||||
onClick={() => {
|
||||
setQuizConfigStr(
|
||||
JSON.stringify({
|
||||
theUpdate: thisQuizOder.index,
|
||||
quiz: thisQuizConfig,
|
||||
quizOrder: thisQuizOder,
|
||||
tabAction: "move",
|
||||
targetTabIndex: selectedTabIndex,
|
||||
targetTabName: tabs[selectedTabIndex as number],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Move to Tab
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
|
||||
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
|
||||
<span class={`w-[200px] py-3 px-6`}></span>
|
||||
<span class={`w-[16px]`}></span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
globalThis.location.href = "/language/dashboard";
|
||||
}}
|
||||
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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<Blob | null> => {
|
||||
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
|
||||
? (
|
||||
<VoiceRecorder
|
||||
text={popup.audioFile.text}
|
||||
audio_file={popup.audioFile.audio_file}
|
||||
role={popup.role}
|
||||
recordingCallback={(answer) => {
|
||||
setPopup((current) => {
|
||||
if (current) {
|
||||
current.resolve(answer);
|
||||
}
|
||||
const next = queueRef.current.shift() ?? null;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
return [record, popupRecorder] as const;
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
"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<ActiveQueueItem[]>([]);
|
||||
// Pool of available new cards (from server) that haven't been added to active set yet
|
||||
const [newCardPool, setNewCardPool] = useState<BrainQueueItem[]>([]);
|
||||
// Current position in active set rotation
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [sessionStats, setSessionStats] = useState<SessionStats>({
|
||||
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<string | null>(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 (
|
||||
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
|
||||
<div className="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto">
|
||||
</div>
|
||||
<p className="mt-4 theme-text">Loading study queue...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">Error</h2>
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchQueue}
|
||||
className="bg-blue-500 text-white px-6 py-2 rounded-full hover:bg-blue-600"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Complete state
|
||||
if (isComplete) {
|
||||
const accuracy = sessionStats.reviewed > 0
|
||||
? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">
|
||||
Session Complete!
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-lg">
|
||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||
{sessionStats.reviewed}
|
||||
</p>
|
||||
<p className="text-sm theme-text">Cards Reviewed</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg">
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{accuracy}%
|
||||
</p>
|
||||
<p className="text-sm theme-text">First-Try Accuracy</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-100 dark:bg-purple-900 p-4 rounded-lg">
|
||||
<p className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{sessionStats.correctFirstTry} / {sessionStats.reviewed}
|
||||
</p>
|
||||
<p className="text-sm theme-text">Correct on First Try</p>
|
||||
</div>
|
||||
|
||||
{sessionStats.modeAdvances > 0 && (
|
||||
<div className="bg-yellow-100 dark:bg-yellow-900 p-4 rounded-lg">
|
||||
<p className="text-xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{sessionStats.modeAdvances}
|
||||
</p>
|
||||
<p className="text-sm theme-text">Mode Advances</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionStats.cardsRetired > 0 && (
|
||||
<div className="bg-emerald-100 dark:bg-emerald-900 p-4 rounded-lg">
|
||||
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{sessionStats.cardsRetired}
|
||||
</p>
|
||||
<p className="text-sm theme-text">
|
||||
Cards Fully Mastered (All 6 Modes)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleRestartSession}
|
||||
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
|
||||
>
|
||||
Start New Session
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReturnToDashboard}
|
||||
className="w-full bg-gray-500 text-white px-6 py-3 rounded-full hover:bg-gray-600 font-semibold"
|
||||
>
|
||||
Return to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasStarted) {
|
||||
return (
|
||||
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
|
||||
<p className="theme-text mb-6">
|
||||
Press Start to begin. This enables audio playback for the first card.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
try {
|
||||
const Webkit = globalThis as unknown as {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
const Ctx = globalThis.AudioContext || Webkit.webkitAudioContext;
|
||||
if (Ctx) {
|
||||
const ctx = new Ctx();
|
||||
ctx.resume?.();
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = 0;
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.01);
|
||||
setTimeout(() => ctx.close?.(), 50);
|
||||
}
|
||||
} catch (_err) {
|
||||
void _err;
|
||||
}
|
||||
|
||||
setHasStarted(true);
|
||||
}}
|
||||
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<div className="theme-container p-2 sm:p-3 md:p-2 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip">
|
||||
{/* Header with progress and mode indicator */}
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
{/* Mode indicator row */}
|
||||
|
||||
{/* Progress row */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-[0.7]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
|
||||
Step {modeInfo.index}/{modeInfo.total}
|
||||
</span>
|
||||
</div>
|
||||
{currentItem?.is_review_only && (
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
|
||||
Review
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-[0.7]">
|
||||
<div className="text-sm theme-text">
|
||||
<span className="font-semibold">{progress}</span>
|
||||
<span className="ml-2 text-gray-500">
|
||||
({currentItem?.is_new ? "New" : "Old"})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-[0.6]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReturnToDashboard}
|
||||
className="px-4 py-2 rounded-full theme-button font-semibold shadow-lg"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
|
||||
>
|
||||
{/* Card */}
|
||||
<Card
|
||||
key={cardKey}
|
||||
cardInputParams={cardInputParams}
|
||||
backgroundUrl={backgroundUrl}
|
||||
hideText={false}
|
||||
useInputHint={useInputHint}
|
||||
/>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={() => handleNextCard(false)}
|
||||
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
|
||||
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
|
||||
aria-label="Next question"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
856
islands/Card.tsx
856
islands/Card.tsx
@ -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<boolean>;
|
||||
}
|
||||
|
||||
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<HTMLAudioElement>(null);
|
||||
const audioRef_fail = useRef<HTMLAudioElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLAudioElement>(null);
|
||||
const audioRef2 = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const [initialAudioUrl1, setInitialAudioUrl1] = useState<string | null>(null);
|
||||
const [initialAudioUrl2, setInitialAudioUrl2] = useState<string | null>(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<void>((resolve) => {
|
||||
const onReady = () => {
|
||||
audio.removeEventListener("canplaythrough", onReady);
|
||||
resolve();
|
||||
};
|
||||
audio.addEventListener("canplaythrough", onReady);
|
||||
});
|
||||
};
|
||||
|
||||
const playAndWaitDone = async (
|
||||
audio: HTMLAudioElement,
|
||||
requestId: number,
|
||||
): Promise<boolean> => {
|
||||
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<void>((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<HTMLInputElement>) => {
|
||||
setInputValue((prev) => {
|
||||
// console.log("keypress",
|
||||
// e.currentTarget.value);
|
||||
|
||||
return e.currentTarget.value.trim();
|
||||
});
|
||||
};
|
||||
|
||||
const waitForAudioEnd = (audio: HTMLAudioElement): Promise<void> => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "320px",
|
||||
height: "320px",
|
||||
backgroundImage: `${
|
||||
backgroundUrl
|
||||
? backgroundUrl.startsWith("/api")
|
||||
? "url(" + backgroundUrl + ")"
|
||||
: backgroundUrl.startsWith("http")
|
||||
? "url(" + CSS.escape(decodeData(backgroundUrl)) + ")"
|
||||
: backgroundUrl.length > 500
|
||||
? "url(" + backgroundUrl + ")"
|
||||
: ""
|
||||
: ""
|
||||
}`,
|
||||
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
className="p-4 rounded-lg theme-card w-80 button-padding"
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-center">
|
||||
{shouldShowPlaceholder
|
||||
? (
|
||||
<span aria-hidden="true" className="select-none">
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<p className="strokeme theme-question-text text-2xl">
|
||||
{cardInputParams.questionPhrase}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-[-20px] left-0 right-0 px-4 space-y-2">
|
||||
<div className="text-center">
|
||||
{useInputHint.value &&
|
||||
(
|
||||
<p className="strokeme theme-question-text">
|
||||
{cardInputParams.answerPhrase ? hint : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div hidden>
|
||||
{initialAudioUrl1 &&
|
||||
(
|
||||
<audio
|
||||
ref={audioRef1}
|
||||
src={initialAudioUrl1}
|
||||
preload="auto"
|
||||
controls={false}
|
||||
/>
|
||||
)}
|
||||
{initialAudioUrl2 &&
|
||||
(
|
||||
<audio
|
||||
ref={audioRef2}
|
||||
src={initialAudioUrl2}
|
||||
preload="auto"
|
||||
controls={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{cardInputParams.answerPhrase &&
|
||||
(
|
||||
<input
|
||||
type="text"
|
||||
id="input1"
|
||||
name="input1"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={(
|
||||
e: TargetedKeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{cardInputParams.answerPhrase &&
|
||||
(
|
||||
<div className="flex pt-0 pb-[30px]">
|
||||
<span className="w-[30px]"></span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFullAutoInterval((prev) => {
|
||||
if (prev) {
|
||||
clearInterval(prev);
|
||||
cardInputParams.setFullAuto(false);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
if (!helpClick) {
|
||||
cardInputParams.onHelpUsed(); // Mark as not first attempt
|
||||
setHelpClick(true);
|
||||
helpNext(inputValue);
|
||||
}
|
||||
}}
|
||||
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
|
||||
>
|
||||
Help
|
||||
</button>
|
||||
<span className="w-[65px]"></span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFullAutoInterval((prev) => {
|
||||
if (prev) {
|
||||
clearInterval(prev);
|
||||
cardInputParams.setFullAuto(false);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
if (!helpClick) {
|
||||
checkAnswer(inputValue);
|
||||
}
|
||||
}}
|
||||
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
|
||||
>
|
||||
Enter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio
|
||||
ref={audioRef_happy}
|
||||
src="/116779__domrodrig-bell-happy-trim.mp3"
|
||||
/>
|
||||
<audio ref={audioRef_fail} src="/342756__rhodesmas__failure-01.mp3" />
|
||||
</div>
|
||||
);
|
||||
} 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, "");
|
||||
}
|
||||
@ -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<QuizConfig>;
|
||||
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>(quizConfig);
|
||||
const [orderedQuizStr, setOrderQuizStr] = useState<string>(
|
||||
JSON.stringify(thisQuizConfig),
|
||||
);
|
||||
const [thisQuizConfigStr, setQuizConfigStr] = useState<string>(
|
||||
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 <div>Error: Unable to load quiz configuration</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
|
||||
>
|
||||
<div hidden>
|
||||
<button onClick={handleClick}>Increment</button>
|
||||
<p>Count: {count}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2 pb-[10px]">
|
||||
<form
|
||||
action="/language/quizzing"
|
||||
method="POST"
|
||||
//onSubmit={handleLaunchQuiz}
|
||||
className="w-[300px] mx-0 px-0"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizConfigData"
|
||||
value={thisQuizConfigStr}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizData"
|
||||
value={orderedQuizStr}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="randomValue"
|
||||
value={`${randomOrder}`}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="modeValue"
|
||||
value={modeType}
|
||||
/>
|
||||
<div className="fixed right-10 top-10 ">
|
||||
<div className="mb-4 flex justify-end items-center">
|
||||
<div className="flex flex-col items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className={`theme-button px-4 py-2 rounded`}
|
||||
title="Start"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
<label
|
||||
htmlFor="randomOrderId"
|
||||
className={`theme-text mb-1 pt-3 text-sm`}
|
||||
>
|
||||
Random Order
|
||||
</label>
|
||||
<input
|
||||
id="randomOrderId"
|
||||
type="checkbox"
|
||||
checked={randomOrder}
|
||||
onChange={(e) =>
|
||||
setRandomOrder((e.target as HTMLInputElement).checked)}
|
||||
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center ">
|
||||
<label htmlFor="modeSelect" className={`block mr-2 theme-text`}>
|
||||
Mode
|
||||
</label>
|
||||
<select
|
||||
id="modeSelect"
|
||||
value={modeType}
|
||||
onChange={(e) =>
|
||||
setModeType((e.target as HTMLSelectElement).value)}
|
||||
className={`theme-select w-36`}
|
||||
>
|
||||
<option className="text-black" value="photo-see-bisaya">
|
||||
Photo, See Bisaya
|
||||
</option>
|
||||
<option className="text-black" value="photo-see-english">
|
||||
Photo, See English
|
||||
</option>
|
||||
<option className="text-black" value="photo-hide-bisaya">
|
||||
Photo, Hide Bisaya
|
||||
</option>
|
||||
<option className="text-black" value="photo-hide-english">
|
||||
Photo, Hide English
|
||||
</option>
|
||||
<option className="text-black" value="blank-see-bisaya">
|
||||
Blank, See Bisaya
|
||||
</option>
|
||||
<option className="text-black" value="blank-see-english">
|
||||
Blank, See English
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<h2 className={`text-2xl font-bold mb-4 theme-text`}>Study Quiz</h2>
|
||||
<div className="w-[300px] mx-0 px-0 pb-[12px] mb-[12px]">
|
||||
<label
|
||||
className={`font-semibold text-left truncate w-full theme-text`}
|
||||
>
|
||||
{"Question count: " + thisQuizConfig.questions.length}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem("lock-route", "/language/dashboard");
|
||||
globalThis.location.href = "/language/dashboard";
|
||||
}}
|
||||
type="button"
|
||||
class={`w-11/12 max-w-md theme-button font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto text-center`}
|
||||
>
|
||||
Back to dashboard
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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<QuizConfig>(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<HTMLButtonElement>(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 <div>Unable to load quiz</div>;
|
||||
}
|
||||
|
||||
if (!quizData) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (quizCompleted) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start pt-20 min-h-screen">
|
||||
<h2 className="text-2xl font-bold mb-4 theme-text">Quiz Completed!</h2>
|
||||
<div className="relative w-24 h-24">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-24 w-24 text-blue-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
>
|
||||
</circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-2xl font-bold">
|
||||
{countdown}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 theme-text">Redirecting ...</p>
|
||||
|
||||
<form
|
||||
hidden
|
||||
action="/language/set-quiz"
|
||||
method="POST"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizConfigData"
|
||||
value={runData}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizMode"
|
||||
value={initialQuizData.quiz_mode}
|
||||
/>
|
||||
<button hidden ref={postButtonRef} type="submit">
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
//const currentQuestion = quizData.questions[currentQuestionIndex];
|
||||
//if (quizStarted){
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
|
||||
>
|
||||
<div hidden>
|
||||
<button onClick={handleClick}>Increment</button>
|
||||
<p>Count: {count}</p>
|
||||
</div>
|
||||
{meaningText !== ""
|
||||
? (
|
||||
<Card
|
||||
key={cardKey}
|
||||
cardInputParams={cardInputParams}
|
||||
backgroundUrl={backgroundUrl}
|
||||
hideText={hideText}
|
||||
useInputHint={useInputHint}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="text-center p-0">
|
||||
<p className="theme-text text-xl mb-4">Ready to start quiz</p>
|
||||
<p className="theme-text text-sm">
|
||||
Questions: {quizData.questions.length}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{meaningText === ""
|
||||
? (
|
||||
<div className="mt-0 flex justify-center w-full px-2">
|
||||
<div className="theme-card w-80 p-4 rounded-lg shadow-md theme-text">
|
||||
<div className="text-sm opacity-90 mb-3">
|
||||
Keyboard: shift + return = help
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>Play recording 1</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useAudio1.value}
|
||||
onChange={(
|
||||
e,
|
||||
) => (useAudio1.value = e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>Play recording 2</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useAudio2.value}
|
||||
onChange={(
|
||||
e,
|
||||
) => (useAudio2.value = e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>Input hint on</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useInputHint.value}
|
||||
onChange={(
|
||||
e,
|
||||
) => (useInputHint.value = e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRunFullAuto(true);
|
||||
handleNextQuestionInternal(true);
|
||||
}}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
|
||||
title={"Listen Through"}
|
||||
>
|
||||
Listen Through
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextQuestion}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
|
||||
title={"Start"}
|
||||
>
|
||||
Start Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: runFullAuto
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
id="auto-pause-play-button"
|
||||
onClick={() => togglePauseRef.current()}
|
||||
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
|
||||
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
|
||||
aria-label={isPausedDisplay ? "Resume auto" : "Pause auto"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{isPausedDisplay
|
||||
? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 9v6m4-6v6"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
id="next-question-button"
|
||||
onClick={handleNextQuestion}
|
||||
style={{ top: "305px", left: "50%", marginLeft: "-12px" }}
|
||||
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
|
||||
aria-label="Next question"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// }
|
||||
// else{
|
||||
// return (
|
||||
// <div className=" bg-opacity-10 backdrop-filter backdrop-blur-lg rounded-xl shadow-2xl p-8 max-w-md w-full">
|
||||
|
||||
// <button
|
||||
// //type="submit"
|
||||
// onClick={() => {setQuizStarted(true)}}
|
||||
// className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 w-full"
|
||||
// >
|
||||
// Start
|
||||
// </button>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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: [] };
|
||||
// }
|
||||
}
|
||||
@ -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<HTMLButtonElement>,
|
||||
) => {
|
||||
//console.log("clicked");
|
||||
if (!setCanStudy.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
form="studyrun"
|
||||
class={`w-16 h-16 bg-green-600 hover:bg-green-700 text-white font-bold rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 ${
|
||||
!setCanStudy.value ? "opacity-40" : ""
|
||||
}`}
|
||||
onClick={handleStudyClick}
|
||||
>
|
||||
Study
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -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<HTMLButtonElement>,
|
||||
) => {
|
||||
//console.log("clicked");
|
||||
if (!setCanStudy.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<a href="/resync">
|
||||
<button
|
||||
className={`w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 flex flex-col justify-center items-center ${
|
||||
setCanStudy.value ? "opacity-0" : ""
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 0115.07-6.36"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M21 3v5h-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 01-15.07 6.36"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 21v-5h5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -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<QuizConfig>(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}
|
||||
<div
|
||||
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
|
||||
>
|
||||
<div className="flex space-x-2 pb-[10px]">
|
||||
<button
|
||||
onClick={uploadFile}
|
||||
className={`flex-1 theme-button py-2 px-2 rounded-md transition duration-300`}
|
||||
>
|
||||
Add from file
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadFile}
|
||||
className={`flex-1 theme-button py-2 px-2 rounded-md transition duration-300`}
|
||||
>
|
||||
Out to file
|
||||
</button>
|
||||
<button
|
||||
onClick={justClear}
|
||||
className={`flex-1 theme-button rounded-md transition duration-300`}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
action="/language/lists"
|
||||
method="POST"
|
||||
onSubmit={handleSaveAddQuiz}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizConfigData"
|
||||
value={quizConfigDataStr}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="quizNameStrData"
|
||||
value={quizNameStr}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="text1StrData"
|
||||
value={text1Str}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="text2StrData"
|
||||
value={text2Str}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="deleteIndexStrData"
|
||||
value={deleteIndexStr}
|
||||
/>
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={quizNameStr}
|
||||
onChange={(e) => {
|
||||
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" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h3 className={`text-lg sm:text-xl font-semibold mb-2 theme-text`}>
|
||||
Add New Translation
|
||||
</h3>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={text1Str}
|
||||
onChange={(e) => {
|
||||
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" : ""
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={text2Str}
|
||||
onChange={(e) => {
|
||||
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" : ""
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
// onClick={handleAddTranslation}
|
||||
className={`theme-button px-4 py-2 rounded w-full`}
|
||||
>
|
||||
Add and Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{quizData.questions.map((translation: any, index: number) => (
|
||||
<li
|
||||
key={translation.id}
|
||||
className={`theme-secondary-container p-1 rounded relative group`}
|
||||
>
|
||||
<div className="flex flex-col scroll pr-2">
|
||||
<div className={`w-full p-2 border rounded mb-2 theme-input`}>
|
||||
{translation.text_1}
|
||||
</div>
|
||||
<div className={`w-full p-2 border rounded theme-input`}>
|
||||
{translation.text_2}
|
||||
</div>
|
||||
</div>
|
||||
{!(!!translation.audio_file_1 || !!translation.audio_file_2 ||
|
||||
!!translation.video_file || !!translation.video_file_data ||
|
||||
!!translation.background_file ||
|
||||
!!translation.background_image_data)
|
||||
? (
|
||||
<button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
setDeleteIndexStr(`${index}`);
|
||||
}}
|
||||
className="absolute top-1/2 -right-2 transform -translate-y-1/2 text-red-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
className="absolute top-1/2 -right-2 transform -translate-y-1/2 text-red-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect
|
||||
x="5"
|
||||
y="11"
|
||||
width="14"
|
||||
height="8"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M7 11V7a5 5 0 0110 0v4"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="15"
|
||||
r="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
|
||||
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
|
||||
<span class={`w-[200px] py-3 px-6`}></span>
|
||||
<span class={`w-[16px]`}></span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
globalThis.location.href = "/language/dashboard";
|
||||
}}
|
||||
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
async function makeQuizWithName(
|
||||
owner_id: string,
|
||||
quiz_name: string,
|
||||
): Promise<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<string | null>(null);
|
||||
const [initialAudioUrl, setInitialAudioUrl] = useState<string | null>(null);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const selectedMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
const initialAudioRef = useRef<HTMLAudioElement>(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 (
|
||||
<div className="theme-popup-overlay">
|
||||
<div
|
||||
className="theme-recorder-container"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className="theme-recorder-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="flex-1 flex items-center justify-center w-full mb-6">
|
||||
<div className="text-center w-full">{text}</div>
|
||||
</div>
|
||||
{/* Initial audio playback */}
|
||||
<div className="mb-4 w-full flex justify-center">
|
||||
{initialAudioUrl && (
|
||||
<audio
|
||||
ref={initialAudioRef}
|
||||
src={initialAudioUrl}
|
||||
autoPlay
|
||||
controls
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{role === "admin" && (
|
||||
<div className="flex gap-4 justify-center w-full mb-4">
|
||||
<button
|
||||
className={`theme-recorder-button theme-recorder-start ${
|
||||
recording ? "opacity-60 pointer-events-none" : ""
|
||||
}`}
|
||||
onClick={startRecording}
|
||||
disabled={recording}
|
||||
>
|
||||
🎤 Start
|
||||
</button>
|
||||
<button
|
||||
className={`theme-recorder-button theme-recorder-stop ${
|
||||
!recording ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
onClick={stopRecording}
|
||||
disabled={!recording}
|
||||
>
|
||||
⏹ Stop
|
||||
</button>
|
||||
<button
|
||||
className={`theme-recorder-button theme-recorder-save ${
|
||||
!audioUrl ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
onClick={handleSave}
|
||||
disabled={!audioUrl}
|
||||
>
|
||||
💾 Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 h-[30px] w-full flex justify-center items-center">
|
||||
{audioUrl && <audio controls src={audioUrl}></audio>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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<T>(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<BrainQuestionProgress[]> {
|
||||
const progress: BrainQuestionProgress[] = [];
|
||||
|
||||
for await (
|
||||
const entry of kvStore.list<BrainQuestionProgress>({
|
||||
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<BrainQuestionProgress | null> {
|
||||
const id = generateBrainProgressId(userId, questionId);
|
||||
const key = ["brain_question_progress", userId, id];
|
||||
return await kvStore.get<BrainQuestionProgress>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all questions due for review
|
||||
*/
|
||||
async getDueQuestions(userId: string): Promise<BrainQuestionProgress[]> {
|
||||
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<BrainQuestionProgress> {
|
||||
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<BrainQuestionProgress>(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<QuizConfig>[],
|
||||
_mode: string, // Deprecated - mode is now per-card based on progression
|
||||
newCardsLimit: number = 10,
|
||||
totalLimit: number = 50,
|
||||
): Promise<BrainQueueItem[]> {
|
||||
const now = Date.now();
|
||||
// Get all existing progress for this user
|
||||
const allProgress = await this.getUserProgress(userId);
|
||||
const progressMap = new Map<string, BrainQuestionProgress>();
|
||||
|
||||
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<number> {
|
||||
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<string, number>;
|
||||
}> {
|
||||
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<string, number> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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<string, string> = {
|
||||
"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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user