Compare commits
16 Commits
409706457a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54324e45d4 | |||
| dcbce863de | |||
| 36b8ee9ec2 | |||
| abfaf397ee | |||
| 3767fc92d9 | |||
| f233652acd | |||
| 285ab987fd | |||
| 309609fccb | |||
| 3e0b3f6a6d | |||
| 088c555515 | |||
| 4bc39225ab | |||
| 427557ef96 | |||
| 1736b8a68f | |||
| efd7fac42f | |||
| 01c5ccbd93 | |||
| 77a0e38fa7 |
@@ -7,7 +7,10 @@ VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
|
|||||||
# Alternative key name (both are supported)
|
# Alternative key name (both are supported)
|
||||||
# VITE_SUPABASE_ANON_KEY=eyJ...
|
# VITE_SUPABASE_ANON_KEY=eyJ...
|
||||||
|
|
||||||
# GLM API — https://open.bigmodel.cn/usercenter/apikeys
|
# GLM API — used by writing-check edge function (server-side only)
|
||||||
# Used by the writing-check Supabase Edge Function (server-side only, never expose in frontend)
|
# Deploy with: supabase secrets set GLM_API_KEY=<your_key>
|
||||||
# Deploy to Supabase with: supabase secrets set GLM_API_KEY=<your_key>
|
|
||||||
GLM_API_KEY=your_glm_api_key_here
|
GLM_API_KEY=your_glm_api_key_here
|
||||||
|
|
||||||
|
# DBIZ API — https://ai-api.dbiz.com
|
||||||
|
# VITE_ prefix = exposed to browser (intentional, for direct streaming without edge function hop)
|
||||||
|
VITE_DBIZ_API_KEY=your_dbiz_api_key_here
|
||||||
|
|||||||
45
.gitea/workflows/build-on-tag.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build and Push Docker Image on Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Extract tag name
|
||||||
|
id: tag
|
||||||
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up QEMU (for multi-platform)
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
bootstrap: true
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and Push multi-platform image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.REGISTRY_URL }}/renolation/english-toeic:${{ steps.tag.outputs.TAG }}
|
||||||
|
${{ secrets.REGISTRY_URL }}/renolation/english-toeic:latest
|
||||||
1
.gitignore
vendored
@@ -33,7 +33,6 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# package manager
|
# package manager
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
|||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
22
Dockerfile
@@ -10,20 +10,10 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source
|
# Copy source and build — no VITE_* args needed at build time.
|
||||||
|
# Supabase keys are injected at runtime via docker/entrypoint.sh → window.__ENV__
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
# VITE_* vars are Supabase public keys — safe for browser, baked into bundle at build time.
|
|
||||||
# Using ARG only (no ENV) so values don't persist as image-layer env vars.
|
|
||||||
ARG VITE_SUPABASE_URL
|
|
||||||
ARG VITE_SUPABASE_ANON_KEY
|
|
||||||
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
|
||||||
|
|
||||||
# Pass vars inline so they're scoped to this RUN layer only
|
|
||||||
RUN VITE_SUPABASE_URL="$VITE_SUPABASE_URL" \
|
|
||||||
VITE_SUPABASE_ANON_KEY="$VITE_SUPABASE_ANON_KEY" \
|
|
||||||
VITE_SUPABASE_PUBLISHABLE_KEY="$VITE_SUPABASE_PUBLISHABLE_KEY" \
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 2 — Serve
|
# Stage 2 — Serve
|
||||||
@@ -37,6 +27,10 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
# Copy built static files from builder
|
# Copy built static files from builder
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Entrypoint generates env.js from runtime env vars before starting nginx
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
|
||||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
|
|
||||||
VITE_SUPABASE_PUBLISHABLE_KEY: ${VITE_SUPABASE_PUBLISHABLE_KEY}
|
|
||||||
image: renolation/english-toeic:latest
|
image: renolation/english-toeic:latest
|
||||||
|
environment:
|
||||||
|
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||||
|
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||||
|
- VITE_SUPABASE_PUBLISHABLE_KEY=${VITE_SUPABASE_PUBLISHABLE_KEY}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:80"
|
- "${APP_PORT:-3000}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
10
docker/entrypoint.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Generate runtime env.js from container environment variables
|
||||||
|
cat > /usr/share/nginx/html/env.js <<EOF
|
||||||
|
window.__ENV__ = {
|
||||||
|
VITE_SUPABASE_URL: "${VITE_SUPABASE_URL}",
|
||||||
|
VITE_SUPABASE_ANON_KEY: "${VITE_SUPABASE_ANON_KEY}",
|
||||||
|
VITE_SUPABASE_PUBLISHABLE_KEY: "${VITE_SUPABASE_PUBLISHABLE_KEY}"
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="vi">
|
<html lang="vi">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<script src="/env.js"></script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>EnglishAI — Luyện TOEIC thông minh</title>
|
<title>EnglishAI — Luyện TOEIC thông minh</title>
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..700,0..100,0..1;1,9..144,300..700,0..100,0..1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ server {
|
|||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
|
access_log off;
|
||||||
return 200 "ok";
|
return 200 "ok";
|
||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
}
|
}
|
||||||
|
|||||||
8105
package-lock.json
generated
Normal file
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"import:tests": "node scripts/import-tests.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
|
|||||||
1
public/env.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
window.__ENV__ = {};
|
||||||
145
scripts/import-tests.mjs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Import TOEIC test JSON files into Supabase.
|
||||||
|
// Usage: SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... node scripts/import-tests.mjs
|
||||||
|
// Requires service_role key to bypass RLS. Idempotent: skips if slug already exists.
|
||||||
|
|
||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const TEST_DIR = join(__dirname, '..', 'test')
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL
|
||||||
|
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||||
|
console.error('Missing env: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = createClient(SUPABASE_URL, SERVICE_KEY, {
|
||||||
|
auth: { persistSession: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function insertReturningId(table, payload) {
|
||||||
|
const { data, error } = await db.from(table).insert(payload).select('id').single()
|
||||||
|
if (error) throw new Error(`${table} insert failed: ${error.message}`)
|
||||||
|
return data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertMany(table, rows) {
|
||||||
|
if (rows.length === 0) return
|
||||||
|
const { error } = await db.from(table).insert(rows)
|
||||||
|
if (error) throw new Error(`${table} bulk insert failed: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importTest(data) {
|
||||||
|
const { data: existing } = await db.from('test').select('id').eq('slug', data.slug).maybeSingle()
|
||||||
|
if (existing) {
|
||||||
|
console.log(` skip (exists): ${data.slug}`)
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const testId = await insertReturningId('test', {
|
||||||
|
title: data.title,
|
||||||
|
slug: data.slug,
|
||||||
|
total_questions: data.total_questions ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
let totalQuestions = 0
|
||||||
|
|
||||||
|
for (const part of data.parts ?? []) {
|
||||||
|
const partId = await insertReturningId('part', {
|
||||||
|
test_id: testId,
|
||||||
|
part_number: part.part_number,
|
||||||
|
title: part.title,
|
||||||
|
display_order: part.display_order ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
let partCount = 0
|
||||||
|
|
||||||
|
for (const group of part.groups ?? []) {
|
||||||
|
const groupId = await insertReturningId('question_group', {
|
||||||
|
part_id: partId,
|
||||||
|
audio_url: group.audio_url ?? null,
|
||||||
|
image_url: group.image_url ?? null,
|
||||||
|
passage_text: group.passage_text ?? null,
|
||||||
|
transcript: group.transcript ?? null,
|
||||||
|
display_order: group.display_order ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const q of group.questions ?? []) {
|
||||||
|
const questionId = await insertReturningId('question', {
|
||||||
|
group_id: groupId,
|
||||||
|
question_number: q.question_number,
|
||||||
|
question_text: q.question_text ?? null,
|
||||||
|
display_order: q.display_order ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const choices = (q.choices ?? []).map((c) => ({
|
||||||
|
question_id: questionId,
|
||||||
|
value: c.value,
|
||||||
|
label_text: c.label_text ?? null,
|
||||||
|
is_correct: c.is_correct ?? false,
|
||||||
|
}))
|
||||||
|
await insertMany('answer_choice', choices)
|
||||||
|
|
||||||
|
partCount++
|
||||||
|
totalQuestions++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.from('part').update({ question_count: partCount }).eq('id', partId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { testId, totalQuestions }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollback(slug) {
|
||||||
|
// CASCADE will clean up parts, groups, questions, choices when test row is deleted.
|
||||||
|
await db.from('test').delete().eq('slug', slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const files = (await readdir(TEST_DIR))
|
||||||
|
.filter((f) => f.startsWith('test_') && f.endsWith('.json'))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
console.log(`Found ${files.length} file(s) in ${TEST_DIR}\n`)
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const path = join(TEST_DIR, file)
|
||||||
|
process.stdout.write(`${file}: `)
|
||||||
|
let slug = null
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await readFile(path, 'utf-8'))
|
||||||
|
slug = data.slug
|
||||||
|
const res = await importTest(data)
|
||||||
|
if (res.skipped) skipped++
|
||||||
|
else {
|
||||||
|
imported++
|
||||||
|
console.log(` ok (${res.totalQuestions} questions)`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failed++
|
||||||
|
console.error(` FAIL: ${err.message}`)
|
||||||
|
if (slug) {
|
||||||
|
console.error(` rolling back ${slug}...`)
|
||||||
|
await rollback(slug).catch((e) => console.error(` rollback failed: ${e.message}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. imported=${imported} skipped=${skipped} failed=${failed}`)
|
||||||
|
process.exit(failed > 0 ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,31 +1,99 @@
|
|||||||
import { useRouterState } from '@tanstack/react-router'
|
import { useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { UserMenu } from '@/components/UserMenu'
|
import { UserMenu } from '@/components/UserMenu'
|
||||||
|
import { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
|
||||||
|
|
||||||
const ROUTE_TITLES: Record<string, string> = {
|
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
|
||||||
'/': 'Trang chủ',
|
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
|
||||||
'/writing': 'AI Chấm Writing',
|
'/archivement': { eyebrow: 'Thành tích của bạn', title: 'Tôi học', accent: 'học' },
|
||||||
'/vocab': 'Từ vựng TOEIC',
|
'/toeic': { eyebrow: 'Luyện đề', title: 'TOEIC Mock Tests', accent: 'Mock' },
|
||||||
'/toeic': 'Luyện đề TOEIC',
|
'/writing': { eyebrow: 'AI Coach', title: 'Chấm Writing', accent: 'Writing' },
|
||||||
'/toeic/session': '', // dynamic — filled below
|
'/flash-card': { eyebrow: 'Từ vựng TOEIC', title: 'Flash Card', accent: 'Card' },
|
||||||
'/toeic/result': 'Kết quả bài thi',
|
'/settings': { eyebrow: 'Tuỳ chỉnh', title: 'Cài đặt' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match `/toeic/<numeric-id>` but not `/toeic/session`, `/toeic/result`, `/toeic/part/...`
|
||||||
|
const TEST_DETAIL_RE = /^\/toeic\/(\d+)$/
|
||||||
|
|
||||||
|
function matchRouteLabel(pathname: string) {
|
||||||
|
if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname]
|
||||||
|
const keys = Object.keys(ROUTE_TITLES).sort((a, b) => b.length - a.length)
|
||||||
|
for (const k of keys) {
|
||||||
|
if (k !== '/' && pathname.startsWith(k)) return ROUTE_TITLES[k]
|
||||||
|
}
|
||||||
|
return { eyebrow: 'EnglishAI', title: 'EnglishAI' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { location } = useRouterState()
|
const { location } = useRouterState()
|
||||||
const { partId, partName, answers, questions } = useTestStore()
|
const { testName, parts, answers } = useTestStore()
|
||||||
const pathname = location.pathname
|
const pathname = location.pathname
|
||||||
|
|
||||||
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
|
// Show test title in header when viewing a specific test's part-selection page.
|
||||||
|
const testDetailMatch = pathname.match(TEST_DETAIL_RE)
|
||||||
|
const testId = testDetailMatch ? Number(testDetailMatch[1]) : null
|
||||||
|
const { data: testDetail } = useQuery({
|
||||||
|
queryKey: ['test-detail', testId],
|
||||||
|
queryFn: () => fetchTestWithParts(testId!),
|
||||||
|
enabled: testId !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In-session mode: show test progress instead of route title
|
||||||
if (pathname === '/toeic/session') {
|
if (pathname === '/toeic/session') {
|
||||||
const answered = answers.filter((a) => a !== null).length
|
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
|
||||||
title = `Part ${partId} — ${partName} · ${answered}/${questions.length} câu`
|
const answered = Object.values(answers).filter((a) => a !== null).length
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="fixed top-0 right-0 left-0 lg:left-60 h-16 z-40 flex items-center justify-between px-6 backdrop-blur-md"
|
||||||
|
style={{
|
||||||
|
background: 'color-mix(in oklab, var(--at-paper) 88%, transparent)',
|
||||||
|
borderBottom: '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="at-eyebrow" style={{ fontSize: 10, marginBottom: 2 }}>Phiên thi</div>
|
||||||
|
<div className="at-serif text-[15px]" style={{ color: 'var(--at-ink)', fontWeight: 500, letterSpacing: '-0.01em' }}>
|
||||||
|
{testName} · <i className="italic" style={{ color: 'var(--at-brand)' }}>{answered}/{totalQuestions}</i> câu
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UserMenu />
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-detail page: show test title in header
|
||||||
|
const routeLabel = testDetail
|
||||||
|
? { eyebrow: 'Luyện đề TOEIC', title: testDetail.test.title }
|
||||||
|
: matchRouteLabel(pathname)
|
||||||
|
const { eyebrow, title } = routeLabel
|
||||||
|
const accent = 'accent' in routeLabel ? routeLabel.accent : undefined
|
||||||
|
const renderTitle = () => {
|
||||||
|
if (!accent || !title.includes(accent)) return title
|
||||||
|
const [before, after] = title.split(accent)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<i className="italic" style={{ color: 'var(--at-brand)' }}>{accent}</i>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 right-0 left-0 lg:left-60 h-16 bg-white/90 backdrop-blur-md border-b border-slate-200 z-40 flex items-center justify-between px-6">
|
<header
|
||||||
<span className="text-sm font-semibold text-slate-700">{title}</span>
|
className="fixed top-0 right-0 left-0 lg:left-60 h-16 z-40 flex items-center justify-between px-6 backdrop-blur-md"
|
||||||
|
style={{
|
||||||
|
background: 'color-mix(in oklab, var(--at-paper) 88%, transparent)',
|
||||||
|
borderBottom: '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="at-eyebrow" style={{ fontSize: 10, marginBottom: 2 }}>{eyebrow}</div>
|
||||||
|
<div className="at-serif text-[15px]" style={{ color: 'var(--at-ink)', fontWeight: 500, letterSpacing: '-0.01em' }}>
|
||||||
|
{renderTitle()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { Home, ClipboardList, Layers, Trophy, User } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
// Atelier Mobile tab bar — 5 tabs matching the mobile design.
|
||||||
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
// Labels keep the mobile design's playful brevity: Hôm nay / Luyện / Thẻ / Thành tích / Tôi
|
||||||
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
|
const TABS = [
|
||||||
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
{ to: '/', label: 'Hôm nay', icon: Home, matchPrefix: '/', exact: true },
|
||||||
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
{ to: '/toeic', label: 'Luyện', icon: ClipboardList, matchPrefix: '/toeic', exact: false },
|
||||||
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
{ to: '/flash-card', label: 'Thẻ', icon: Layers, matchPrefix: '/flash-card', exact: false },
|
||||||
|
{ to: '/archivement', label: 'Thành tích', icon: Trophy, matchPrefix: '/archivement', exact: false },
|
||||||
|
{ to: '/settings', label: 'Tôi', icon: User, matchPrefix: '/settings', exact: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
function isActive(pathname: string, prefix: string, exact: boolean) {
|
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||||
@@ -18,22 +21,19 @@ export function MobileNav() {
|
|||||||
const pathname = location.pathname
|
const pathname = location.pathname
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
|
<nav className="m-tabbar lg:hidden" aria-label="Chuyển màn hình">
|
||||||
{NAV_ITEMS.map((item) => {
|
{TABS.map((tab) => {
|
||||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
const active = isActive(pathname, tab.matchPrefix, tab.exact)
|
||||||
|
const Icon = tab.icon
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={tab.to}
|
||||||
to={item.to}
|
to={tab.to}
|
||||||
className={cn(
|
className={cn('m-tab', active && 'is-active')}
|
||||||
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
|
aria-current={active ? 'page' : undefined}
|
||||||
active ? 'text-blue-600' : 'text-slate-400',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
<Icon width={22} height={22} strokeWidth={active ? 2.25 : 1.75} />
|
||||||
{item.icon}
|
<span>{tab.label}</span>
|
||||||
</span>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useAuthModalStore } from '@/store/auth-modal-store'
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
|
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
|
||||||
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
|
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
|
||||||
{ to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
{ to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||||
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
||||||
{ to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
|
{ to: '/flash-card', label: 'Flash Card', icon: 'menu_book', matchPrefix: '/flash-card', exact: false },
|
||||||
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -23,60 +23,111 @@ export function Sidebar() {
|
|||||||
const openModal = useAuthModalStore((s) => s.open)
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col bg-slate-50 border-r border-slate-200 z-50">
|
<aside
|
||||||
|
className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col z-50"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-paper)',
|
||||||
|
borderRight: '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div className="px-6 py-5 border-b border-slate-200">
|
<div className="px-5 pt-7 pb-9 flex items-start gap-2.5">
|
||||||
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
|
<div
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</div>
|
className="w-[34px] h-[34px] rounded-[10px] grid place-items-center flex-shrink-0 at-serif italic"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-ink)',
|
||||||
|
color: 'var(--at-paper)',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="at-serif" style={{ fontSize: 18, fontWeight: 500, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}>
|
||||||
|
EnglishAI
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--at-mute)', letterSpacing: '0.14em', textTransform: 'uppercase', marginTop: 2 }}>
|
||||||
|
TOEIC Curator
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 py-3 overflow-y-auto">
|
<nav className="flex-1 px-4 overflow-y-auto">
|
||||||
{NAV_ITEMS.map((item) => {
|
<div className="px-3 pb-2" style={{ fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--at-mute-2)', fontWeight: 600 }}>
|
||||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
Học tập
|
||||||
return (
|
</div>
|
||||||
<Link
|
<div className="flex flex-col gap-0.5">
|
||||||
key={item.to}
|
{NAV_ITEMS.map((item) => {
|
||||||
to={item.to}
|
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||||
className={cn(
|
return (
|
||||||
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
<Link
|
||||||
active
|
key={item.to}
|
||||||
? 'bg-white text-blue-600 font-semibold shadow-sm'
|
to={item.to}
|
||||||
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
|
className={cn(
|
||||||
)}
|
'relative flex items-center gap-3 px-3 py-2.5 rounded-[10px] text-[13.5px] font-medium transition-colors',
|
||||||
>
|
)}
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
style={{
|
||||||
{item.icon}
|
background: active ? 'var(--at-line-2)' : 'transparent',
|
||||||
</span>
|
color: active ? 'var(--at-ink)' : 'var(--at-ink-2)',
|
||||||
{item.label}
|
}}
|
||||||
</Link>
|
>
|
||||||
)
|
{active && (
|
||||||
})}
|
<span
|
||||||
|
className="absolute top-2 bottom-2 rounded-full"
|
||||||
|
style={{ left: -18, width: 2, background: 'var(--at-brand)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: 20, color: active ? 'var(--at-brand)' : 'var(--at-mute)' }}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<div className="px-3 py-4 border-t border-slate-200">
|
<div className="px-3 py-4">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
|
<div
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
|
className="flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-[10px] grid place-items-center flex-shrink-0 at-serif italic"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #F0E6D8, #E5D4B7)',
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{user.name.charAt(0).toUpperCase()}
|
{user.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold truncate">{user.name}</div>
|
<div className="text-[13px] font-semibold truncate" style={{ color: 'var(--at-ink)' }}>{user.name}</div>
|
||||||
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
<div className="text-[11px] truncate" style={{ color: 'var(--at-mute)' }}>{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('login')}
|
onClick={() => openModal('login')}
|
||||||
className="w-full flex items-center gap-3 bg-white rounded-xl px-3 py-2.5 hover:bg-blue-50 transition-colors group"
|
className="w-full flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl hover:bg-[var(--at-line-2)] transition-colors"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-100 transition-colors">
|
<div className="w-9 h-9 rounded-[10px] grid place-items-center flex-shrink-0" style={{ background: 'var(--at-line-2)' }}>
|
||||||
<span className="material-symbols-outlined text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 18, color: 'var(--at-mute)' }}>person</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 text-left">
|
<div className="min-w-0 text-left">
|
||||||
<div className="text-sm font-semibold text-slate-600">Khách</div>
|
<div className="text-[13px] font-semibold" style={{ color: 'var(--at-ink-2)' }}>Khách</div>
|
||||||
<div className="text-xs text-blue-600 font-medium">Đăng nhập →</div>
|
<div className="text-[11px] font-medium at-serif italic" style={{ color: 'var(--at-brand)' }}>Đăng nhập →</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,22 +3,59 @@ import { useAuthStore } from '@/store/auth-store'
|
|||||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
|
import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
|
||||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
import { XP_REWARDS } from '@/lib/gamification-service'
|
||||||
import { StatsRow } from './StatsRow'
|
|
||||||
import { XpProgressCard } from './XpProgressCard'
|
|
||||||
import { WeeklySection } from './WeeklySection'
|
|
||||||
import { XuEconomyCard } from './XuEconomyCard'
|
|
||||||
import { LeaderboardCard } from './LeaderboardCard'
|
|
||||||
|
|
||||||
// Numeric level from XP (1 per 100 XP, min 1)
|
const LEVEL_LABEL: Record<string, string> = {
|
||||||
export function calcNumericLevel(xp: number) {
|
beginner: 'Beginner',
|
||||||
return Math.max(1, Math.floor(xp / 100))
|
bronze: 'Bronze',
|
||||||
|
silver: 'Silver',
|
||||||
|
gold: 'Gold',
|
||||||
|
master: 'Master',
|
||||||
}
|
}
|
||||||
|
|
||||||
// XP needed for next numeric level
|
function calcNumericLevel(xp: number) {
|
||||||
export function calcXpNextLevel(xp: number) {
|
return Math.max(1, Math.floor(xp / 100))
|
||||||
|
}
|
||||||
|
function xpForNext(xp: number) {
|
||||||
return (Math.floor(xp / 100) + 1) * 100
|
return (Math.floor(xp / 100) + 1) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EARN_ITEMS = [
|
||||||
|
{ label: 'Hoàn thành mục tiêu ngày', amt: 10 },
|
||||||
|
{ label: 'Mốc chuỗi (Streak)', amt: 20 },
|
||||||
|
{ label: 'Xem quảng cáo', amt: 5 },
|
||||||
|
{ label: 'Chia sẻ với bạn bè', amt: 15 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SPEND_ITEMS = [
|
||||||
|
{ label: 'Streak Freeze', amt: 20, desc: 'Giữ streak 1 ngày nghỉ' },
|
||||||
|
{ label: 'AI Writing Feedback', amt: 30, desc: 'Phân tích bài viết sâu' },
|
||||||
|
{ label: 'Bộ thẻ Premium', amt: 50, desc: 'Mở khoá toàn bộ chủ đề' },
|
||||||
|
{ label: 'Đổi theme hiếm', amt: 40, desc: 'Giao diện Atelier Noir' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type Badge = { id: string; name: string; desc: string; earned: boolean; progress?: number; icon: string; color: string }
|
||||||
|
|
||||||
|
const BADGES: Badge[] = [
|
||||||
|
{ id: 'b1', name: 'Khởi hành', desc: 'Học ngày đầu tiên', earned: true, icon: 'auto_awesome', color: 'var(--at-brand)' },
|
||||||
|
{ id: 'b2', name: 'Một tuần', desc: '7 ngày liên tiếp', earned: false, progress: 40, icon: 'local_fire_department', color: 'var(--at-streak)' },
|
||||||
|
{ id: 'b3', name: 'Bền bỉ', desc: '30 ngày liên tiếp', earned: false, progress: 10, icon: 'local_fire_department', color: 'var(--at-warm)' },
|
||||||
|
{ id: 'b4', name: 'Mọt sách', desc: 'Thuộc 100 từ vựng', earned: false, progress: 30, icon: 'style', color: '#8B5CF6' },
|
||||||
|
{ id: 'b5', name: 'Nhà ngôn ngữ', desc: 'Thuộc 500 từ vựng', earned: false, progress: 10, icon: 'style', color: '#8B5CF6' },
|
||||||
|
{ id: 'b6', name: 'Điểm số vàng', desc: 'Đạt 800+ TOEIC', earned: false, progress: 20, icon: 'emoji_events', color: 'var(--at-good)' },
|
||||||
|
{ id: 'b7', name: 'Thí sinh', desc: 'Hoàn thành 10 đề full', earned: false, progress: 0, icon: 'fact_check', color: 'var(--at-brand)' },
|
||||||
|
{ id: 'b8', name: 'Cây viết', desc: 'Gửi 20 bài AI Writing', earned: false, progress: 10, icon: 'edit_note', color: 'var(--at-good)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Coin({ size = 14 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: '-2px' }}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#F5B94A" stroke="#C9902F" strokeWidth="1.2" />
|
||||||
|
<circle cx="12" cy="12" r="7" fill="none" stroke="#C9902F" strokeWidth="0.8" opacity="0.6" />
|
||||||
|
<text x="12" y="15.5" textAnchor="middle" fontFamily="var(--at-serif)" fontSize="9.5" fontWeight="700" fill="#7B5210">XU</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const openModal = useAuthModalStore((s) => s.open)
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
@@ -27,15 +64,15 @@ export function Dashboard() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
|
<div className="px-4 lg:px-6 py-20 flex flex-col items-center text-center gap-4">
|
||||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>emoji_events</span>
|
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
|
||||||
<h1 className="text-xl font-bold text-slate-700">Bảng thành tích</h1>
|
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
<p className="text-slate-400 text-sm max-w-xs">
|
|
||||||
Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn.
|
Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('login')}
|
onClick={() => openModal('login')}
|
||||||
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
|
className="mt-2 px-6 py-2.5 rounded-xl font-semibold text-sm hover:opacity-90 transition"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
||||||
>
|
>
|
||||||
Đăng nhập
|
Đăng nhập
|
||||||
</button>
|
</button>
|
||||||
@@ -43,53 +80,524 @@ export function Dashboard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive weekly completed from leaderboard XP
|
|
||||||
const userLbRow = leaderboard?.find((r) => r.userId === user.id)
|
|
||||||
const weeklyXp = userLbRow?.xpEarned ?? 0
|
|
||||||
const weeklyCompleted = Math.min(Math.floor(weeklyXp / XP_REWARDS.test), 5)
|
|
||||||
|
|
||||||
const xu = gam?.xu ?? 50
|
const xu = gam?.xu ?? 50
|
||||||
const streak = gam?.streak ?? 0
|
const streak = gam?.streak ?? 0
|
||||||
const xp = gam?.xp ?? 0
|
const xp = gam?.xp ?? 0
|
||||||
const level = gam?.level ?? 'beginner'
|
const levelLabel = LEVEL_LABEL[gam?.level ?? 'beginner']
|
||||||
const lastActive = gam?.lastActive ?? null
|
const numericLevel = calcNumericLevel(xp)
|
||||||
|
const nextLevelXp = xpForNext(xp)
|
||||||
|
const xpIntoLevel = xp - numericLevel * 100
|
||||||
|
const levelPct = Math.round((xpIntoLevel / 100) * 100)
|
||||||
|
const xpLeft = nextLevelXp - xp
|
||||||
|
|
||||||
|
// Week metrics
|
||||||
|
const userLbRow = leaderboard?.find((r) => r.userId === user.id)
|
||||||
|
const weeklyXp = userLbRow?.xpEarned ?? 0
|
||||||
|
const weeklyCompleted = Math.min(Math.floor(weeklyXp / XP_REWARDS.test), 5)
|
||||||
|
const weekGoalTotal = 5
|
||||||
|
|
||||||
|
// 7-day history mock pattern — actual tracking would need daily_activity table
|
||||||
|
const todayDayIdx = (new Date().getDay() + 6) % 7 // Mon=0..Sun=6
|
||||||
|
const history = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => {
|
||||||
|
if (i === todayDayIdx) return { d, state: 'today' as const }
|
||||||
|
if (i < todayDayIdx) return { d, state: i < weeklyCompleted ? 'done' as const : 'empty' as const }
|
||||||
|
return { d, state: 'future' as const }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Leaderboard display (top 5, highlight self)
|
||||||
|
const board = (leaderboard ?? []).slice(0, 5).map((row, idx) => ({
|
||||||
|
rank: idx + 1,
|
||||||
|
name: row.userId === user.id ? `${user.name} (Bạn)` : `User ${row.userId.slice(0, 6)}`,
|
||||||
|
xp: row.xpEarned,
|
||||||
|
you: row.userId === user.id,
|
||||||
|
avatar: (row.userId === user.id ? user.name : 'U').charAt(0).toUpperCase(),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
<div className="mb-6">
|
{/* Editorial head */}
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Bảng thành tích</h1>
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<p className="text-slate-400 text-sm">
|
<div>
|
||||||
Xin chào, <span className="font-semibold text-slate-600">{user.name}</span> — tiếp tục chuỗi học tập nhé!
|
<div className="at-eyebrow mb-3">Thành tích</div>
|
||||||
</p>
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
|
Bảng <i>thành tích</i>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Xin chào, <b style={{ color: 'var(--at-ink)' }}>{user.name}</b> — tiếp tục chuỗi học tập nhé!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2.5 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13.5px] font-semibold hover:bg-[var(--at-line-2)] transition-colors"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>share</span>
|
||||||
|
Chia sẻ
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/toeic"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-[13.5px] font-semibold hover:opacity-90 transition-opacity"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>play_arrow</span>
|
||||||
|
Học tiếp
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="bg-white rounded-xl p-6 shadow-sm h-32 animate-pulse bg-slate-100" />
|
<div key={i} className="rounded-2xl h-32 animate-pulse" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<StatsRow xu={xu} streak={streak} xp={xp} level={level} />
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5" style={{ gridTemplateColumns: '1fr 1.2fr 1fr' }}>
|
||||||
|
{/* XU */}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-5 relative overflow-hidden"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{ background: 'radial-gradient(120% 80% at 100% 0%, color-mix(in oklab, #F5B94A 18%, transparent) 0%, transparent 55%)' }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="at-eyebrow" style={{ color: '#B88432' }}>Số dư Xu</div>
|
||||||
|
<div className="flex items-baseline gap-2.5 mt-2">
|
||||||
|
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
|
||||||
|
{xu}
|
||||||
|
</div>
|
||||||
|
<Coin size={26} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[12.5px] mt-2.5 max-w-[200px]" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Dùng để mở tính năng premium, freeze streak hoặc đổi giao diện.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STREAK (featured, ink) */}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-5 relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, var(--at-ink) 0%, color-mix(in oklab, var(--at-ink) 88%, var(--at-brand)) 100%)',
|
||||||
|
color: 'var(--at-paper)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute at-serif italic"
|
||||||
|
style={{ top: -20, right: -20, fontSize: 160, opacity: 0.08, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
♦
|
||||||
|
</div>
|
||||||
|
<div className="at-eyebrow" style={{ color: 'color-mix(in oklab, var(--at-paper) 70%, transparent)' }}>
|
||||||
|
Chuỗi học tập
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2.5 mt-2">
|
||||||
|
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
|
||||||
|
{streak}
|
||||||
|
</div>
|
||||||
|
<div className="at-serif italic" style={{ fontSize: 26, fontWeight: 300 }}>ngày</div>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 28, color: '#F5B94A', fontVariationSettings: "'FILL' 1" }}>
|
||||||
|
local_fire_department
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12.5px] opacity-75 mt-2.5">Giữ vững chuỗi học mỗi ngày nhé!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LEVEL */}
|
||||||
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="at-eyebrow">Cấp độ</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-2">
|
||||||
|
<div className="flex items-baseline gap-2.5">
|
||||||
|
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
|
||||||
|
{numericLevel}
|
||||||
|
</div>
|
||||||
|
<div className="at-serif italic" style={{ fontSize: 20, color: 'var(--at-mute)' }}>Level</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-11 h-11 rounded-xl grid place-items-center"
|
||||||
|
style={{ background: 'var(--at-paper-2)', color: 'var(--at-brand)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>emoji_events</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<span className="at-chip at-chip-warm" style={{ fontSize: 10.5 }}>
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
Hạng {levelLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
|
{/* Row 2 — level ring + week goal + history */}
|
||||||
<XpProgressCard xp={xp} />
|
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
|
||||||
<WeeklySection streak={streak} lastActive={lastActive} weeklyCompleted={weeklyCompleted} />
|
{/* Level progress ring */}
|
||||||
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Tiến độ <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>cấp độ</i>
|
||||||
|
</div>
|
||||||
|
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Lv.{numericLevel} → Lv.{numericLevel + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center py-5">
|
||||||
|
<LevelRing value={levelPct} xpInto={xpIntoLevel} xpGoal={100} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-[12.5px] mb-3" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Chỉ còn <b style={{ color: 'var(--at-brand)' }}>{xpLeft} XP</b> nữa để đạt Level {numericLevel + 1}!
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="w-full py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined inline-block align-middle mr-1" style={{ fontSize: 15 }}>target</span>
|
||||||
|
Xem nhiệm vụ XP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Week goal + history */}
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Mục tiêu <i style={{ color: 'var(--at-good)', fontStyle: 'italic' }}>tuần</i>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-1" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Hoàn thành {weekGoalTotal} bài học mỗi tuần
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 36, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: 'var(--at-good)' }}
|
||||||
|
>
|
||||||
|
{weeklyCompleted}
|
||||||
|
<span className="italic" style={{ color: 'var(--at-mute-2)' }}>/{weekGoalTotal}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="at-bar" style={{ height: 8 }}>
|
||||||
|
<span style={{ width: `${(weeklyCompleted / weekGoalTotal) * 100}%`, background: 'var(--at-good)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2.5 text-[11.5px]" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
<span>Đã hoàn thành</span>
|
||||||
|
{weeklyCompleted >= weekGoalTotal ? (
|
||||||
|
<span>
|
||||||
|
<b style={{ color: 'var(--at-good)' }}>Đạt mục tiêu!</b> · +50 XP thưởng
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Còn <b style={{ color: 'var(--at-good)' }}>{weekGoalTotal - weeklyCompleted} bài</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="flex justify-between items-baseline mb-4">
|
||||||
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Lịch sử <i style={{ color: 'var(--at-streak)', fontStyle: 'italic' }}>rèn luyện</i>
|
||||||
|
</div>
|
||||||
|
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>7 ngày qua</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{history.map((h, i) => {
|
||||||
|
const isDone = h.state === 'done'
|
||||||
|
const isToday = h.state === 'today'
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: isToday ? 'var(--at-brand)' : 'var(--at-mute)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isToday ? 'H.NAY' : h.d}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="grid place-items-center"
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: isDone ? 'color-mix(in oklab, var(--at-good) 18%, transparent)' : 'transparent',
|
||||||
|
border: isToday
|
||||||
|
? '2px dashed var(--at-brand)'
|
||||||
|
: isDone
|
||||||
|
? '1px solid color-mix(in oklab, var(--at-good) 30%, transparent)'
|
||||||
|
: '1px solid var(--at-line)',
|
||||||
|
color: isDone ? 'var(--at-good)' : isToday ? 'var(--at-brand)' : 'var(--at-mute-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone && <span className="material-symbols-outlined" style={{ fontSize: 18 }}>check</span>}
|
||||||
|
{isToday && <span className="material-symbols-outlined" style={{ fontSize: 14 }}>play_arrow</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
{/* Row 3 — Xu shop + leaderboard */}
|
||||||
<XuEconomyCard />
|
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
|
||||||
<LeaderboardCard />
|
{/* Xu shop */}
|
||||||
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="flex justify-between items-baseline mb-3.5">
|
||||||
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Cửa hàng <i style={{ color: '#B88432', fontStyle: 'italic' }}>Xu</i>
|
||||||
|
</div>
|
||||||
|
<span className="at-chip" style={{ fontSize: 10.5 }}>
|
||||||
|
<Coin size={11} /> {xu} xu
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--at-good)', marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
Kiếm xu
|
||||||
|
</div>
|
||||||
|
{EARN_ITEMS.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex justify-between items-center py-2.5"
|
||||||
|
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<span className="text-[13px]" style={{ color: 'var(--at-ink-2)' }}>{e.label}</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-bold"
|
||||||
|
style={{ color: 'var(--at-good)' }}
|
||||||
|
>
|
||||||
|
+{e.amt} <Coin size={11} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#C8383E',
|
||||||
|
marginTop: 18,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tiêu xu
|
||||||
|
</div>
|
||||||
|
{SPEND_ITEMS.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex justify-between items-center py-2.5 gap-2.5"
|
||||||
|
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-medium" style={{ color: 'var(--at-ink-2)' }}>{s.label}</div>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--at-mute)' }}>{s.desc}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={xu < s.amt}
|
||||||
|
className="px-2.5 py-1 rounded-lg text-[11.5px] flex-shrink-0 inline-flex items-center gap-1 transition-opacity disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{s.amt} <Coin size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboard */}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl overflow-hidden"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center px-5 py-4" style={{ borderBottom: '1px solid var(--at-line)' }}>
|
||||||
|
<div>
|
||||||
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Bảng xếp hạng <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>tuần</i>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--at-mute)' }}>Top học viên tuần này</div>
|
||||||
|
</div>
|
||||||
|
<span className="at-chip at-chip-brand" style={{ fontSize: 10.5 }}>
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
Top tuần này
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="grid px-5 py-2.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '60px 1fr auto',
|
||||||
|
gap: 12,
|
||||||
|
background: 'var(--at-paper-2)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--at-mute)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Hạng</span>
|
||||||
|
<span>Người học</span>
|
||||||
|
<span>XP tuần</span>
|
||||||
|
</div>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Chưa có ai trên bảng xếp hạng tuần này.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
board.map((p) => {
|
||||||
|
const rankColors: Record<number, string> = { 1: '#F5B94A', 2: '#BFC5CC', 3: '#C8844A' }
|
||||||
|
const rc = rankColors[p.rank]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.rank}
|
||||||
|
className="grid items-center px-5 py-3.5"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '60px 1fr auto',
|
||||||
|
gap: 12,
|
||||||
|
borderTop: '1px solid var(--at-line)',
|
||||||
|
background: p.you ? 'color-mix(in oklab, var(--at-brand) 6%, transparent)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-full grid place-items-center at-serif"
|
||||||
|
style={{
|
||||||
|
background: rc ? `color-mix(in oklab, ${rc} 25%, var(--at-paper-2))` : 'var(--at-paper-2)',
|
||||||
|
border: rc ? `1px solid ${rc}` : '1px solid var(--at-line)',
|
||||||
|
color: rc ? 'var(--at-ink)' : 'var(--at-mute)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.rank}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full grid place-items-center text-[13px] font-bold flex-shrink-0"
|
||||||
|
style={{ background: p.you ? 'var(--at-brand)' : 'var(--at-ink-2)', color: 'var(--at-paper)' }}
|
||||||
|
>
|
||||||
|
{p.avatar}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className="text-[13.5px]"
|
||||||
|
style={{ fontWeight: p.you ? 700 : 500, color: p.you ? 'var(--at-brand)' : 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="at-serif" style={{ fontSize: 17, fontWeight: 400, letterSpacing: '-0.01em' }}>
|
||||||
|
{p.xp}
|
||||||
|
<span className="italic ml-1" style={{ fontSize: 11, color: 'var(--at-mute)', fontWeight: 400 }}>XP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
{/* Row 4 — Badges */}
|
||||||
to="/toeic"
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
className="fixed bottom-24 right-6 lg:bottom-8 lg:right-8 w-14 h-14 bg-blue-600 text-white rounded-2xl flex items-center justify-center shadow-2xl hover:scale-110 active:scale-95 transition-all z-40"
|
<div className="flex justify-between items-baseline mb-4">
|
||||||
title="Học ngay"
|
<div>
|
||||||
>
|
<div className="at-eyebrow mb-1">Huy hiệu</div>
|
||||||
<span className="material-symbols-outlined text-2xl">play_arrow</span>
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
</Link>
|
Thành tựu <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>đã mở</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
<b style={{ color: 'var(--at-ink)' }}>{BADGES.filter((b) => b.earned).length}</b> / {BADGES.length} mở khoá
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3.5">
|
||||||
|
{BADGES.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="p-4 rounded-2xl relative"
|
||||||
|
style={{
|
||||||
|
background: b.earned ? 'var(--at-surface)' : 'var(--at-paper-2)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
opacity: b.earned ? 1 : 0.72,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl grid place-items-center mb-3"
|
||||||
|
style={{
|
||||||
|
background: b.earned ? `color-mix(in oklab, ${b.color} 16%, transparent)` : 'var(--at-line-2)',
|
||||||
|
color: b.earned ? b.color : 'var(--at-mute-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>{b.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="at-serif mb-1"
|
||||||
|
style={{ fontSize: 16, fontWeight: 500, letterSpacing: '-0.01em', color: 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{b.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11.5px] leading-[1.4] mb-2" style={{ color: 'var(--at-mute)' }}>{b.desc}</div>
|
||||||
|
{b.earned ? (
|
||||||
|
<span className="at-chip at-chip-good" style={{ fontSize: 10 }}>
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
Đã mở
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="at-bar" style={{ height: 4, marginBottom: 4 }}>
|
||||||
|
<span style={{ width: `${b.progress ?? 0}%`, background: b.color }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10.5px] font-semibold" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
{b.progress ?? 0}% tiến độ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelRing({ value, xpInto, xpGoal }: { value: number; xpInto: number; xpGoal: number }) {
|
||||||
|
const r = 80
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const offset = c - (value / 100) * c
|
||||||
|
return (
|
||||||
|
<div className="relative grid place-items-center" style={{ width: 180, height: 180 }}>
|
||||||
|
<svg width="180" height="180">
|
||||||
|
<circle cx="90" cy="90" r={r} fill="none" stroke="var(--at-line-2)" strokeWidth="10" />
|
||||||
|
<circle
|
||||||
|
cx="90"
|
||||||
|
cy="90"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--at-brand)"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 90 90)"
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.6s cubic-bezier(0.2, 0.7, 0.2, 1)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute text-center">
|
||||||
|
<div className="at-serif" style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: 'var(--at-ink)' }}>
|
||||||
|
{value}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: 'var(--at-mute)', marginTop: 4, fontWeight: 600, letterSpacing: '0.1em' }}>
|
||||||
|
{xpInto} / {xpGoal} XP
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useAuthStore } from '@/store/auth-store'
|
|
||||||
import { useLeaderboard } from '@/hooks/use-gamification'
|
|
||||||
|
|
||||||
function RankBadge({ rank }: { rank: number }) {
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
'w-8 h-8 flex items-center justify-center font-bold rounded-full text-xs',
|
|
||||||
rank === 1 ? 'bg-amber-200 text-amber-800' :
|
|
||||||
rank === 2 ? 'bg-slate-200 text-slate-700' :
|
|
||||||
rank === 3 ? 'bg-orange-200 text-orange-700' :
|
|
||||||
'bg-slate-100 text-slate-600',
|
|
||||||
)}>
|
|
||||||
{rank}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(name: string) {
|
|
||||||
return name.split(' ').map((w) => w[0]).slice(-2).join('').toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeaderboardCard() {
|
|
||||||
const user = useAuthStore((s) => s.user)
|
|
||||||
const { data: rows, isLoading } = useLeaderboard()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:col-span-8 bg-white p-6 rounded-xl shadow-sm">
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<h3 className="text-base font-bold text-slate-800">Bảng xếp hạng tuần</h3>
|
|
||||||
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-bold">Top tuần này</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-12 bg-slate-100 rounded-xl animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && (!rows || rows.length === 0) && (
|
|
||||||
<div className="text-center py-8 text-slate-400 text-sm">
|
|
||||||
<span className="material-symbols-outlined block mb-2 text-slate-300" style={{ fontSize: 40 }}>leaderboard</span>
|
|
||||||
Chưa có dữ liệu tuần này. Hãy hoàn thành bài học để xuất hiện trên bảng!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && rows && rows.length > 0 && (
|
|
||||||
<table className="w-full text-left border-separate border-spacing-y-1.5">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
||||||
<th className="pb-2 pl-4 w-16">Hạng</th>
|
|
||||||
<th className="pb-2">Người học</th>
|
|
||||||
<th className="pb-2 text-right pr-4">XP tuần</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => {
|
|
||||||
const isMe = row.userId === user?.id
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={row.userId}
|
|
||||||
className={cn(
|
|
||||||
'transition-colors',
|
|
||||||
isMe
|
|
||||||
? 'bg-blue-50 outline outline-2 outline-blue-200 rounded-xl'
|
|
||||||
: 'bg-slate-50/60 hover:bg-slate-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="py-2.5 pl-4 rounded-l-xl">
|
|
||||||
<RankBadge rank={row.rank} />
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className={cn(
|
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
|
||||||
isMe
|
|
||||||
? 'bg-blue-600 text-white ring-2 ring-blue-300 ring-offset-1'
|
|
||||||
: 'bg-slate-200 text-slate-600',
|
|
||||||
)}>
|
|
||||||
{initials(row.displayName)}
|
|
||||||
</div>
|
|
||||||
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
|
|
||||||
{isMe ? `${row.displayName} (Bạn)` : row.displayName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 pr-4 text-right rounded-r-xl">
|
|
||||||
<span className={cn('text-sm font-bold', isMe ? 'text-blue-600' : 'text-slate-600')}>
|
|
||||||
{row.xpEarned.toLocaleString('vi-VN')} XP
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { UserLevel } from '@/types'
|
|
||||||
import { calcNumericLevel } from './Dashboard'
|
|
||||||
|
|
||||||
const LEVEL_NAMES: Record<UserLevel, string> = {
|
|
||||||
beginner: 'Beginner',
|
|
||||||
bronze: 'Đồng',
|
|
||||||
silver: 'Bạc',
|
|
||||||
gold: 'Vàng',
|
|
||||||
master: 'Master',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
xu: number
|
|
||||||
streak: number
|
|
||||||
xp: number
|
|
||||||
level: UserLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatsRow({ xu, streak, xp, level }: Props) {
|
|
||||||
const numericLevel = calcNumericLevel(xp)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
|
|
||||||
{/* Xu Balance */}
|
|
||||||
<div className="relative overflow-hidden bg-white p-6 rounded-xl shadow-sm group">
|
|
||||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-amber-100 rounded-full opacity-40 blur-2xl group-hover:opacity-60 transition-opacity" />
|
|
||||||
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Số dư Xu</span>
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
|
||||||
<span className="text-4xl font-extrabold text-slate-800">{xu.toLocaleString('vi-VN')}</span>
|
|
||||||
<span className="material-symbols-outlined text-3xl text-amber-400" style={{ fontVariationSettings: "'FILL' 1" }}>
|
|
||||||
monetization_on
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-400 mt-1.5 font-medium">Dùng để mở tính năng premium</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Streak */}
|
|
||||||
<div className="relative overflow-hidden bg-blue-600 p-6 rounded-xl shadow-sm">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500 to-blue-700 opacity-90" />
|
|
||||||
<div className="relative z-10 text-white">
|
|
||||||
<span className="text-xs uppercase tracking-widest opacity-75 font-bold">Chuỗi học tập</span>
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
|
||||||
<span className="text-4xl font-extrabold">{streak} Ngày</span>
|
|
||||||
<span className="material-symbols-outlined text-3xl text-amber-300" style={{ fontVariationSettings: "'FILL' 1" }}>
|
|
||||||
local_fire_department
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs opacity-80 mt-1.5 font-medium">
|
|
||||||
{streak >= 7 ? 'Bạn thuộc top 5% người học!' : 'Giữ vững chuỗi học mỗi ngày nhé!'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Level */}
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow-sm flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Cấp độ</span>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-4xl font-extrabold text-slate-800">Level {numericLevel}</span>
|
|
||||||
</div>
|
|
||||||
<span className="inline-block mt-2 px-3 py-1 bg-amber-50 text-amber-600 text-xs font-bold rounded-full border border-amber-200">
|
|
||||||
Hạng {LEVEL_NAMES[level]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-14 h-14 bg-slate-100 flex items-center justify-center rounded-2xl rotate-12 flex-shrink-0">
|
|
||||||
<span className="material-symbols-outlined text-blue-600 text-3xl">military_tech</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
streak: number
|
|
||||||
lastActive: string | null
|
|
||||||
weeklyCompleted: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEEKLY_GOAL = 5
|
|
||||||
const DAY_LABELS = ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN']
|
|
||||||
|
|
||||||
function getTodayIdx() {
|
|
||||||
const d = new Date().getDay() // 0=Sun
|
|
||||||
return d === 0 ? 6 : d - 1 // Mon=0 … Sun=6
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive which days were active this week from streak + lastActive
|
|
||||||
function getWeekActivity(streak: number, lastActive: string | null): boolean[] {
|
|
||||||
const todayIdx = getTodayIdx()
|
|
||||||
const activity = Array(7).fill(false)
|
|
||||||
if (!lastActive) return activity
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
|
||||||
const isActiveToday = lastActive === today
|
|
||||||
// Mark past days as done based on streak length (up to but not including today)
|
|
||||||
const doneDays = isActiveToday ? Math.min(todayIdx, streak - 1) : Math.min(todayIdx, streak)
|
|
||||||
for (let i = todayIdx - doneDays; i < todayIdx; i++) {
|
|
||||||
if (i >= 0) activity[i] = true
|
|
||||||
}
|
|
||||||
return activity
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WeeklySection({ streak, lastActive, weeklyCompleted }: Props) {
|
|
||||||
const todayIdx = getTodayIdx()
|
|
||||||
const weekActivity = getWeekActivity(streak, lastActive)
|
|
||||||
const progressPct = Math.round((weeklyCompleted / WEEKLY_GOAL) * 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:col-span-7 space-y-5">
|
|
||||||
{/* Weekly goal */}
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow-sm">
|
|
||||||
<div className="flex justify-between items-end mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-slate-800">Mục tiêu tuần</h3>
|
|
||||||
<p className="text-xs text-slate-400">Hoàn thành {WEEKLY_GOAL} bài học mỗi tuần</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-black text-green-600">
|
|
||||||
{weeklyCompleted}/{WEEKLY_GOAL}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-3 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-green-400 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${progressPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Weekly heatmap */}
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow-sm">
|
|
||||||
<h3 className="text-base font-bold text-slate-800 mb-5">Lịch sử rèn luyện</h3>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
{DAY_LABELS.map((label, i) => {
|
|
||||||
const isToday = i === todayIdx
|
|
||||||
const done = weekActivity[i]
|
|
||||||
const future = i > todayIdx
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={label} className={cn('flex flex-col items-center gap-2.5', future && 'opacity-30')}>
|
|
||||||
<span className={cn('text-[10px] font-bold uppercase', isToday ? 'text-blue-600' : 'text-slate-400')}>
|
|
||||||
{isToday ? 'H.Nay' : label}
|
|
||||||
</span>
|
|
||||||
{isToday ? (
|
|
||||||
<div className="w-10 h-10 rounded-xl border-2 border-blue-600 border-dashed flex items-center justify-center">
|
|
||||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>play_arrow</span>
|
|
||||||
</div>
|
|
||||||
) : done ? (
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-green-200 flex items-center justify-center">
|
|
||||||
<span className="material-symbols-outlined text-green-700" style={{ fontSize: 18 }}>check</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-slate-100" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { calcXpNextLevel, calcNumericLevel } from './Dashboard'
|
|
||||||
|
|
||||||
interface Props { xp: number }
|
|
||||||
|
|
||||||
function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xpNext: number }) {
|
|
||||||
const r = 72
|
|
||||||
const circ = 2 * Math.PI * r
|
|
||||||
const offset = circ - (percent / 100) * circ
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-44 h-44">
|
|
||||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
|
|
||||||
<circle cx="80" cy="80" r={r} fill="transparent" stroke="#e8eaed" strokeWidth="12" />
|
|
||||||
<circle
|
|
||||||
cx="80" cy="80" r={r}
|
|
||||||
fill="transparent"
|
|
||||||
stroke="#2563eb"
|
|
||||||
strokeWidth="12"
|
|
||||||
strokeDasharray={circ}
|
|
||||||
strokeDashoffset={offset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
className="transition-all duration-700"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-3xl font-extrabold text-slate-800">{percent}%</span>
|
|
||||||
<span className="text-[10px] text-slate-400 font-bold mt-0.5">
|
|
||||||
{xp.toLocaleString()} / {xpNext.toLocaleString()} XP
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XpProgressCard({ xp }: Props) {
|
|
||||||
const xpNext = calcXpNextLevel(xp)
|
|
||||||
const levelXpStart = Math.floor(xp / 100) * 100
|
|
||||||
const percent = Math.round(((xp - levelXpStart) / (xpNext - levelXpStart)) * 100)
|
|
||||||
const nextLevel = calcNumericLevel(xp) + 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:col-span-5 bg-white p-6 rounded-xl shadow-sm flex flex-col items-center justify-center text-center">
|
|
||||||
<h3 className="text-base font-bold mb-5 self-start text-slate-800">Tiến độ Cấp độ</h3>
|
|
||||||
|
|
||||||
<ProgressRing percent={percent} xp={xp} xpNext={xpNext} />
|
|
||||||
|
|
||||||
<p className="text-sm text-slate-400 font-medium mt-4">
|
|
||||||
Chỉ còn {(xpNext - xp).toLocaleString()} XP nữa để đạt Level {nextLevel}!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button className="mt-5 w-full py-2.5 bg-slate-100 hover:bg-slate-200 transition-colors rounded-xl font-bold text-sm text-blue-600">
|
|
||||||
Xem nhiệm vụ XP
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
const EARN_ITEMS = [
|
|
||||||
{ label: 'Mục tiêu ngày', reward: '+10 xu' },
|
|
||||||
{ label: 'Mốc chuỗi (Streak)', reward: '+20 xu' },
|
|
||||||
{ label: 'Xem quảng cáo', reward: '+5 xu' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const SPEND_ITEMS = [
|
|
||||||
{ label: 'Streak Freeze', cost: '20 xu' },
|
|
||||||
{ label: 'AI Writing Feedback', cost: '30 xu' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function XuEconomyCard() {
|
|
||||||
return (
|
|
||||||
<div className="lg:col-span-4 bg-white p-6 rounded-xl shadow-sm">
|
|
||||||
<h3 className="text-base font-bold text-slate-800 mb-5">Cửa hàng Xu</h3>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Earn */}
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-green-600 font-bold uppercase tracking-wider block mb-2.5">Kiếm Xu</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{EARN_ITEMS.map((item) => (
|
|
||||||
<div key={item.label} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{item.label}</span>
|
|
||||||
<span className="text-sm font-bold text-amber-600">{item.reward}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spend */}
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-red-500 font-bold uppercase tracking-wider block mb-2.5">Tiêu Xu</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{SPEND_ITEMS.map((item) => (
|
|
||||||
<div key={item.label} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg opacity-80">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{item.label}</span>
|
|
||||||
<span className="text-sm font-bold text-slate-400">{item.cost}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
154
src/features/flash-card/api/flashcard-api.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { EASE, computeNextReview, statusFor, type EaseKey } from '../lib/srs-intervals'
|
||||||
|
|
||||||
|
export interface FlashcardList {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
total_words: number
|
||||||
|
is_public: boolean
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string
|
||||||
|
// aggregated from user progress
|
||||||
|
count_new?: number
|
||||||
|
count_learning?: number
|
||||||
|
count_known?: number
|
||||||
|
progress_pct?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlashcardTerm {
|
||||||
|
id: number
|
||||||
|
list_id: number
|
||||||
|
word: string
|
||||||
|
part_of_speech: string | null
|
||||||
|
phonetic: string | null
|
||||||
|
definition: string | null
|
||||||
|
example: string | null
|
||||||
|
image_url: string | null
|
||||||
|
audio_tts_text: string | null
|
||||||
|
audio_lang: string | null
|
||||||
|
display_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProgress {
|
||||||
|
id: number
|
||||||
|
user_id: string
|
||||||
|
term_id: number
|
||||||
|
list_id: number
|
||||||
|
status: 'new' | 'learning' | 'known' | 'ignored'
|
||||||
|
ease_factor: number
|
||||||
|
review_count: number
|
||||||
|
last_reviewed_at: string | null
|
||||||
|
next_review_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch all public flashcard lists with term counts */
|
||||||
|
export async function fetchFlashcardLists(): Promise<FlashcardList[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('flashcard_list')
|
||||||
|
.select('id, title, description, total_words, is_public, created_by, created_at')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
if (error) throw error
|
||||||
|
return data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch all terms for a flashcard list */
|
||||||
|
export async function fetchFlashcardTerms(listId: number): Promise<FlashcardTerm[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('flashcard_term')
|
||||||
|
.select('id, list_id, word, part_of_speech, phonetic, definition, example, image_url, audio_tts_text, audio_lang, display_order')
|
||||||
|
.eq('list_id', listId)
|
||||||
|
.order('display_order', { ascending: true })
|
||||||
|
if (error) throw error
|
||||||
|
return data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch user progress for all terms in a list */
|
||||||
|
export async function fetchUserProgress(userId: string, listId: number): Promise<UserProgress[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_flashcard_progress')
|
||||||
|
.select('id, user_id, term_id, list_id, status, ease_factor, review_count, last_reviewed_at, next_review_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('list_id', listId)
|
||||||
|
if (error) throw error
|
||||||
|
return data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert user progress for a term. Increments review_count, writes next_review_at via interval ladder. */
|
||||||
|
export async function upsertTermProgress(
|
||||||
|
userId: string,
|
||||||
|
termId: number,
|
||||||
|
listId: number,
|
||||||
|
easeKey: EaseKey,
|
||||||
|
currentReviewCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const nextReview = computeNextReview(easeKey, currentReviewCount)
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('user_flashcard_progress')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
term_id: termId,
|
||||||
|
list_id: listId,
|
||||||
|
status: statusFor(easeKey),
|
||||||
|
ease_factor: EASE[easeKey],
|
||||||
|
review_count: currentReviewCount + 1,
|
||||||
|
last_reviewed_at: now,
|
||||||
|
next_review_at: nextReview,
|
||||||
|
},
|
||||||
|
{ onConflict: 'user_id,term_id,list_id' },
|
||||||
|
)
|
||||||
|
if (error) console.error('upsertTermProgress failed:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearnSession {
|
||||||
|
id: number
|
||||||
|
user_id: string
|
||||||
|
list_id: number
|
||||||
|
started_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSession(userId: string, listId: number): Promise<LearnSession> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_flashcard_session')
|
||||||
|
.insert({ user_id: userId, list_id: listId })
|
||||||
|
.select('id, user_id, list_id, started_at')
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return data as LearnSession
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSession(
|
||||||
|
sessionId: number,
|
||||||
|
termsReviewed: number,
|
||||||
|
termsNew: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('user_flashcard_session')
|
||||||
|
.update({
|
||||||
|
ended_at: new Date().toISOString(),
|
||||||
|
terms_reviewed: termsReviewed,
|
||||||
|
terms_new: termsNew,
|
||||||
|
})
|
||||||
|
.eq('id', sessionId)
|
||||||
|
if (error) console.error('endSession failed:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logReview(
|
||||||
|
sessionId: number,
|
||||||
|
userId: string,
|
||||||
|
termId: number,
|
||||||
|
actionValue: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('user_flashcard_review_log')
|
||||||
|
.insert({
|
||||||
|
session_id: sessionId,
|
||||||
|
user_id: userId,
|
||||||
|
term_id: termId,
|
||||||
|
action_value: actionValue,
|
||||||
|
})
|
||||||
|
if (error) console.error('logReview failed:', error.message)
|
||||||
|
}
|
||||||
673
src/features/flash-card/components/FlashCardLearnPage.tsx
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
|
import {
|
||||||
|
fetchFlashcardTerms,
|
||||||
|
fetchUserProgress,
|
||||||
|
upsertTermProgress,
|
||||||
|
startSession,
|
||||||
|
endSession,
|
||||||
|
logReview,
|
||||||
|
fetchFlashcardLists,
|
||||||
|
} from '../api/flashcard-api'
|
||||||
|
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
|
||||||
|
import { EASE, type EaseKey } from '../lib/srs-intervals'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
listId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStats = { known: number; learning: number; ignored: number }
|
||||||
|
|
||||||
|
function speak(word: string) {
|
||||||
|
try {
|
||||||
|
const u = new SpeechSynthesisUtterance(word)
|
||||||
|
u.lang = 'en-US'
|
||||||
|
u.rate = 0.9
|
||||||
|
speechSynthesis.cancel()
|
||||||
|
speechSynthesis.speak(u)
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlashCardLearnPage({ listId }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const openAuthModal = useAuthModalStore(s => s.open)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const isGuest = !user
|
||||||
|
|
||||||
|
const [isFlipped, setIsFlipped] = useState(false)
|
||||||
|
const [currentIdx, setCurrentIdx] = useState(0)
|
||||||
|
const [sessionStats, setSessionStats] = useState<SessionStats>({ known: 0, learning: 0, ignored: 0 })
|
||||||
|
const [isDone, setIsDone] = useState(false)
|
||||||
|
const [fx, setFx] = useState<'known' | 'review' | null>(null)
|
||||||
|
|
||||||
|
// Bookmarks — per-list, persisted in localStorage
|
||||||
|
const bookmarkKey = `flashcard-bookmarks-${listId}`
|
||||||
|
const [bookmarks, setBookmarks] = useState<Set<number>>(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(bookmarkKey)
|
||||||
|
return raw ? new Set<number>(JSON.parse(raw)) : new Set()
|
||||||
|
} catch { return new Set() }
|
||||||
|
})
|
||||||
|
const toggleBookmark = useCallback((termId: number) => {
|
||||||
|
setBookmarks(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(termId)) next.delete(termId)
|
||||||
|
else next.add(termId)
|
||||||
|
try { localStorage.setItem(bookmarkKey, JSON.stringify([...next])) } catch { /* noop */ }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [bookmarkKey])
|
||||||
|
|
||||||
|
// Refs for unmount cleanup so effects see fresh values
|
||||||
|
const sessionIdRef = useRef<number | null>(null)
|
||||||
|
const statsRef = useRef<SessionStats>(sessionStats)
|
||||||
|
const isDoneRef = useRef(false)
|
||||||
|
const newTermIdsAtStartRef = useRef<Set<number>>(new Set())
|
||||||
|
const answeredNewIdsRef = useRef<Set<number>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => { statsRef.current = sessionStats }, [sessionStats])
|
||||||
|
useEffect(() => { isDoneRef.current = isDone }, [isDone])
|
||||||
|
|
||||||
|
const { data: terms = [], isLoading: loadingTerms } = useQuery({
|
||||||
|
queryKey: ['flashcard-terms', listId],
|
||||||
|
queryFn: () => fetchFlashcardTerms(listId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: lists = [] } = useQuery({
|
||||||
|
queryKey: ['flashcard-lists'],
|
||||||
|
queryFn: fetchFlashcardLists,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const currentList = lists.find(l => l.id === listId)
|
||||||
|
|
||||||
|
const { data: progress = [] } = useQuery({
|
||||||
|
queryKey: ['flashcard-progress', user?.id, listId],
|
||||||
|
queryFn: () => fetchUserProgress(user!.id, listId),
|
||||||
|
enabled: !!user,
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressMap = useMemo(() => {
|
||||||
|
const m: Record<number, UserProgress> = {}
|
||||||
|
progress.forEach(p => { m[p.term_id] = p })
|
||||||
|
return m
|
||||||
|
}, [progress])
|
||||||
|
|
||||||
|
// Session term ordering: prioritise due-for-review, then new, then known
|
||||||
|
const sessionTerms: FlashcardTerm[] = useMemo(() => {
|
||||||
|
if (!terms.length) return []
|
||||||
|
const now = Date.now()
|
||||||
|
const due: FlashcardTerm[] = []
|
||||||
|
const fresh: FlashcardTerm[] = []
|
||||||
|
const known: FlashcardTerm[] = []
|
||||||
|
for (const t of terms) {
|
||||||
|
const p = progressMap[t.id]
|
||||||
|
if (p?.status === 'ignored') continue
|
||||||
|
if (!p) { fresh.push(t); continue }
|
||||||
|
if (!p.next_review_at) { fresh.push(t); continue }
|
||||||
|
if (new Date(p.next_review_at).getTime() <= now) { due.push(t); continue }
|
||||||
|
if (p.status === 'known') known.push(t)
|
||||||
|
else fresh.push(t)
|
||||||
|
}
|
||||||
|
return [...due, ...fresh, ...known]
|
||||||
|
}, [terms, progressMap])
|
||||||
|
|
||||||
|
// Snapshot "new" term IDs at session start (runs once when data is loaded)
|
||||||
|
useEffect(() => {
|
||||||
|
if (newTermIdsAtStartRef.current.size === 0 && terms.length > 0) {
|
||||||
|
const newIds = new Set<number>()
|
||||||
|
for (const t of terms) {
|
||||||
|
const s = progressMap[t.id]?.status ?? 'new'
|
||||||
|
if (s === 'new') newIds.add(t.id)
|
||||||
|
}
|
||||||
|
newTermIdsAtStartRef.current = newIds
|
||||||
|
}
|
||||||
|
}, [terms, progressMap])
|
||||||
|
|
||||||
|
// Start session on mount (guarded against StrictMode double-invoke)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || sessionIdRef.current !== null) return
|
||||||
|
let cancelled = false
|
||||||
|
startSession(user.id, listId)
|
||||||
|
.then(s => { if (!cancelled) sessionIdRef.current = s.id })
|
||||||
|
.catch(err => console.error('startSession failed:', err))
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [user, listId])
|
||||||
|
|
||||||
|
// End session on unmount (if not already ended via done-screen effect)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const sid = sessionIdRef.current
|
||||||
|
if (sid === null || isDoneRef.current) return
|
||||||
|
const s = statsRef.current
|
||||||
|
const reviewed = s.known + s.learning + s.ignored
|
||||||
|
endSession(sid, reviewed, answeredNewIdsRef.current.size)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// End session when reaching done screen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDone) return
|
||||||
|
const sid = sessionIdRef.current
|
||||||
|
if (sid === null) return
|
||||||
|
const s = statsRef.current
|
||||||
|
const reviewed = s.known + s.learning + s.ignored
|
||||||
|
endSession(sid, reviewed, answeredNewIdsRef.current.size)
|
||||||
|
}, [isDone])
|
||||||
|
|
||||||
|
const { mutate: saveAnswer } = useMutation({
|
||||||
|
mutationFn: async ({ termId, easeKey, reviewCount }: {
|
||||||
|
termId: number
|
||||||
|
easeKey: EaseKey
|
||||||
|
reviewCount: number
|
||||||
|
}) => {
|
||||||
|
if (!user) return
|
||||||
|
const sid = sessionIdRef.current
|
||||||
|
await Promise.all([
|
||||||
|
upsertTermProgress(user.id, termId, listId, easeKey, reviewCount),
|
||||||
|
sid !== null ? logReview(sid, user.id, termId, EASE[easeKey]) : Promise.resolve(),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const advance = useCallback(() => {
|
||||||
|
if (currentIdx + 1 >= sessionTerms.length) {
|
||||||
|
setIsDone(true)
|
||||||
|
} else {
|
||||||
|
setCurrentIdx(i => i + 1)
|
||||||
|
setIsFlipped(false)
|
||||||
|
}
|
||||||
|
setFx(null)
|
||||||
|
}, [currentIdx, sessionTerms.length])
|
||||||
|
|
||||||
|
const handleAnswer = useCallback((key: EaseKey) => {
|
||||||
|
const term = sessionTerms[currentIdx]
|
||||||
|
if (!term || !user) return
|
||||||
|
|
||||||
|
const currentProgress = progressMap[term.id]
|
||||||
|
const reviewCount = currentProgress?.review_count ?? 0
|
||||||
|
|
||||||
|
saveAnswer({ termId: term.id, easeKey: key, reviewCount })
|
||||||
|
|
||||||
|
if (newTermIdsAtStartRef.current.has(term.id)) {
|
||||||
|
answeredNewIdsRef.current.add(term.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionStats(prev => ({
|
||||||
|
known: prev.known + (key === 'known' ? 1 : 0),
|
||||||
|
learning: prev.learning + (key === 'easy' || key === 'hard' ? 1 : 0),
|
||||||
|
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Visual feedback: known swipes right, hard/ignored swipes left
|
||||||
|
if (key === 'known' || key === 'easy') {
|
||||||
|
setFx('known')
|
||||||
|
} else {
|
||||||
|
setFx('review')
|
||||||
|
}
|
||||||
|
setTimeout(advance, 450)
|
||||||
|
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
|
||||||
|
|
||||||
|
// Jump to a specific card in the deck (no progress write — just navigate)
|
||||||
|
const jumpTo = useCallback((idx: number) => {
|
||||||
|
setCurrentIdx(idx)
|
||||||
|
setIsFlipped(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (isDone || !sessionTerms[currentIdx]) return
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsFlipped(v => !v)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Arrow nav — works for everyone, no progress write
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIdx > 0) jumpTo(currentIdx - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIdx < sessionTerms.length - 1) jumpTo(currentIdx + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SRS keys — auth users only
|
||||||
|
if (isGuest || !isFlipped) return
|
||||||
|
if (e.key.toLowerCase() === 'j') handleAnswer('known')
|
||||||
|
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
|
||||||
|
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer, isGuest, jumpTo])
|
||||||
|
|
||||||
|
const total = sessionTerms.length
|
||||||
|
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
|
||||||
|
const current = sessionTerms[currentIdx]
|
||||||
|
|
||||||
|
if (loadingTerms) {
|
||||||
|
return (
|
||||||
|
<div className="atelier flex items-center justify-center min-h-screen">
|
||||||
|
<div className="w-8 h-8 border-2 border-[var(--at-line)] border-t-[var(--at-accent)] rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionTerms.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-4 px-4">
|
||||||
|
<div className="at-serif text-5xl italic text-[var(--at-mute-2)]">All clear.</div>
|
||||||
|
<p className="text-[var(--at-mute)] text-center max-w-sm">
|
||||||
|
Không có thẻ nào cần học ngay bây giờ. Quay lại sau khi đến lịch ôn tập.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
||||||
|
className="mt-4 px-6 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Quay lại danh sách
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDone) {
|
||||||
|
return (
|
||||||
|
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-8 px-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="at-serif italic text-[var(--at-accent)] text-6xl mb-4">Bravo.</div>
|
||||||
|
<h2 className="at-serif text-3xl tracking-tight text-[var(--at-ink)] mb-2">Hoàn thành phiên học</h2>
|
||||||
|
<p className="text-[var(--at-mute)]">Bạn đã ôn xong {total} thẻ trong phiên này</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
||||||
|
<div className="at-serif text-3xl text-[var(--at-good)]">{sessionStats.known}</div>
|
||||||
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đã biết</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
||||||
|
<div className="at-serif text-3xl text-[var(--at-accent)]">{sessionStats.learning}</div>
|
||||||
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đang học</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
||||||
|
<div className="at-serif text-3xl text-[var(--at-mute-2)]">{sessionStats.ignored}</div>
|
||||||
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Bỏ qua</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentIdx(0)
|
||||||
|
setIsFlipped(false)
|
||||||
|
setIsDone(false)
|
||||||
|
setSessionStats({ known: 0, learning: 0, ignored: 0 })
|
||||||
|
sessionIdRef.current = null
|
||||||
|
answeredNewIdsRef.current = new Set()
|
||||||
|
newTermIdsAtStartRef.current = new Set()
|
||||||
|
if (user) startSession(user.id, listId).then(s => { sessionIdRef.current = s.id })
|
||||||
|
}}
|
||||||
|
className="px-5 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Học lại
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
||||||
|
className="px-5 py-2.5 border border-[var(--at-line)] text-[var(--at-ink-2)] rounded-xl text-sm font-semibold bg-white hover:border-[var(--at-ink)] transition"
|
||||||
|
>
|
||||||
|
Xem danh sách
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
|
||||||
|
style={{ background: 'var(--at-paper)' }}
|
||||||
|
>
|
||||||
|
{/* Header row: breadcrumb + serif title on left, actions on right */}
|
||||||
|
<div className="flex items-end justify-between gap-4 mb-4 flex-shrink-0 min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 text-[13px]" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card' })}
|
||||||
|
className="hover:text-[var(--at-ink)] transition-colors"
|
||||||
|
>
|
||||||
|
Chủ đề
|
||||||
|
</button>
|
||||||
|
<span>/</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
||||||
|
className="hover:text-[var(--at-ink)] transition-colors truncate"
|
||||||
|
style={{ color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
{currentList?.title ?? 'Bộ thẻ'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="at-serif tracking-tight"
|
||||||
|
style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1.05, color: 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
Thẻ <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>{currentIdx + 1}</i>
|
||||||
|
<span className="at-serif italic" style={{ color: 'var(--at-mute-2)' }}> / {total}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>arrow_back</span>
|
||||||
|
Danh sách
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => current && toggleBookmark(current.id)}
|
||||||
|
disabled={!current}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
|
||||||
|
style={{
|
||||||
|
background: current && bookmarks.has(current.id) ? 'var(--at-warm-soft)' : 'var(--at-surface)',
|
||||||
|
border: '1px solid ' + (current && bookmarks.has(current.id) ? 'var(--at-warm)' : 'var(--at-line)'),
|
||||||
|
color: current && bookmarks.has(current.id) ? 'var(--at-warm-ink)' : 'var(--at-ink-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontVariationSettings: current && bookmarks.has(current.id) ? "'FILL' 1" : "'FILL' 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
bookmark
|
||||||
|
</span>
|
||||||
|
Đánh dấu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body: card column + sidebar */}
|
||||||
|
<div
|
||||||
|
className="flex-1 min-h-0 lg:grid flex flex-col gap-5"
|
||||||
|
style={{ gridTemplateColumns: 'minmax(0, 1fr) 260px' }}
|
||||||
|
>
|
||||||
|
{/* Main: card + actions + progress */}
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-0">
|
||||||
|
{/* Card */}
|
||||||
|
{current && (
|
||||||
|
<div className="at-card-outer" style={{ maxWidth: 420, flexShrink: 0 }}>
|
||||||
|
<div
|
||||||
|
className={cn('at-card', isFlipped && 'is-flipped', fx === 'known' && 'fx-known', fx === 'review' && 'fx-review')}
|
||||||
|
key={current.id}
|
||||||
|
onClick={() => setIsFlipped(v => !v)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={isFlipped ? 'Lật để xem từ' : 'Lật để xem nghĩa'}
|
||||||
|
>
|
||||||
|
{/* FRONT */}
|
||||||
|
<div className="at-card-face" style={{ padding: '20px 24px' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="at-chip">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
{current.part_of_speech?.toUpperCase() ?? 'TỪ VỰNG'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
|
||||||
|
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
|
||||||
|
aria-label="Phát âm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col justify-center">
|
||||||
|
<div className="at-word" style={{ fontSize: 'clamp(40px, 5vw, 60px)' }}>{current.word}</div>
|
||||||
|
{(current.phonetic || current.part_of_speech) && (
|
||||||
|
<div className="at-mono text-sm text-[var(--at-mute)] mt-3">
|
||||||
|
{current.phonetic}
|
||||||
|
{current.part_of_speech && (
|
||||||
|
<span className="at-serif italic text-[var(--at-mute-2)]"> · {current.part_of_speech}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
|
||||||
|
<span className="at-kbd">Space</span>
|
||||||
|
<span>để lật thẻ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BACK */}
|
||||||
|
<div className="at-card-face at-card-back" style={{ padding: '20px 24px' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="at-chip at-chip-mute">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
NGHĨA
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
|
||||||
|
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
|
||||||
|
aria-label="Phát âm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col justify-center gap-4">
|
||||||
|
<div className="at-meaning" style={{ fontSize: 22 }}>{current.definition ?? '—'}</div>
|
||||||
|
{current.example && (
|
||||||
|
<div className="at-example">
|
||||||
|
<div className="at-serif italic text-[14px] leading-[1.45] text-[var(--at-ink-2)]">
|
||||||
|
"{current.example}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
|
||||||
|
<span className="at-kbd">↵</span>
|
||||||
|
<span>lật lại</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions — auth users get SRS buttons, guests get prev/next + login CTA */}
|
||||||
|
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
|
||||||
|
{isGuest ? (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-stretch gap-2.5 w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => currentIdx > 0 && jumpTo(currentIdx - 1)}
|
||||||
|
disabled={currentIdx === 0}
|
||||||
|
className="at-action"
|
||||||
|
style={{ padding: '11px 14px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<span className="at-kbd">←</span> Trước
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => currentIdx < sessionTerms.length - 1 && jumpTo(currentIdx + 1)}
|
||||||
|
disabled={currentIdx >= sessionTerms.length - 1}
|
||||||
|
className="at-action"
|
||||||
|
style={{ padding: '11px 14px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Sau <span className="at-kbd">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openAuthModal('register')}
|
||||||
|
className="at-action at-action-known"
|
||||||
|
style={{ padding: '11px 14px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Đăng nhập để theo dõi tiến độ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
|
||||||
|
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
|
Bỏ qua <span className="at-kbd">I</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
|
Cần ôn <span className="at-kbd">K</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
|
Đã thuộc
|
||||||
|
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
|
||||||
|
<span>
|
||||||
|
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total}
|
||||||
|
{!isGuest && (
|
||||||
|
<> · {sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="at-progress-bar">
|
||||||
|
<span style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right sidebar */}
|
||||||
|
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
|
||||||
|
{/* Today stats — auth users; guests see a login nudge */}
|
||||||
|
{isGuest ? (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-4 flex-shrink-0"
|
||||||
|
style={{ background: 'var(--at-brand-soft)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="at-eyebrow mb-1" style={{ fontSize: 11, color: 'var(--at-brand-ink)' }}>Chế độ khách</div>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 18, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.2, color: 'var(--at-brand-ink)' }}
|
||||||
|
>
|
||||||
|
Đăng nhập để <i>ghi nhớ</i> tiến độ
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openAuthModal('register')}
|
||||||
|
className="mt-3 w-full text-[12px] font-semibold py-2 rounded-lg transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'var(--at-brand)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Đăng nhập / Đăng ký
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-4 flex-shrink-0"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-1">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
|
||||||
|
Đã học
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
|
||||||
|
Đúng
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
|
||||||
|
>
|
||||||
|
{sessionStats.known}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cards in deck — compact rows (word only) */}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-3 flex flex-col"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
maxHeight: 'calc((100vh - 4rem) / 2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="at-eyebrow mb-2 px-1" style={{ fontSize: 11 }}>Trong bộ này</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto -mx-1 px-1">
|
||||||
|
{sessionTerms.map((t, i) => {
|
||||||
|
const p = progressMap[t.id]
|
||||||
|
const isActive = i === currentIdx
|
||||||
|
const isKnown = p?.status === 'known'
|
||||||
|
const isBookmarked = bookmarks.has(t.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => jumpTo(i)}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isActive ? 'var(--at-brand-soft)' : 'transparent',
|
||||||
|
borderTop: i === 0 || isActive ? 'none' : '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="at-serif italic flex-shrink-0 text-center"
|
||||||
|
style={{ fontSize: 13, color: 'var(--at-mute)', width: 20 }}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-[13px] font-bold truncate"
|
||||||
|
style={{ color: isActive ? 'var(--at-brand-ink)' : 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{t.word}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[11.5px] truncate mt-0.5"
|
||||||
|
style={{ color: 'var(--at-mute)' }}
|
||||||
|
>
|
||||||
|
{t.definition ?? '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isBookmarked && (
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: 13, color: 'var(--at-warm)', fontVariationSettings: "'FILL' 1" }}
|
||||||
|
>
|
||||||
|
bookmark
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isKnown && (
|
||||||
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 14, color: 'var(--at-good)' }}>
|
||||||
|
check
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
src/features/flash-card/components/FlashCardListPage.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { fetchFlashcardLists } from '../api/flashcard-api'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { fetchUserProgress } from '../api/flashcard-api'
|
||||||
|
import type { FlashcardList } from '../api/flashcard-api'
|
||||||
|
|
||||||
|
function ListCard({ list, userId }: { list: FlashcardList; userId: string | null }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: progress = [] } = useQuery({
|
||||||
|
queryKey: ['flashcard-progress', userId, list.id],
|
||||||
|
queryFn: () => fetchUserProgress(userId!, list.id),
|
||||||
|
enabled: !!userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const countLearning = progress.filter(p => p.status === 'learning').length
|
||||||
|
const countKnown = progress.filter(p => p.status === 'known').length
|
||||||
|
const progressPct = list.total_words > 0
|
||||||
|
? Math.round(((countLearning + countKnown) / list.total_words) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-11 h-11 rounded-xl grid place-items-center flex-shrink-0 at-serif italic"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', fontSize: 18, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{list.title.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3
|
||||||
|
className="at-serif text-[17px] leading-[1.2] tracking-tight line-clamp-2"
|
||||||
|
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{list.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 13, color: 'var(--at-mute)' }}>book</span>
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
{list.total_words} từ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`at-chip ${list.is_public ? 'at-chip-brand' : ''}`}>
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
{list.is_public ? 'Công khai' : 'Riêng tư'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.description && (
|
||||||
|
<p className="text-xs leading-[1.5] mb-4 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
{list.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 mb-4">
|
||||||
|
<div className="flex justify-between items-baseline mb-2">
|
||||||
|
<span className="at-eyebrow" style={{ fontSize: 10 }}>Tiến độ</span>
|
||||||
|
<span
|
||||||
|
className="at-serif italic"
|
||||||
|
style={{ fontSize: 18, color: 'var(--at-brand)', letterSpacing: '-0.02em', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
{progressPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="at-bar">
|
||||||
|
<span style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-5">
|
||||||
|
<Stat num={list.total_words - countLearning - countKnown} label="Mới" />
|
||||||
|
<Stat num={countLearning} label="Học" color="var(--at-brand)" />
|
||||||
|
<Stat num={countKnown} label="Biết" color="var(--at-good)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(list.id) } })}
|
||||||
|
className="py-2.5 rounded-xl text-[13px] font-semibold transition-colors"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
Xem thẻ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(list.id) } })}
|
||||||
|
className="py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
||||||
|
>
|
||||||
|
Học ngay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ num, label, color }: { num: number; label: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-1.5 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 20, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-semibold mt-1 tracking-wider uppercase" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlashCardListPage() {
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const { data: lists = [], isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['flashcard-lists'],
|
||||||
|
queryFn: fetchFlashcardLists,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
|
<div>
|
||||||
|
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>
|
||||||
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
|
Bộ thẻ <i>ghi nhớ</i>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Chọn bộ thẻ để bắt đầu — {lists.length} bộ sưu tầm
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-2xl p-6 h-64 animate-pulse"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-10 text-center"
|
||||||
|
style={{ background: 'var(--at-bad-soft)', border: '1px solid rgba(193,68,62,0.2)' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--at-bad)' }}>
|
||||||
|
Không thể tải danh sách bộ thẻ. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : lists.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-16 text-center"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined mb-3 block" style={{ fontSize: 48, color: 'var(--at-mute-2)' }}>library_books</span>
|
||||||
|
<p className="at-serif text-lg" style={{ color: 'var(--at-ink)' }}>Chưa có bộ thẻ nào.</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: 'var(--at-mute)' }}>Bộ thẻ từ vựng TOEIC sẽ được thêm sớm!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{lists.map(list => (
|
||||||
|
<ListCard key={list.id} list={list} userId={user?.id ?? null} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
249
src/features/flash-card/components/FlashCardTermsPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { fetchFlashcardTerms, fetchUserProgress } from '../api/flashcard-api'
|
||||||
|
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
|
||||||
|
import { resolveMediaUrl } from '../lib/media-url'
|
||||||
|
|
||||||
|
type FilterStatus = 'all' | 'new' | 'learning' | 'known' | 'ignored'
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
new: 'Mới',
|
||||||
|
learning: 'Đang học',
|
||||||
|
known: 'Đã biết',
|
||||||
|
ignored: 'Bỏ qua',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CLASS: Record<string, string> = {
|
||||||
|
new: 'at-chip',
|
||||||
|
learning: 'at-chip at-chip-brand',
|
||||||
|
known: 'at-chip at-chip-good',
|
||||||
|
ignored: 'at-chip at-chip-warm',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
listId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlashCardTermsPage({ listId }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const [filter, setFilter] = useState<FilterStatus>('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const { data: terms = [], isLoading: loadingTerms } = useQuery({
|
||||||
|
queryKey: ['flashcard-terms', listId],
|
||||||
|
queryFn: () => fetchFlashcardTerms(listId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: progress = [] } = useQuery({
|
||||||
|
queryKey: ['flashcard-progress', user?.id, listId],
|
||||||
|
queryFn: () => fetchUserProgress(user!.id, listId),
|
||||||
|
enabled: !!user,
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressMap: Record<number, UserProgress> = {}
|
||||||
|
progress.forEach(p => { progressMap[p.term_id] = p })
|
||||||
|
|
||||||
|
const getStatus = (termId: number): UserProgress['status'] =>
|
||||||
|
progressMap[termId]?.status ?? 'new'
|
||||||
|
|
||||||
|
const countAll = terms.length
|
||||||
|
const countNew = terms.filter(t => getStatus(t.id) === 'new').length
|
||||||
|
const countLearning = terms.filter(t => getStatus(t.id) === 'learning').length
|
||||||
|
const countKnown = terms.filter(t => getStatus(t.id) === 'known').length
|
||||||
|
|
||||||
|
const filtered = terms.filter(t => {
|
||||||
|
if (filter !== 'all' && getStatus(t.id) !== filter) return false
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
t.word.toLowerCase().includes(q) ||
|
||||||
|
(t.definition ?? '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
|
{/* Editorial head */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
|
<div className="flex items-start gap-4 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card' })}
|
||||||
|
className="w-10 h-10 flex-shrink-0 grid place-items-center rounded-xl transition-colors hover:bg-[var(--at-line-2)]"
|
||||||
|
style={{ color: 'var(--at-mute)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="at-eyebrow mb-2">Bộ thẻ từ vựng</div>
|
||||||
|
<h1 className="at-title text-[32px] lg:text-4xl">
|
||||||
|
{countAll} <i>từ</i>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(listId) } })}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
|
||||||
|
Bắt đầu học
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats + filters */}
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-5 mb-6"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-4 justify-between mb-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined absolute left-3.5 top-1/2 -translate-y-1/2"
|
||||||
|
style={{ fontSize: 18, color: 'var(--at-mute)' }}
|
||||||
|
>
|
||||||
|
search
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Tìm kiếm từ..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-full text-sm focus:outline-none"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-paper-2)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||||
|
{(['all', 'new', 'learning', 'known', 'ignored'] as FilterStatus[]).map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={cn(
|
||||||
|
'px-3.5 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-colors',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: filter === f ? 'var(--at-ink)' : 'var(--at-paper-2)',
|
||||||
|
color: filter === f ? 'var(--at-paper)' : 'var(--at-ink-2)',
|
||||||
|
border: '1px solid ' + (filter === f ? 'var(--at-ink)' : 'var(--at-line)'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'Tất cả' : STATUS_LABEL[f]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<HeadStat num={countAll} label="Tổng" />
|
||||||
|
<HeadStat num={countNew} label="Mới" />
|
||||||
|
<HeadStat num={countLearning} label="Đang học" color="var(--at-brand)" />
|
||||||
|
<HeadStat num={countKnown} label="Đã biết" color="var(--at-good)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms list */}
|
||||||
|
{loadingTerms ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-xl h-20 animate-pulse"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-12 text-center"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--at-mute)' }}>Không tìm thấy từ nào phù hợp.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map(term => (
|
||||||
|
<TermRow key={term.id} term={term} status={getStatus(term.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeadStat({ num, label, color }: { num: number; label: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-2 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 22, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1"
|
||||||
|
style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TermRow({ term, status }: { term: FlashcardTerm; status: UserProgress['status'] }) {
|
||||||
|
const imageSrc = resolveMediaUrl(term.image_url)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl p-4 flex items-center gap-4 transition-shadow hover:shadow-sm"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
{imageSrc && (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={term.word}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||||
|
style={{ background: 'var(--at-line-2)' }}
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="w-1/4 min-w-0">
|
||||||
|
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
|
||||||
|
<h3
|
||||||
|
className="at-serif text-[17px] tracking-tight truncate"
|
||||||
|
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{term.word}
|
||||||
|
</h3>
|
||||||
|
{term.phonetic && (
|
||||||
|
<span className="at-mono text-[11.5px] shrink-0" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
{term.phonetic}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{term.part_of_speech && (
|
||||||
|
<span
|
||||||
|
className="at-serif italic"
|
||||||
|
style={{ fontSize: 11, color: 'var(--at-mute-2)' }}
|
||||||
|
>
|
||||||
|
· {term.part_of_speech}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-sm line-clamp-2" style={{ color: 'var(--at-ink-2)' }}>
|
||||||
|
{term.definition ?? '—'}
|
||||||
|
</div>
|
||||||
|
<span className={STATUS_CLASS[status]}>
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
{STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export function Vocabulary() {
|
|||||||
.reverse()
|
.reverse()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-4 lg:px-6 py-6 page-enter">
|
||||||
{/* Mobile topic chips */}
|
{/* Mobile topic chips */}
|
||||||
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
||||||
<div className="flex gap-2 w-max">
|
<div className="flex gap-2 w-max">
|
||||||
7
src/features/flash-card/lib/media-url.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const MEDIA_BASE_URL = 'https://study4.com'
|
||||||
|
|
||||||
|
export function resolveMediaUrl(path: string | null | undefined): string | null {
|
||||||
|
if (!path) return null
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) return path
|
||||||
|
return `${MEDIA_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`
|
||||||
|
}
|
||||||
30
src/features/flash-card/lib/srs-intervals.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { UserProgress } from '../api/flashcard-api'
|
||||||
|
|
||||||
|
export const EASE = {
|
||||||
|
ignored: -1,
|
||||||
|
hard: 0.1,
|
||||||
|
easy: 0.65,
|
||||||
|
known: 1.0,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type EaseKey = keyof typeof EASE
|
||||||
|
|
||||||
|
const INTERVAL_LADDER: Record<Exclude<EaseKey, 'ignored'>, number[]> = {
|
||||||
|
// count: 0 1 2 3 4 5+
|
||||||
|
known: [1, 3, 7, 14, 30, 60],
|
||||||
|
easy: [1, 2, 4, 8, 14, 30],
|
||||||
|
hard: [1, 1, 1, 2, 3, 5],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeNextReview(key: EaseKey, reviewCount: number): string | null {
|
||||||
|
if (key === 'ignored') return null
|
||||||
|
const ladder = INTERVAL_LADDER[key]
|
||||||
|
const days = ladder[Math.min(reviewCount, ladder.length - 1)]
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusFor(key: EaseKey): UserProgress['status'] {
|
||||||
|
if (key === 'known') return 'known'
|
||||||
|
if (key === 'ignored') return 'ignored'
|
||||||
|
return 'learning'
|
||||||
|
}
|
||||||
@@ -6,199 +6,293 @@ const FEATURES = [
|
|||||||
{
|
{
|
||||||
to: '/toeic',
|
to: '/toeic',
|
||||||
icon: 'assignment',
|
icon: 'assignment',
|
||||||
iconBg: 'bg-blue-50',
|
title: 'Luyện đề',
|
||||||
iconColor: 'text-blue-600',
|
accent: 'TOEIC',
|
||||||
borderColor: 'border-l-blue-600',
|
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu từng Part.',
|
||||||
title: 'Luyện đề TOEIC',
|
|
||||||
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu chi tiết từng Part.',
|
|
||||||
cta: 'Bắt đầu ngay',
|
|
||||||
ctaColor: 'text-blue-600',
|
|
||||||
stat: '350+ câu hỏi',
|
stat: '350+ câu hỏi',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/writing',
|
to: '/writing',
|
||||||
icon: 'auto_fix_high',
|
icon: 'auto_fix_high',
|
||||||
iconBg: 'bg-green-50',
|
title: 'AI chấm',
|
||||||
iconColor: 'text-green-600',
|
accent: 'Writing',
|
||||||
borderColor: 'border-l-green-600',
|
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu.',
|
||||||
title: 'AI Chấm Writing',
|
|
||||||
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu từ AI.',
|
|
||||||
cta: 'Thử ngay',
|
|
||||||
ctaColor: 'text-green-600',
|
|
||||||
stat: '3 lượt / ngày',
|
stat: '3 lượt / ngày',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/vocab',
|
to: '/flash-card',
|
||||||
icon: 'menu_book',
|
icon: 'menu_book',
|
||||||
iconBg: 'bg-amber-50',
|
title: 'Từ vựng',
|
||||||
iconColor: 'text-amber-600',
|
accent: 'thông minh',
|
||||||
borderColor: 'border-l-amber-600',
|
desc: 'Bộ thẻ TOEIC với spaced-repetition, lật 3D, ảnh minh hoạ.',
|
||||||
title: 'Từ vựng thông minh',
|
stat: '18 000+ từ',
|
||||||
desc: '720 từ TOEIC theo 6 chủ đề. Flashcard với hiệu ứng lật 3D.',
|
|
||||||
cta: 'Khám phá',
|
|
||||||
ctaColor: 'text-amber-600',
|
|
||||||
stat: '720 từ vựng',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const openModal = useAuthModalStore((s) => s.open)
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
const firstName = user?.name ?? 'bạn'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Hero */}
|
{/* Page head — editorial */}
|
||||||
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div className="flex-1 min-w-0">
|
<div>
|
||||||
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1.5 rounded-full mb-5 uppercase tracking-wider">
|
<div className="at-eyebrow mb-3">Học TOEIC cùng AI</div>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
AI-Powered Learning
|
Chào <i>{firstName}</i>,<br />
|
||||||
</div>
|
hôm nay học <i>15 phút</i>?
|
||||||
<h1 className="text-4xl lg:text-5xl font-extrabold leading-tight text-slate-800 mb-4" style={{ letterSpacing: '-0.02em' }}>
|
|
||||||
Luyện TOEIC<br />thông minh<br />
|
|
||||||
<span className="text-blue-600 italic">cùng AI</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
|
<div className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
Cá nhân hóa lộ trình học tập để bứt phá điểm số trong thời gian ngắn nhất. AI phân tích điểm yếu và tối ưu bài tập cho bạn.
|
Mục tiêu <b style={{ color: 'var(--at-ink)' }}>850</b>
|
||||||
</p>
|
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
|
||||||
<div className="flex gap-3 flex-wrap">
|
hiện tại <b style={{ color: 'var(--at-ink)' }}>720</b>
|
||||||
<Link
|
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
|
||||||
to="/toeic"
|
còn <b style={{ color: 'var(--at-brand)' }}>130 điểm</b> nữa
|
||||||
className="bg-blue-600 text-white px-8 py-3.5 rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/20"
|
|
||||||
>
|
|
||||||
Bắt đầu ngay
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/writing"
|
|
||||||
className="border border-slate-200 px-8 py-3.5 rounded-xl font-bold text-sm text-slate-500 hover:bg-white hover:border-blue-600 hover:text-blue-600 transition-all"
|
|
||||||
>
|
|
||||||
Thử AI Writing
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-6 mt-8">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-extrabold text-blue-600">350+</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Câu hỏi TOEIC</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-px bg-slate-200" />
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-extrabold text-green-600">720</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Từ vựng</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-px bg-slate-200" />
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-extrabold text-amber-600">AI</div>
|
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Writing Checker</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Link
|
||||||
|
to="/flash-card"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-colors"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
Học từ vựng
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/toeic"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-90"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>play_arrow</span>
|
||||||
|
Tiếp tục học
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preview card — hidden on mobile */}
|
<div className="grid lg:grid-cols-[2fr_1fr] gap-5">
|
||||||
<div className="hidden lg:block flex-shrink-0 w-80">
|
{/* MAIN COL */}
|
||||||
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
|
<div className="flex flex-col gap-5 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-5">
|
{/* Progress hero */}
|
||||||
|
<div className="rounded-2xl p-7 flex flex-wrap items-center gap-7" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<ProgressRing value={85} />
|
||||||
|
<div className="flex-1 min-w-[240px]">
|
||||||
|
<div className="at-eyebrow mb-1">Lộ trình</div>
|
||||||
|
<div className="at-serif text-[22px] leading-[1.2] tracking-tight mb-3" style={{ color: 'var(--at-ink)' }}>
|
||||||
|
Bạn đang đi <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>đúng hướng</i> — tuần này 4/7 ngày.
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4 items-stretch">
|
||||||
|
<Stat num="24" label="ngày còn lại" />
|
||||||
|
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
|
||||||
|
<Stat num="+46" label="điểm tháng này" />
|
||||||
|
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
|
||||||
|
<Stat num="68%" label="tỷ lệ đúng" color="var(--at-good)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature cards */}
|
||||||
|
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="at-eyebrow mb-1">Khám phá</div>
|
||||||
|
<h2 className="at-serif text-[22px] tracking-tight mb-5" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Tính năng <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>nổi bật</i>
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f.to}
|
||||||
|
to={f.to}
|
||||||
|
className="rounded-xl p-4 transition-all hover:-translate-y-0.5"
|
||||||
|
style={{ background: 'var(--at-paper-2)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-lg grid place-items-center"
|
||||||
|
style={{ background: 'var(--at-brand-soft)', color: 'var(--at-brand)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>{f.icon}</span>
|
||||||
|
</div>
|
||||||
|
<span className="at-chip at-chip-brand">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
{f.stat}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="at-serif text-[17px] leading-[1.15] tracking-tight mb-1" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
{f.title} <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>{f.accent}</i>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12.5px] leading-[1.5]" style={{ color: 'var(--at-mute)' }}>{f.desc}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7-day journey */}
|
||||||
|
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="flex justify-between items-end mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-base text-slate-800">Tiến độ tuần này</div>
|
<div className="at-eyebrow mb-1">Tuần này</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Lộ trình <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>7 ngày</i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
|
<span className="at-chip at-chip-good">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
+24% so với tuần trước
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="grid grid-cols-7 gap-2.5">
|
||||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => {
|
||||||
<span>Reading Score</span><span className="text-blue-600">420/495</span>
|
const h = [60, 85, 40, 90, 75, 0, 0][i]
|
||||||
</div>
|
const done = h > 0
|
||||||
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
const today = i === 4
|
||||||
<div className="h-full bg-blue-600 rounded-full" style={{ width: '85%' }} />
|
return (
|
||||||
</div>
|
<div key={d} className="flex flex-col items-center gap-2">
|
||||||
</div>
|
<div className="w-full relative overflow-hidden rounded-[10px]" style={{ height: 96, background: 'var(--at-line-2)' }}>
|
||||||
<div className="mb-4">
|
<div
|
||||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
className="absolute left-0 right-0 bottom-0 rounded-[10px] transition-[height] duration-500"
|
||||||
<span>Listening Score</span><span className="text-green-600">380/495</span>
|
style={{
|
||||||
</div>
|
height: `${h}%`,
|
||||||
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
background: today ? 'var(--at-brand)' : done ? 'var(--at-brand-soft)' : 'var(--at-line-2)',
|
||||||
<div className="h-full bg-green-600 rounded-full" style={{ width: '77%' }} />
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
<div
|
||||||
<div className="bg-blue-50 rounded-xl p-3 border-l-4 border-blue-600">
|
className={today ? 'at-serif italic' : ''}
|
||||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>local_fire_department</span>
|
style={{ fontSize: 11, color: today ? 'var(--at-brand)' : 'var(--at-mute)', fontWeight: today ? 700 : 500 }}
|
||||||
<div className="text-xl font-extrabold text-blue-600 mt-1">14</div>
|
>
|
||||||
<div className="text-xs text-slate-400">Ngày Streak</div>
|
{d}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 rounded-xl p-3 border-l-4 border-green-600">
|
</div>
|
||||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>star</span>
|
)
|
||||||
<div className="text-xl font-extrabold text-green-600 mt-1">1,250</div>
|
})}
|
||||||
<div className="text-xs text-slate-400">Điểm tích lũy</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>psychology</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
<span className="font-semibold">AI gợi ý:</span> Ôn thêm Part 5 — Ngữ pháp
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Feature cards */}
|
{/* SIDE COL */}
|
||||||
<section>
|
<div className="flex flex-col gap-5">
|
||||||
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
|
{/* Streak card (inky) */}
|
||||||
<p className="text-slate-500 mb-6">Hệ sinh thái học tập toàn diện được thiết kế để tối ưu hoá điểm số.</p>
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
<div className="flex items-center justify-between mb-3.5">
|
||||||
{FEATURES.map((f) => (
|
<div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(250,248,243,0.55)', fontWeight: 600 }}>
|
||||||
<Link
|
Streak
|
||||||
key={f.to}
|
|
||||||
to={f.to}
|
|
||||||
className={`bg-white rounded-2xl p-6 border border-slate-200 border-l-4 ${f.borderColor} hover:-translate-y-1 hover:shadow-md transition-all duration-200`}
|
|
||||||
>
|
|
||||||
<div className={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
|
|
||||||
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
|
<div className="w-10 h-10 rounded-xl grid place-items-center" style={{ background: 'rgba(255,255,255,0.08)', color: '#FFC27A' }}>
|
||||||
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
|
<span className="material-symbols-outlined" style={{ fontSize: 20, fontVariationSettings: "'FILL' 1" }}>local_fire_department</span>
|
||||||
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
|
|
||||||
{f.cta}
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
<div className="at-serif" style={{ fontSize: 44, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 1, marginBottom: 4 }}>
|
||||||
</div>
|
7 <span className="italic opacity-65" style={{ fontSize: 18 }}>ngày</span>
|
||||||
</section>
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'rgba(250,248,243,0.55)', marginBottom: 14 }}>Kỷ lục: 21 ngày</div>
|
||||||
{/* CTA banner */}
|
<div className="flex gap-1.5">
|
||||||
<section className="mt-10">
|
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => (
|
||||||
<div className="bg-blue-600 rounded-2xl p-8 flex items-center justify-between overflow-hidden relative">
|
<div
|
||||||
<div className="absolute right-4 top-0 bottom-0 flex items-center opacity-10">
|
key={i}
|
||||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 120 }}>emoji_events</span>
|
className="flex-1 rounded-md grid place-items-center"
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: i < 5 ? '#C15A34' : 'rgba(255,255,255,0.08)',
|
||||||
|
color: i < 5 ? 'white' : 'rgba(255,255,255,0.4)',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
|
||||||
<h3 className="text-2xl font-extrabold text-white mb-2">Sẵn sàng chinh phục 990 TOEIC?</h3>
|
{/* AI nudge */}
|
||||||
<p className="text-blue-100 mb-5">
|
<div className="at-pullquote">
|
||||||
{user
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
|
<div className="w-6 h-6 rounded-lg grid place-items-center" style={{ background: 'var(--at-brand)', color: 'white' }}>
|
||||||
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
|
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>auto_awesome</span>
|
||||||
</p>
|
</div>
|
||||||
{user ? (
|
<div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--at-brand-ink)', fontWeight: 700 }}>
|
||||||
<Link
|
AI gợi ý
|
||||||
to="/toeic"
|
</div>
|
||||||
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
</div>
|
||||||
>
|
<div className="at-pullquote-q">
|
||||||
Luyện thi ngay
|
"Bạn yếu nhất <b style={{ fontWeight: 600 }}>Part 3</b> — dành 10 phút hôm nay có thể tăng <b style={{ fontWeight: 600 }}>30+ điểm</b>."
|
||||||
</Link>
|
</div>
|
||||||
) : (
|
<div className="mt-2.5 text-[11px] opacity-70" style={{ color: 'var(--at-brand-ink)' }}>— EnglishAI Coach</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pro tip */}
|
||||||
|
<div className="at-tip">
|
||||||
|
<div className="at-tip-label">Pro tip</div>
|
||||||
|
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
|
||||||
|
Học theo <b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>cụm từ</b> (collocations) giúp bạn ghi nhớ nhanh hơn{' '}
|
||||||
|
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>40%</b> so với học từ đơn lẻ.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guest CTA (only if not logged in) */}
|
||||||
|
{!user && (
|
||||||
|
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
|
<div className="at-eyebrow mb-2">Khách</div>
|
||||||
|
<div className="at-serif text-[17px] leading-[1.2] tracking-tight mb-3" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
|
Đăng ký để <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>lưu tiến độ</i>.
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('register')}
|
onClick={() => openModal('register')}
|
||||||
className="bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
className="w-full py-2.5 rounded-xl text-[13.5px] font-semibold transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
||||||
>
|
>
|
||||||
Đăng ký miễn phí
|
Đăng ký miễn phí
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ num, label, color }: { num: string; label: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="at-serif" style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}>
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--at-mute)', marginTop: 4 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressRing({ value }: { value: number }) {
|
||||||
|
const r = 58
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const offset = c - (value / 100) * c
|
||||||
|
return (
|
||||||
|
<div className="relative grid place-items-center" style={{ width: 132, height: 132 }}>
|
||||||
|
<svg width="132" height="132">
|
||||||
|
<circle cx="66" cy="66" r={r} fill="none" stroke="var(--at-line-2)" strokeWidth="7" />
|
||||||
|
<circle
|
||||||
|
cx="66"
|
||||||
|
cy="66"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--at-brand)"
|
||||||
|
strokeWidth="7"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 66 66)"
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.6s cubic-bezier(0.2, 0.7, 0.2, 1)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute text-center">
|
||||||
|
<div className="at-serif" style={{ fontSize: 34, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1, color: 'var(--at-ink)' }}>
|
||||||
|
720
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 4, fontWeight: 600 }}>
|
||||||
|
/ 850
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function Settings() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
<div className="px-4 lg:px-6 py-12 flex flex-col items-center justify-center gap-4 text-center">
|
||||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
|
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
|
||||||
<h1 className="text-xl font-bold text-slate-700">Cài đặt</h1>
|
<h1 className="text-xl font-bold text-slate-700">Cài đặt</h1>
|
||||||
<p className="text-slate-400 text-sm max-w-xs">
|
<p className="text-slate-400 text-sm max-w-xs">
|
||||||
@@ -30,7 +30,7 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
|
<div className="px-4 lg:px-6 py-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đặt</h1>
|
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đặt</h1>
|
||||||
<p className="text-slate-400 text-sm">Quản lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
<p className="text-slate-400 text-sm">Quản lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
||||||
|
|||||||
53
src/features/toeic/api/test-list-api.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { TestRecord, PartRecord } from '@/types'
|
||||||
|
|
||||||
|
export async function fetchTests(): Promise<TestRecord[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('test')
|
||||||
|
.select('id, title, description, total_questions, duration_minutes, test_category(name)')
|
||||||
|
.order('id')
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map((row: Record<string, unknown>) => ({
|
||||||
|
id: row.id as number,
|
||||||
|
title: row.title as string,
|
||||||
|
description: row.description as string | null,
|
||||||
|
totalQuestions: row.total_questions as number,
|
||||||
|
durationMinutes: row.duration_minutes as number,
|
||||||
|
categoryName: (row.test_category as { name: string } | null)?.name ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTestWithParts(testId: number): Promise<{ test: TestRecord; parts: PartRecord[] }> {
|
||||||
|
const { data: testRow, error: testErr } = await supabase
|
||||||
|
.from('test')
|
||||||
|
.select('id, title, description, total_questions, duration_minutes, test_category(name)')
|
||||||
|
.eq('id', testId)
|
||||||
|
.single()
|
||||||
|
if (testErr) throw testErr
|
||||||
|
|
||||||
|
const { data: partRows, error: partErr } = await supabase
|
||||||
|
.from('part')
|
||||||
|
.select('id, test_id, part_number, title, question_count')
|
||||||
|
.eq('test_id', testId)
|
||||||
|
.order('part_number')
|
||||||
|
if (partErr) throw partErr
|
||||||
|
|
||||||
|
const row = testRow as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
test: {
|
||||||
|
id: row.id as number,
|
||||||
|
title: row.title as string,
|
||||||
|
description: row.description as string | null,
|
||||||
|
totalQuestions: row.total_questions as number,
|
||||||
|
durationMinutes: row.duration_minutes as number,
|
||||||
|
categoryName: (row.test_category as { name: string } | null)?.name ?? null,
|
||||||
|
},
|
||||||
|
parts: (partRows ?? []).map((p: Record<string, unknown>) => ({
|
||||||
|
id: p.id as number,
|
||||||
|
testId: p.test_id as number,
|
||||||
|
partNumber: p.part_number as number,
|
||||||
|
title: p.title as string,
|
||||||
|
questionCount: p.question_count as number,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { cn } from '@/lib/utils'
|
import { Check, Home, Minus, RotateCcw, Sparkles, X } from 'lucide-react'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
import { useAuthStore } from '@/store/auth-store'
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
@@ -8,220 +8,579 @@ import { saveTestResult } from '@/lib/progress-service'
|
|||||||
import { useAwardActivity } from '@/hooks/use-gamification'
|
import { useAwardActivity } from '@/hooks/use-gamification'
|
||||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
import { XP_REWARDS } from '@/lib/gamification-service'
|
||||||
|
|
||||||
function formatTime(s: number) {
|
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
const sec = s % 60
|
function formatMinSec(s: number) {
|
||||||
if (m === 0) return `${sec}s`
|
const mm = String(Math.floor(s / 60)).padStart(2, '0')
|
||||||
return `${m}m ${sec}s`
|
const ss = String(s % 60).padStart(2, '0')
|
||||||
|
return `${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ring({ percent, size = 110, stroke = 7, color, bg, children }: {
|
||||||
|
percent: number; size?: number; stroke?: number; color: string; bg: string; children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const cx = size / 2
|
||||||
|
const r = cx - stroke
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const offset = c - (Math.min(percent, 100) / 100) * c
|
||||||
|
return (
|
||||||
|
<div className="relative grid place-items-center flex-shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg className="-rotate-90" width={size} height={size}>
|
||||||
|
<circle cx={cx} cy={cx} r={r} fill="none" stroke={bg} strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cx} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.7s ease' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 grid place-items-center">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeadlineByPercent({ pct }: { pct: number }) {
|
||||||
|
if (pct >= 80) return <>Xuất <i>sắc!</i></>
|
||||||
|
if (pct >= 60) return <>Khá <i>tốt</i></>
|
||||||
|
return <>Cần <i>ôn thêm</i></>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero theme by estimated TOEIC score band — aligned to ETS proficiency levels.
|
||||||
|
// <225 Novice · 225–549 Elementary→Basic · 550–749 Working · ≥750 Professional
|
||||||
|
// Light backgrounds use ink text; dark use paper text.
|
||||||
|
function heroThemeByScore(score: number) {
|
||||||
|
if (score < 225) {
|
||||||
|
return {
|
||||||
|
bg: 'var(--at-warm)',
|
||||||
|
text: 'var(--at-paper)',
|
||||||
|
muted: 'rgba(250,248,243,0.6)',
|
||||||
|
labelMuted: 'rgba(250,248,243,0.5)',
|
||||||
|
ringBg: 'rgba(255,255,255,0.15)',
|
||||||
|
ringColor: 'var(--at-paper)',
|
||||||
|
glow: 'rgba(255,255,255,0.18)',
|
||||||
|
btnSolidBg: 'var(--at-paper)',
|
||||||
|
btnSolidText: 'var(--at-ink)',
|
||||||
|
btnGhostBorder: 'rgba(255,255,255,0.25)',
|
||||||
|
btnGhostHover: 'rgba(255,255,255,0.1)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (score < 550) {
|
||||||
|
return {
|
||||||
|
bg: 'var(--at-warm-soft)',
|
||||||
|
text: 'var(--at-ink)',
|
||||||
|
muted: 'var(--at-mute)',
|
||||||
|
labelMuted: 'var(--at-mute)',
|
||||||
|
ringBg: 'rgba(15,17,20,0.08)',
|
||||||
|
ringColor: 'var(--at-warm)',
|
||||||
|
glow: 'rgba(210,106,59,0.18)',
|
||||||
|
btnSolidBg: 'var(--at-ink)',
|
||||||
|
btnSolidText: 'var(--at-paper)',
|
||||||
|
btnGhostBorder: 'var(--at-line)',
|
||||||
|
btnGhostHover: 'rgba(15,17,20,0.05)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (score < 750) {
|
||||||
|
return {
|
||||||
|
bg: 'var(--at-good-soft)',
|
||||||
|
text: 'var(--at-ink)',
|
||||||
|
muted: 'var(--at-mute)',
|
||||||
|
labelMuted: 'var(--at-mute)',
|
||||||
|
ringBg: 'rgba(15,17,20,0.08)',
|
||||||
|
ringColor: 'var(--at-good)',
|
||||||
|
glow: 'rgba(47,125,74,0.18)',
|
||||||
|
btnSolidBg: 'var(--at-ink)',
|
||||||
|
btnSolidText: 'var(--at-paper)',
|
||||||
|
btnGhostBorder: 'var(--at-line)',
|
||||||
|
btnGhostHover: 'rgba(15,17,20,0.05)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bg: 'var(--at-good)',
|
||||||
|
text: 'var(--at-paper)',
|
||||||
|
muted: 'rgba(250,248,243,0.65)',
|
||||||
|
labelMuted: 'rgba(250,248,243,0.55)',
|
||||||
|
ringBg: 'rgba(255,255,255,0.15)',
|
||||||
|
ringColor: 'var(--at-paper)',
|
||||||
|
glow: 'rgba(255,255,255,0.18)',
|
||||||
|
btnSolidBg: 'var(--at-paper)',
|
||||||
|
btnSolidText: 'var(--at-ink)',
|
||||||
|
btnGhostBorder: 'rgba(255,255,255,0.25)',
|
||||||
|
btnGhostHover: 'rgba(255,255,255,0.1)',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestResult() {
|
export function TestResult() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { partId, partName, questions, answers, timeUsed, reset } = useTestStore()
|
const { testId, testName, parts, answers, timeUsed, reset } = useTestStore()
|
||||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const savedRef = useRef(false)
|
const savedRef = useRef(false)
|
||||||
const { mutate: awardActivity } = useAwardActivity()
|
const { mutate: awardActivity } = useAwardActivity()
|
||||||
|
|
||||||
|
const allQuestions = parts.flatMap(p => p.questions)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
if (!isAuthenticated) navigate({ to: '/toeic' })
|
if (!isAuthenticated) navigate({ to: '/toeic' })
|
||||||
}, [isLoading, isAuthenticated, navigate])
|
}, [isLoading, isAuthenticated, navigate])
|
||||||
|
|
||||||
// Save test result once when page mounts (fire-and-forget)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || savedRef.current || questions.length === 0) return
|
if (!user || savedRef.current || allQuestions.length === 0) return
|
||||||
savedRef.current = true
|
savedRef.current = true
|
||||||
|
const correctCount = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
|
||||||
awardActivity({ xp: XP_REWARDS.test })
|
awardActivity({ xp: XP_REWARDS.test })
|
||||||
saveTestResult(user.id, {
|
saveTestResult(user.id, {
|
||||||
partId,
|
testId,
|
||||||
partName,
|
selectedParts: parts.map(p => p.partNumber),
|
||||||
score: answers.filter((a, i) => a === questions[i]?.correctAnswer).length,
|
score: correctCount,
|
||||||
total: questions.length,
|
total: allQuestions.length,
|
||||||
timeUsed,
|
timeUsed,
|
||||||
answers: questions.map((q, i) => ({
|
answers: allQuestions.map(q => ({
|
||||||
questionId: q.id,
|
questionId: q.id,
|
||||||
selected: answers[i],
|
selected: answers[q.id] ?? null,
|
||||||
correct: answers[i] === q.correctAnswer,
|
correct: answers[q.id] === q.correctAnswer,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}, [user, questions, answers, partId, partName, timeUsed])
|
}, [user, allQuestions.length])
|
||||||
|
|
||||||
const correct = answers.filter((a, i) => a === questions[i]?.correctAnswer).length
|
if (allQuestions.length === 0) {
|
||||||
const wrong = answers.filter((a, i) => a !== null && a !== questions[i]?.correctAnswer).length
|
|
||||||
const skipped = answers.filter((a) => a === null).length
|
|
||||||
const total = questions.length
|
|
||||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
|
|
||||||
|
|
||||||
const circumference = 2 * Math.PI * 52
|
|
||||||
const offset = circumference - (percent / 100) * circumference
|
|
||||||
|
|
||||||
function handleRetry() {
|
|
||||||
navigate({ to: '/toeic/session' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHome() {
|
|
||||||
reset()
|
|
||||||
navigate({ to: '/' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questions.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
|
<div className="px-6 py-10 text-center">
|
||||||
<p className="text-slate-500 mb-4">Không có dữ liệu bài thi.</p>
|
<p style={{ color: 'var(--at-mute)' }} className="mb-4">Không có dữ liệu bài thi.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/toeic' })}
|
onClick={() => navigate({ to: '/toeic' })}
|
||||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors"
|
style={{
|
||||||
|
background: 'var(--at-ink)', color: 'var(--at-paper)',
|
||||||
|
padding: '10px 20px', borderRadius: 10, fontWeight: 600, fontSize: 14,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Chọn Part để luyện thi
|
Chọn đề thi
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
|
||||||
|
const wrong = allQuestions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length
|
||||||
|
const skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length
|
||||||
|
const total = allQuestions.length
|
||||||
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||||
|
// TOEIC scaled score 10–990. Linear approximation from raw accuracy;
|
||||||
|
// real ETS scaling is non-linear, but good enough as a practice estimate.
|
||||||
|
const toeicEstimate = Math.round(10 + pct * 9.8)
|
||||||
|
const theme = heroThemeByScore(toeicEstimate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Score header */}
|
{/* Top row — headline card (dark) + TOEIC estimate card */}
|
||||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
|
<div className="grid gap-5 mb-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))' }}>
|
||||||
<div className="flex flex-col lg:flex-row items-center gap-6">
|
{/* Hero result card — themed by score */}
|
||||||
{/* Circle */}
|
<div
|
||||||
<div className="flex-shrink-0 relative w-32 h-32">
|
className="relative overflow-hidden"
|
||||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
style={{
|
||||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
|
background: theme.bg,
|
||||||
<circle
|
color: theme.text,
|
||||||
cx="60"
|
borderRadius: 24,
|
||||||
cy="60"
|
padding: 32,
|
||||||
r="52"
|
boxShadow: 'var(--shadow-card)',
|
||||||
fill="none"
|
transition: 'background 0.3s ease',
|
||||||
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
|
}}
|
||||||
strokeWidth="8"
|
>
|
||||||
strokeLinecap="round"
|
<div
|
||||||
strokeDasharray={circumference}
|
style={{
|
||||||
strokeDashoffset={offset}
|
position: 'absolute', top: -40, right: -40, width: 200, height: 200,
|
||||||
className="transition-all duration-700"
|
borderRadius: '50%',
|
||||||
/>
|
background: `radial-gradient(circle, ${theme.glow}, transparent 60%)`,
|
||||||
</svg>
|
}}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
/>
|
||||||
<span className="text-3xl font-extrabold text-slate-800">{correct}/{total}</span>
|
<div className="relative">
|
||||||
<span className="text-xs text-slate-400 font-medium">điểm</span>
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5, fontWeight: 700, letterSpacing: '0.16em',
|
||||||
|
color: theme.labelMuted, marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kết quả
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
style={{
|
||||||
{/* Stats */}
|
fontFamily: 'var(--at-serif)', fontSize: 40, fontWeight: 400,
|
||||||
<div className="flex-1 text-center lg:text-left">
|
letterSpacing: '-0.025em', lineHeight: 1.05, marginBottom: 6,
|
||||||
<div className="text-2xl font-extrabold text-slate-800 mb-1">
|
}}
|
||||||
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
|
>
|
||||||
|
<HeadlineByPercent pct={pct} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-400 mb-4">
|
<div style={{ fontSize: 13, color: theme.muted, marginBottom: 24 }}>
|
||||||
Part {partId} — {partName}
|
{testName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
<div className="flex items-center gap-7 flex-wrap">
|
||||||
<div className="bg-green-50 border border-green-100 rounded-xl px-4 py-2 text-center">
|
<Ring percent={pct} color={theme.ringColor} bg={theme.ringBg}>
|
||||||
<div className="text-xl font-extrabold text-green-600">{correct}</div>
|
<div style={{ color: theme.text, textAlign: 'center' }}>
|
||||||
<div className="text-xs text-slate-400">Đúng</div>
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontSize: 30, fontWeight: 400,
|
||||||
|
letterSpacing: '-0.02em', lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pct}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 9.5, opacity: 0.6, letterSpacing: '0.14em',
|
||||||
|
marginTop: 4, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Ring>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5, color: theme.labelMuted,
|
||||||
|
letterSpacing: '0.14em', fontWeight: 600, marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đúng
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontSize: 28, fontWeight: 400,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{correct}
|
||||||
|
<span style={{ opacity: 0.5, fontSize: 18, fontStyle: 'italic' }}>
|
||||||
|
/{total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5, color: theme.labelMuted,
|
||||||
|
letterSpacing: '0.14em', fontWeight: 600, marginTop: 14, marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Thời gian
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-mono)', fontSize: 24, fontWeight: 500,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatMinSec(timeUsed)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-red-50 border border-red-100 rounded-xl px-4 py-2 text-center">
|
<div className="ml-auto flex flex-col gap-2">
|
||||||
<div className="text-xl font-extrabold text-red-600">{wrong}</div>
|
<button
|
||||||
<div className="text-xs text-slate-400">Sai</div>
|
onClick={() => navigate({ to: '/toeic/session' })}
|
||||||
</div>
|
className="inline-flex items-center gap-2 transition-[filter] hover:brightness-110"
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-center">
|
style={{
|
||||||
<div className="text-xl font-extrabold text-slate-500">{skipped}</div>
|
padding: '10px 18px', borderRadius: 10,
|
||||||
<div className="text-xs text-slate-400">Bỏ qua</div>
|
background: theme.btnSolidBg, color: theme.btnSolidText,
|
||||||
</div>
|
fontSize: 13, fontWeight: 600,
|
||||||
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-2 text-center">
|
}}
|
||||||
<div className="text-xl font-extrabold text-blue-600">{formatTime(timeUsed)}</div>
|
>
|
||||||
<div className="text-xs text-slate-400">Thời gian</div>
|
<RotateCcw size={14} />
|
||||||
|
Làm lại
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { reset(); navigate({ to: '/toeic' }) }}
|
||||||
|
className="inline-flex items-center gap-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
padding: '10px 18px', borderRadius: 10,
|
||||||
|
background: 'transparent', color: theme.text,
|
||||||
|
border: `1px solid ${theme.btnGhostBorder}`,
|
||||||
|
fontSize: 13, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = theme.btnGhostHover)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<Home size={14} />
|
||||||
|
Trang chủ
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* TOEIC estimate card */}
|
||||||
<div className="flex lg:flex-col gap-3 flex-shrink-0">
|
<div
|
||||||
<button
|
style={{
|
||||||
onClick={handleRetry}
|
background: 'var(--at-surface)',
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
border: '1px solid var(--at-line)',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 32,
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontStyle: 'italic', fontWeight: 500,
|
||||||
|
fontSize: 12, letterSpacing: '0.08em',
|
||||||
|
color: 'var(--at-mute)', marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dự kiến TOEIC
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontSize: 24, fontWeight: 500,
|
||||||
|
letterSpacing: '-0.015em', marginBottom: 4, color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Điểm <i style={{ color: 'var(--at-brand)' }}>ước tính</i>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', marginBottom: 18 }}>
|
||||||
|
Dựa trên bài thi hôm nay
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2 mb-4">
|
||||||
|
<div
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontSize: 56, fontWeight: 400,
|
||||||
|
letterSpacing: '-0.035em', color: 'var(--at-brand)', lineHeight: 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>
|
{toeicEstimate}
|
||||||
Làm lại
|
</div>
|
||||||
</button>
|
<div
|
||||||
<button
|
style={{
|
||||||
onClick={handleHome}
|
fontSize: 14, color: 'var(--at-mute)',
|
||||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors"
|
fontFamily: 'var(--at-serif)', fontStyle: 'italic',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>
|
/ 990
|
||||||
Về trang chủ
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6, background: 'var(--at-line-2)',
|
||||||
|
borderRadius: 999, overflow: 'hidden', position: 'relative',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: '0 auto 0 0',
|
||||||
|
background: 'var(--at-brand)',
|
||||||
|
width: `${(toeicEstimate / 990) * 100}%`,
|
||||||
|
borderRadius: 999,
|
||||||
|
transition: 'width 0.7s cubic-bezier(0.2,0.7,0.2,1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between" style={{ fontSize: 11, color: 'var(--at-mute)' }}>
|
||||||
|
<span>0</span>
|
||||||
|
<span>Mục tiêu: 850</span>
|
||||||
|
<span>990</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 1, background: 'var(--at-line)', margin: '18px 0' }} />
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Stat label="Đúng" value={correct} color="var(--at-good)" />
|
||||||
|
<Stat label="Sai" value={wrong} color="var(--at-bad)" />
|
||||||
|
<Stat label="Bỏ qua" value={skipped} color="var(--at-mute)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Answer review */}
|
{/* Per-part review */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
{parts.map(part => (
|
||||||
<h2 className="text-base font-bold text-slate-800 mb-4">Xem lại đáp án</h2>
|
<div
|
||||||
<div className="space-y-4">
|
key={part.partNumber}
|
||||||
{questions.map((q, i) => {
|
style={{
|
||||||
const userAnswer = answers[i]
|
background: 'var(--at-surface)',
|
||||||
const isCorrect = userAnswer === q.correctAnswer
|
border: '1px solid var(--at-line)',
|
||||||
const isSkipped = userAnswer === null
|
borderRadius: 18,
|
||||||
|
padding: 24,
|
||||||
return (
|
marginBottom: 16,
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
key={q.id}
|
className="uppercase"
|
||||||
className={cn(
|
style={{
|
||||||
'rounded-xl border p-4',
|
fontFamily: 'var(--at-serif)', fontStyle: 'italic', fontWeight: 500,
|
||||||
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
|
fontSize: 12, letterSpacing: '0.08em', color: 'var(--at-brand)',
|
||||||
)}
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
Part {part.partNumber}
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
<div
|
||||||
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
|
style={{
|
||||||
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
|
fontFamily: 'var(--at-serif)', fontSize: 22, fontWeight: 400,
|
||||||
)}
|
letterSpacing: '-0.015em', color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part.partName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip kind="good" icon={<Check size={10} strokeWidth={3} />}>
|
||||||
|
{part.questions.filter(q => answers[q.id] === q.correctAnswer).length} đúng
|
||||||
|
</Chip>
|
||||||
|
<Chip kind="bad" icon={<X size={10} strokeWidth={3} />}>
|
||||||
|
{part.questions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length} sai
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{part.questions.map((q, i) => {
|
||||||
|
const userAnswer = answers[q.id] ?? null
|
||||||
|
const isCorrect = userAnswer === q.correctAnswer
|
||||||
|
const isSkipped = userAnswer === null || userAnswer === undefined
|
||||||
|
const statusColor = isCorrect
|
||||||
|
? 'var(--at-good)'
|
||||||
|
: isSkipped
|
||||||
|
? 'var(--at-mute)'
|
||||||
|
: 'var(--at-bad)'
|
||||||
|
const statusBg = isCorrect
|
||||||
|
? 'var(--at-good-soft)'
|
||||||
|
: isSkipped
|
||||||
|
? 'var(--at-line-2)'
|
||||||
|
: 'var(--at-bad-soft)'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className="flex items-start gap-3"
|
||||||
|
style={{
|
||||||
|
padding: '14px 0',
|
||||||
|
borderTop: i === 0 ? 'none' : '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid place-items-center flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28, borderRadius: 8,
|
||||||
|
background: statusBg, color: statusColor,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{isCorrect
|
||||||
</span>
|
? <Check size={16} strokeWidth={2.5} />
|
||||||
|
: isSkipped
|
||||||
|
? <Minus size={16} strokeWidth={2.5} />
|
||||||
|
: <X size={16} strokeWidth={2.5} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>
|
<div
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
className="mb-1.5"
|
||||||
{q.options.map((opt, j) => (
|
style={{
|
||||||
<span
|
fontSize: 13.5, fontWeight: 500, color: 'var(--at-ink)',
|
||||||
key={j}
|
lineHeight: 1.5,
|
||||||
className={cn(
|
}}
|
||||||
'text-xs px-2.5 py-1 rounded-lg font-medium',
|
>
|
||||||
j === q.correctAnswer
|
<span style={{ color: 'var(--at-mute)', fontFamily: 'var(--at-serif)', fontStyle: 'italic' }}>
|
||||||
? 'bg-green-100 text-green-700 border border-green-200'
|
Câu {i + 1}.{' '}
|
||||||
: j === userAnswer && !isCorrect
|
</span>
|
||||||
? 'bg-red-100 text-red-700 border border-red-200 line-through'
|
{q.text || <span style={{ color: 'var(--at-mute-2)' }}>— (nghe/nhìn)</span>}
|
||||||
: 'bg-slate-100 text-slate-500',
|
</div>
|
||||||
)}
|
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', lineHeight: 1.5 }}>
|
||||||
>
|
Đáp án đúng:{' '}
|
||||||
{['A', 'B', 'C', 'D'][j]}. {opt}
|
<b style={{ color: 'var(--at-good)' }}>
|
||||||
</span>
|
{ANSWER_LABELS[q.correctAnswer]}. {q.options[q.correctAnswer]}
|
||||||
))}
|
</b>
|
||||||
|
{!isCorrect && !isSkipped && userAnswer !== null && (
|
||||||
|
<>
|
||||||
|
{' · '}Bạn chọn:{' '}
|
||||||
|
<b style={{ color: 'var(--at-bad)' }}>
|
||||||
|
{ANSWER_LABELS[userAnswer]}. {q.options[userAnswer]}
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{q.explanation && (
|
{q.explanation && (
|
||||||
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
|
<div
|
||||||
<span className="font-semibold text-slate-600">Giải thích: </span>
|
className="mt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: 'var(--at-ink-2)',
|
||||||
|
background: 'var(--at-paper-2)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontStyle: 'italic',
|
||||||
|
color: 'var(--at-brand)', marginRight: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={10} className="inline -mt-0.5 mr-1" />
|
||||||
|
Giải thích
|
||||||
|
</span>
|
||||||
{q.explanation}
|
{q.explanation}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-shrink-0">
|
|
||||||
{isCorrect ? (
|
|
||||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
|
|
||||||
) : isSkipped ? (
|
|
||||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
|
|
||||||
) : (
|
|
||||||
<span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5, color: 'var(--at-mute)',
|
||||||
|
letterSpacing: '0.1em', fontWeight: 600, marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontSize: 22, fontWeight: 400,
|
||||||
|
letterSpacing: '-0.02em', color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Chip({ kind, icon, children }: {
|
||||||
|
kind: 'good' | 'bad'; icon: React.ReactNode; children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const bg = kind === 'good' ? 'var(--at-good-soft)' : 'var(--at-bad-soft)'
|
||||||
|
const fg = kind === 'good' ? 'var(--at-good-ink)' : 'var(--at-bad)'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px', borderRadius: 999,
|
||||||
|
background: bg, color: fg,
|
||||||
|
fontSize: 11, fontWeight: 600, letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,207 +1,556 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Play } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
import { TestSessionHeader } from './TestSessionHeader'
|
||||||
|
import { TestSessionSidebar } from './TestSessionSidebar'
|
||||||
|
import { TestSessionFooter } from './TestSessionFooter'
|
||||||
|
import type { Question } from '@/types'
|
||||||
|
|
||||||
const TOTAL_SECONDS = 600 // 10 minutes
|
|
||||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||||
|
const LETTER_PLACEHOLDER_RE =
|
||||||
|
/^(Statement|Response|Choice)\s+[A-D]$/i
|
||||||
|
|
||||||
function formatTime(s: number) {
|
interface QuestionGroup {
|
||||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
groupId: number
|
||||||
|
questions: Question[]
|
||||||
|
passageText?: string
|
||||||
|
audioUrl?: string
|
||||||
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestSession() {
|
function groupByGroupId(questions: Question[]): QuestionGroup[] {
|
||||||
const navigate = useNavigate()
|
const groups: QuestionGroup[] = []
|
||||||
const { partId, partName, questions, answers, setAnswer, submitExam } = useTestStore()
|
for (const q of questions) {
|
||||||
const [currentQ, setCurrentQ] = useState(0)
|
const last = groups[groups.length - 1]
|
||||||
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS)
|
if (last && last.groupId === q.groupId) {
|
||||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
last.questions.push(q)
|
||||||
|
} else {
|
||||||
const handleSubmit = useCallback(() => {
|
groups.push({
|
||||||
submitExam(TOTAL_SECONDS - timeLeft)
|
groupId: q.groupId,
|
||||||
navigate({ to: '/toeic/result' })
|
questions: [q],
|
||||||
}, [submitExam, navigate, timeLeft])
|
passageText: q.passageText,
|
||||||
|
audioUrl: q.audioUrl,
|
||||||
// Countdown
|
imageUrl: q.imageUrl,
|
||||||
useEffect(() => {
|
|
||||||
if (questions.length === 0) return
|
|
||||||
const id = setInterval(() => {
|
|
||||||
setTimeLeft((t) => {
|
|
||||||
if (t <= 1) { clearInterval(id); handleSubmit(); return 0 }
|
|
||||||
return t - 1
|
|
||||||
})
|
})
|
||||||
}, 1000)
|
}
|
||||||
return () => clearInterval(id)
|
}
|
||||||
}, [questions.length, handleSubmit])
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect if no exam started or not authenticated (wait for auth init)
|
function PassageBlock({ group }: { group: QuestionGroup }) {
|
||||||
useEffect(() => {
|
const { audioUrl, imageUrl, passageText } = group
|
||||||
if (isLoading) return
|
|
||||||
if (!isAuthenticated || questions.length === 0) navigate({ to: '/toeic' })
|
|
||||||
}, [isLoading, isAuthenticated, questions.length, navigate])
|
|
||||||
|
|
||||||
if (questions.length === 0) return null
|
if (!audioUrl && !imageUrl && !passageText) return null
|
||||||
|
|
||||||
const question = questions[currentQ]
|
|
||||||
const answeredCount = answers.filter((a) => a !== null).length
|
|
||||||
const isUrgent = timeLeft < 60
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div
|
||||||
{/* Mobile progress bar */}
|
style={{
|
||||||
<div className="lg:hidden mb-4">
|
background: 'var(--at-paper-2)',
|
||||||
<div className="flex justify-between text-sm font-semibold mb-2">
|
borderRadius: 10,
|
||||||
<span className="text-slate-700">Part {partId} — Câu {currentQ + 1}/{questions.length}</span>
|
padding: '18px 20px',
|
||||||
<span className={cn('font-bold tabular-nums', isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600')}>
|
fontFamily: 'var(--at-mono)',
|
||||||
{formatTime(timeLeft)}
|
fontSize: 12.5,
|
||||||
</span>
|
lineHeight: 1.7,
|
||||||
</div>
|
color: 'var(--at-ink-2)',
|
||||||
<div className="h-1.5 w-full rounded-full bg-slate-200">
|
marginBottom: 24,
|
||||||
<div
|
whiteSpace: 'pre-wrap',
|
||||||
className="h-full bg-blue-600 rounded-full transition-all"
|
}}
|
||||||
style={{ width: `${((currentQ + 1) / questions.length) * 100}%` }}
|
>
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="flex items-center gap-3" style={{ marginBottom: passageText || imageUrl ? 12 : 0 }}>
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
src={audioUrl}
|
||||||
|
className="w-full"
|
||||||
|
style={{ height: 32 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex gap-5">
|
{imageUrl && (
|
||||||
{/* Left: Question */}
|
<div
|
||||||
<div className="flex-1 min-w-0">
|
style={{
|
||||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-4">
|
marginTop: audioUrl ? 12 : 0,
|
||||||
<div className="flex items-center gap-2 mb-4">
|
borderRadius: 8,
|
||||||
<span className="bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
overflow: 'hidden',
|
||||||
Câu {currentQ + 1}
|
border: '1px solid var(--at-line)',
|
||||||
</span>
|
aspectRatio: '16 / 9',
|
||||||
<span className="text-xs text-slate-400">Part {partId} — {partName}</span>
|
background: '#d8d4c9',
|
||||||
</div>
|
}}
|
||||||
<p className="text-base font-medium text-slate-800 leading-relaxed mb-6">
|
|
||||||
{question.text}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{question.options.map((opt, i) => {
|
|
||||||
const selected = answers[currentQ] === i
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setAnswer(currentQ, i)}
|
|
||||||
className={cn(
|
|
||||||
'w-full flex items-center gap-3 p-4 border-2 rounded-xl text-sm font-medium text-left transition-all',
|
|
||||||
selected
|
|
||||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
|
||||||
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
|
||||||
selected ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{ANSWER_LABELS[i]}
|
|
||||||
</span>
|
|
||||||
{opt}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentQ((q) => Math.max(0, q - 1))}
|
|
||||||
disabled={currentQ === 0}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
|
||||||
Câu trước
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-slate-400 tabular-nums">{currentQ + 1} / {questions.length}</span>
|
|
||||||
{currentQ < questions.length - 1 ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentQ((q) => q + 1)}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Câu tiếp theo
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl text-sm font-semibold hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Nộp bài
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — desktop only */}
|
|
||||||
<div className="hidden lg:flex flex-col gap-4 w-60 flex-shrink-0">
|
|
||||||
{/* Timer */}
|
|
||||||
<div className="bg-white rounded-2xl p-5 border border-slate-200 text-center">
|
|
||||||
<div className="text-xs text-slate-400 font-medium mb-2">Thời gian còn lại</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-5xl font-extrabold tabular-nums mb-1',
|
|
||||||
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatTime(timeLeft)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400">phút : giây</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question dots */}
|
|
||||||
<div className="bg-white rounded-2xl p-5 border border-slate-200">
|
|
||||||
<div className="text-xs text-slate-400 font-medium mb-3">
|
|
||||||
Danh sách câu · {answeredCount}/{questions.length} đã trả lời
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{questions.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setCurrentQ(i)}
|
|
||||||
className={cn(
|
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-semibold transition-all',
|
|
||||||
i === currentQ
|
|
||||||
? 'border-2 border-blue-600 text-blue-600 shadow-sm shadow-blue-600/20'
|
|
||||||
: answers[i] !== null
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'border-2 border-slate-200 text-slate-400 hover:border-blue-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-4 text-xs text-slate-400">
|
|
||||||
<span className="w-4 h-4 rounded-full bg-blue-600 inline-block" /> Đã trả lời
|
|
||||||
<span className="w-4 h-4 rounded-full border-2 border-slate-200 inline-block" /> Chưa làm
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="w-full py-3 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
|
||||||
Nộp bài
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile submit */}
|
|
||||||
<div className="lg:hidden mt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="w-full py-3.5 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
Nộp bài ngay
|
<img src={imageUrl} alt="" className="w-full h-full object-contain" />
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!audioUrl && !imageUrl && (
|
||||||
|
// No audio/image → text-only passage (Part 6/7)
|
||||||
|
<div>{passageText}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(audioUrl || imageUrl) && passageText && (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--at-mute)' }}>{passageText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioPlaceholder({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '14px 16px',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="grid place-items-center"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--at-brand)',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play size={14} fill="white" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--at-mute)',
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
background: 'var(--at-line)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'block', width: 0, height: '100%' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuestionBlock({
|
||||||
|
question, globalNum, answer, onSelect, isFirst, registerRef,
|
||||||
|
}: {
|
||||||
|
question: Question
|
||||||
|
globalNum: number
|
||||||
|
answer: number | null
|
||||||
|
onSelect: (idx: number) => void
|
||||||
|
isFirst: boolean
|
||||||
|
registerRef: (el: HTMLDivElement | null) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={registerRef}
|
||||||
|
data-qid={question.id}
|
||||||
|
style={{
|
||||||
|
padding: '16px 0',
|
||||||
|
borderTop: isFirst ? 'none' : '1px solid var(--at-line)',
|
||||||
|
paddingTop: isFirst ? 4 : 16,
|
||||||
|
scrollMarginTop: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<span
|
||||||
|
className="inline-block"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-brand-soft)',
|
||||||
|
color: 'var(--at-brand)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Câu {globalNum}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{question.text && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
marginBottom: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{question.options.map((opt, i) => {
|
||||||
|
const letter = ANSWER_LABELS[i]
|
||||||
|
const isSelected = answer === i
|
||||||
|
const hideText = !question.text && LETTER_PLACEHOLDER_RE.test(opt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
className={cn('flex items-center gap-3 text-left transition-all')}
|
||||||
|
style={{
|
||||||
|
padding: hideText ? '14px 18px' : '12px 16px',
|
||||||
|
minHeight: hideText ? 44 : undefined,
|
||||||
|
background: isSelected ? 'var(--at-brand-soft)' : 'var(--at-surface)',
|
||||||
|
border: `1px solid ${isSelected ? 'var(--at-brand)' : 'var(--at-line)'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="grid place-items-center flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: isSelected ? 'var(--at-brand)' : 'var(--at-surface)',
|
||||||
|
border: `1px solid ${isSelected ? 'var(--at-brand)' : 'var(--at-line)'}`,
|
||||||
|
color: isSelected ? '#fff' : 'var(--at-ink-2)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
{!hideText && <span>{opt}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({
|
||||||
|
group, globalOffset, startIndex, answers, onSelect, registerQuestionRef,
|
||||||
|
}: {
|
||||||
|
group: QuestionGroup
|
||||||
|
globalOffset: number
|
||||||
|
startIndex: number
|
||||||
|
answers: Record<number, number | null>
|
||||||
|
onSelect: (qid: number, idx: number) => void
|
||||||
|
registerQuestionRef: (qid: number, el: HTMLDivElement | null) => void
|
||||||
|
}) {
|
||||||
|
const hasAudio = !!group.audioUrl
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '24px 28px',
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasAudio && !group.imageUrl && !group.passageText && (
|
||||||
|
<AudioPlaceholder label={`Audio · Câu ${globalOffset + startIndex + 1}`} />
|
||||||
|
)}
|
||||||
|
<PassageBlock group={group} />
|
||||||
|
|
||||||
|
{group.questions.map((q, i) => (
|
||||||
|
<QuestionBlock
|
||||||
|
key={q.id}
|
||||||
|
question={q}
|
||||||
|
globalNum={globalOffset + startIndex + i + 1}
|
||||||
|
answer={answers[q.id] ?? null}
|
||||||
|
onSelect={(idx) => onSelect(q.id, idx)}
|
||||||
|
isFirst={i === 0 && !hasAudio && !group.imageUrl && !group.passageText}
|
||||||
|
registerRef={(el) => registerQuestionRef(q.id, el)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestSession() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
testName, parts, currentPartIndex, answers, totalSeconds,
|
||||||
|
setAnswer, setCurrentPart, submitExam,
|
||||||
|
} = useTestStore()
|
||||||
|
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||||
|
const [timeLeft, setTimeLeft] = useState(() => (totalSeconds > 0 ? totalSeconds : -1))
|
||||||
|
const [timeUsed, setTimeUsed] = useState(0)
|
||||||
|
|
||||||
|
// Map of questionId → DOM node, so sidebar clicks can scroll to a specific question.
|
||||||
|
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
|
// When jumping to a question in another part, we switch part first, then scroll
|
||||||
|
// after the new part renders. `pendingScrollQid` holds the target until mount.
|
||||||
|
const [pendingScrollQid, setPendingScrollQid] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const registerQuestionRef = useCallback((qid: number, el: HTMLDivElement | null) => {
|
||||||
|
if (el) questionRefs.current.set(qid, el)
|
||||||
|
else questionRefs.current.delete(qid)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function scrollToQuestion(qid: number) {
|
||||||
|
const el = questionRefs.current.get(qid)
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToQuestion(qid: number) {
|
||||||
|
// Find part that owns this question.
|
||||||
|
const targetPartIndex = parts.findIndex(p => p.questions.some(q => q.id === qid))
|
||||||
|
if (targetPartIndex === -1) return
|
||||||
|
if (targetPartIndex !== currentPartIndex) {
|
||||||
|
setCurrentPart(targetPartIndex)
|
||||||
|
setPendingScrollQid(qid)
|
||||||
|
} else {
|
||||||
|
scrollToQuestion(qid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a part change, if we have a pending scroll target, run it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingScrollQid === null) return
|
||||||
|
// Wait one frame so refs are registered for the newly mounted part.
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
scrollToQuestion(pendingScrollQid)
|
||||||
|
setPendingScrollQid(null)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [currentPartIndex, pendingScrollQid])
|
||||||
|
|
||||||
|
const totalQuestions = useMemo(
|
||||||
|
() => parts.reduce((sum, p) => sum + p.questions.length, 0),
|
||||||
|
[parts],
|
||||||
|
)
|
||||||
|
const answeredCount = useMemo(
|
||||||
|
() => Object.values(answers).filter((v) => v !== null && v !== undefined).length,
|
||||||
|
[answers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
|
||||||
|
navigate({ to: '/toeic/result' })
|
||||||
|
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
|
||||||
|
|
||||||
|
// Manual click → confirm if any unanswered; auto (timer expire) skips confirm.
|
||||||
|
const requestSubmit = useCallback(() => {
|
||||||
|
if (totalQuestions > 0 && answeredCount < totalQuestions) {
|
||||||
|
setShowSubmitConfirm(true)
|
||||||
|
} else {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}, [totalQuestions, answeredCount, handleSubmit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parts.length === 0) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (timeLeft > 0) {
|
||||||
|
setTimeLeft((t) => {
|
||||||
|
if (t <= 1) {
|
||||||
|
clearInterval(id)
|
||||||
|
handleSubmit()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t - 1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTimeUsed((t) => t + 1)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [parts.length, timeLeft, handleSubmit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
if (!isAuthenticated || parts.length === 0) navigate({ to: '/toeic' })
|
||||||
|
}, [isLoading, isAuthenticated, parts.length, navigate])
|
||||||
|
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
|
||||||
|
const currentPart = parts[currentPartIndex]
|
||||||
|
|
||||||
|
let globalOffset = 0
|
||||||
|
for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length
|
||||||
|
|
||||||
|
const groups = groupByGroupId(currentPart.questions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - var(--app-header-height, 0px))',
|
||||||
|
background: 'var(--at-paper-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TestSessionHeader
|
||||||
|
testName={testName}
|
||||||
|
timeLeft={timeLeft}
|
||||||
|
timeUsed={timeUsed}
|
||||||
|
totalQuestions={totalQuestions}
|
||||||
|
answeredCount={answeredCount}
|
||||||
|
onSubmit={requestSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<TestSessionSidebar
|
||||||
|
parts={parts}
|
||||||
|
currentPartIndex={currentPartIndex}
|
||||||
|
answers={answers}
|
||||||
|
onSelectPart={setCurrentPart}
|
||||||
|
onSelectQuestion={jumpToQuestion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
style={{ padding: '24px 32px 80px' }}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full" style={{ maxWidth: 880 }}>
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--at-mute)',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đang làm
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '-0.015em',
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Part {currentPart.partNumber}:{' '}
|
||||||
|
<i style={{ fontStyle: 'italic', color: 'var(--at-ink-2)' }}>
|
||||||
|
{currentPart.partName}
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px dashed var(--at-line)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 40,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--at-mute)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Part này chưa có dữ liệu.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.map((group) => {
|
||||||
|
let startIndex = 0
|
||||||
|
for (const g of groups) {
|
||||||
|
if (g === group) break
|
||||||
|
startIndex += g.questions.length
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<GroupCard
|
||||||
|
key={group.groupId}
|
||||||
|
group={group}
|
||||||
|
globalOffset={globalOffset}
|
||||||
|
startIndex={startIndex}
|
||||||
|
answers={answers}
|
||||||
|
onSelect={setAnswer}
|
||||||
|
registerQuestionRef={registerQuestionRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TestSessionFooter
|
||||||
|
currentPartIndex={currentPartIndex}
|
||||||
|
totalParts={parts.length}
|
||||||
|
currentPartName={currentPart.partName}
|
||||||
|
onPrev={() => setCurrentPart(currentPartIndex - 1)}
|
||||||
|
onNext={() => setCurrentPart(currentPartIndex + 1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showSubmitConfirm && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center px-4"
|
||||||
|
style={{ background: 'rgba(15, 17, 20, 0.5)' }}
|
||||||
|
onClick={() => setShowSubmitConfirm(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl p-6 shadow-2xl"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 22, fontWeight: 500, letterSpacing: '-0.02em', color: 'var(--at-ink)', marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
Nộp bài <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>ngay?</i>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--at-mute)', lineHeight: 1.55, marginBottom: 20 }}>
|
||||||
|
Bạn còn{' '}
|
||||||
|
<b style={{ color: 'var(--at-bad)' }}>{totalQuestions - answeredCount}</b>/{totalQuestions} câu
|
||||||
|
chưa trả lời. Các câu chưa trả lời sẽ tính là{' '}
|
||||||
|
<b>bỏ qua</b> và không có điểm.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSubmitConfirm(false)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--at-line-2)]"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
Tiếp tục làm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSubmitConfirm(false); handleSubmit() }}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-semibold text-white transition-[filter] hover:brightness-110"
|
||||||
|
style={{ background: '#e53935' }}
|
||||||
|
>
|
||||||
|
Nộp bài
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
94
src/features/toeic/components/TestSessionFooter.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPartIndex: number
|
||||||
|
totalParts: number
|
||||||
|
currentPartName: string
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestSessionFooter({
|
||||||
|
currentPartIndex, totalParts, currentPartName, onPrev, onNext,
|
||||||
|
}: Props) {
|
||||||
|
const isFirst = currentPartIndex === 0
|
||||||
|
const isLast = currentPartIndex === totalParts - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="grid items-center flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '1fr auto 1fr',
|
||||||
|
padding: '12px 24px',
|
||||||
|
gap: 16,
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
borderTop: '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={isFirst}
|
||||||
|
className="inline-flex items-center gap-1.5 transition-colors disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
justifySelf: 'start',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
color: 'var(--at-ink-2)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: isFirst ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Part trước
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--at-ink-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-paper-2)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontFamily: 'var(--at-sans)',
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Part {currentPartIndex + 1} / {totalParts}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--at-mute)' }}>— {currentPartName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={isLast}
|
||||||
|
className="inline-flex items-center gap-1.5 transition-[filter] hover:brightness-110 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
justifySelf: 'end',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--at-brand)',
|
||||||
|
border: '1px solid var(--at-brand)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: isLast ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Part tiếp theo
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/features/toeic/components/TestSessionHeader.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
testName: string
|
||||||
|
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
|
||||||
|
timeUsed: number // seconds elapsed (used when no limit)
|
||||||
|
totalQuestions: number
|
||||||
|
answeredCount: number
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestSessionHeader({
|
||||||
|
testName, timeLeft, timeUsed, totalQuestions, answeredCount, onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
const isUnlimited = timeLeft === -1
|
||||||
|
const displaySeconds = isUnlimited ? timeUsed : timeLeft
|
||||||
|
const isUrgent = !isUnlimited && timeLeft < 300
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="grid items-center flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '1fr auto 1fr',
|
||||||
|
padding: '12px 24px',
|
||||||
|
gap: 16,
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
borderBottom: '1px solid var(--at-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5" style={{ justifySelf: 'start' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: 'var(--at-mute)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Phiên thi
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--at-ink)' }}>
|
||||||
|
{testName} ·{' '}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--at-brand)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{answeredCount}/{totalQuestions}
|
||||||
|
</span>{' '}
|
||||||
|
câu
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="tabular-nums text-center"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-mono)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
color: isUrgent ? 'var(--at-bad)' : 'var(--at-brand)',
|
||||||
|
animation: isUrgent ? 'pulse 1s infinite' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUnlimited ? (
|
||||||
|
<span style={{ color: 'var(--at-mute-2)', fontSize: 16 }}>∞</span>
|
||||||
|
) : (
|
||||||
|
formatTime(displaySeconds)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="inline-flex items-center gap-1.5 transition-[filter] hover:brightness-110"
|
||||||
|
style={{
|
||||||
|
justifySelf: 'end',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#e53935',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={14} strokeWidth={2.5} />
|
||||||
|
Nộp bài
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/features/toeic/components/TestSessionSidebar.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { SessionPart } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
parts: SessionPart[]
|
||||||
|
currentPartIndex: number
|
||||||
|
answers: Record<number, number | null>
|
||||||
|
onSelectPart: (index: number) => void
|
||||||
|
onSelectQuestion: (questionId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestSessionSidebar({
|
||||||
|
parts, currentPartIndex, answers, onSelectPart, onSelectQuestion,
|
||||||
|
}: Props) {
|
||||||
|
// Global question offset per part for sequential numbering
|
||||||
|
let offset = 0
|
||||||
|
const partOffsets: number[] = parts.map((p) => {
|
||||||
|
const o = offset
|
||||||
|
offset += p.questions.length
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
width: 240,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
borderRight: '1px solid var(--at-line)',
|
||||||
|
padding: '18px 16px 40px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--at-mute)',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Question Map
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parts.map((part, partIdx) => {
|
||||||
|
const isCurrent = partIdx === currentPartIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={part.partNumber} style={{ marginBottom: 18 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectPart(partIdx)}
|
||||||
|
className="text-left w-full"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 13,
|
||||||
|
color: isCurrent ? 'var(--at-brand)' : 'var(--at-ink-2)',
|
||||||
|
marginBottom: 8,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Part {part.partNumber}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{part.questions.map((q, qIdx) => {
|
||||||
|
const globalNum = partOffsets[partIdx] + qIdx + 1
|
||||||
|
const answered = answers[q.id] !== null && answers[q.id] !== undefined
|
||||||
|
// Soft brand-tinted border for all unanswered cells.
|
||||||
|
const unansweredBorder = 'rgba(84, 114, 158, 0.35)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => onSelectQuestion(q.id)}
|
||||||
|
title={`Câu ${globalNum}`}
|
||||||
|
className={cn(
|
||||||
|
'tabular-nums transition-all aspect-square',
|
||||||
|
'hover:border-[var(--at-ink-2)]',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${answered ? 'var(--at-brand)' : unansweredBorder}`,
|
||||||
|
background: answered ? 'var(--at-brand)' : 'var(--at-surface)',
|
||||||
|
color: answered ? '#fff' : 'var(--at-ink-2)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{globalNum}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
|
|||||||
import { CircularProgress } from '@/components/CircularProgress'
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { TOEIC_PARTS } from '@/temp/local-data'
|
import { TOEIC_PARTS } from '@/temp/local-data'
|
||||||
import { fetchQuestions } from '@/hooks/use-questions'
|
import { fetchQuestionsForTest } from '@/hooks/use-questions'
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
|
||||||
export function ToeicPractice() {
|
export function ToeicPractice() {
|
||||||
@@ -16,8 +16,9 @@ export function ToeicPractice() {
|
|||||||
if (!requireAuth()) return
|
if (!requireAuth()) return
|
||||||
setLoadingPartId(partId)
|
setLoadingPartId(partId)
|
||||||
try {
|
try {
|
||||||
const questions = await fetchQuestions(partId, 10)
|
// TODO: replace hardcoded testId=1 with real test selection
|
||||||
startExam(partId, partName, questions)
|
const parts = await fetchQuestionsForTest(1, [partId])
|
||||||
|
startExam({ testId: 1, testName: partName, parts, totalSeconds: 0 })
|
||||||
navigate({ to: '/toeic/session' })
|
navigate({ to: '/toeic/session' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load questions:', err)
|
console.error('Failed to load questions:', err)
|
||||||
@@ -27,7 +28,7 @@ export function ToeicPractice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
<div className="px-6 py-8 page-enter">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
||||||
<p className="text-slate-500">
|
<p className="text-slate-500">
|
||||||
|
|||||||
624
src/features/toeic/components/ToeicTestDetail.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ArrowRight, Check, Clock, Sparkles, Target } from 'lucide-react'
|
||||||
|
import { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
|
||||||
|
import { fetchQuestionsForTest } from '@/hooks/use-questions'
|
||||||
|
import { useTestStore } from '@/store/test-store'
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
import type { PartRecord } from '@/types'
|
||||||
|
|
||||||
|
interface Props { testId: number }
|
||||||
|
|
||||||
|
// TOEIC part metadata (stable across all tests)
|
||||||
|
const PART_META: Record<number, { subtitle: string; desc: string; skill: 'listening' | 'reading' }> = {
|
||||||
|
1: { subtitle: 'Photographs', desc: 'Mô tả hình ảnh', skill: 'listening' },
|
||||||
|
2: { subtitle: 'Question-Response', desc: 'Hỏi – đáp', skill: 'listening' },
|
||||||
|
3: { subtitle: 'Conversations', desc: 'Hội thoại ngắn', skill: 'listening' },
|
||||||
|
4: { subtitle: 'Short Talks', desc: 'Bài nói ngắn', skill: 'listening' },
|
||||||
|
5: { subtitle: 'Incomplete Sentences', desc: 'Ngữ pháp câu', skill: 'reading' },
|
||||||
|
6: { subtitle: 'Text Completion', desc: 'Điền vào đoạn văn', skill: 'reading' },
|
||||||
|
7: { subtitle: 'Reading Comprehension', desc: 'Đọc hiểu', skill: 'reading' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = ['Tất cả', 'Listening', 'Reading', 'Chưa làm', 'Cần ôn'] as const
|
||||||
|
type Tab = (typeof TABS)[number]
|
||||||
|
|
||||||
|
function Ring({ percent, size = 56, stroke = 5, color }: {
|
||||||
|
percent: number; size?: number; stroke?: number; color: string
|
||||||
|
}) {
|
||||||
|
const cx = size / 2
|
||||||
|
const r = cx - stroke
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const offset = c - (Math.min(percent, 100) / 100) * c
|
||||||
|
return (
|
||||||
|
<div className="relative grid place-items-center flex-shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg className="-rotate-90" width={size} height={size}>
|
||||||
|
<circle cx={cx} cy={cx} r={r} fill="none" stroke="var(--at-line)" strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cx} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
className="absolute tabular-nums"
|
||||||
|
style={{ fontSize: 13, fontWeight: 700, color: 'var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{percent}
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 500, color: 'var(--at-mute)' }}>%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusChip({ done, fresh }: { done: boolean; fresh: boolean }) {
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--at-good-soft)',
|
||||||
|
color: 'var(--at-good-ink)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={11} strokeWidth={3} />
|
||||||
|
Hoàn thành
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (fresh) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--at-line-2)',
|
||||||
|
color: 'var(--at-ink-3)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mới
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--at-warm-soft)',
|
||||||
|
color: 'var(--at-warm)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đang làm
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartCard({ part, selected, onToggle, disabled }: {
|
||||||
|
part: PartRecord; selected: boolean; onToggle: () => void; disabled: boolean
|
||||||
|
}) {
|
||||||
|
const meta = PART_META[part.partNumber] ?? { subtitle: part.title, desc: '', skill: 'reading' as const }
|
||||||
|
// Progress data not yet available in API — render as fresh.
|
||||||
|
const done = 0
|
||||||
|
const score = 0
|
||||||
|
const pct = part.questionCount > 0 ? Math.round((done / part.questionCount) * 100) : 0
|
||||||
|
const isDone = done === part.questionCount && part.questionCount > 0
|
||||||
|
const isFresh = done === 0
|
||||||
|
const ringColor = isDone
|
||||||
|
? 'var(--at-good)'
|
||||||
|
: isFresh
|
||||||
|
? 'var(--at-mute-2)'
|
||||||
|
: 'var(--at-brand)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className="text-left transition-all hover:-translate-y-0.5 disabled:opacity-60 relative"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: `1px solid ${selected ? 'var(--at-brand)' : 'var(--at-line)'}`,
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 20,
|
||||||
|
boxShadow: selected
|
||||||
|
? '0 0 0 1px var(--at-brand), 0 20px 40px -16px rgba(61,75,215,0.25)'
|
||||||
|
: '0 1px 2px rgba(15,17,20,0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Checker — top-right corner */}
|
||||||
|
<span
|
||||||
|
className="absolute grid place-items-center"
|
||||||
|
style={{
|
||||||
|
top: 14,
|
||||||
|
right: 14,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: selected ? 'var(--at-brand)' : 'var(--at-surface)',
|
||||||
|
border: `1.5px solid ${selected ? 'var(--at-brand)' : 'var(--at-line)'}`,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && <Check size={13} strokeWidth={3} color="white" />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-4" style={{ paddingRight: 30 }}>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--at-brand)',
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Part {part.partNumber}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.subtitle}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', marginTop: 6 }}>
|
||||||
|
{meta.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Ring percent={pct} color={ringColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'var(--at-line)', margin: '12px 0' }} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10, color: 'var(--at-mute)',
|
||||||
|
letterSpacing: '0.1em', fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Câu hỏi
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums" style={{ fontSize: 16, fontWeight: 700, color: 'var(--at-ink)' }}>
|
||||||
|
{done}/{part.questionCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10, color: 'var(--at-mute)',
|
||||||
|
letterSpacing: '0.1em', fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Điểm
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontSize: 16, fontWeight: 700,
|
||||||
|
color: isFresh
|
||||||
|
? 'var(--at-mute)'
|
||||||
|
: score >= 80
|
||||||
|
? 'var(--at-good)'
|
||||||
|
: score >= 60
|
||||||
|
? 'var(--at-ink)'
|
||||||
|
: 'var(--at-bad)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFresh ? '—' : score}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusChip done={isDone} fresh={isFresh} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6, background: 'var(--at-line-2)',
|
||||||
|
borderRadius: 999, overflow: 'hidden', position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: '0 auto 0 0',
|
||||||
|
background: 'var(--at-brand)',
|
||||||
|
borderRadius: 999,
|
||||||
|
width: `${pct}%`,
|
||||||
|
transition: 'width 0.5s cubic-bezier(0.2,0.7,0.2,1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AiGeneratedCard({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="text-left relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-ink)',
|
||||||
|
color: 'var(--at-paper)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 20,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: -30, right: -30,
|
||||||
|
width: 140, height: 140, borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(124,139,250,0.2), transparent 60%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5 uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5, fontWeight: 700,
|
||||||
|
color: '#A9B3FA', letterSpacing: '0.14em', marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={12} />
|
||||||
|
AI Generated
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontSize: 22, fontWeight: 400,
|
||||||
|
letterSpacing: '-0.02em', lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đề thi <i style={{ color: '#A9B3FA' }}>cá nhân hóa</i>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: 'rgba(250,248,243,0.65)',
|
||||||
|
marginTop: 6, marginBottom: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AI tạo đề dựa trên điểm yếu của bạn. Mỗi lần mỗi khác.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center"
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 999,
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
color: 'var(--at-paper)', fontSize: 11, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
20 câu · 15 phút
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(250,248,243,0.5)' }}>Miễn phí</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-0 bottom-0">
|
||||||
|
<ArrowRight size={20} style={{ color: 'var(--at-paper)', opacity: 0.8 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToeicTestDetail({ testId }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { startExam } = useTestStore()
|
||||||
|
const { requireAuth } = useRequireAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('Tất cả')
|
||||||
|
const [selectedParts, setSelectedParts] = useState<number[]>([])
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState(30)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['test-detail', testId],
|
||||||
|
queryFn: () => fetchTestWithParts(testId),
|
||||||
|
})
|
||||||
|
|
||||||
|
function togglePart(partNumber: number) {
|
||||||
|
setSelectedParts(prev =>
|
||||||
|
prev.includes(partNumber) ? prev.filter(p => p !== partNumber) : [...prev, partNumber],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
setSelectedParts([])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStart(
|
||||||
|
mode: 'full' | 'short' | 'custom',
|
||||||
|
partNumbers?: number[],
|
||||||
|
minutes?: number,
|
||||||
|
) {
|
||||||
|
if (!requireAuth()) return
|
||||||
|
if (!data) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const parts = await fetchQuestionsForTest(testId, partNumbers)
|
||||||
|
const totalSeconds = mode === 'full'
|
||||||
|
? data.test.durationMinutes * 60
|
||||||
|
: mode === 'short'
|
||||||
|
? 20 * 60
|
||||||
|
: (minutes ?? 30) * 60
|
||||||
|
startExam({ testId, testName: data.test.title, parts, totalSeconds })
|
||||||
|
navigate({ to: '/toeic/session' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredParts = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const all = data.parts
|
||||||
|
if (activeTab === 'Tất cả') return all
|
||||||
|
if (activeTab === 'Listening') return all.filter(p => PART_META[p.partNumber]?.skill === 'listening')
|
||||||
|
if (activeTab === 'Reading') return all.filter(p => PART_META[p.partNumber]?.skill === 'reading')
|
||||||
|
// "Chưa làm" / "Cần ôn" — no progress data yet, show all
|
||||||
|
return all
|
||||||
|
}, [data, activeTab])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-10 py-10">
|
||||||
|
<div
|
||||||
|
className="animate-pulse rounded h-10 mb-6"
|
||||||
|
style={{ background: 'var(--at-line-2)', width: 280 }}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="animate-pulse"
|
||||||
|
style={{ height: 220, borderRadius: 18, background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
const { test, parts } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
|
{/* Editorial head */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)',
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
lineHeight: 1.05,
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chọn <i style={{ color: 'var(--at-brand)', fontWeight: 400 }}>phần</i> bạn muốn luyện
|
||||||
|
</h1>
|
||||||
|
<p style={{ marginTop: 12, fontSize: 13, color: 'var(--at-mute)' }}>
|
||||||
|
{parts.length} phần thi · {test.totalQuestions} câu hỏi · đầy đủ Listening + Reading
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStart('short')}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 transition-colors hover:bg-[var(--at-line-2)] disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
padding: '10px 18px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
color: 'var(--at-ink-2)',
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={14} /> Đề ngắn 20 phút
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStart('full')}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 transition-all hover:brightness-110 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
padding: '10px 18px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--at-ink)',
|
||||||
|
color: 'var(--at-paper)',
|
||||||
|
border: '1px solid var(--at-ink)',
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Target size={14} /> Thi thử đầy đủ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div
|
||||||
|
className="flex gap-2 mb-6"
|
||||||
|
style={{ borderBottom: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
{TABS.map(t => {
|
||||||
|
const active = activeTab === t
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
color: active ? 'var(--at-brand)' : 'var(--at-mute)',
|
||||||
|
borderBottom: active ? '2px solid var(--at-brand)' : '2px solid transparent',
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Part grid */}
|
||||||
|
<div
|
||||||
|
className="grid gap-5"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
paddingBottom: selectedParts.length > 0 ? 96 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredParts.map(part => (
|
||||||
|
<PartCard
|
||||||
|
key={part.partNumber}
|
||||||
|
part={part}
|
||||||
|
selected={selectedParts.includes(part.partNumber)}
|
||||||
|
onToggle={() => togglePart(part.partNumber)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeTab === 'Tất cả' && (
|
||||||
|
<AiGeneratedCard onClick={() => handleStart('short')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky selection bar — full viewport width */}
|
||||||
|
{selectedParts.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 right-0 left-0 z-30 flex items-center justify-between gap-4 px-6 lg:px-10 py-4"
|
||||||
|
style={{
|
||||||
|
background: 'color-mix(in oklab, var(--at-paper) 92%, transparent)',
|
||||||
|
borderTop: '1px solid var(--at-line)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="grid place-items-center tabular-nums"
|
||||||
|
style={{
|
||||||
|
width: 34, height: 34, borderRadius: 10,
|
||||||
|
background: 'var(--at-brand-soft)',
|
||||||
|
color: 'var(--at-brand)',
|
||||||
|
fontWeight: 700, fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedParts.length}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--at-ink)' }}>
|
||||||
|
Đã chọn {selectedParts.length} phần
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--at-mute)' }}>
|
||||||
|
{data?.parts
|
||||||
|
.filter(p => selectedParts.includes(p.partNumber))
|
||||||
|
.reduce((sum, p) => sum + p.questionCount, 0)}{' '}
|
||||||
|
câu hỏi
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
color: 'var(--at-ink-2)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={14} style={{ color: 'var(--at-mute)' }} />
|
||||||
|
<span style={{ color: 'var(--at-mute)' }}>Thời gian</span>
|
||||||
|
<select
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(e) => setDurationMinutes(Number(e.target.value))}
|
||||||
|
className="tabular-nums outline-none"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 19 }, (_, i) => 20 + i * 10).map(m => (
|
||||||
|
<option key={m} value={m}>{m} phút</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
disabled={loading}
|
||||||
|
className="transition-colors hover:bg-[var(--at-line-2)]"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
color: 'var(--at-ink-2)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bỏ chọn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStart('custom', [...selectedParts].sort((a, b) => a - b), durationMinutes)}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 transition-[filter] hover:brightness-110 disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--at-brand)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Target size={14} />
|
||||||
|
Bắt đầu luyện
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
src/features/toeic/components/ToeicTestList.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchTests } from '@/features/toeic/api/test-list-api'
|
||||||
|
|
||||||
|
export function ToeicTestList() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: tests = [], isLoading, error } = useQuery({
|
||||||
|
queryKey: ['tests'],
|
||||||
|
queryFn: fetchTests,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
|
{/* Editorial head */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
|
<div>
|
||||||
|
<div className="at-eyebrow mb-3">Luyện đề</div>
|
||||||
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
|
TOEIC <i>Mock Tests</i>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Chọn đề để bắt đầu luyện tập — {tests.length} đề thi
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid gap-6" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-2xl h-64 animate-pulse"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-6 text-sm"
|
||||||
|
style={{ background: 'var(--at-bad-soft)', border: '1px solid rgba(193,68,62,0.2)', color: 'var(--at-bad)' }}
|
||||||
|
>
|
||||||
|
Không thể tải danh sách đề thi. Vui lòng thử lại.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && tests.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-16 text-center"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined mb-3 block" style={{ fontSize: 48, color: 'var(--at-mute-2)' }}>
|
||||||
|
library_books
|
||||||
|
</span>
|
||||||
|
<p className="at-serif text-lg" style={{ color: 'var(--at-ink)' }}>Chưa có đề thi nào.</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: 'var(--at-mute)' }}>Dữ liệu đang được cập nhật.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tests.length > 0 && (
|
||||||
|
<div className="grid gap-6" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
||||||
|
{tests.map((test) => (
|
||||||
|
<div
|
||||||
|
key={test.id}
|
||||||
|
className="rounded-3xl flex flex-col transition-all hover:-translate-y-1 hover:shadow-[0_20px_40px_-16px_rgba(15,17,20,0.12)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
padding: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{test.categoryName && (
|
||||||
|
<span className="at-chip at-chip-brand self-start mb-5">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
{test.categoryName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="at-serif tracking-tight mb-3"
|
||||||
|
style={{
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{test.title}
|
||||||
|
</h3>
|
||||||
|
{test.description && (
|
||||||
|
<p
|
||||||
|
className="line-clamp-2 mb-6"
|
||||||
|
style={{ color: 'var(--at-mute)', fontSize: 14, lineHeight: 1.55 }}
|
||||||
|
>
|
||||||
|
{test.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-6 mt-auto mb-6"
|
||||||
|
style={{ color: 'var(--at-mute)', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>list_alt</span>
|
||||||
|
<b
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{test.totalQuestions}
|
||||||
|
</b>
|
||||||
|
câu
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>timer</span>
|
||||||
|
<b
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{test.durationMinutes}
|
||||||
|
</b>
|
||||||
|
phút
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
|
||||||
|
className="w-full rounded-xl font-semibold transition-opacity hover:opacity-90"
|
||||||
|
style={{
|
||||||
|
background: 'var(--at-ink)',
|
||||||
|
color: 'var(--at-paper)',
|
||||||
|
padding: '14px 20px',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bắt đầu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,10 +12,37 @@ const MAX_CHARS = 1000
|
|||||||
const GUEST_LIMIT = 3
|
const GUEST_LIMIT = 3
|
||||||
const AUTH_LIMIT = 10
|
const AUTH_LIMIT = 10
|
||||||
|
|
||||||
|
// Extract a string field from partial JSON stream
|
||||||
|
function extractTextField(partial: string, field: string): string {
|
||||||
|
const m = partial.match(new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`))
|
||||||
|
if (!m) return ''
|
||||||
|
return m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract score from partial JSON stream as soon as the field is complete
|
||||||
|
function extractScore(partial: string): string | null {
|
||||||
|
const m = partial.match(/"score"\s*:\s*"([^"]+)"/)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract completed array items from a partial JSON array field
|
||||||
|
function extractArrayField(partial: string, field: string): string[] {
|
||||||
|
const m = partial.match(new RegExp(`"${field}"\\s*:\\s*\\[([^\\]]*)`))
|
||||||
|
if (!m) return []
|
||||||
|
const items: string[] = []
|
||||||
|
const re = /"((?:[^"\\]|\\.)*)"/g
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(m[1])) !== null) {
|
||||||
|
items.push(match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
export function WritingChecker() {
|
export function WritingChecker() {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
||||||
const [remaining, setRemaining] = useState(getRemainingChecks)
|
const [remaining, setRemaining] = useState(getRemainingChecks)
|
||||||
|
const [streamingText, setStreamingText] = useState('')
|
||||||
|
|
||||||
const { mutate: checkWriting, isPending, isError, error, data: feedback, reset: resetMutation } = useWritingCheck()
|
const { mutate: checkWriting, isPending, isError, error, data: feedback, reset: resetMutation } = useWritingCheck()
|
||||||
const { requireAuth } = useRequireAuth()
|
const { requireAuth } = useRequireAuth()
|
||||||
@@ -36,80 +63,136 @@ export function WritingChecker() {
|
|||||||
})
|
})
|
||||||
}, [user, resetMutation])
|
}, [user, resetMutation])
|
||||||
|
|
||||||
|
const streamingScore = isPending ? extractScore(streamingText) : null
|
||||||
|
const streamingGrammar = isPending ? extractArrayField(streamingText, 'grammar') : []
|
||||||
|
const streamingVocab = isPending ? extractArrayField(streamingText, 'vocabulary') : []
|
||||||
|
const streamingStructure = isPending ? extractTextField(streamingText, 'structure') : ''
|
||||||
|
const streamingSummary = isPending ? extractTextField(streamingText, 'summary') : ''
|
||||||
const charCount = text.length
|
const charCount = text.length
|
||||||
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!requireAuth()) return
|
if (!requireAuth()) return
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
checkWriting(text, {
|
setStreamingText('')
|
||||||
onSuccess: () => {
|
checkWriting(
|
||||||
if (user) {
|
{ content: text, onChunk: (chunk) => setStreamingText((prev) => prev + chunk) },
|
||||||
awardActivity({ xp: XP_REWARDS.writing })
|
{
|
||||||
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
|
onSuccess: () => {
|
||||||
} else {
|
setStreamingText('')
|
||||||
setRemaining(getRemainingChecks())
|
if (user) {
|
||||||
}
|
awardActivity({ xp: XP_REWARDS.writing })
|
||||||
|
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
|
||||||
|
} else {
|
||||||
|
setRemaining(getRemainingChecks())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setStreamingText('')
|
||||||
|
if (!user) setRemaining(getRemainingChecks())
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError: () => {
|
)
|
||||||
if (!user) setRemaining(getRemainingChecks())
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sentenceCount = text.split(/[.!?]+/).filter(s => s.trim()).length
|
||||||
|
const wordCount = text.split(/\s+/).filter(Boolean).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
<div className="mb-6">
|
{/* Editorial page head */}
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">AI Chấm Writing</h1>
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<p className="text-slate-500 text-sm">Nhận phản hồi tức thì về ngữ pháp, từ vựng và cấu trúc bài viết.</p>
|
<div className="min-w-0">
|
||||||
|
<div className="at-eyebrow mb-3 inline-flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>auto_awesome</span>
|
||||||
|
AI Writing Checker
|
||||||
|
</div>
|
||||||
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
|
Kiểm tra <i>bài viết</i>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
|
Dán bài viết — AI sẽ kiểm tra ngữ pháp, chính tả, và chấm điểm IELTS/TOEIC
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2.5 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-80"
|
||||||
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>mic</span>
|
||||||
|
Nhập bằng giọng nói
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity',
|
||||||
|
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||||
|
Đang chấm...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>auto_awesome</span>
|
||||||
|
Kiểm tra ngay
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-5">
|
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-5">
|
||||||
{/* Left: Input */}
|
{/* Left: Input */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
<div
|
||||||
<div className="flex items-center justify-between mb-3">
|
className="rounded-2xl overflow-hidden"
|
||||||
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
|
>
|
||||||
{charCount}/{MAX_CHARS}
|
<div
|
||||||
</span>
|
className="px-5 py-3.5 flex items-center justify-between"
|
||||||
|
style={{ background: 'var(--at-paper-2)', borderBottom: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="at-chip">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
Đề: Working from home
|
||||||
|
</span>
|
||||||
|
<span className="at-chip at-chip-brand">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
Essay · Band 6-7
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs tabular-nums" style={{ color: charCount > MAX_CHARS ? 'var(--at-bad)' : 'var(--at-mute)' }}>
|
||||||
|
{wordCount} từ · {sentenceCount} câu · {charCount}/{MAX_CHARS}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<div className="p-5">
|
||||||
value={text}
|
<textarea
|
||||||
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
|
value={text}
|
||||||
rows={12}
|
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
|
||||||
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
|
rows={12}
|
||||||
className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
|
dir="ltr"
|
||||||
/>
|
placeholder="Bắt đầu viết hoặc dán bài của bạn ở đây..."
|
||||||
<div className="mt-3 flex items-center justify-between">
|
className="w-full resize-none bg-transparent border-none outline-none"
|
||||||
<div className="flex items-center gap-1.5">
|
style={{
|
||||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>info</span>
|
fontFamily: 'var(--at-sans)',
|
||||||
<span className={cn('text-xs font-medium', remaining <= 1 ? 'text-red-500' : 'text-slate-400')}>
|
fontSize: 15,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
minHeight: 280,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14, color: 'var(--at-mute)' }}>info</span>
|
||||||
|
<span className="text-xs font-medium" style={{ color: remaining <= 1 ? 'var(--at-bad)' : 'var(--at-mute)' }}>
|
||||||
Còn {remaining}/{dailyLimit} lượt hôm nay
|
Còn {remaining}/{dailyLimit} lượt hôm nay
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
|
|
||||||
canSubmit
|
|
||||||
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
|
|
||||||
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<>
|
|
||||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
|
||||||
Đang chấm...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>auto_fix_high</span>
|
|
||||||
Chấm bài ngay
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,19 +216,115 @@ export function WritingChecker() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Feedback */}
|
{/* Right: Feedback */}
|
||||||
<div className="lg:w-80 flex-shrink-0">
|
<div className="flex flex-col gap-5">
|
||||||
{!feedback && !isPending && (
|
{!feedback && !isPending && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
<div className="at-tip">
|
||||||
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
|
<div className="at-tip-label">AI kiểm tra gì?</div>
|
||||||
<p className="text-sm text-slate-400">Nhập bài và nhấn "Chấm bài ngay" để nhận phản hồi từ AI</p>
|
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
|
||||||
|
Ngữ pháp · Chính tả · Từ vựng học thuật · Tính mạch lạc · Chấm điểm theo band IELTS/TOEIC.
|
||||||
|
Một bài TOEIC Writing band 7+ cần{' '}
|
||||||
|
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>ít nhất 250 từ</b> và sử dụng{' '}
|
||||||
|
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>3-4 linking words</b>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
<div className="space-y-3">
|
||||||
<div className="w-10 h-10 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin mb-4" />
|
{/* Score */}
|
||||||
<p className="text-sm text-slate-500 font-medium">AI đang phân tích bài viết...</p>
|
<div className="bg-blue-600 rounded-2xl p-5 text-center">
|
||||||
<p className="text-xs text-slate-400 mt-1">Thường mất 3–5 giây</p>
|
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ước tính</div>
|
||||||
|
{streamingScore ? (
|
||||||
|
<div className="text-5xl font-extrabold text-white mb-1">{streamingScore}</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-12 w-20 mx-auto bg-blue-500/40 rounded-xl animate-pulse mb-1" />
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
|
||||||
|
</div>
|
||||||
|
{/* Grammar */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
|
||||||
|
</div>
|
||||||
|
{streamingGrammar.length > 0 ? (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{streamingGrammar.map((item, i) => (
|
||||||
|
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||||
|
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[78, 92, 65].map((w, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<div className="w-3.5 h-3.5 mt-0.5 rounded-full bg-slate-100 animate-pulse flex-shrink-0" />
|
||||||
|
<div className="h-3 bg-slate-100 rounded animate-pulse" style={{ width: `${w}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Vocabulary */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
|
||||||
|
</div>
|
||||||
|
{streamingVocab.length > 0 ? (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{streamingVocab.map((item, i) => (
|
||||||
|
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||||
|
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Structure */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
|
||||||
|
</div>
|
||||||
|
{streamingStructure ? (
|
||||||
|
<p className="text-xs text-slate-600">{streamingStructure}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-3 bg-slate-100 rounded animate-pulse" />
|
||||||
|
<div className="h-3 bg-slate-100 rounded animate-pulse w-5/6" />
|
||||||
|
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{streamingSummary ? (
|
||||||
|
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4 bg-green-200 rounded animate-pulse" />
|
||||||
|
)}
|
||||||
|
{streamingSummary ? (
|
||||||
|
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-24 bg-green-200 rounded animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{streamingSummary ? (
|
||||||
|
<p className="text-xs text-slate-600">{streamingSummary}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-3 bg-green-100 rounded animate-pulse" />
|
||||||
|
<div className="h-3 bg-green-100 rounded animate-pulse w-3/4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
134
src/features/writing/components/WritingHistory.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useWritingHistory } from '@/hooks/use-writing-history'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import type { WritingSubmission } from '@/types'
|
||||||
|
|
||||||
|
function scoreColor(score: string) {
|
||||||
|
const n = parseFloat(score)
|
||||||
|
if (n >= 7) return 'bg-green-100 text-green-700'
|
||||||
|
if (n >= 5) return 'bg-amber-100 text-amber-700'
|
||||||
|
return 'bg-red-100 text-red-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const mins = Math.floor(diff / 60_000)
|
||||||
|
if (mins < 1) return 'vừa xong'
|
||||||
|
if (mins < 60) return `${mins} phút trước`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `${hours} giờ trước`
|
||||||
|
return `${Math.floor(hours / 24)} ngày trước`
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmissionCard({ item }: { item: WritingSubmission }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const fb = item.feedback
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full text-left p-4 flex items-start gap-3 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs font-bold px-2 py-1 rounded-lg flex-shrink-0 mt-0.5 ${scoreColor(fb?.score ?? '0')}`}>
|
||||||
|
{fb?.score ?? '–'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-slate-700 line-clamp-1">{item.content}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{relativeTime(item.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-slate-400 flex-shrink-0 mt-0.5" style={{ fontSize: 18 }}>
|
||||||
|
{open ? 'expand_less' : 'expand_more'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && fb && (
|
||||||
|
<div className="border-t border-slate-100 p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-2">Bài viết gốc</p>
|
||||||
|
<p className="text-xs text-slate-600 leading-relaxed whitespace-pre-wrap">{item.content}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fb.grammar?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-600 mb-1.5 flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" />
|
||||||
|
Ngữ pháp
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{fb.grammar.map((g, i) => (
|
||||||
|
<li key={i} className="text-xs text-slate-600 flex gap-1.5">
|
||||||
|
<span className="text-red-400 flex-shrink-0">•</span>{g}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fb.vocabulary?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-600 mb-1.5 flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 inline-block" />
|
||||||
|
Từ vựng
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{fb.vocabulary.map((v, i) => (
|
||||||
|
<li key={i} className="text-xs text-slate-600 flex gap-1.5">
|
||||||
|
<span className="text-amber-400 flex-shrink-0">•</span>{v}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fb.structure && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-600 mb-1">Cấu trúc</p>
|
||||||
|
<p className="text-xs text-slate-600">{fb.structure}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fb.summary && (
|
||||||
|
<div className="bg-green-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs font-semibold text-green-700 mb-1">Tổng nhận xét</p>
|
||||||
|
<p className="text-xs text-slate-600">{fb.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WritingHistory() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const { data: history, isLoading } = useWritingHistory()
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="px-4 lg:px-6 pb-10">
|
||||||
|
<h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<div className="w-4 h-4 border-2 border-slate-200 border-t-blue-500 rounded-full animate-spin" />
|
||||||
|
Đang tải...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !history?.length && (
|
||||||
|
<div className="text-center py-10 text-slate-400">
|
||||||
|
<span className="material-symbols-outlined mb-2 block" style={{ fontSize: 36 }}>history</span>
|
||||||
|
<p className="text-sm">Chưa có bài nào được chấm.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!history?.length && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.map((item) => <SubmissionCard key={item.id} item={item} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,96 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { supabase } from "@/lib/supabase"
|
import { supabase } from "@/lib/supabase"
|
||||||
import type { Question } from "@/types"
|
import type { Question, SessionPart } from "@/types"
|
||||||
|
|
||||||
const ANSWER_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3 }
|
type AnswerChoiceRow = { value: string; label_text: string | null; is_correct: boolean }
|
||||||
|
type QuestionRow = { id: number; question_text: string | null; explanation: string | null; group_id: number; answer_choice: AnswerChoiceRow[] }
|
||||||
|
type GroupRow = { id: number; part_id: number; audio_url: string | null; image_url: string | null; passage_text: string | null }
|
||||||
|
type PartRow = { id: number; part_number: number }
|
||||||
|
|
||||||
// Maps a Supabase row to the shared Question interface.
|
function buildOptions(choices: AnswerChoiceRow[]): string[] {
|
||||||
// DB uses `content` + `answer` ('A'–'D'); interface uses `text` + `correctAnswer` (0–3).
|
return [...choices].sort((a, b) => a.value.localeCompare(b.value)).map(c => c.label_text ?? '')
|
||||||
function rowToQuestion(row: Record<string, unknown>): Question {
|
}
|
||||||
|
|
||||||
|
function getCorrectIndex(choices: AnswerChoiceRow[]): number {
|
||||||
|
const sorted = [...choices].sort((a, b) => a.value.localeCompare(b.value))
|
||||||
|
const idx = sorted.findIndex(c => c.is_correct)
|
||||||
|
return idx >= 0 ? idx : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToQuestion(row: QuestionRow, group: GroupRow, partNumber: number): Question {
|
||||||
return {
|
return {
|
||||||
id: row.id as string,
|
id: row.id,
|
||||||
part: row.part as number,
|
partNumber,
|
||||||
text: row.content as string,
|
text: row.question_text,
|
||||||
options: row.options as string[],
|
options: buildOptions(row.answer_choice),
|
||||||
correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0,
|
correctAnswer: getCorrectIndex(row.answer_choice),
|
||||||
explanation: (row.explanation as string) ?? '',
|
explanation: row.explanation,
|
||||||
|
groupId: row.group_id,
|
||||||
|
audioUrl: group.audio_url ?? undefined,
|
||||||
|
imageUrl: group.image_url ?? undefined,
|
||||||
|
passageText: group.passage_text ?? undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported for imperative use (e.g. ToeicPractice click handler).
|
/**
|
||||||
// part=0 fetches all parts (Full Test).
|
* Fetch all questions for a test, optionally filtered to specific part numbers.
|
||||||
export async function fetchQuestions(part: number, limit = 10): Promise<Question[]> {
|
* partNumbers=[] or undefined → fetch all parts of the test.
|
||||||
let query = supabase.from('questions').select('*').limit(limit)
|
* Returns questions grouped into SessionPart[] ordered by part_number.
|
||||||
if (part > 0) query = query.eq('part', part)
|
*/
|
||||||
const { data, error } = await query
|
export async function fetchQuestionsForTest(
|
||||||
if (error) throw error
|
testId: number,
|
||||||
return (data ?? []).map(rowToQuestion)
|
partNumbers?: number[],
|
||||||
}
|
): Promise<SessionPart[]> {
|
||||||
|
// Step 1: Get parts for this test
|
||||||
|
let partsQuery = supabase.from('part').select('id, part_number, title').eq('test_id', testId).order('part_number')
|
||||||
|
if (partNumbers?.length) partsQuery = partsQuery.in('part_number', partNumbers)
|
||||||
|
const { data: parts, error: partsError } = await partsQuery
|
||||||
|
if (partsError) throw partsError
|
||||||
|
if (!parts?.length) return []
|
||||||
|
|
||||||
export function useQuestions(part: number, limit = 10) {
|
const partRows = parts as (PartRow & { title: string })[]
|
||||||
return useQuery({
|
const partIds = partRows.map(p => p.id)
|
||||||
queryKey: ['questions', part, limit],
|
const partNumberById = new Map(partRows.map(p => [p.id, p.part_number]))
|
||||||
queryFn: () => fetchQuestions(part, limit),
|
const partTitleByNumber = new Map(partRows.map(p => [p.part_number, p.title]))
|
||||||
})
|
|
||||||
|
// Step 2: Get question_groups for those parts
|
||||||
|
const { data: groups, error: groupsError } = await supabase
|
||||||
|
.from('question_group')
|
||||||
|
.select('id, part_id, audio_url, image_url, passage_text')
|
||||||
|
.in('part_id', partIds)
|
||||||
|
if (groupsError) throw groupsError
|
||||||
|
if (!groups?.length) return []
|
||||||
|
|
||||||
|
const groupMap = new Map<number, GroupRow>((groups as GroupRow[]).map(g => [g.id, g]))
|
||||||
|
const groupIds = (groups as GroupRow[]).map(g => g.id)
|
||||||
|
|
||||||
|
// Step 3: Get questions with answer choices
|
||||||
|
const { data: rows, error } = await supabase
|
||||||
|
.from('question')
|
||||||
|
.select('id, question_text, explanation, group_id, answer_choice(value, label_text, is_correct)')
|
||||||
|
.in('group_id', groupIds)
|
||||||
|
.order('question_number')
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
const questions = (rows as QuestionRow[] ?? [])
|
||||||
|
.map(row => {
|
||||||
|
const group = groupMap.get(row.group_id)!
|
||||||
|
const partNumber = partNumberById.get(group.part_id)!
|
||||||
|
return rowToQuestion(row, group, partNumber)
|
||||||
|
})
|
||||||
|
.filter(q => q.options.length > 0)
|
||||||
|
|
||||||
|
// Group into SessionPart[] ordered by partNumber
|
||||||
|
const byPart = new Map<number, Question[]>()
|
||||||
|
for (const q of questions) {
|
||||||
|
if (!byPart.has(q.partNumber)) byPart.set(q.partNumber, [])
|
||||||
|
byPart.get(q.partNumber)!.push(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
return partRows
|
||||||
|
.filter(p => byPart.has(p.part_number))
|
||||||
|
.map(p => ({
|
||||||
|
partNumber: p.part_number,
|
||||||
|
partName: partTitleByNumber.get(p.part_number) ?? `Part ${p.part_number}`,
|
||||||
|
questions: byPart.get(p.part_number)!,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,135 @@
|
|||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||||
import { useAuthStore } from "@/store/auth-store"
|
import { useAuthStore } from "@/store/auth-store"
|
||||||
import { supabase } from "@/lib/supabase"
|
|
||||||
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||||
import type { WritingFeedback } from "@/types"
|
import type { WritingFeedback } from "@/types"
|
||||||
|
|
||||||
const AUTH_DAILY_LIMIT = 10
|
const AUTH_DAILY_LIMIT = 10
|
||||||
const GUEST_DAILY_LIMIT = 3
|
const GUEST_DAILY_LIMIT = 3
|
||||||
|
|
||||||
async function callEdgeFunction(content: string): Promise<WritingFeedback> {
|
// Resolve env at runtime — production injects window.__ENV__ via docker/entrypoint.sh,
|
||||||
const { data, error } = await supabase.functions.invoke<WritingFeedback>("writing-check", {
|
// dev reads from Vite's import.meta.env. Must match src/lib/supabase.ts.
|
||||||
body: { content },
|
function resolveSupabaseEnv() {
|
||||||
|
const runtime = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
||||||
|
const url = runtime.VITE_SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL
|
||||||
|
const key =
|
||||||
|
runtime.VITE_SUPABASE_ANON_KEY ||
|
||||||
|
runtime.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
||||||
|
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||||
|
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
|
return { url: url as string | undefined, key: key as string | undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls the writing-check-dbiz Supabase edge function.
|
||||||
|
// SSE format emitted by the function: data: {"text":"..."} | data: [DONE]
|
||||||
|
async function callEdgeFunction(
|
||||||
|
content: string,
|
||||||
|
onChunk?: (text: string) => void,
|
||||||
|
): Promise<WritingFeedback> {
|
||||||
|
const { url, key } = resolveSupabaseEnv()
|
||||||
|
if (!url || !key) {
|
||||||
|
throw new Error("Supabase chưa được cấu hình. Vui lòng kiểm tra biến môi trường.")
|
||||||
|
}
|
||||||
|
const res = await fetch(`${url}/functions/v1/writing-check-dbiz`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
apikey: key,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (!res.ok) {
|
||||||
// The Supabase SDK wraps non-2xx responses in a generic FunctionsHttpError.
|
const body = await res.json().catch(() => ({}))
|
||||||
// Try to parse the actual error body returned by the edge function.
|
throw new Error(body?.error ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
|
||||||
try {
|
|
||||||
const body = await (error as unknown as { context: Response }).context.json()
|
|
||||||
if (body?.error) throw new Error(body.error)
|
|
||||||
} catch {
|
|
||||||
// ignore parse failure, fall through to generic message
|
|
||||||
}
|
|
||||||
throw new Error("Đã có lỗi khi chấm bài. Vui lòng thử lại.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
const reader = res.body!.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
let accumulated = ""
|
||||||
|
|
||||||
return data
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split("\n")
|
||||||
|
buffer = lines.pop() ?? ""
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue
|
||||||
|
const payload = line.slice(6).trim()
|
||||||
|
if (payload === "[DONE]") continue
|
||||||
|
|
||||||
|
let chunk: { text?: string; error?: string }
|
||||||
|
try {
|
||||||
|
chunk = JSON.parse(payload)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.error) throw new Error(chunk.error)
|
||||||
|
|
||||||
|
const text = chunk.text ?? ""
|
||||||
|
if (text) {
|
||||||
|
accumulated += text
|
||||||
|
onChunk?.(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = accumulated.indexOf("{")
|
||||||
|
const end = accumulated.lastIndexOf("}")
|
||||||
|
if (start === -1 || end === -1) {
|
||||||
|
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = JSON.parse(accumulated.slice(start, end + 1))
|
||||||
|
|
||||||
|
const toArray = (v: unknown): string[] => {
|
||||||
|
if (Array.isArray(v)) return v
|
||||||
|
if (typeof v === "string" && v.length > 0) return [v]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
grammar: toArray(raw.grammar),
|
||||||
|
vocabulary: toArray(raw.vocabulary),
|
||||||
|
improvedVersion: raw.improved_version ?? raw.improvedVersion ?? "",
|
||||||
|
} as WritingFeedback
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWritingCheck() {
|
export function useWritingCheck() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (content: string): Promise<WritingFeedback> => {
|
mutationFn: async ({
|
||||||
|
content,
|
||||||
|
onChunk,
|
||||||
|
}: {
|
||||||
|
content: string
|
||||||
|
onChunk?: (text: string) => void
|
||||||
|
}): Promise<WritingFeedback> => {
|
||||||
const user = useAuthStore.getState().user
|
const user = useAuthStore.getState().user
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Server-side rate limit for authenticated users (10/day)
|
|
||||||
const usedToday = await countTodayWritingSubmissions(user.id)
|
const usedToday = await countTodayWritingSubmissions(user.id)
|
||||||
if (usedToday >= AUTH_DAILY_LIMIT) {
|
if (usedToday >= AUTH_DAILY_LIMIT) {
|
||||||
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// localStorage rate limit for guests (3/day)
|
|
||||||
if (!canUseWritingCheck()) {
|
if (!canUseWritingCheck()) {
|
||||||
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedback = await callEdgeFunction(content)
|
const feedback = await callEdgeFunction(content, onChunk)
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Save submission to DB (fire-and-forget)
|
await saveWritingSubmission(user.id, content, feedback)
|
||||||
saveWritingSubmission(user.id, content, feedback)
|
queryClient.invalidateQueries({ queryKey: ["writing-history"] })
|
||||||
} else {
|
} else {
|
||||||
recordWritingCheckUsage()
|
recordWritingCheckUsage()
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/hooks/use-writing-history.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { useAuthStore } from "@/store/auth-store"
|
||||||
|
import { fetchWritingHistory } from "@/lib/progress-service"
|
||||||
|
import type { WritingSubmission } from "@/types"
|
||||||
|
|
||||||
|
export function useWritingHistory() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
return useQuery<WritingSubmission[]>({
|
||||||
|
queryKey: ["writing-history", user?.id],
|
||||||
|
queryFn: () => fetchWritingHistory(user!.id) as Promise<WritingSubmission[]>,
|
||||||
|
enabled: !!user,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
331
src/index.css
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "./styles/mobile.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -48,6 +49,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Atelier palette (global — applied across entire app) */
|
||||||
|
--at-brand: #3D4BD7;
|
||||||
|
--at-brand-ink: #1A2280;
|
||||||
|
--at-brand-soft: #E9ECFE;
|
||||||
|
--at-brand-softer: #F4F5FE;
|
||||||
|
--at-ink: #0F1114;
|
||||||
|
--at-ink-2: #2A2D33;
|
||||||
|
--at-ink-3: #3E4149;
|
||||||
|
--at-mute: #6B6F76;
|
||||||
|
--at-mute-2: #9CA0A8;
|
||||||
|
--at-line: #E8E5DE;
|
||||||
|
--at-line-2: #EFECE4;
|
||||||
|
--at-paper: #FAF8F3;
|
||||||
|
--at-paper-2: #F4F1EA;
|
||||||
|
--at-surface: #FFFFFF;
|
||||||
|
--at-good: #2F7D4A;
|
||||||
|
--at-good-soft: #E4F0E7;
|
||||||
|
--at-good-ink: #1B4B2C;
|
||||||
|
--at-warm: #D26A3B;
|
||||||
|
--at-warm-soft: #F8E9DE;
|
||||||
|
--at-warm-ink: #6B2A14;
|
||||||
|
--at-bad: #C1443E;
|
||||||
|
--at-bad-soft: #F4DEDC;
|
||||||
|
--at-streak: #C15A34;
|
||||||
|
--at-streak-soft: #F7E6DC;
|
||||||
|
|
||||||
|
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
|
||||||
|
--at-sans: "Geist", "Geist Variable", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
|
||||||
|
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -121,13 +152,106 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-50 text-slate-800;
|
background: var(--at-paper);
|
||||||
|
color: var(--at-ink);
|
||||||
|
font-family: var(--at-sans);
|
||||||
|
font-feature-settings: "ss01", "cv11";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Atelier global helpers — usable outside .atelier scope */
|
||||||
|
.at-serif { font-family: var(--at-serif); }
|
||||||
|
.at-mono { font-family: var(--at-mono); }
|
||||||
|
.at-eyebrow {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--at-mute);
|
||||||
|
}
|
||||||
|
.at-title {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: var(--at-ink);
|
||||||
|
}
|
||||||
|
.at-title i { font-style: italic; color: var(--at-brand); font-weight: 400; }
|
||||||
|
.at-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--at-line-2);
|
||||||
|
color: var(--at-ink-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.at-chip-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||||
|
.at-chip-brand { background: var(--at-brand-soft); color: var(--at-brand-ink); }
|
||||||
|
.at-chip-good { background: var(--at-good-soft); color: var(--at-good-ink); }
|
||||||
|
.at-chip-warm { background: var(--at-warm-soft); color: var(--at-warm); }
|
||||||
|
.at-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--at-line-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.at-bar > span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: var(--at-brand);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
.at-pullquote {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--at-brand-soft);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.at-pullquote-q {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--at-brand-ink);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.at-tip {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--at-warm-soft);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(210, 106, 59, 0.18);
|
||||||
|
}
|
||||||
|
.at-tip-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--at-warm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.at-tip-label::before {
|
||||||
|
content: "";
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: var(--at-warm);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Flashcard 3D flip ── */
|
/* ── Flashcard 3D flip ── */
|
||||||
.flashcard-scene {
|
.flashcard-scene {
|
||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
@@ -174,3 +298,208 @@
|
|||||||
.timer-urgent {
|
.timer-urgent {
|
||||||
animation: timer-pulse 1s ease-in-out infinite;
|
animation: timer-pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────
|
||||||
|
The Atelier — flashcard learn page scope
|
||||||
|
Tokens + typography + 3D flip card
|
||||||
|
Fonts: Fraunces + Geist + Geist Mono (loaded by route)
|
||||||
|
──────────────────────────────────────────────────────────── */
|
||||||
|
.atelier {
|
||||||
|
--at-accent: #3D4BD7;
|
||||||
|
--at-accent-soft: #E9ECFE;
|
||||||
|
--at-accent-ink: #1A2280;
|
||||||
|
--at-ink: #0F1114;
|
||||||
|
--at-ink-2: #2A2D33;
|
||||||
|
--at-mute: #6B6F76;
|
||||||
|
--at-mute-2: #9CA0A8;
|
||||||
|
--at-line: #E8E5DE;
|
||||||
|
--at-line-2: #EFECE4;
|
||||||
|
--at-paper: #FAF8F3;
|
||||||
|
--at-paper-2: #F4F1EA;
|
||||||
|
--at-good: #2F7D4A;
|
||||||
|
--at-good-soft: #E4F0E7;
|
||||||
|
--at-warm: #D26A3B;
|
||||||
|
--at-warm-soft: #F8E9DE;
|
||||||
|
|
||||||
|
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
|
||||||
|
--at-sans: "Geist", "Geist Variable", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
|
||||||
|
|
||||||
|
background: var(--at-paper);
|
||||||
|
color: var(--at-ink);
|
||||||
|
font-family: var(--at-sans);
|
||||||
|
font-feature-settings: "ss01", "cv11";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atelier .at-serif { font-family: var(--at-serif); }
|
||||||
|
.atelier .at-mono { font-family: var(--at-mono); }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.atelier .at-card-outer {
|
||||||
|
perspective: 2000px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
/* Size by available viewport height — never overflow */
|
||||||
|
height: min(560px, calc(100vh - 14rem));
|
||||||
|
max-height: 560px;
|
||||||
|
}
|
||||||
|
.atelier .at-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.75s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
animation: at-cardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
@keyframes at-cardIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.atelier .at-card.is-flipped { transform: rotateY(180deg); }
|
||||||
|
.atelier .at-card-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(15,17,20,0.04),
|
||||||
|
0 20px 40px -16px rgba(15,17,20,0.12),
|
||||||
|
0 4px 12px -4px rgba(15,17,20,0.06);
|
||||||
|
}
|
||||||
|
.atelier .at-card-back { transform: rotateY(180deg); }
|
||||||
|
|
||||||
|
.atelier .at-word {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-size: clamp(44px, 6vw, 72px);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
color: var(--at-ink);
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 30, "WONK" 1;
|
||||||
|
}
|
||||||
|
.atelier .at-meaning {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--at-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atelier .at-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--at-accent-soft);
|
||||||
|
color: var(--at-accent-ink);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
.atelier .at-chip-dot {
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: var(--at-accent);
|
||||||
|
}
|
||||||
|
.atelier .at-chip-mute { background: var(--at-line-2); color: var(--at-mute); }
|
||||||
|
.atelier .at-chip-mute .at-chip-dot { background: var(--at-mute); }
|
||||||
|
|
||||||
|
.atelier .at-kbd {
|
||||||
|
font-family: var(--at-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--at-ink-2);
|
||||||
|
background: var(--at-paper-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atelier .at-example {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--at-paper-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 2px solid var(--at-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atelier .at-action {
|
||||||
|
flex: 1;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--at-ink-2);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.atelier .at-action:hover:not(:disabled) { border-color: var(--at-ink); color: var(--at-ink); transform: translateY(-1px); }
|
||||||
|
.atelier .at-action:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.atelier .at-action-known {
|
||||||
|
background: var(--at-good);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--at-good);
|
||||||
|
}
|
||||||
|
.atelier .at-action-known:hover:not(:disabled) { background: #236238; border-color: #236238; color: white; }
|
||||||
|
.atelier .at-action-review {
|
||||||
|
background: var(--at-warm-soft);
|
||||||
|
color: var(--at-warm);
|
||||||
|
border-color: rgba(210, 106, 59, 0.3);
|
||||||
|
}
|
||||||
|
.atelier .at-action-review:hover:not(:disabled) { background: var(--at-warm); color: white; border-color: var(--at-warm); }
|
||||||
|
|
||||||
|
.atelier .at-progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--at-line-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.atelier .at-progress-bar > span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: linear-gradient(90deg, var(--at-accent), color-mix(in oklab, var(--at-accent) 80%, white));
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atelier .at-pct {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--at-accent);
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swipe-off FX */
|
||||||
|
@keyframes at-knownFx {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
40% { transform: translateY(-8px) rotate(2deg); }
|
||||||
|
100% { transform: translateX(120%) rotate(8deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes at-reviewFx {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
40% { transform: translateY(-8px) rotate(-2deg); }
|
||||||
|
100% { transform: translateX(-120%) rotate(-8deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
.atelier .at-card.fx-known { animation: at-knownFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }
|
||||||
|
.atelier .at-card.fx-review { animation: at-reviewFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }
|
||||||
@@ -1,22 +1,47 @@
|
|||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
const ANSWER_VALUES = ['A', 'B', 'C', 'D'] as const
|
||||||
|
|
||||||
interface TestResultData {
|
interface TestResultData {
|
||||||
partId: number
|
testId: number | null
|
||||||
partName: string
|
selectedParts: number[]
|
||||||
score: number
|
score: number
|
||||||
total: number
|
total: number
|
||||||
timeUsed: number
|
timeUsed: number
|
||||||
answers: { questionId: string; selected: number | null; correct: boolean }[]
|
answers: { questionId: number; selected: number | null; correct: boolean }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
|
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
|
||||||
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
|
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
|
||||||
const { error } = await supabase.from('user_progress').insert({
|
const { data: attempt, error: attemptError } = await supabase
|
||||||
user_id: userId,
|
.from('user_test_attempt')
|
||||||
type: 'test',
|
.insert({
|
||||||
data,
|
user_id: userId,
|
||||||
})
|
test_id: data.testId,
|
||||||
if (error) console.error('Failed to save test result:', error.message)
|
selected_parts: data.selectedParts,
|
||||||
|
time_limit_minutes: 10,
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
time_spent_seconds: data.timeUsed,
|
||||||
|
total_correct: data.score,
|
||||||
|
total_questions: data.total,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (attemptError) {
|
||||||
|
console.error('Failed to save test attempt:', attemptError.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerRows = data.answers.map(a => ({
|
||||||
|
attempt_id: attempt.id,
|
||||||
|
question_id: a.questionId,
|
||||||
|
selected_value: a.selected !== null ? ANSWER_VALUES[a.selected] : null,
|
||||||
|
is_correct: a.correct,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { error: answersError } = await supabase.from('user_answer').insert(answerRows)
|
||||||
|
if (answersError) console.error('Failed to save answers:', answersError.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget: save writing submission with AI feedback. */
|
/** Fire-and-forget: save writing submission with AI feedback. */
|
||||||
@@ -48,10 +73,9 @@ export async function countTodayWritingSubmissions(userId: string): Promise<numb
|
|||||||
/** Fetch test history for a user (most recent first, max 20). */
|
/** Fetch test history for a user (most recent first, max 20). */
|
||||||
export async function fetchTestHistory(userId: string) {
|
export async function fetchTestHistory(userId: string) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('user_progress')
|
.from('user_test_attempt')
|
||||||
.select('*')
|
.select('id, selected_parts, time_spent_seconds, total_correct, total_questions, score, submitted_at, created_at')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.eq('type', 'test')
|
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(20)
|
.limit(20)
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { createClient } from "@supabase/supabase-js"
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
// Runtime env (injected by docker/entrypoint.sh) takes priority over build-time vars
|
||||||
// Supports both key name conventions
|
const runtimeEnv = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
||||||
|
|
||||||
|
const supabaseUrl =
|
||||||
|
runtimeEnv.VITE_SUPABASE_URL ||
|
||||||
|
import.meta.env.VITE_SUPABASE_URL
|
||||||
|
|
||||||
const supabaseAnonKey =
|
const supabaseAnonKey =
|
||||||
|
runtimeEnv.VITE_SUPABASE_ANON_KEY ||
|
||||||
|
runtimeEnv.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
||||||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Sidebar } from '@/components/layout/Sidebar'
|
import { Sidebar } from '@/components/layout/Sidebar'
|
||||||
import { AppHeader } from '@/components/layout/AppHeader'
|
import { AppHeader } from '@/components/layout/AppHeader'
|
||||||
@@ -10,18 +10,34 @@ export const Route = createRootRoute({
|
|||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Routes that own the full viewport — hide sidebar, top header, and mobile nav.
|
||||||
|
const FULLSCREEN_ROUTES = new Set(['/toeic/session'])
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const initialize = useAuthStore((s) => s.initialize)
|
const initialize = useAuthStore((s) => s.initialize)
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const isFullscreen = FULLSCREEN_ROUTES.has(pathname)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialize()
|
initialize()
|
||||||
}, [initialize])
|
}, [initialize])
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--at-paper-2)' }}>
|
||||||
|
<Outlet />
|
||||||
|
<AuthModal />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen" style={{ background: 'var(--at-paper)' }}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
|
{/* Extra bottom padding on mobile to clear the floating tab bar
|
||||||
|
(68px pill + 10px margin + safe-area). */}
|
||||||
|
<main className="lg:ml-60 pt-16 pb-28 lg:pb-0 min-h-screen">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { Dashboard } from '@/features/dashboard/components/Dashboard'
|
import { Dashboard } from '@/features/dashboard/components/Dashboard'
|
||||||
|
|
||||||
export const Route = createFileRoute('/dashboard')({
|
export const Route = createFileRoute('/archivement')({
|
||||||
component: Dashboard,
|
component: Dashboard,
|
||||||
})
|
})
|
||||||
11
src/routes/flash-card.$listId.index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { FlashCardTermsPage } from "@/features/flash-card/components/FlashCardTermsPage"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/flash-card/$listId/")({
|
||||||
|
component: TermsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function TermsPage() {
|
||||||
|
const { listId } = Route.useParams()
|
||||||
|
return <FlashCardTermsPage listId={Number(listId)} />
|
||||||
|
}
|
||||||
11
src/routes/flash-card.$listId.learn.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { FlashCardLearnPage } from "@/features/flash-card/components/FlashCardLearnPage"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/flash-card/$listId/learn")({
|
||||||
|
component: LearnPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function LearnPage() {
|
||||||
|
const { listId } = Route.useParams()
|
||||||
|
return <FlashCardLearnPage listId={Number(listId)} />
|
||||||
|
}
|
||||||
5
src/routes/flash-card.$listId.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/flash-card/$listId")({
|
||||||
|
component: () => <Outlet />,
|
||||||
|
})
|
||||||
6
src/routes/flash-card.index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { FlashCardListPage } from "@/features/flash-card/components/FlashCardListPage"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/flash-card/")({
|
||||||
|
component: FlashCardListPage,
|
||||||
|
})
|
||||||
5
src/routes/flash-card.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/flash-card")({
|
||||||
|
component: () => <Outlet />,
|
||||||
|
})
|
||||||
11
src/routes/toeic.$testId.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { ToeicTestDetail } from '@/features/toeic/components/ToeicTestDetail'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/toeic/$testId')({
|
||||||
|
component: TestDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function TestDetailPage() {
|
||||||
|
const { testId } = Route.useParams()
|
||||||
|
return <ToeicTestDetail testId={Number(testId)} />
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { ToeicPractice } from "@/features/toeic/components/ToeicPractice"
|
import { ToeicTestList } from "@/features/toeic/components/ToeicTestList"
|
||||||
|
|
||||||
export const Route = createFileRoute("/toeic/")({
|
export const Route = createFileRoute("/toeic/")({
|
||||||
component: ToeicPractice,
|
component: ToeicTestList,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Vocabulary } from "@/features/vocab/components/Vocabulary"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/vocab")({
|
|
||||||
component: Vocabulary,
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { WritingChecker } from "@/features/writing/components/WritingChecker"
|
import { WritingChecker } from "@/features/writing/components/WritingChecker"
|
||||||
|
import { WritingHistory } from "@/features/writing/components/WritingHistory"
|
||||||
|
|
||||||
|
function WritingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WritingChecker />
|
||||||
|
<WritingHistory />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/writing")({
|
export const Route = createFileRoute("/writing")({
|
||||||
component: WritingChecker,
|
component: WritingPage,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,53 +1,67 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import type { Question } from '@/types'
|
import type { SessionPart } from '@/types'
|
||||||
|
|
||||||
|
interface StartExamConfig {
|
||||||
|
testId: number | null
|
||||||
|
testName: string
|
||||||
|
parts: SessionPart[]
|
||||||
|
totalSeconds: number // 0 = no limit
|
||||||
|
}
|
||||||
|
|
||||||
interface TestStore {
|
interface TestStore {
|
||||||
partId: number
|
testId: number | null
|
||||||
partName: string
|
testName: string
|
||||||
questions: Question[]
|
parts: SessionPart[]
|
||||||
answers: (number | null)[]
|
currentPartIndex: number
|
||||||
|
answers: Record<number, number | null> // questionId → answerIndex (0-3), null=unanswered
|
||||||
isSubmitted: boolean
|
isSubmitted: boolean
|
||||||
timeUsed: number // seconds elapsed when submitted
|
timeUsed: number // seconds elapsed when submitted
|
||||||
|
totalSeconds: number // time limit (0 = no limit)
|
||||||
|
|
||||||
startExam: (partId: number, partName: string, questions: Question[]) => void
|
startExam: (config: StartExamConfig) => void
|
||||||
setAnswer: (questionIndex: number, answerIndex: number) => void
|
setAnswer: (questionId: number, answerIndex: number) => void
|
||||||
|
setCurrentPart: (partIndex: number) => void
|
||||||
submitExam: (timeUsed: number) => void
|
submitExam: (timeUsed: number) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE = {
|
||||||
|
testId: null,
|
||||||
|
testName: '',
|
||||||
|
parts: [],
|
||||||
|
currentPartIndex: 0,
|
||||||
|
answers: {},
|
||||||
|
isSubmitted: false,
|
||||||
|
timeUsed: 0,
|
||||||
|
totalSeconds: 0,
|
||||||
|
}
|
||||||
|
|
||||||
export const useTestStore = create<TestStore>()(
|
export const useTestStore = create<TestStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
partId: 2,
|
...INITIAL_STATE,
|
||||||
partName: '',
|
|
||||||
questions: [],
|
|
||||||
answers: [],
|
|
||||||
isSubmitted: false,
|
|
||||||
timeUsed: 0,
|
|
||||||
|
|
||||||
startExam: (partId, partName, questions) =>
|
startExam: ({ testId, testName, parts, totalSeconds }) => {
|
||||||
set({
|
// Pre-fill all question IDs with null (unanswered)
|
||||||
partId,
|
const answers: Record<number, number | null> = {}
|
||||||
partName,
|
for (const part of parts) {
|
||||||
questions,
|
for (const q of part.questions) answers[q.id] = null
|
||||||
answers: new Array(questions.length).fill(null),
|
}
|
||||||
isSubmitted: false,
|
set({ testId, testName, parts, currentPartIndex: 0, answers, isSubmitted: false, timeUsed: 0, totalSeconds })
|
||||||
timeUsed: 0,
|
},
|
||||||
}),
|
|
||||||
|
|
||||||
setAnswer: (questionIndex, answerIndex) =>
|
setAnswer: (questionId, answerIndex) =>
|
||||||
set((state) => {
|
set((state) => ({
|
||||||
const answers = [...state.answers]
|
answers: { ...state.answers, [questionId]: answerIndex },
|
||||||
answers[questionIndex] = answerIndex
|
})),
|
||||||
return { answers }
|
|
||||||
}),
|
setCurrentPart: (partIndex) => set({ currentPartIndex: partIndex }),
|
||||||
|
|
||||||
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
|
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
|
||||||
|
|
||||||
reset: () =>
|
reset: () => set(INITIAL_STATE),
|
||||||
set({ partId: 2, partName: '', questions: [], answers: [], isSubmitted: false, timeUsed: 0 }),
|
|
||||||
}),
|
}),
|
||||||
{ name: 'test-store' },
|
{ name: 'test-store', version: 2 },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
295
src/styles/mobile.css
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/* Mobile UI primitives — Atelier Mobile design.
|
||||||
|
Only kicks in on narrow viewports via `@media (max-width: 880px)`.
|
||||||
|
Uses the existing `--at-*` design tokens; does NOT touch desktop layouts. */
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
/* Mobile page background gradient */
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 60% at 50% 0%, color-mix(in oklab, var(--at-brand) 6%, transparent) 0%, transparent 70%),
|
||||||
|
var(--at-paper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Mobile primitives (usable on any viewport) -------- */
|
||||||
|
|
||||||
|
/* Card — soft surface with hairline border and small shadow */
|
||||||
|
.m-card {
|
||||||
|
background: var(--at-surface);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 17, 20, 0.04);
|
||||||
|
}
|
||||||
|
.m-card + .m-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.m-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Big serif display numbers */
|
||||||
|
.m-num {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
.m-num.xl {
|
||||||
|
font-size: 72px;
|
||||||
|
}
|
||||||
|
.m-num.lg {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
.m-num.md {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eyebrow */
|
||||||
|
.m-eyebrow {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--at-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.m-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--at-paper-2);
|
||||||
|
color: var(--at-ink-2);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
}
|
||||||
|
.m-chip.brand {
|
||||||
|
background: var(--at-brand-soft);
|
||||||
|
color: var(--at-brand);
|
||||||
|
border-color: color-mix(in oklab, var(--at-brand) 18%, transparent);
|
||||||
|
}
|
||||||
|
.m-chip.good {
|
||||||
|
background: var(--at-good-soft);
|
||||||
|
color: var(--at-good);
|
||||||
|
border-color: color-mix(in oklab, var(--at-good) 20%, transparent);
|
||||||
|
}
|
||||||
|
.m-chip.streak {
|
||||||
|
background: var(--at-streak-soft);
|
||||||
|
color: var(--at-streak);
|
||||||
|
border-color: color-mix(in oklab, var(--at-streak) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.m-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--at-line-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.m-bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--at-brand);
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.m-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--at-surface);
|
||||||
|
color: var(--at-ink);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
transition: transform 0.08s ease;
|
||||||
|
}
|
||||||
|
.m-btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.m-btn.primary {
|
||||||
|
background: var(--at-ink);
|
||||||
|
color: var(--at-paper);
|
||||||
|
border-color: var(--at-ink);
|
||||||
|
}
|
||||||
|
.m-btn.brand {
|
||||||
|
background: var(--at-brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--at-brand);
|
||||||
|
box-shadow: 0 4px 14px -4px color-mix(in oklab, var(--at-brand) 60%, transparent);
|
||||||
|
}
|
||||||
|
.m-btn.block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.m-btn.sm {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.m-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 4px 12px;
|
||||||
|
}
|
||||||
|
.m-section-title {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.m-section-title i {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--at-brand);
|
||||||
|
}
|
||||||
|
.m-section-link {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--at-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topic tile */
|
||||||
|
.m-topic-tile {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--at-surface);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.m-topic-tile:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.m-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.m-grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen transition */
|
||||||
|
.m-fade {
|
||||||
|
animation: mFade 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
@keyframes mFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Mobile-only responsive tweaks (only < 880px) -------- */
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
/* Tab bar — floating glass pill */
|
||||||
|
.m-tabbar {
|
||||||
|
position: fixed;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: color-mix(in oklab, var(--at-surface) 88%, transparent);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
box-shadow:
|
||||||
|
0 -2px 14px rgba(15, 17, 20, 0.04),
|
||||||
|
0 14px 32px -12px rgba(15, 17, 20, 0.16);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
z-index: 30;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.m-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--at-mute);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 6px 4px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.m-tab.is-active {
|
||||||
|
color: var(--at-brand);
|
||||||
|
background: var(--at-brand-softer);
|
||||||
|
}
|
||||||
|
.m-tab > svg {
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.m-tab.is-active > svg {
|
||||||
|
transform: translateY(-1px) scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Mobile top bar (floating title) -------- */
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.m-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
background: color-mix(in oklab, var(--at-paper) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--at-line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.m-topbar-title {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-topbar-title i {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--at-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
* When real database data is available, remove the relevant export and update the consumer.
|
* When real database data is available, remove the relevant export and update the consumer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Question, VocabWord, WritingFeedback, ToeicPart } from '@/types'
|
import type { VocabWord, WritingFeedback, ToeicPart } from '@/types'
|
||||||
|
|
||||||
// ─── [ACTIVE TEMP] ─────────────────────────────────────────────────────────────
|
// ─── [ACTIVE TEMP] ─────────────────────────────────────────────────────────────
|
||||||
// Used by: src/pages/ToeicPractice.tsx
|
// Used by: src/pages/ToeicPractice.tsx
|
||||||
@@ -28,8 +28,9 @@ export const TOEIC_PARTS: ToeicPart[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
|
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
|
||||||
// Real questions come from Supabase via fetchQuestions() in src/hooks/use-questions.ts
|
// Real questions come from Supabase via fetchQuestionsForTest() in src/hooks/use-questions.ts
|
||||||
export const MOCK_QUESTIONS: Question[] = [
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const MOCK_QUESTIONS: any[] = [
|
||||||
{ id: 'q1', part: 2, text: 'What does the man suggest the woman do about the budget report?', options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'], correctAnswer: 1, explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.' },
|
{ id: 'q1', part: 2, text: 'What does the man suggest the woman do about the budget report?', options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'], correctAnswer: 1, explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.' },
|
||||||
{ id: 'q2', part: 2, text: 'Where most likely are the speakers?', options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'], correctAnswer: 2, explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.' },
|
{ id: 'q2', part: 2, text: 'Where most likely are the speakers?', options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'], correctAnswer: 2, explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.' },
|
||||||
{ id: 'q3', part: 2, text: 'Why is the man calling?', options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'], correctAnswer: 0, explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.' },
|
{ id: 'q3', part: 2, text: 'Why is the man calling?', options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'], correctAnswer: 0, explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.' },
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
export interface Question {
|
export interface Question {
|
||||||
id: string
|
id: number // SERIAL from question table
|
||||||
part: number
|
partNumber: number // from part.part_number — needed for session grouping
|
||||||
text: string
|
text: string | null // question_text (null for photo/audio-only questions)
|
||||||
options: string[]
|
options: string[] // answer_choice.label_text ordered A→D
|
||||||
correctAnswer: number // 0-3
|
correctAnswer: number // 0-3 derived from answer_choice.is_correct
|
||||||
explanation: string
|
explanation: string | null
|
||||||
|
groupId: number
|
||||||
|
audioUrl?: string // from question_group
|
||||||
|
imageUrl?: string // from question_group
|
||||||
|
passageText?: string // from question_group (Part 6/7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One part's worth of questions inside a test session
|
||||||
|
export interface SessionPart {
|
||||||
|
partNumber: number
|
||||||
|
partName: string // e.g. "Mô tả hình ảnh"
|
||||||
|
questions: Question[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A test record from the test table
|
||||||
|
export interface TestRecord {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
totalQuestions: number
|
||||||
|
durationMinutes: number
|
||||||
|
categoryName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A part record from the part table
|
||||||
|
export interface PartRecord {
|
||||||
|
id: number
|
||||||
|
testId: number
|
||||||
|
partNumber: number
|
||||||
|
title: string
|
||||||
|
questionCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabWord {
|
export interface VocabWord {
|
||||||
@@ -44,6 +74,13 @@ export interface WritingFeedback {
|
|||||||
summary: string
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WritingSubmission {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
feedback: WritingFeedback
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToeicPart {
|
export interface ToeicPart {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
101
supabase/create/flash_card.sql
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- FLASHCARD SYSTEM
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE flashcard_list (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
total_words INT DEFAULT 0,
|
||||||
|
created_by INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE flashcard_term (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||||
|
word VARCHAR(255) NOT NULL,
|
||||||
|
part_of_speech VARCHAR(50), -- "adjective", "noun", "verb"...
|
||||||
|
phonetic VARCHAR(100), -- "/kəˈmɜːʃəl/"
|
||||||
|
definition TEXT, -- nghĩa tiếng Việt
|
||||||
|
example TEXT, -- câu ví dụ (EN + VI)
|
||||||
|
image_url VARCHAR(500),
|
||||||
|
audio_tts_text VARCHAR(255), -- text để TTS (thường = word)
|
||||||
|
audio_lang VARCHAR(10) DEFAULT 'en-US',
|
||||||
|
display_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_flashcard_list (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||||
|
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, list_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_flashcard_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
term_id INT NOT NULL REFERENCES flashcard_term(id) ON DELETE CASCADE,
|
||||||
|
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- SRS fields
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||||
|
-- 'new' | 'learning' | 'known' | 'ignored'
|
||||||
|
ease_factor DECIMAL(4,2) DEFAULT 1.0,
|
||||||
|
-- 1.0=Dễ | 0.65=Trung bình | 0.1=Khó | -1=Đã biết/bỏ qua
|
||||||
|
review_count INT DEFAULT 0,
|
||||||
|
last_reviewed_at TIMESTAMP,
|
||||||
|
next_review_at TIMESTAMP, -- SRS scheduling
|
||||||
|
|
||||||
|
UNIQUE (user_id, term_id, list_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_flashcard_session (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||||
|
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ended_at TIMESTAMP,
|
||||||
|
terms_reviewed INT DEFAULT 0,
|
||||||
|
terms_new INT DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_flashcard_review_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id INT NOT NULL REFERENCES user_flashcard_session(id) ON DELETE CASCADE,
|
||||||
|
term_id INT NOT NULL REFERENCES flashcard_term(id) ON DELETE CASCADE,
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
action_value DECIMAL(4,2) NOT NULL, -- 1 | 0.65 | 0.1 | -1
|
||||||
|
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- INDEXES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE INDEX idx_term_list_id ON flashcard_term(list_id);
|
||||||
|
CREATE INDEX idx_term_display_order ON flashcard_term(list_id, display_order);
|
||||||
|
CREATE INDEX idx_progress_user ON user_flashcard_progress(user_id);
|
||||||
|
CREATE INDEX idx_progress_next_review ON user_flashcard_progress(user_id, next_review_at);
|
||||||
|
CREATE INDEX idx_progress_status ON user_flashcard_progress(user_id, list_id, status);
|
||||||
|
CREATE INDEX idx_review_log_session ON user_flashcard_review_log(session_id);
|
||||||
|
CREATE INDEX idx_enrolled_user ON user_flashcard_list(user_id);
|
||||||
|
|
||||||
|
-- 1. Cài đặt chế độ review — trên trang có nút "Cài đặt chế độ review".
|
||||||
|
-- Cần lưu preferences của user như số từ mới mỗi ngày, thứ tự hiển thị (ngẫu nhiên hay tuần tự), hiển thị mặt trước là EN hay VI. Cần thêm table:
|
||||||
|
|
||||||
|
CREATE TABLE user_flashcard_settings (
|
||||||
|
user_id INT REFERENCES users(id),
|
||||||
|
list_id INT REFERENCES flashcard_list(id),
|
||||||
|
daily_new_limit INT DEFAULT 20,
|
||||||
|
shuffle BOOLEAN DEFAULT TRUE,
|
||||||
|
front_side VARCHAR(10) DEFAULT 'word', -- 'word' | 'definition'
|
||||||
|
show_all_terms BOOLEAN DEFAULT FALSE,
|
||||||
|
-- Khi TRUE: hiển thị tất cả từ kể cả ignored
|
||||||
|
-- Khi FALSE: từ ignored sẽ bị bỏ qua cho đến khi hết từ cần ôn
|
||||||
|
PRIMARY KEY (user_id, list_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- cho user tạo list riêng
|
||||||
|
ALTER TABLE flashcard_list ADD COLUMN is_public BOOLEAN DEFAULT TRUE;
|
||||||
56
supabase/create/test.sql
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
CREATE TABLE test (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
total_questions INT DEFAULT 0,
|
||||||
|
duration_minutes INT DEFAULT 120,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE part (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
test_id INT NOT NULL REFERENCES test(id) ON DELETE CASCADE,
|
||||||
|
part_number INT NOT NULL,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
question_count INT DEFAULT 0,
|
||||||
|
display_order INT DEFAULT 0,
|
||||||
|
UNIQUE (test_id, part_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tag (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE part_tag (
|
||||||
|
part_id INT NOT NULL REFERENCES part(id) ON DELETE CASCADE,
|
||||||
|
tag_id INT NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (part_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE question_group (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
part_id INT NOT NULL REFERENCES part(id) ON DELETE CASCADE,
|
||||||
|
audio_url VARCHAR(500),
|
||||||
|
image_url VARCHAR(500),
|
||||||
|
passage_text TEXT,
|
||||||
|
transcript TEXT,
|
||||||
|
display_order INT DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE question (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INT NOT NULL REFERENCES question_group(id) ON DELETE CASCADE,
|
||||||
|
question_number INT NOT NULL,
|
||||||
|
question_text TEXT,
|
||||||
|
display_order INT DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE answer_choice (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
question_id INT NOT NULL REFERENCES question(id) ON DELETE CASCADE,
|
||||||
|
value CHAR(1) NOT NULL CHECK (value IN ('A', 'B', 'C', 'D')),
|
||||||
|
label_text TEXT,
|
||||||
|
is_correct BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
7
supabase/create/update.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE test_category (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL, -- "TOEIC", "IELTS Academic", "HSK 1"...
|
||||||
|
slug VARCHAR(100) UNIQUE -- "toeic", "ielts", "hsk-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE test ADD COLUMN category_id INT REFERENCES test_category(id);
|
||||||
30
supabase/create/user_test_history.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE user_test_attempt (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
test_id INT NOT NULL REFERENCES test(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- các part được chọn để luyện tập (có thể chọn 1 hoặc nhiều part)
|
||||||
|
selected_parts INT[], -- hoặc dùng junction table
|
||||||
|
|
||||||
|
-- thời gian
|
||||||
|
time_limit_minutes INT, -- user chọn từ dropdown (NULL = không giới hạn)
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
submitted_at TIMESTAMP,
|
||||||
|
time_spent_seconds INT, -- thời gian thực tế đã dùng
|
||||||
|
|
||||||
|
-- kết quả
|
||||||
|
total_correct INT DEFAULT 0,
|
||||||
|
total_questions INT DEFAULT 0,
|
||||||
|
score DECIMAL(5,2), -- scaled score nếu full test
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_answer (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
attempt_id INT NOT NULL REFERENCES user_test_attempt(id) ON DELETE CASCADE,
|
||||||
|
question_id INT NOT NULL REFERENCES question(id),
|
||||||
|
selected_value CHAR(1), -- A / B / C / D (NULL nếu bỏ qua)
|
||||||
|
is_correct BOOLEAN,
|
||||||
|
UNIQUE (attempt_id, question_id)
|
||||||
|
);
|
||||||
734
supabase/data/flashcard_lists.json
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1835",
|
||||||
|
"title": "Từ vựng tiếng Anh văn phòng",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1833",
|
||||||
|
"title": "Từ vựng tiếng Anh giao tiếp trung cấp",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1831",
|
||||||
|
"title": "900 từ TOEFL (có ảnh)",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1832",
|
||||||
|
"title": "Từ vựng Tiếng Anh giao tiếp cơ bản",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1830",
|
||||||
|
"title": "900 từ IELTS (có ảnh)",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "624",
|
||||||
|
"title": "Academic word list",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "parent",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": [
|
||||||
|
"634",
|
||||||
|
"633",
|
||||||
|
"632",
|
||||||
|
"631",
|
||||||
|
"630",
|
||||||
|
"629",
|
||||||
|
"628",
|
||||||
|
"627",
|
||||||
|
"626",
|
||||||
|
"625"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1829",
|
||||||
|
"title": "900 từ SAT (có ảnh)",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1417",
|
||||||
|
"title": "GRE-GMAT Vocabulary List",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "500",
|
||||||
|
"title": "New Academic Word List",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "497",
|
||||||
|
"title": "TOEIC Word List",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "499",
|
||||||
|
"title": "Business Word List",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "383",
|
||||||
|
"title": "Most common IELTS Listening words",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "parent",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": [
|
||||||
|
"417",
|
||||||
|
"416",
|
||||||
|
"415",
|
||||||
|
"414",
|
||||||
|
"413",
|
||||||
|
"412",
|
||||||
|
"411",
|
||||||
|
"410",
|
||||||
|
"409",
|
||||||
|
"408",
|
||||||
|
"407",
|
||||||
|
"406",
|
||||||
|
"405",
|
||||||
|
"404",
|
||||||
|
"403",
|
||||||
|
"402",
|
||||||
|
"401",
|
||||||
|
"400",
|
||||||
|
"399",
|
||||||
|
"398",
|
||||||
|
"397",
|
||||||
|
"396",
|
||||||
|
"395",
|
||||||
|
"394",
|
||||||
|
"393",
|
||||||
|
"392",
|
||||||
|
"391",
|
||||||
|
"390",
|
||||||
|
"389",
|
||||||
|
"388",
|
||||||
|
"387",
|
||||||
|
"386",
|
||||||
|
"385",
|
||||||
|
"384"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "361",
|
||||||
|
"title": "600 TOEIC words",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "parent",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": [
|
||||||
|
"371",
|
||||||
|
"370",
|
||||||
|
"369",
|
||||||
|
"368",
|
||||||
|
"367",
|
||||||
|
"366",
|
||||||
|
"365",
|
||||||
|
"364",
|
||||||
|
"363",
|
||||||
|
"362"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "420",
|
||||||
|
"title": "Powerscore GRE 700 Repeat Offenders",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "354",
|
||||||
|
"title": "Essential English Idioms",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "329",
|
||||||
|
"title": "Barron's Essentials Words for IELTS",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "parent",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": [
|
||||||
|
"339",
|
||||||
|
"338",
|
||||||
|
"337",
|
||||||
|
"336",
|
||||||
|
"335",
|
||||||
|
"334",
|
||||||
|
"333",
|
||||||
|
"332",
|
||||||
|
"331",
|
||||||
|
"330"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "417",
|
||||||
|
"title": "IELTS Listening: OTHERS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "352",
|
||||||
|
"title": "The College Panda 400 SAT words",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "355",
|
||||||
|
"title": "Barron's SAT words",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "634",
|
||||||
|
"title": "Academic word sublist 10",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "633",
|
||||||
|
"title": "Academic word sublist 9",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "632",
|
||||||
|
"title": "Academic word sublist 8",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "631",
|
||||||
|
"title": "Academic word sublist 7",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "630",
|
||||||
|
"title": "Academic word sublist 6",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "629",
|
||||||
|
"title": "Academic word sublist 5",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "628",
|
||||||
|
"title": "Academic word sublist 4",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "627",
|
||||||
|
"title": "Academic word sublist 3",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "626",
|
||||||
|
"title": "Academic word sublist 2",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "625",
|
||||||
|
"title": "Academic word sublist 1",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "624",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "416",
|
||||||
|
"title": "IELTS Listening: VEHICLES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "415",
|
||||||
|
"title": "IELTS Listening: TRANSPORTATIONS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "414",
|
||||||
|
"title": "IELTS Listening: WORKS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "413",
|
||||||
|
"title": "IELTS Listening: EQUIPMENT - TOOLS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "411",
|
||||||
|
"title": "IELTS Listening: ARTS - MEDIA",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "412",
|
||||||
|
"title": "IELTS Listening: SPORTS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "410",
|
||||||
|
"title": "IELTS Listening: TOURING",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "409",
|
||||||
|
"title": "IELTS Listening: ENVIRONMENT",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "408",
|
||||||
|
"title": "IELTS Listening: MATERIALS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "407",
|
||||||
|
"title": "IELTS Listening: HOBBIES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "405",
|
||||||
|
"title": "IELTS Listening: ARCHITECTURE",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "404",
|
||||||
|
"title": "IELTS Listening: QUALITIES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "406",
|
||||||
|
"title": "IELTS Listening: EDUCATION",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "402",
|
||||||
|
"title": "IELTS Listening: PLACES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "403",
|
||||||
|
"title": "IELTS Listening: HOMES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "400",
|
||||||
|
"title": "IELTS Listening: IN THE CITY",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "401",
|
||||||
|
"title": "IELTS Listening: HEALTH",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "399",
|
||||||
|
"title": "IELTS Listening: TIME EXPRESSION",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "398",
|
||||||
|
"title": "IELTS Listening: COLORS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "397",
|
||||||
|
"title": "IELTS Listening: SHAPES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "396",
|
||||||
|
"title": "IELTS Listening: ADJECTIVES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "394",
|
||||||
|
"title": "IELTS Listening: LANGUAGES",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "395",
|
||||||
|
"title": "IELTS Listening: VERBS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "393",
|
||||||
|
"title": "IELTS Listening: COUNTRY",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "392",
|
||||||
|
"title": "IELTS Listening: WEATHER",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "390",
|
||||||
|
"title": "IELTS Listening: MONEY MATTERS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "389",
|
||||||
|
"title": "IELTS Listening: OCEANS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "391",
|
||||||
|
"title": "IELTS Listening: NATURE",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "388",
|
||||||
|
"title": "IELTS Listening: CONTINENTS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "387",
|
||||||
|
"title": "IELTS Listening: MARKETING",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "386",
|
||||||
|
"title": "IELTS Listening: SUBJECTS",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "384",
|
||||||
|
"title": "IELTS Listening: DAYS OF THE WEEK",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "385",
|
||||||
|
"title": "IELTS Listening: MONTHS OF THE YEAR",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "383",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "370",
|
||||||
|
"title": "600 TOEIC words: Entertainment",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "371",
|
||||||
|
"title": "600 TOEIC words: Health",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "369",
|
||||||
|
"title": "600 TOEIC words: Travel",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "368",
|
||||||
|
"title": "600 TOEIC words: Restaurants and Events",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "367",
|
||||||
|
"title": "600 TOEIC words: Management Issues",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "366",
|
||||||
|
"title": "600 TOEIC words: Financing and Budgeting",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "365",
|
||||||
|
"title": "600 TOEIC words: Purchasing",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "364",
|
||||||
|
"title": "600 TOEIC words: Personnel",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "363",
|
||||||
|
"title": "600 TOEIC words: Office Issues",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "362",
|
||||||
|
"title": "600 TOEIC words: General Business",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "361",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "339",
|
||||||
|
"title": "Barron's IELTS: Education",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "338",
|
||||||
|
"title": "Barron's IELTS: Society",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "337",
|
||||||
|
"title": "Barron's IELTS: Business",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "336",
|
||||||
|
"title": "Barron's IELTS: Tourism",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "335",
|
||||||
|
"title": "Barron's IELTS: Health",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "334",
|
||||||
|
"title": "Barron's IELTS: Culture",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "333",
|
||||||
|
"title": "Barron's IELTS: Transportation",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "332",
|
||||||
|
"title": "Barron's IELTS: Leisure Time",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "331",
|
||||||
|
"title": "Barron's IELTS: The Natural World",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "330",
|
||||||
|
"title": "Barron's IELTS: Technology",
|
||||||
|
"is_public": false,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": "329",
|
||||||
|
"child_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "498",
|
||||||
|
"title": "TOEFL Word List",
|
||||||
|
"is_public": true,
|
||||||
|
"type": "term",
|
||||||
|
"parent_id": null,
|
||||||
|
"child_ids": []
|
||||||
|
}
|
||||||
|
]
|
||||||
0
supabase/data/terms/.gitkeep
Normal file
9550
supabase/data/terms/flashcard_terms_1417.json
Normal file
9462
supabase/data/terms/flashcard_terms_1829.json
Normal file
9891
supabase/data/terms/flashcard_terms_1830.json
Normal file
9891
supabase/data/terms/flashcard_terms_1831.json
Normal file
10925
supabase/data/terms/flashcard_terms_1832.json
Normal file
8780
supabase/data/terms/flashcard_terms_1833.json
Normal file
5898
supabase/data/terms/flashcard_terms_1835.json
Normal file
1
supabase/data/terms/flashcard_terms_329.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
651
supabase/data/terms/flashcard_terms_330.json
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "aviation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] the development, design and use of aircraft",
|
||||||
|
"example": "job losses in the aviation industry",
|
||||||
|
"image_url": "/media/decks_media/barron/avia.jpg",
|
||||||
|
"audio_tts_text": "aviation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "back",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] to support, especially financially",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/back.jpg",
|
||||||
|
"audio_tts_text": "back",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "blade",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a thin, flat part of a machine",
|
||||||
|
"example": "a razor blade\nThe blade of the knife flashed in the moonlight.",
|
||||||
|
"image_url": "/media/decks_media/barron/blade.jpg",
|
||||||
|
"audio_tts_text": "blade",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "cable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] wire used for sending electric signals",
|
||||||
|
"example": "cables and switches for computers",
|
||||||
|
"image_url": "/media/decks_media/barron/cable.jpg",
|
||||||
|
"audio_tts_text": "cable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "catastrophic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. extremely bad",
|
||||||
|
"example": "The failure of the talks could have catastrophic consequences.\ncatastrophic floods.",
|
||||||
|
"image_url": "/media/decks_media/barron/catas.JPG",
|
||||||
|
"audio_tts_text": "catastrophic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "clamp",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to fasten or hold tightly",
|
||||||
|
"example": "Creed opened his mouth to speak, then clamped it shut.\nShe clamped her hands over her ears.",
|
||||||
|
"image_url": "/media/decks_media/barron/WGFItsQlaLdRGM5hXA0Hzw_m.jpg",
|
||||||
|
"audio_tts_text": "clamp",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "coarse",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. rough; not smooth",
|
||||||
|
"example": "a jacket of coarse wool.",
|
||||||
|
"image_url": "/media/decks_media/barron/coarse.jpg",
|
||||||
|
"audio_tts_text": "coarse",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "compensate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to make up for; balance out",
|
||||||
|
"example": "Her intelligence more than compensates for her lack of experience.\nBecause my left eye is so weak, my right eye has to work harder to compensate.",
|
||||||
|
"image_url": "/media/decks_media/barron/balance_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "compensate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "confer",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v.(formal) to discuss; consult with somebody",
|
||||||
|
"example": "Franklin conferred with his lawyers.",
|
||||||
|
"image_url": "/media/decks_media/barron/confer.jpg",
|
||||||
|
"audio_tts_text": "confer",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "critical",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. very important",
|
||||||
|
"example": "Many parents are strongly critical of the school.\nHe made some highly critical remarks.",
|
||||||
|
"image_url": "/media/decks_media/barron/critical (1).jpg",
|
||||||
|
"audio_tts_text": "critical",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "cruise",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to travel at a steady speed",
|
||||||
|
"example": "We fly at a cruising speed of 500 mph.\nWe were cruising along at 50 miles per hour.",
|
||||||
|
"image_url": "/media/decks_media/barron/cruise (1).jpg",
|
||||||
|
"audio_tts_text": "cruise",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "curiosity",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[singular, uncountable] interest; need to know",
|
||||||
|
"example": "SChildren have a natural curiosity about the world around them.\nI opened the packet just to satisfy my curiosity.",
|
||||||
|
"image_url": "/media/decks_media/barron/curi.jpg",
|
||||||
|
"audio_tts_text": "curiosity",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "current",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a flow of electricity, water, or air",
|
||||||
|
"example": "In its current state, the car is worth £1,000.\nthe current president.",
|
||||||
|
"image_url": "/media/decks_media/barron/current.png",
|
||||||
|
"audio_tts_text": "current",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "derive",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to get from something; from something else; originate",
|
||||||
|
"example": "This word is derived from Latin.",
|
||||||
|
"image_url": "/media/decks_media/barron/dervice.jpg",
|
||||||
|
"audio_tts_text": "derive",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "design",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] a plan for making something",
|
||||||
|
"example": "the design process.\na course in graphic design\nThe new plane is in its final design stage.",
|
||||||
|
"image_url": "/media/decks_media/barron/graphic-Design.jpg",
|
||||||
|
"audio_tts_text": "design",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "device",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a machine or tool",
|
||||||
|
"example": "a measuring device",
|
||||||
|
"image_url": "/media/decks_media/barron/dev.jpg",
|
||||||
|
"audio_tts_text": "device",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "disparate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj.(formal) different",
|
||||||
|
"example": "the difficulties of dealing with disparate groups of people.\na meeting covering many disparate subjects.",
|
||||||
|
"image_url": "/media/decks_media/barron/dif.jpg",
|
||||||
|
"audio_tts_text": "disparate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "enthusiast",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a person who is very interested in something",
|
||||||
|
"example": "an enthusiast for the latest management thinking (enthusiast for)\na keep-fit enthusiast (baseball/outdoors/sailing etc enthusiast)",
|
||||||
|
"image_url": "/media/decks_media/barron/ennthu.png",
|
||||||
|
"audio_tts_text": "enthusiast",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "entrepreneur",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] someone who starts a business",
|
||||||
|
"example": "The British Library has a range of services for entrepreneurs and small businesses.",
|
||||||
|
"image_url": "/media/decks_media/barron/entrepreneur.jpg",
|
||||||
|
"audio_tts_text": "entrepreneur",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "file",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to officially record something",
|
||||||
|
"example": "The officer left the scene without filing a report.\nThe contracts are filed alphabetically.",
|
||||||
|
"image_url": "/media/decks_media/barron/file.jpg",
|
||||||
|
"audio_tts_text": "file",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "flaw",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a mistake or weakness, especially in design",
|
||||||
|
"example": "a slight flaw in the glass\n(serious/major/basic/minor etc flaw)\na flaw in the software.",
|
||||||
|
"image_url": "/media/decks_media/barron/fault.jpg",
|
||||||
|
"audio_tts_text": "flaw",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "handle",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to manage; work well with",
|
||||||
|
"example": "Computers can handle huge amounts of data.\nI handled most of the paperwork.",
|
||||||
|
"image_url": "/media/decks_media/barron/management_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "handle",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "indispensable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. completely necessary",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "indispensable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inexplicably",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. without explanation",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "inexplicably",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inflexibility",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. inability to change",
|
||||||
|
"example": "inflexible attitudes towards change.\nSome of his employees find him inflexible.",
|
||||||
|
"image_url": "/media/decks_media/barron/inflexible.jpg",
|
||||||
|
"audio_tts_text": "inflexibility",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "infringement",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. an action that breaks a rule or law",
|
||||||
|
"example": "copyright infringement\nThe company doesn’t believe its promotional material is a trademark infringement.",
|
||||||
|
"image_url": "/media/decks_media/barron/infring.jpg",
|
||||||
|
"audio_tts_text": "infringement",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inquiry",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. an official investigation",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "inquiry",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inspiration",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a sudden good idea; a role model for creativity",
|
||||||
|
"example": "Helen had one of her flashes of inspiration",
|
||||||
|
"image_url": "/media/decks_media/barron/inspiration_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "inspiration",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "insulation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] material used to prevent the passage of electricity, heat, or sound",
|
||||||
|
"example": "glass-fibre insulation\nGood insulation can save you money on heating bills.",
|
||||||
|
"image_url": "/media/decks_media/barron/insulation-12g.jpg",
|
||||||
|
"audio_tts_text": "insulation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "invalid",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. not legally correct or acceptable",
|
||||||
|
"example": "Without the right date stamped on it, your ticket will be invalid.",
|
||||||
|
"image_url": "/media/decks_media/barron/invalid_1470756627987.png",
|
||||||
|
"audio_tts_text": "invalid",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inventor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a person who creates new things",
|
||||||
|
"example": "Franklin was a scientist, an inventor, and a statesman.",
|
||||||
|
"image_url": "/media/decks_media/barron/inventor.jpg",
|
||||||
|
"audio_tts_text": "inventor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "investor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. someone who puts money into a business",
|
||||||
|
"example": "The approach is attractive to foreign investors.",
|
||||||
|
"image_url": "/media/decks_media/barron/investor.jpg",
|
||||||
|
"audio_tts_text": "investor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "isolation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] the condition of being alone or separated from others",
|
||||||
|
"example": "the isolation of rural areas.\nthe isolation of older people.",
|
||||||
|
"image_url": "/media/decks_media/barron/isolation-2.jpg",
|
||||||
|
"audio_tts_text": "isolation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "patent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a right to an invention granted by the government",
|
||||||
|
"example": "He wants to take out a patent on his new type of dustbin.\nThe drugs are protected by patent.\nHe applied for a patent for a new method of removing paint.",
|
||||||
|
"image_url": "/media/decks_media/barron/patent.jpg",
|
||||||
|
"audio_tts_text": "patent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "perseverance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] continuation with a task despite difficulties",
|
||||||
|
"example": "It took perseverance to overcome his reading problems.",
|
||||||
|
"image_url": "/media/decks_media/barron/preser.jpg",
|
||||||
|
"audio_tts_text": "perseverance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pitch",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the angle or slope of something",
|
||||||
|
"example": "the steep pitch of the roof",
|
||||||
|
"image_url": "/media/decks_media/barron/pitch.png",
|
||||||
|
"audio_tts_text": "pitch",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prolonged",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. continuing for a long time",
|
||||||
|
"example": "a prolonged period of time.\nProlonged-Labour\nthe region suffered a prolonged drought.",
|
||||||
|
"image_url": "/media/decks_media/barron/Prolonged-Labour.jpg",
|
||||||
|
"audio_tts_text": "prolonged",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "propeller",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a device that causes an airplane or boat to move",
|
||||||
|
"example": "Three hundred seventy propellers were replaced.",
|
||||||
|
"image_url": "/media/decks_media/barron/propeller.jpg",
|
||||||
|
"audio_tts_text": "propeller",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "rally",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to come together, or to bring people together, to support an idea, a political party etc",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/rally.jpg",
|
||||||
|
"audio_tts_text": "rally",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ransack",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to go through a place, stealing things and causing damage.to search a place very thoroughly, often making it untidy",
|
||||||
|
"example": "She ransacked the wardrobe for something to wear\n(ransack something for something)\nThe whole flat had been ransacked.",
|
||||||
|
"image_url": "/media/decks_media/barron/ransack.jpg",
|
||||||
|
"audio_tts_text": "ransack",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "refinement",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] an improvement, usually a small one, to something",
|
||||||
|
"example": "the refinement of uranium.\nThe new model has a number of refinements.",
|
||||||
|
"image_url": "/media/decks_media/barron/refinement.jpg",
|
||||||
|
"audio_tts_text": "refinement",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reliably",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. dependably",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "reliably",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "requisite",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. need; requirement",
|
||||||
|
"example": "He lacks the requisite qualifications.",
|
||||||
|
"image_url": "/media/decks_media/barron/urgent.jpg",
|
||||||
|
"audio_tts_text": "requisite",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "revolutionize",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to change completely",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "revolutionize",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "rotation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. turning motion",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "rotation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ruling",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] an official decision, especially one made by a court",
|
||||||
|
"example": "the recent Supreme Court ruling on defendants’ rights",
|
||||||
|
"image_url": "/media/decks_media/barron/ruling.jpg",
|
||||||
|
"audio_tts_text": "ruling",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "set out",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to start an activity",
|
||||||
|
"example": "The band are setting out on a European tour in March.\nsalesmen who deliberately set out to defraud customers.",
|
||||||
|
"image_url": "/media/decks_media/barron/set out.jpg",
|
||||||
|
"audio_tts_text": "set out",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "snap",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to break suddenly",
|
||||||
|
"example": "I snapped the ends off the beans and dropped them into a bowl\n(snap (something) off (something))\nThe teacher snapped the chalk in two and gave me a piece\n(snap (something) in two/in half (=break into two pieces))",
|
||||||
|
"image_url": "/media/decks_media/barron/snap.jpg",
|
||||||
|
"audio_tts_text": "snap",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "suitable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. appropriate; acceptable for something",
|
||||||
|
"example": "The house is not really suitable for a large family\n(suitable for somebody/something)\nThe house is not really suitable for a large family\n(suitable for somebody/something)",
|
||||||
|
"image_url": "/media/decks_media/barron/suitable.jpg",
|
||||||
|
"audio_tts_text": "suitable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "sustained",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. having the ability to continue for a long time",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "sustained",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "tow",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to pull behind",
|
||||||
|
"example": "Our car had been towed away\n(tow something away)\nThe ship had to be towed into the harbor.",
|
||||||
|
"image_url": "/media/decks_media/barron/tow.jpg",
|
||||||
|
"audio_tts_text": "tow",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transmit",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to send",
|
||||||
|
"example": "Mathematical knowledge is transmitted from teacher to student\n(transmit something (from somebody/something) to somebody/something)\nThe system transmits information over digital phone lines.",
|
||||||
|
"image_url": "/media/decks_media/barron/transmit.GIF",
|
||||||
|
"audio_tts_text": "transmit",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "triumph",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v.(formal) to succeed; win",
|
||||||
|
"example": "Trump triumphed\nIn the end, good shall triumph over evil.",
|
||||||
|
"image_url": "/media/decks_media/barron/triumph (1).jpg",
|
||||||
|
"audio_tts_text": "triumph",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "turbulence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. strong, sudden movements in air",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "turbulence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "unveil",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to make public; to uncover",
|
||||||
|
"example": "The statue was unveiled by the Queen.\nThe club has unveiled plans to build a new stadium.",
|
||||||
|
"image_url": "/media/decks_media/barron/unveil.jpg",
|
||||||
|
"audio_tts_text": "unveil",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "utterly",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. totally",
|
||||||
|
"example": "You look utterly miserable.",
|
||||||
|
"image_url": "/media/decks_media/barron/whole-3 (1).jpg",
|
||||||
|
"audio_tts_text": "utterly",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "variable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. able to change",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "variable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Vilified",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. having one's reputation ruined; being spoken about in a bad way",
|
||||||
|
"example": "Johnson was vilified in the press for refusing to resign\n(vilify somebody/something for (doing) something)",
|
||||||
|
"image_url": "/media/decks_media/barron/Vilified.jpg",
|
||||||
|
"audio_tts_text": "Vilified",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "voltage",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable, uncountable] measure of electric power",
|
||||||
|
"example": "low-voltage electrical current.",
|
||||||
|
"image_url": "/media/decks_media/barron/voltage.jpg",
|
||||||
|
"audio_tts_text": "voltage",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
}
|
||||||
|
]
|
||||||
1003
supabase/data/terms/flashcard_terms_331.json
Normal file
1058
supabase/data/terms/flashcard_terms_332.json
Normal file
1036
supabase/data/terms/flashcard_terms_333.json
Normal file
893
supabase/data/terms/flashcard_terms_334.json
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "abstract",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "based on general ideas and not on any particular real person, thing or situation, not realistic (TRỪU TƯỢNG)",
|
||||||
|
"example": "We may talk of beautiful things but beauty itself is abstract.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-16213501542401 (1).jpg",
|
||||||
|
"audio_tts_text": "abstract",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "accompaniment",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the noun form of \"accompany\"",
|
||||||
|
"example": "Te accompaniment of drums andchants helps the hula dancersmaintain their energy",
|
||||||
|
"image_url": "/media/decks_media/barron/accompaniment.jpg",
|
||||||
|
"audio_tts_text": "accompaniment",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "accompany",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to go wit, happen at the se time",
|
||||||
|
"example": "strong winds accompanied by heavy rain\nHis wife accompanied him on the trip.",
|
||||||
|
"image_url": "/media/decks_media/barron/505_fit512.jpg",
|
||||||
|
"audio_tts_text": "accompany",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "adopt",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to start to use a particular method or to show a particular attitude towards somebody/something",
|
||||||
|
"example": "All three teams adopted different approaches to the problem.",
|
||||||
|
"image_url": "/media/decks_media/barron/when_to_use_use_cases.jpg",
|
||||||
|
"audio_tts_text": "adopt",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "agricultural",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "related to farmig",
|
||||||
|
"example": "agricultural policy/land/production/development",
|
||||||
|
"image_url": "/media/decks_media/barron/1560183_orig.jpg",
|
||||||
|
"audio_tts_text": "agricultural",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "agriculture",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the science or practice of farming",
|
||||||
|
"example": "The number of people employed in agriculture has fallen in the last decade.\n50% of the country’s population depends on agriculture.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-35334695944193.jpg",
|
||||||
|
"audio_tts_text": "agriculture",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "altar",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a holy table for religous ceremonies (BÀN THỜ)",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/paste-4741643894785.jpg",
|
||||||
|
"audio_tts_text": "altar",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "atmosphere",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the feeling or mood that you have in a particular place or situation (BẦU KHÔNG KHÍ)",
|
||||||
|
"example": "Use music and lighting to create a romantic atmosphere.\nThe hotel offers a friendly atmosphere and personal service.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-16612933500929 (1).jpg",
|
||||||
|
"audio_tts_text": "atmosphere",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "beneficial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "having a helpful or useful effect",
|
||||||
|
"example": "Tourism is beneficial to theeconomy of Hawaii.",
|
||||||
|
"image_url": "/media/decks_media/barron/content-curation.png",
|
||||||
|
"audio_tts_text": "beneficial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "benefit",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "an advantage that something gives you; a helpful and useful effect that something has (LỢI ÍCH)",
|
||||||
|
"example": "He couldn't see the benefit of arguing any longer.\nI've had the benefit of a good education.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-5179730558977 (1).jpg",
|
||||||
|
"audio_tts_text": "benefit",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "benefit",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to be in a better position because of something",
|
||||||
|
"example": "Hawaii benefits fom the largenumbers of tourists who visitthe islands",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-8839042695169.jpg",
|
||||||
|
"audio_tts_text": "benefit",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "carve",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make objects, patterns, etc. by cutting away material from wood or stone",
|
||||||
|
"example": "The statue was carved out of a single piece of stone.",
|
||||||
|
"image_url": "/media/decks_media/barron/4d15103ba2b5f42735c73c237f90984d.jpg",
|
||||||
|
"audio_tts_text": "carve",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "celebrate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to show that a day or an event is important by doing something special on it",
|
||||||
|
"example": "People like to celebrate importantevents by dancing\nJake's passed his exams\nWe're going out to celebrate.",
|
||||||
|
"image_url": "/media/decks_media/barron/Labor-Day-.jpg",
|
||||||
|
"audio_tts_text": "celebrate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "celebration",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a special event that people organize in order to celebrate something",
|
||||||
|
"example": "a party in celebration of their fiftieth wedding anniversary\nbirthday/wedding celebrations",
|
||||||
|
"image_url": "/media/decks_media/barron/birthday-celebration-despicable-me-minions-35194953-284-177.jpg",
|
||||||
|
"audio_tts_text": "celebration",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "celebratory",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the adjective form of \"celebrate\"",
|
||||||
|
"example": "Celebrator dances wereperformed in honor of the kng",
|
||||||
|
"image_url": "/media/decks_media/barron/Celebratory-toast.jpg",
|
||||||
|
"audio_tts_text": "celebratory",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "civilization",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a society, its culture and its way of life during a particular period of time or in a particular part of the world",
|
||||||
|
"example": "the civilizations of ancient Greece and Rome",
|
||||||
|
"image_url": "/media/decks_media/barron/nf1098_exploring-the-ancient-civilization_maya2.jpg",
|
||||||
|
"audio_tts_text": "civilization",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "conflict",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a situation in which there are opposing ideas, opinions, feelings or wishes; a situation in which it is difficult to choose",
|
||||||
|
"example": "The story tells of a classic conflict between love and duty.",
|
||||||
|
"image_url": "/media/decks_media/barron/conflict-management-980x505.jpg",
|
||||||
|
"audio_tts_text": "conflict",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "considerable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "great in amount, size, importance, etc. (= significant)",
|
||||||
|
"example": "The project wasted a considerable amount of time and money.\nDamage to the building was considerable.\nConsiderable progress has been made in finding a cure for the disease.",
|
||||||
|
"image_url": "/media/decks_media/barron/considerable-quantity-copper-copecks-12767049.jpg",
|
||||||
|
"audio_tts_text": "considerable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "considerably",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the adverb form of \"considerable\"",
|
||||||
|
"example": "The need for sleep varies considerably from person to person.\nInterest rates on bank loans have increased considerably in recent years.",
|
||||||
|
"image_url": "/media/decks_media/barron/mcdonaldsinafrica1.jpg",
|
||||||
|
"audio_tts_text": "considerably",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "creation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the act or process of making something that is new, or of causing something to exist that did not exist before",
|
||||||
|
"example": "Te ancient Sumers usedclay ad reeds for the creation ofproprt records.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-35867271888897.jpg",
|
||||||
|
"audio_tts_text": "creation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "creator",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a person who has made or invented a particular thing",
|
||||||
|
"example": "Walt Disney, the creator of Mickey Mouse",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-6648609374209 (1).jpg",
|
||||||
|
"audio_tts_text": "creator",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "culminate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to end with a particular result, or at a particular point",
|
||||||
|
"example": "a gun battle which culminated in the death of two police officers\nMonths of hard work culminated in success.\nTheir summer tour will culminate at a spectacular concert in London.",
|
||||||
|
"image_url": "/media/decks_media/barron/grades.gif",
|
||||||
|
"audio_tts_text": "culminate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "deed",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a thing that somebody does that is usually very good or very bad",
|
||||||
|
"example": "a brave/charitable/evil/good deed\na tale of heroic deeds",
|
||||||
|
"image_url": "/media/decks_media/barron/Good-deed-R2.jpg",
|
||||||
|
"audio_tts_text": "deed",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "discourage",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to try to prevent something or to prevent somebody from doing something ( >< ENCOURAGE)",
|
||||||
|
"example": "a campaign to discourage smoking among teenagers",
|
||||||
|
"image_url": "/media/decks_media/barron/aid615435-728px-Discourage-People-from-Messing-With-You-Step-4.jpg",
|
||||||
|
"audio_tts_text": "discourage",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "effect",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a result",
|
||||||
|
"example": "His sklled perforce was theeffect of years of experience.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-24859270709249.jpg",
|
||||||
|
"audio_tts_text": "effect",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "effect",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(quite rare and formal) to make something happen, to achieve or produce",
|
||||||
|
"example": "He worked hard to effect change.",
|
||||||
|
"image_url": "/media/decks_media/barron/3b2cd6a.jpg",
|
||||||
|
"audio_tts_text": "effect",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "effective",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "producing the result that is wanted or intended; producing a successful result",
|
||||||
|
"example": "Aspirin is a simple but highly effective treatment.",
|
||||||
|
"image_url": "/media/decks_media/barron/img3B.jpg",
|
||||||
|
"audio_tts_text": "effective",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "effectively",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the adverb form of \"effective\"",
|
||||||
|
"example": "The company must reduce costs to compete effectively.\nYou dealt with the situation very effectively.",
|
||||||
|
"image_url": "/media/decks_media/barron/How-to-Effectively-Manage-Your-PR-Agency.jpg",
|
||||||
|
"audio_tts_text": "effectively",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "elaborate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "very complicated and detailed; carefully prepared and organized",
|
||||||
|
"example": "elaborate designs\nShe had prepared a very elaborate meal.\nan elaborate computer system",
|
||||||
|
"image_url": "/media/decks_media/barron/779db0285b58b32973f9906c2fcfeb6c.jpg",
|
||||||
|
"audio_tts_text": "elaborate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "emerge",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to start to exist; to appear or become known",
|
||||||
|
"example": "After the elections opposition groups began to emerge.\nthe emerging markets of South Asia",
|
||||||
|
"image_url": "/media/decks_media/barron/11. Butterfly emerge 4 web.jpg",
|
||||||
|
"audio_tts_text": "emerge",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "encompass",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to include a large number or range of things",
|
||||||
|
"example": "The job encompasses a wide range of responsibilities.\nThe group encompasses all ages.",
|
||||||
|
"image_url": "/media/decks_media/barron/include-everyone.gif",
|
||||||
|
"audio_tts_text": "encompass",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "energetic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "having a lot of energy and enthusiasm",
|
||||||
|
"example": "He knew I was energetic and dynamic and would get things done.\nan energetic supporter",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-6897717477377.jpg",
|
||||||
|
"audio_tts_text": "energetic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "energetically",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the adverb form of \"energetic\"",
|
||||||
|
"example": "The dancers performedenergetically all evening.",
|
||||||
|
"image_url": "/media/decks_media/barron/stock-photo-four-young-people-dancing-energetically-76990903.jpg",
|
||||||
|
"audio_tts_text": "energetically",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "energize",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make somebody enthusiastic about something",
|
||||||
|
"example": "Te beating of the drumsenergized the crowd.",
|
||||||
|
"image_url": "/media/decks_media/barron/8-Steps-to-Energize-Your-Life.jpg",
|
||||||
|
"audio_tts_text": "energize",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "energy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the physical and mental effort that you use to do something",
|
||||||
|
"example": "It takes a geat deal of energyto dance hula.",
|
||||||
|
"image_url": "/media/decks_media/barron/2016-07-12-1468334538-63186-energy.jpg",
|
||||||
|
"audio_tts_text": "energy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "evidence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the facts, signs or objects that make you believe that something is true (BẰNG CHỨNG)",
|
||||||
|
"example": "We found further scientific evidence for this theory.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-7743826034689.jpg",
|
||||||
|
"audio_tts_text": "evidence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "evoke",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to bring a feeling, a memory or an image into your mind (GỢI LẠI)",
|
||||||
|
"example": "The music evoked memories of her youth.\nHis case is unlikely to evoke public sympathy.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-8130373091329.jpg",
|
||||||
|
"audio_tts_text": "evoke",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "exaggerate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make something seem larger, better, worse or more important than it really is",
|
||||||
|
"example": "I'm sure he exaggerates his Irish accent (= tries to sound more Irish than he really is).\nThe hotel was really filthy and I'm not exaggerating.",
|
||||||
|
"image_url": "/media/decks_media/barron/Screen-Shot-2012-10-29-at-10.30.22-PM.png",
|
||||||
|
"audio_tts_text": "exaggerate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "exaggerated",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "made to seem larger, better, worse or more important than it really is or needs to be",
|
||||||
|
"example": "to make greatly/grossly/wildly exaggerated claims\nShe has an exaggerated sense of her own importance.",
|
||||||
|
"image_url": "/media/decks_media/barron/140711172348exaggeration.jpg",
|
||||||
|
"audio_tts_text": "exaggerated",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "exaggeration",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a statement or description that makes something seem larger, better, worse or more important than it really is; the act of making a statement like this",
|
||||||
|
"example": "It would be an exaggeration to say I knew her well—I only met her twice.",
|
||||||
|
"image_url": "/media/decks_media/barron/exaggeration.jpg",
|
||||||
|
"audio_tts_text": "exaggeration",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "excavation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a place where people are digging to look for old buildings or objects",
|
||||||
|
"example": "The excavations are open to the public.",
|
||||||
|
"image_url": "/media/decks_media/barron/23saw_bigpit1-blog480.jpg",
|
||||||
|
"audio_tts_text": "excavation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "floral",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "related to flowers",
|
||||||
|
"example": "wallpaper with a floral design/pattern\na floral dress",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-8461085573121_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "floral",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "frail",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(especially of an old person) physically weak and thinweak; easily damaged or broken",
|
||||||
|
"example": "the frail stems of the flowersHuman nature is frail.\nthe frail stems of the flowers\nHuman nature is frail.\nMother was becoming too frail to live alone.",
|
||||||
|
"image_url": "/media/decks_media/barron/bigstock-Son-Elderly-Father-2904425.jpg",
|
||||||
|
"audio_tts_text": "frail",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "frailty",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "weakness and poor health",
|
||||||
|
"example": "We are all subject to the frailties of human nature.",
|
||||||
|
"image_url": "/media/decks_media/barron/superman-sm.jpg",
|
||||||
|
"audio_tts_text": "frailty",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "function",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to work in the correct way",
|
||||||
|
"example": "Despite the power cuts, the hospital continued to function normally",
|
||||||
|
"image_url": "/media/decks_media/barron/MA_SafetyonBoard_w734.png",
|
||||||
|
"audio_tts_text": "function",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "garland",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a circle of flowers and leaves that is worn on the head or around the neck or is hung in a room as decoration (VÒNG HOA)",
|
||||||
|
"example": "The office was decked with garlands for the party.",
|
||||||
|
"image_url": "/media/decks_media/barron/garland.jpgbe37fc79-b104-4c06-8c6d-51e9d2588d8eLarger.jpg",
|
||||||
|
"audio_tts_text": "garland",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "gesture",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a movement that you make with your hands, your head or your face to show a particular meaning",
|
||||||
|
"example": "He made a rude gesture at the driver of the other car.\nShe finished what she had to say with a gesture of despair.\nThey communicated entirely by gesture.",
|
||||||
|
"image_url": "/media/decks_media/barron/a683b85450d05b41b4e119bc11f34ad954617781.jpg",
|
||||||
|
"audio_tts_text": "gesture",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "graceful",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "havng beaut of movement",
|
||||||
|
"example": "Dolphins are incredibly graceful and efficient swimmers.\nHe gave a graceful bow to the audience.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-9242769620993.jpg",
|
||||||
|
"audio_tts_text": "graceful",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "humorous",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "funny and entertaining; showing a sense of humour",
|
||||||
|
"example": "He gave a humorous account of their trip to Spain.\na humorous look at the world of fashion\nHe had a wide mouth and humorous grey eyes.",
|
||||||
|
"image_url": "/media/decks_media/barron/1B255508000005DC-0-image-a-102_1458350569326.jpg",
|
||||||
|
"audio_tts_text": "humorous",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "illusion",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a false idea or belief, especially about somebody or about a situation",
|
||||||
|
"example": "She's under the illusion that (= believes wrongly that) she'll get the job.",
|
||||||
|
"image_url": "/media/decks_media/barron/A.jpg",
|
||||||
|
"audio_tts_text": "illusion",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "image",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a mental picture that you have of what somebody/something is like or looks like",
|
||||||
|
"example": "images of the past\nI had a mental image of what she would look like.",
|
||||||
|
"image_url": "/media/decks_media/barron/trolltunga.jpg",
|
||||||
|
"audio_tts_text": "image",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "influence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the effect that somebody/something has on the way a person thinks or behaves or on the way that something works or develops",
|
||||||
|
"example": "to have/exert a strong influence on somebody\nthe influence of the climate on agricultural production\nWhat exactly is the influence of television on children?",
|
||||||
|
"image_url": "/media/decks_media/barron/20141219182513-want-gain-influence-social-media-work.jpeg",
|
||||||
|
"audio_tts_text": "influence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "influence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to have an effect on the way that somebody behaves or thinks, especially by giving them an example to follow",
|
||||||
|
"example": "Ancient hula influenced themodem style of hula dancing.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-13597866459137.jpg",
|
||||||
|
"audio_tts_text": "influence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "influential",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "having a lot of influence on somebody/something",
|
||||||
|
"example": "King David Klaaua wasinfluential in the retum to old\ntraditions",
|
||||||
|
"image_url": "/media/decks_media/barron/time100landingimage.jpg",
|
||||||
|
"audio_tts_text": "influential",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inscribe",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to write or cut words, your name, etc. onto something",
|
||||||
|
"example": "His name was inscribed on the trophy.",
|
||||||
|
"image_url": "/media/decks_media/barron/1294509697-inscribe.jpg",
|
||||||
|
"audio_tts_text": "inscribe",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "literacy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the ability to read and write (opposite: illiteracy)",
|
||||||
|
"example": "basic literacy skills\na campaign to promote adult literacy",
|
||||||
|
"image_url": "/media/decks_media/barron/new-literacy-centers-whoo-hoo-B96mFK-clipart.gif",
|
||||||
|
"audio_tts_text": "literacy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "literal",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "folowing the exact meaning (THEO ĐÚNG NGHĨA ĐEN)",
|
||||||
|
"example": "The literal meaning of ‘petrify’ is ‘turn to stone’.",
|
||||||
|
"image_url": "/media/decks_media/barron/literal-signs.jpg",
|
||||||
|
"audio_tts_text": "literal",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "merge",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to combine or make two or more things combine to form a single thing",
|
||||||
|
"example": "The banks are set to merge next year.\nThe two groups have merged to form a new party.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-21680994910209 (2).jpg",
|
||||||
|
"audio_tts_text": "merge",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "mythology",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "set of traditional stories used to explain the origns of things",
|
||||||
|
"example": "Greek mythology\na study of the religions and mythologies of ancient Rome",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-32018981191681.jpg",
|
||||||
|
"audio_tts_text": "mythology",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "portray",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to represent, act out",
|
||||||
|
"example": "Throughout the trial, he portrayed himself as the victim.\nHer father will be portrayed by Sean Connery.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-22071836934145 (1).jpg",
|
||||||
|
"audio_tts_text": "portray",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 59
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "portrayal",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the noun form of \"portray\"",
|
||||||
|
"example": "The article examines the portrayal of gay men in the media.\nHe is best known for his chilling portrayal of Hannibal Lecter.",
|
||||||
|
"image_url": "/media/decks_media/barron/the-new-superman-isn_t-as-memorable-as-christopher-reeve_s-iconic-portrayal.jpg",
|
||||||
|
"audio_tts_text": "portrayal",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prominent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "important or well known",
|
||||||
|
"example": "a prominent politician\nHe played a prominent part in the campaign.\nShe was prominent in the fashion industry.",
|
||||||
|
"image_url": "/media/decks_media/barron/Steve-Jobs-is-now-in-the-iCloud-1.png",
|
||||||
|
"audio_tts_text": "prominent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 61
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prop",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "an object used by actors",
|
||||||
|
"example": "He is responsible for all the stage props and lighting.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-22570053140481.jpg",
|
||||||
|
"audio_tts_text": "prop",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "property",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a thing or things that are owned by somebody",
|
||||||
|
"example": "This building is government property.\nBe careful not to damage other people's property.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-34106335297537.jpg",
|
||||||
|
"audio_tts_text": "property",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reign",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the period during which a king, queen, emperor, etc. rules (TRIỀU ĐẠI)",
|
||||||
|
"example": "in/during the reign of Charles II",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-4737348927489.jpg",
|
||||||
|
"audio_tts_text": "reign",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reminisce",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to think, talk or write about a happy time in your past",
|
||||||
|
"example": "We spent a happy evening reminiscing about the past.",
|
||||||
|
"image_url": "/media/decks_media/barron/reminisce.jpg",
|
||||||
|
"audio_tts_text": "reminisce",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reminiscence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the act of remembering things that happened in the past (= recollection)",
|
||||||
|
"example": "Reminiscences of the early days off would include stories of starssuch as Charlie Chaplin andBuster Keaton.",
|
||||||
|
"image_url": "/media/decks_media/barron/qqy6ww.jpg",
|
||||||
|
"audio_tts_text": "reminiscence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reminiscent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "reminding you of somebody/something",
|
||||||
|
"example": "The way he laughed was strongly reminiscent of his father.\nShe writes in a style reminiscent of both Proust and Faulkner.",
|
||||||
|
"image_url": "/media/decks_media/barron/maxresdefault (15).jpg",
|
||||||
|
"audio_tts_text": "reminiscent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "renowned",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "famous and respected",
|
||||||
|
"example": "a renowned author\nWe asked for advice from the renowned legal expert, Sam Pincher.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-23540715749377.jpg",
|
||||||
|
"audio_tts_text": "renowned",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "scholar",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a person who knows a lot about a particular subject because they have studied it in detail",
|
||||||
|
"example": "a classical scholar\nHe was the most distinguished scholar in his field.",
|
||||||
|
"image_url": "/media/decks_media/barron/aid1935030-728px-Become-a-Scholar-Step-13.jpg",
|
||||||
|
"audio_tts_text": "scholar",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "settle",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make a place your permanent home",
|
||||||
|
"example": "She settled in Vienna after her father's death.",
|
||||||
|
"image_url": "/media/decks_media/barron/Optimized-Screen-Shot-2016-05-04-at-19.13.48-860x450.jpg",
|
||||||
|
"audio_tts_text": "settle",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "sharpen",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to become or make something better, more skilful, more effective, etc. than before",
|
||||||
|
"example": "She's doing a course to sharpen her business skills",
|
||||||
|
"image_url": "/media/decks_media/barron/29mccall-articleLarge.jpg",
|
||||||
|
"audio_tts_text": "sharpen",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 71
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "specialized",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "designed or developed for a particular purpose or area of knowledge",
|
||||||
|
"example": "specialized equipment\nspecialized skills",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-32435593019393 (1).jpg",
|
||||||
|
"audio_tts_text": "specialized",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stereotype",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a fixed idea or image that many people have of a particular type of person or thing, but which is often not true in reality",
|
||||||
|
"example": "cultural/gender/racial stereotypes\nHe doesn't conform to the usual stereotype of the businessman with a dark suit and briefcase.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-5222680231937.jpg",
|
||||||
|
"audio_tts_text": "stereotype",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 73
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "structure",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a thing that is made of several parts, especially a building",
|
||||||
|
"example": "a stone/brick/wooden structure",
|
||||||
|
"image_url": "/media/decks_media/barron/Poker-Structure.jpg",
|
||||||
|
"audio_tts_text": "structure",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "sway",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to move slowly from side to side; to move something in this way",
|
||||||
|
"example": "The branches were swaying in the wind.\nVicky swayed and fell.\nThey danced rhythmically, swaying their hips to the music.",
|
||||||
|
"image_url": "/media/decks_media/barron/wind8.jpg",
|
||||||
|
"audio_tts_text": "sway",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "tablet",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a flat piece of stone that has words written on it",
|
||||||
|
"example": "The school has a memorial tablet engraved with the name of the founder.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-33247341838337.jpg",
|
||||||
|
"audio_tts_text": "tablet",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "token",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "something that is a symbol of a feeling, a fact, an event, etc.",
|
||||||
|
"example": "Please accept this small gift as a token of our gratitude.",
|
||||||
|
"image_url": "/media/decks_media/barron/tokens.png",
|
||||||
|
"audio_tts_text": "token",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "tradition",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "truyền thống",
|
||||||
|
"example": "The British are said to love tradition\nreligious/cultural, etc\ntraditions",
|
||||||
|
"image_url": "/media/decks_media/barron/carvingpumpkins.jpg",
|
||||||
|
"audio_tts_text": "tradition",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "traditional",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the adjective form of \"traditional\"",
|
||||||
|
"example": "Hula is the traditional dance ofHawaii.",
|
||||||
|
"image_url": "/media/decks_media/barron/traditional_1.jpg",
|
||||||
|
"audio_tts_text": "traditional",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 79
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "traditionally",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the adverb form of \"tradition\"",
|
||||||
|
"example": "Hula was traditionally performedin honor of the gods.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-14705968021505_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "traditionally",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
981
supabase/data/terms/flashcard_terms_335.json
Normal file
@@ -0,0 +1,981 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "abroad",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "in or to a foreign country",
|
||||||
|
"example": "to be/go/travel/live abroad\nShe worked abroad for a year.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-33092723015681.jpg",
|
||||||
|
"audio_tts_text": "abroad",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "absorb",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to take in a liquid, gas or other substance from the surface or space around",
|
||||||
|
"example": "Plants absorb carbon dioxide from the air.\nLet the rice cook until it has absorbed all the water.",
|
||||||
|
"image_url": "/media/decks_media/barron/android_18_absorb_by_godvore-d9bd88c.png",
|
||||||
|
"audio_tts_text": "absorb",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "absorbent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "able to take in something easily, especially liquid",
|
||||||
|
"example": "absorbent paper/materials",
|
||||||
|
"image_url": "/media/decks_media/barron/products_image_18.jpg",
|
||||||
|
"audio_tts_text": "absorbent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "absorption",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "noun form of \"absorb\"",
|
||||||
|
"example": "Vitamin D is necessary to aid the absorption of calcium from food.",
|
||||||
|
"image_url": "/media/decks_media/barron/rtalight.jpg",
|
||||||
|
"audio_tts_text": "absorption",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "administer",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(formal) to give drugs, medicine, etc. to somebody",
|
||||||
|
"example": "Police believe his wife could not have administered the poison.",
|
||||||
|
"image_url": "/media/decks_media/barron/aid177206-728px-Administer-General-Anesthesia-Step-2-Version-2.jpg",
|
||||||
|
"audio_tts_text": "administer",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "aerobic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(of physical exercise) especially designed to improve the function of the heart and lungs",
|
||||||
|
"example": "aerobic exercise",
|
||||||
|
"image_url": "/media/decks_media/barron/tap-aerobic-co-tot-khong-jpg-201503261741120SttPbKFkS.jpg",
|
||||||
|
"audio_tts_text": "aerobic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alleviate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make something less severe",
|
||||||
|
"example": "to alleviate suffering\nA number of measures were taken to alleviate the problem.",
|
||||||
|
"image_url": "/media/decks_media/barron/use_acupuncture_to_help_alleviate_symptoms_of_bipolar_disorder.jpg",
|
||||||
|
"audio_tts_text": "alleviate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ascertain",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to find out the true or correct information about something",
|
||||||
|
"example": "I ascertained that the driver was not badly hurt.\nCould you ascertain whether she will be coming to the meeting?\nIt can be difficult to ascertain the facts.",
|
||||||
|
"image_url": "/media/decks_media/barron/15372987-Lawyer-Ascertain-Stock-Photo.jpg",
|
||||||
|
"audio_tts_text": "ascertain",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "bulk",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(of something) the main part of something; most of something",
|
||||||
|
"example": "The bulk of the population lives in cities.",
|
||||||
|
"image_url": "/media/decks_media/barron/screenshot1.png",
|
||||||
|
"audio_tts_text": "bulk",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "capacity",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the total number of things or people that a container or space can hold",
|
||||||
|
"example": "The theatre has a seating capacity of 2 000.\na fuel tank with a capacity of 50 litres",
|
||||||
|
"image_url": "/media/decks_media/barron/T-N-116-Capacity-display-posters-cup_ver_1.jpg",
|
||||||
|
"audio_tts_text": "capacity",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "chronic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(especially of a disease) lasting for a long time; difficult to cure or get rid of",
|
||||||
|
"example": "chronic bronchitis/arthritis/asthma\nthe country’s chronic unemployment problem\na chronic shortage of housing in rural areas",
|
||||||
|
"image_url": "/media/decks_media/barron/pain-management-los-angeles.jpg",
|
||||||
|
"audio_tts_text": "chronic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "cognition",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the use of mental processes",
|
||||||
|
"example": "The connections between cognition and language seem to be similar in all cultures.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-6627134537729 (1).jpg",
|
||||||
|
"audio_tts_text": "cognition",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "combat",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to stop something unpleasant or harmful from happening or from getting worse",
|
||||||
|
"example": "measures to combat crime/inflation/unemployment/disease",
|
||||||
|
"image_url": "/media/decks_media/barron/fight_back_fists.png",
|
||||||
|
"audio_tts_text": "combat",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "complex",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "complicated, difficult to understand",
|
||||||
|
"example": "a complex argument/problem/subject\nthe complex structure of the human brain",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-34398393073665 (1).jpg",
|
||||||
|
"audio_tts_text": "complex",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "complexity",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "noun form of \"complex\"",
|
||||||
|
"example": "the increasing complexity of modern telecommunication systems\nI was astonished by the size and complexity of the problem.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-5682241732609 (1).jpg",
|
||||||
|
"audio_tts_text": "complexity",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "concentration",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(of something) a lot of something in one place",
|
||||||
|
"example": "a concentration of industry in the north of the country",
|
||||||
|
"image_url": "/media/decks_media/barron/sapporo_buildings.jpg",
|
||||||
|
"audio_tts_text": "concentration",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "counteract",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to work against something ; to reduce or prevent the bad or harmful effects of something",
|
||||||
|
"example": "These exercises aim to counteract the effects of stress and tension.",
|
||||||
|
"image_url": "/media/decks_media/barron/netForce2.png",
|
||||||
|
"audio_tts_text": "counteract",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "cripple",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to seriously damage or harm somebody/something",
|
||||||
|
"example": "The industry has been financially crippled by these policies.\nHe was crippled by polio as a child.",
|
||||||
|
"image_url": "/media/decks_media/barron/4_help-any-cripple.jpg",
|
||||||
|
"audio_tts_text": "cripple",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "culture",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(specialist) the growing of plants or breeding of particular animals in order to get a particular substance or crop from them",
|
||||||
|
"example": "the culture of silkworms (= for silk)",
|
||||||
|
"image_url": "/media/decks_media/barron/nextgov-medium.jpg",
|
||||||
|
"audio_tts_text": "culture",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "decade",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a period of ten years, especially a continuous period, such as 1910–1919 or 2000–2009",
|
||||||
|
"example": "The past decade has seen a huge rise in the number of broadband users.",
|
||||||
|
"image_url": "/media/decks_media/barron/years-2009.jpg",
|
||||||
|
"audio_tts_text": "decade",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "decline",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to become smaller, fewer, weaker, etc.",
|
||||||
|
"example": "Support for the party continues to decline.\nThe number of tourists to the resort declined by 10% last year.",
|
||||||
|
"image_url": "/media/decks_media/barron/decline1.jpg",
|
||||||
|
"audio_tts_text": "decline",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "deem",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to have a particular opinion about something",
|
||||||
|
"example": "The evening was deemed a great success.\nI deem it an honour to be invited.\nShe deemed it prudent not to say anything.\nThey would take any action deemed necessary",
|
||||||
|
"image_url": "/media/decks_media/barron/deem-scrutinizing1.jpg",
|
||||||
|
"audio_tts_text": "deem",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "dementia",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a serious mental disorder caused by brain disease or injury, that affects the ability to think, remember and behave normally",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/AlzvsDem690x400.jpg",
|
||||||
|
"audio_tts_text": "dementia",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "desirable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(formal) that you would like to have or do; worth having or doing",
|
||||||
|
"example": "The house has many desirable features.\nShe chatted for a few minutes about the qualities she considered desirable in a secretary.",
|
||||||
|
"image_url": "/media/decks_media/barron/18jgupgscc8hujpg.jpg",
|
||||||
|
"audio_tts_text": "desirable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "desire",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a strong wish to have or do something",
|
||||||
|
"example": "a strong desire for power\nShe felt an overwhelming desire to return home.\nenough money to satisfy all your desires",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-14864881811457 (1).jpg",
|
||||||
|
"audio_tts_text": "desire",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "desire",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(formal) to want something; to wish for something",
|
||||||
|
"example": "We all desire health and happiness.\nThe house had everything you could desire.",
|
||||||
|
"image_url": "/media/decks_media/barron/k12020251.jpg",
|
||||||
|
"audio_tts_text": "desire",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "deterioration",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the fact or process of becoming worse",
|
||||||
|
"example": "a serious deterioration in relations between the two countries",
|
||||||
|
"image_url": "/media/decks_media/barron/4266397-earth-deterioration.jpg",
|
||||||
|
"audio_tts_text": "deterioration",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "diagnose",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to say exactly what an illness or the cause of a problem is",
|
||||||
|
"example": "He has recently been diagnosed with angina.\nThe test is used to diagnose a variety of diseases.",
|
||||||
|
"image_url": "/media/decks_media/barron/doctor1.jpeg.jpg",
|
||||||
|
"audio_tts_text": "diagnose",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "diagnosis",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(of something) the act of discovering or identifying the exact cause of an illness or a problem",
|
||||||
|
"example": "An accurate diagnosis was made after a series of tests.\ndiagnosis of lung cancer",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-7636451852289.jpg",
|
||||||
|
"audio_tts_text": "diagnosis",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "diagnostic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "connected with identifying something, especially an illness",
|
||||||
|
"example": "to carry out diagnostic assessments/tests\nspecific conditions which are diagnostic of AIDS",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-8143257993217 (1).jpg",
|
||||||
|
"audio_tts_text": "diagnostic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "disorder",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(medical) an illness that causes a part of the body to stop functioning correctly (CHỨNG RỐI LOẠN)",
|
||||||
|
"example": "a rare disorder of the liverMost people with acute mental disorder can be treated at home.\na rare disorder of the liver\nMost people with acute mental disorder can be treated at home.\neating disorders",
|
||||||
|
"image_url": "/media/decks_media/barron/man-cleaning-wall-350.jpg",
|
||||||
|
"audio_tts_text": "disorder",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "enhance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to increase or further improve the good quality, value or status of somebody/something",
|
||||||
|
"example": "This is an opportunity to enhance the reputation of the company.\nthe skilled use of make-up to enhance your best features",
|
||||||
|
"image_url": "/media/decks_media/barron/pareto_principle_improve.jpg",
|
||||||
|
"audio_tts_text": "enhance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "epidemic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "rapid spread of a disease",
|
||||||
|
"example": "the outbreak of a flu epidemic\nan epidemic of measles",
|
||||||
|
"image_url": "/media/decks_media/barron/global-epidemic-swine-flu-mexico_02.jpg",
|
||||||
|
"audio_tts_text": "epidemic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "estimate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to guess about the cost, size, value etc. of something, without calculating it exactly",
|
||||||
|
"example": "Police estimate the crowd at 30 000.\nThe satellite will cost an estimated £400 million.",
|
||||||
|
"image_url": "/media/decks_media/barron/estimate_1470756627987.gif",
|
||||||
|
"audio_tts_text": "estimate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "fraction",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a small part or amount of something",
|
||||||
|
"example": "Only a small fraction of a bank's total deposits will be withdrawn at any one time.\nShe hesitated for the merest fraction of a second.\nHe raised his voice a fraction.",
|
||||||
|
"image_url": "/media/decks_media/barron/fractions14.gif",
|
||||||
|
"audio_tts_text": "fraction",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "grave",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(of situations, feelings, etc.) very serious and important; giving you a reason to feel worried",
|
||||||
|
"example": "The police have expressed grave concern about the missing child's safety.\nThe consequences will be very grave if nothing is done.\nWe were in grave danger.",
|
||||||
|
"image_url": "/media/decks_media/barron/11987213_1484585818501913_5621734615785924041_n.jpg",
|
||||||
|
"audio_tts_text": "grave",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "gravely",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "in a very serious and important way; in a way that gives you a reason to feel worried",
|
||||||
|
"example": "She is gravely ill.\nLocal people are gravely concerned.",
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "gravely",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "gravity",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(formal) extreme importance and a cause for worry (synonym: seriousness)",
|
||||||
|
"example": "I don't think you realise the gravity of the situation.\nPunishment varies according to the gravity of the offence.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-9779640532993 (2).jpg",
|
||||||
|
"audio_tts_text": "gravity",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "impair",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(formal) to damage something or make something worse",
|
||||||
|
"example": "His age impaired his chances of finding a new job.",
|
||||||
|
"image_url": "/media/decks_media/barron/is_151027_e_cigarette_800x600.jpg",
|
||||||
|
"audio_tts_text": "impair",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "impaired",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "damaged or not functioning normally",
|
||||||
|
"example": "impaired vision/memory",
|
||||||
|
"image_url": "/media/decks_media/barron/aid75038-728px-Be-Independent-When-Visually-Impaired-Step-1.jpg",
|
||||||
|
"audio_tts_text": "impaired",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "impairment",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the state of having a physical or mental condition which means that part of your body or brain does not work correctly; a particular condition of this sort",
|
||||||
|
"example": "impairment of the functions of the kidney\nvisual impairments",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-10432475561985.jpg",
|
||||||
|
"audio_tts_text": "impairment",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "indicate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to show that something is true or exists",
|
||||||
|
"example": "A yellowing of the skin indicates jaundice.\nRecord profits in the retail market indicate a boom in the economy.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-10264971837441.jpg",
|
||||||
|
"audio_tts_text": "indicate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "indication",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a remark or sign that shows that something is happening or what somebody is thinking or feeling",
|
||||||
|
"example": "He shows every indication (= clear signs) of wanting to accept the post.\nThey gave no indication of how the work should be done.",
|
||||||
|
"image_url": "/media/decks_media/barron/26b87ac2dfa718d8e4f9b2319819a75d.jpg",
|
||||||
|
"audio_tts_text": "indication",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "indicative",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(of something) (formal) showing or suggesting something",
|
||||||
|
"example": "Their failure to act is indicative of their lack of interest.\nThe rise in unemployment is seen as indicative of a new economic recession.",
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "indicative",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "indicator",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a sign that shows you what something is like or how a situation is changing",
|
||||||
|
"example": "These atmospheric waves are a reliable indicator of weather changes.\nThe economic indicators are better than expected.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-9723805958145.jpg",
|
||||||
|
"audio_tts_text": "indicator",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "interval",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a period of time between two events",
|
||||||
|
"example": "The interval between major earthquakes might be 200 years.\nHe knocked on the door and after a brief interval it was opened.",
|
||||||
|
"image_url": "/media/decks_media/barron/time_interval_watch_JR.png",
|
||||||
|
"audio_tts_text": "interval",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "investigate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to find out information and facts about a subject or problem by study or research",
|
||||||
|
"example": "The research investigates how foreign speakers gain fluency.\nScientists are investigating the effects of diet on fighting cancer.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-6524055322625 (1).jpg",
|
||||||
|
"audio_tts_text": "investigate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "investigation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a scientific or academic examination of the facts of a subject or problem",
|
||||||
|
"example": "an investigation into the spending habits of teenagers",
|
||||||
|
"image_url": "/media/decks_media/barron/investigate.png",
|
||||||
|
"audio_tts_text": "investigation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "investigator",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a person who examines a situation such as an accident or a crime to find out the truth",
|
||||||
|
"example": "air safety investigators\na private investigator (= a detective)",
|
||||||
|
"image_url": "/media/decks_media/barron/investigator.png",
|
||||||
|
"audio_tts_text": "investigator",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "link",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a connection between two or more people or things",
|
||||||
|
"example": "Police suspect there may be a link between the two murders.\nevidence for a strong causal link between exposure to sun and skin cancer",
|
||||||
|
"image_url": "/media/decks_media/barron/B.S SHORT LINK CHAIN.jpg",
|
||||||
|
"audio_tts_text": "link",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "lure",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to persuade or trick somebody to go somewhere or to do something by promising them a reward",
|
||||||
|
"example": "The child was lured into a car but managed to escape.\nYoung people are lured to the city by the prospect of a job and money.",
|
||||||
|
"image_url": "/media/decks_media/barron/amo-dei-pesci-oh-26446184.jpg",
|
||||||
|
"audio_tts_text": "lure",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "manufacture",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(specialist) to produce a substance",
|
||||||
|
"example": "Vitamins cannot be manufactured by our bodies.\nPlants use the sun's light to manufacture their food.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-10149007720449.jpg",
|
||||||
|
"audio_tts_text": "manufacture",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "monitor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to watch and check something over a period of time in order to see how it develops, so that you can make any necessary changes",
|
||||||
|
"example": "Each student's progress is closely monitored.",
|
||||||
|
"image_url": "/media/decks_media/barron/observe-look-magnifying-glass1.jpg",
|
||||||
|
"audio_tts_text": "monitor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "mood",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the way you are feeling at a particular time",
|
||||||
|
"example": "She's in a good mood today (= happy and friendly).\nHe's always in a bad mood (= unhappy, or angry and impatient).",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-5987184410625.jpg",
|
||||||
|
"audio_tts_text": "mood",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "moodily",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "in a bad-tempered way",
|
||||||
|
"example": "He stared moodily into the fire.",
|
||||||
|
"image_url": "/media/decks_media/barron/Brothers-fighting-e1391087131643-600x295.jpg",
|
||||||
|
"audio_tts_text": "moodily",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "moodiness",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the quality of having moods that change quickly and often",
|
||||||
|
"example": "a teenager's moodiness",
|
||||||
|
"image_url": "/media/decks_media/barron/3-tips-for-dealing-with-tween-moodiness-that-dont-involve-drinking-wine-0.jpg",
|
||||||
|
"audio_tts_text": "moodiness",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "moody",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "having moods that change quickly and often",
|
||||||
|
"example": "Moody people are very difficult to deal with.\nShe’s a bit moody and never the same two days in a row.\nTeenagers tend to get a bad name for being moody, rude and irresponsible.",
|
||||||
|
"image_url": "/media/decks_media/barron/MOODY.jpeg",
|
||||||
|
"audio_tts_text": "moody",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "outcome",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the result or effect of an action or event",
|
||||||
|
"example": "We are waiting to hear the final outcome of the negotiations.\nThese costs are payable whatever the outcome of the case.",
|
||||||
|
"image_url": "/media/decks_media/barron/targetarrowgraphrectangle_med.jpeg",
|
||||||
|
"audio_tts_text": "outcome",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "previously",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "at a time before the time that you are talking about",
|
||||||
|
"example": "The building had previously been used as a hotel.\nThe book contains a number of photographs not previously published.\nI had visited them three days previously.",
|
||||||
|
"image_url": "/media/decks_media/barron/TOP-10-Celebrities-photos-Now-Before-they-were-famous_04.jpg",
|
||||||
|
"audio_tts_text": "previously",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "primary",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "main, most imporant",
|
||||||
|
"example": "Good health care is of primary importance.\nThe primary aim of this course is to improve your spoken English.",
|
||||||
|
"image_url": "/media/decks_media/barron/most-important-part-of-onboarding.jpg",
|
||||||
|
"audio_tts_text": "primary",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 59
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "qualification",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a skill or type of experience that you need for a particular job or activity",
|
||||||
|
"example": "Previous teaching experience is a necessary qualification for this job.",
|
||||||
|
"image_url": "/media/decks_media/barron/qualification_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "qualification",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "qualified",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "having the training and experience that are necessary in order to do a particular job",
|
||||||
|
"example": "She's extremely well qualified for the job.\na qualified accountant/teacher, etc.",
|
||||||
|
"image_url": "/media/decks_media/barron/Qualified-versus-competent-person-SD-040513.jpg",
|
||||||
|
"audio_tts_text": "qualified",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 61
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "qualify",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to reach the standard of ability or knowledge needed to do a particular job",
|
||||||
|
"example": "He qualified as a doctor last year.",
|
||||||
|
"image_url": "/media/decks_media/barron/qualify-for-hud-homes.jpg",
|
||||||
|
"audio_tts_text": "qualify",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "rampant",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "existing or spreading out of control",
|
||||||
|
"example": "rampant inflation\nUnemployment is now rampant in most of Europe.",
|
||||||
|
"image_url": "/media/decks_media/barron/AAEAAQAAAAAAAAJcAAAAJDNkZmZlMWMyLTYwMWItNDY2Mi05NDE4LWEzMmFhMTRiZTY0MA.jpg",
|
||||||
|
"audio_tts_text": "rampant",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recur",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to happen again or a number of times",
|
||||||
|
"example": "This theme recurs several times throughout the book.\na recurring illness/problem/nightmare, etc.",
|
||||||
|
"image_url": "/media/decks_media/barron/recur.png",
|
||||||
|
"audio_tts_text": "recur",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "regulate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to control something by means of rules",
|
||||||
|
"example": "The activities of credit companies are regulated by law.",
|
||||||
|
"image_url": "/media/decks_media/barron/banned_wordsb.jpg",
|
||||||
|
"audio_tts_text": "regulate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "retain",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to keep something; to continue to have something",
|
||||||
|
"example": "to retain your independence\nHe struggled to retain control of the situation.",
|
||||||
|
"image_url": "/media/decks_media/barron/Complaints-handling-done-well-can-retain-customers-495x375.jpg",
|
||||||
|
"audio_tts_text": "retain",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "rodent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the goup of small animals that includes mice and rats",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/rodents.jpg",
|
||||||
|
"audio_tts_text": "rodent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "rudimentary",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "basic, not well developed",
|
||||||
|
"example": "Some dinosaurs had only rudimentary teeth.\nThey were given only rudimentary training in the job.",
|
||||||
|
"image_url": "/media/decks_media/barron/112.jpg",
|
||||||
|
"audio_tts_text": "rudimentary",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "short",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "not having enough of something; lacking something",
|
||||||
|
"example": "Both nurses and doctors are inshort supply in many places.\nI'm afraid I'm a little short of money this month.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-7481833029633 (1)_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "short",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "shortage",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a lack of something",
|
||||||
|
"example": "food/housing/water shortages\nThere is no shortage of (= there are plenty of) things to do in the town.",
|
||||||
|
"image_url": "/media/decks_media/barron/bk-reports-inv-shortage.jpg",
|
||||||
|
"audio_tts_text": "shortage",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "shorten",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make something shorter; to become shorter",
|
||||||
|
"example": "a shortened version of the gameIn November the temperatures drop and the days shorten.\na shortened version of the game\nIn November the temperatures drop and the days shorten.\nHer name's Katherine, generally shortened to Kay.",
|
||||||
|
"image_url": "/media/decks_media/barron/2586334.jpg",
|
||||||
|
"audio_tts_text": "shorten",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 71
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "shortly",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "soon, not long",
|
||||||
|
"example": "I'll be ready shortly.\nShe arrived shortly after us.",
|
||||||
|
"image_url": "/media/decks_media/barron/Coming-soon.png",
|
||||||
|
"audio_tts_text": "shortly",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "spatial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "relating to space and the position, size, shape, etc. of things in it",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/main-set-data-rules.png",
|
||||||
|
"audio_tts_text": "spatial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 73
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "standpoint",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "an opinion, a way of thinking, a point of view",
|
||||||
|
"example": "He is writing from the standpoint of someone who knows what life is like in prison.",
|
||||||
|
"image_url": "/media/decks_media/barron/tumblr_nksiyu1Zh91tpri36o1_1280.jpg",
|
||||||
|
"audio_tts_text": "standpoint",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stave off",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to prevent something bad from affecting you for a period of time; to delay something",
|
||||||
|
"example": "to stave off hunger\nThe company managed to stave off bankruptcy for another few months.",
|
||||||
|
"image_url": "/media/decks_media/barron/stave-off-stock-outs.jpg",
|
||||||
|
"audio_tts_text": "stave off",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stem",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to come fom, originate",
|
||||||
|
"example": "Most people’s insecurities stem from something that happened in their childhood.",
|
||||||
|
"image_url": "/media/decks_media/barron/maxresdefault (16)_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "stem",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stimulate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to make something develop or become more active; to encourage something",
|
||||||
|
"example": "a government package designed to stimulate economic growth\nThe exhibition has stimulated interest in her work.",
|
||||||
|
"image_url": "/media/decks_media/barron/aid5094155-728px-Stimulate-Gross-Motor-Skills-in-Infants-Step-3.jpg",
|
||||||
|
"audio_tts_text": "stimulate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "substance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a type of solid, liquid or gas that has particular qualities",
|
||||||
|
"example": "a chemical/radioactive, etc\nsubstance\nbanned/illegal substances (= drugs)\na sticky substance",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-12476879994881.jpg",
|
||||||
|
"audio_tts_text": "substance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "supply",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "an amount of something that is provided or available to be used",
|
||||||
|
"example": "The water supply is unsafe.\nSupplies of food are almost exhausted.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-7438883356673.jpg",
|
||||||
|
"audio_tts_text": "supply",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 79
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "target",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to focus on",
|
||||||
|
"example": "The missiles were mainly targeted at the United States.\nThe company has been targeted by animal rights groups for its use of dogs in drugs trials.",
|
||||||
|
"image_url": "/media/decks_media/barron/Target-Free-Download-PNG.png",
|
||||||
|
"audio_tts_text": "target",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "theoretical",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "abstact; based on theory",
|
||||||
|
"example": "The first year provides students with a sound theoretical basis for later study.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-13486197309441.jpg",
|
||||||
|
"audio_tts_text": "theoretical",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 81
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "theorize",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "to suggest facts and ideas to explain something; to form a theory or theories about something",
|
||||||
|
"example": "The study theorizes about the role of dreams in peoples' lives.",
|
||||||
|
"image_url": "/media/decks_media/barron/conspiracy-theory-image-250x230.jpg",
|
||||||
|
"audio_tts_text": "theorize",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "theory",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a formal set of ideas that is intended to explain why something happens or exists",
|
||||||
|
"example": "According to the theory of relativity, nothing can travel faster than light.\nThe debate is centred around two conflicting theories.",
|
||||||
|
"image_url": "/media/decks_media/barron/einstein.jpg",
|
||||||
|
"audio_tts_text": "theory",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 83
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "toxic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "containing poison; poisonous",
|
||||||
|
"example": "toxic chemicals/fumes/gases/substances\nto dispose of toxic waste\nMany pesticides are highly toxic.",
|
||||||
|
"image_url": "/media/decks_media/barron/wpid-toxic-sign.jpg",
|
||||||
|
"audio_tts_text": "toxic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "toxicity",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "the quality of being poisonous; the extent to which something is poisonous",
|
||||||
|
"example": "substances with high levels of toxicity",
|
||||||
|
"image_url": "/media/decks_media/barron/toxic-chemical.jpg",
|
||||||
|
"audio_tts_text": "toxicity",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "toxin",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a poisonous substance, especially one that is produced by bacteria in plants and animals",
|
||||||
|
"example": "The algae kills off plant and animal life and, in some cases, produces dangerous toxins.",
|
||||||
|
"image_url": "/media/decks_media/barron/paste-7657926688769 (1)_1470756627994.jpg",
|
||||||
|
"audio_tts_text": "toxin",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "vacancy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "a job that is available for somebody to do",
|
||||||
|
"example": "There’s a vacancy in the accounts department.",
|
||||||
|
"image_url": "/media/decks_media/barron/Job-Vacancy.png",
|
||||||
|
"audio_tts_text": "vacancy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "vacant",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "(formal) if a job in a company is va_____t, nobody is doing it and it is available for somebody to take",
|
||||||
|
"example": "A vacant position at a hospital willbe filled quickly if the salary andbenefits are attractive.\nThe position left vacant in July has not yet been filled.",
|
||||||
|
"image_url": "/media/decks_media/barron/805644-vacantseat-1418362423-712-640x480.jpg",
|
||||||
|
"audio_tts_text": "vacant",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 88
|
||||||
|
}
|
||||||
|
]
|
||||||
662
supabase/data/terms/flashcard_terms_336.json
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "accessible",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. reachable; easy to get",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/accessible.gif",
|
||||||
|
"audio_tts_text": "accessible",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "accommodations",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a place to stay such as a hotel",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/accommodations.jpg",
|
||||||
|
"audio_tts_text": "accommodations",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "acquire",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to learn something or get something",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/acquire.jpg",
|
||||||
|
"audio_tts_text": "acquire",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "adventurous",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. daring; willing to try new or dangerous activities",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/adventurous.jpg",
|
||||||
|
"audio_tts_text": "adventurous",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "archeologist",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a person who studies ancient cultures",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/archeologist.jpg",
|
||||||
|
"audio_tts_text": "archeologist",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "avoid",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to prevent from happening; stay away",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/avoid.jpg",
|
||||||
|
"audio_tts_text": "avoid",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "barrier",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. something that blocks or separates",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/barrier.png",
|
||||||
|
"audio_tts_text": "barrier",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "breeze",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. light wind",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/breeze.jpg",
|
||||||
|
"audio_tts_text": "breeze",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "broad",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. wide or large",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/broad.png",
|
||||||
|
"audio_tts_text": "broad",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "budget",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a plan for spending money",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/budget.jpg",
|
||||||
|
"audio_tts_text": "budget",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "category",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a group of things that have something in common",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/category.jpg",
|
||||||
|
"audio_tts_text": "category",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ceremonial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. related to traditional or formal practices",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/ceremonial.jpg",
|
||||||
|
"audio_tts_text": "ceremonial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "colorful",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. interesting and unusual",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/colorful.png",
|
||||||
|
"audio_tts_text": "colorful",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Concept",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. idea",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/Concept.jpg",
|
||||||
|
"audio_tts_text": "Concept",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "construct",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to build",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/construct.jpg",
|
||||||
|
"audio_tts_text": "construct",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "content",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. subject matter",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/content.jpg",
|
||||||
|
"audio_tts_text": "content",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "costly",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. expensive",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/costly.jpg",
|
||||||
|
"audio_tts_text": "costly",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "cuisine",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. style of cooking",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/cuisine.jpg",
|
||||||
|
"audio_tts_text": "cuisine",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "culprit",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. guilty party; origin of the problem",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/culprit.jpg",
|
||||||
|
"audio_tts_text": "culprit",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "delicate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. easily hurt or broken",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/delicate.jpg",
|
||||||
|
"audio_tts_text": "delicate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "destination",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the place somebody or something is going to",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/destination.jpg",
|
||||||
|
"audio_tts_text": "destination",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "draw",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to attract, pull",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/draw.jpg",
|
||||||
|
"audio_tts_text": "draw",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "dump",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to get rid of garbage and trash",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/dump.jpg",
|
||||||
|
"audio_tts_text": "dump",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "economical",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. inexpensive",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/economical.jpg",
|
||||||
|
"audio_tts_text": "economical",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "endeavor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. activity with a specific purpose",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/endeavor.jpg",
|
||||||
|
"audio_tts_text": "endeavor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "enroll",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to sign up for a class",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/enroll.jpg",
|
||||||
|
"audio_tts_text": "enroll",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "hone",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to sharpen, improve",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/hone.jpg",
|
||||||
|
"audio_tts_text": "hone",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "imagination",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the ability to think creatively or form pictures in the mind",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/imageination.png",
|
||||||
|
"audio_tts_text": "imagination",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ingredient",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. an item in a recipe",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/ingredient.jpg",
|
||||||
|
"audio_tts_text": "ingredient",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "injure",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to hurt",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/injure.jpg",
|
||||||
|
"audio_tts_text": "injure",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "institute",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to start; put in place",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/institute.jpg",
|
||||||
|
"audio_tts_text": "institute",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "luxury",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. something expensive and desirable but unnessary",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/luxury.jpg",
|
||||||
|
"audio_tts_text": "luxury",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "marvel",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a wonderful thing",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/marvel.jpg",
|
||||||
|
"audio_tts_text": "marvel",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "mystery",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. something strange, unknown, or difficult to understand",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/mystery.jpg",
|
||||||
|
"audio_tts_text": "mystery",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "native",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. original to a place",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/native.jpg",
|
||||||
|
"audio_tts_text": "native",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "network",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a system of various parts that work together",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/network.jpg",
|
||||||
|
"audio_tts_text": "network",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ongoing",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. continuing",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/ongoing.png",
|
||||||
|
"audio_tts_text": "ongoing",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pertain",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. be related to something",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/pertain.png",
|
||||||
|
"audio_tts_text": "pertain",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pleasure",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. enjoyment",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "pleasure",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "practice",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a custom; method",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/practice.jpg",
|
||||||
|
"audio_tts_text": "practice",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "precisely",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. exactly",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/precisely.jpg",
|
||||||
|
"audio_tts_text": "precisely",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "preserve",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to protect; save",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/preserve.png",
|
||||||
|
"audio_tts_text": "preserve",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "principle",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. rule; basic idea behind a system",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/principle.jpg",
|
||||||
|
"audio_tts_text": "principle",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "publicity",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. activity that makes something known to the public",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/publicity.jpg",
|
||||||
|
"audio_tts_text": "publicity",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recycling",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. collection and treatment of trash for reuse",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/recycling.jpg",
|
||||||
|
"audio_tts_text": "recycling",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "remote",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. far away",
|
||||||
|
"example": null,
|
||||||
|
"image_url": null,
|
||||||
|
"audio_tts_text": "remote",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "residential",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. with living accommodation, related to housing",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/residential.jpg",
|
||||||
|
"audio_tts_text": "residential",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "resort",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a vacation place",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/resort.jpg",
|
||||||
|
"audio_tts_text": "resort",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "restriction",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. an official limit on something",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/restriction.jpg",
|
||||||
|
"audio_tts_text": "restriction",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "site",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a place",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/site.jpg",
|
||||||
|
"audio_tts_text": "site",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "spectacular",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. wonderful to see",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/spectacular.jpg",
|
||||||
|
"audio_tts_text": "spectacular",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "sponsor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to organize and be responsible",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/sponsor.jpg",
|
||||||
|
"audio_tts_text": "sponsor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "strive",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to work very hard to do something",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/strive.jpg",
|
||||||
|
"audio_tts_text": "strive",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "supervision",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. direction, assistance",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/supervision.jpg",
|
||||||
|
"audio_tts_text": "supervision",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "survey",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a study of opinions in a sample of the population",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/survey.jpg",
|
||||||
|
"audio_tts_text": "survey",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "taste",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. preference",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/taste.jpg",
|
||||||
|
"audio_tts_text": "taste",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "upside",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. advantage; good part",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/upside.jpg",
|
||||||
|
"audio_tts_text": "upside",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "volunteer",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to work for no pay; freely offer to do something",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/volunteer.jpg",
|
||||||
|
"audio_tts_text": "volunteer",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "wary",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. not completely trusting",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/wary.jpg",
|
||||||
|
"audio_tts_text": "wary",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "wilderness",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. natural region away from towns and cities",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/wilderness.jpg",
|
||||||
|
"audio_tts_text": "wilderness",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 59
|
||||||
|
}
|
||||||
|
]
|
||||||
662
supabase/data/terms/flashcard_terms_337.json
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "afloat",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. having enough money to pay what you owe",
|
||||||
|
"example": "Somehow we kept the ship afloat\n(keep (somebody/something) afloat/stay afloat)\nThe Treasury borrowed £40 billion, just to stay afloat\n(keep (somebody/something) afloat/stay afloat)",
|
||||||
|
"image_url": "/media/decks_media/barron/afloat.jpg",
|
||||||
|
"audio_tts_text": "afloat",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "bond",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] connection",
|
||||||
|
"example": "the United States’ special bond with Britain.\nthe emotional bond between mother and child.\nIn each methane molecule there are four CH bonds.",
|
||||||
|
"image_url": "/media/decks_media/barron/bond.png",
|
||||||
|
"audio_tts_text": "bond",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "boon",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable usually singular] a benefit; advantage",
|
||||||
|
"example": "The bus service is a real boon to people in the village.",
|
||||||
|
"image_url": "/media/decks_media/barron/boon.jpg",
|
||||||
|
"audio_tts_text": "boon",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "branch",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. local office of a larger company",
|
||||||
|
"example": "She now works in our Denver branch.\na branch office in Boston",
|
||||||
|
"image_url": "/media/decks_media/barron/branch.jpg",
|
||||||
|
"audio_tts_text": "branch",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "brand",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] company name for a product",
|
||||||
|
"example": "products which lack a strong brand image",
|
||||||
|
"image_url": "/media/decks_media/barron/brand.jpg",
|
||||||
|
"audio_tts_text": "brand",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "burgeoning",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. growing",
|
||||||
|
"example": "the burgeoning market for digital cameras.",
|
||||||
|
"image_url": "/media/decks_media/barron/burgeoning.jpg",
|
||||||
|
"audio_tts_text": "burgeoning",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Catch up",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to reach someone or something that is ahead",
|
||||||
|
"example": "You go on ahead\nI’ll catch you up in a minute.\nYou go on ahead\nI’ll catch you up in a minute.",
|
||||||
|
"image_url": "/media/decks_media/barron/Catch up.jpg",
|
||||||
|
"audio_tts_text": "Catch up",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "characteristic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable usually plural] a feature or a quality",
|
||||||
|
"example": "a baby discovering the physical characteristics of objects",
|
||||||
|
"image_url": "/media/decks_media/barron/characteristics.jpg",
|
||||||
|
"audio_tts_text": "characteristic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "coincide",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v.[intransitive] to happen at the same time",
|
||||||
|
"example": "His entry to the party coincided with his marriage.\nThe show is timed to coincide with the launch of a new book\n(planned/timed/arranged to coincide)\nThe show is timed to coincide with the launch of a new book.",
|
||||||
|
"image_url": "/media/decks_media/barron/coincide.jpg",
|
||||||
|
"audio_tts_text": "coincide",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "compete",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to do as well as or better than others",
|
||||||
|
"example": "The stores have to compete for customers in the Christmas season.\nThe company must be able to compete in the international marketplace.\nThey found themselves competing with/againstforeign companies for a share of the market.",
|
||||||
|
"image_url": "/media/decks_media/barron/compete.jpg",
|
||||||
|
"audio_tts_text": "compete",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "confront",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to face a difficulty",
|
||||||
|
"example": "Troops were confronted by an angry mob.\nWe try to help people confront their problems.",
|
||||||
|
"image_url": "/media/decks_media/barron/confront.jpg",
|
||||||
|
"audio_tts_text": "confront",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "conglomerate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a large company that owns smaller companies",
|
||||||
|
"example": "an international conglomerate",
|
||||||
|
"image_url": "/media/decks_media/barron/conglomerate.jpg",
|
||||||
|
"audio_tts_text": "conglomerate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "consistently",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. regularly; always",
|
||||||
|
"example": "consistently high performance",
|
||||||
|
"image_url": "/media/decks_media/barron/consistently.jpg",
|
||||||
|
"audio_tts_text": "consistently",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "controversy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable, uncountable] a lot of disagreement affecting many people",
|
||||||
|
"example": "The judges’ decision provoked controversy.\na political controversy",
|
||||||
|
"image_url": "/media/decks_media/barron/controversy.jpg",
|
||||||
|
"audio_tts_text": "controversy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "convince",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to get somebody to do or believe something",
|
||||||
|
"example": "I’ve been trying to convince Jean to come with me.\nThe officials were eager to convince us of the safety of the nuclear reactors.",
|
||||||
|
"image_url": "/media/decks_media/barron/convince.jpg",
|
||||||
|
"audio_tts_text": "convince",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "decisive",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. important; affecting a decision",
|
||||||
|
"example": "a talent for quick decisive action.\nThe answer was a decisive no.\na decisive leader.",
|
||||||
|
"image_url": "/media/decks_media/barron/decisive (1).jpg",
|
||||||
|
"audio_tts_text": "decisive",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "edge",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[singular, uncountable] an advantage",
|
||||||
|
"example": "The next version of the software will have the edge over its competitors.\nCompanies are employing more research teams to get an edge.",
|
||||||
|
"image_url": "/media/decks_media/barron/edge.jpg",
|
||||||
|
"audio_tts_text": "edge",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "endorsement",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable, uncountable] public support for something",
|
||||||
|
"example": "celebrity endorsements.\nthe official endorsement of his candidacy.",
|
||||||
|
"image_url": "/media/decks_media/barron/endorsement.jpg",
|
||||||
|
"audio_tts_text": "endorsement",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "enticing",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. attractive",
|
||||||
|
"example": "It was a hot day and the water looked enticing.",
|
||||||
|
"image_url": "/media/decks_media/barron/enticing.jpg",
|
||||||
|
"audio_tts_text": "enticing",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "epicenter",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] central or most important place",
|
||||||
|
"example": "Its epicentre was in the sea 19 miles from the town of Maumere, with its 70,000 inhabitants.\nLondon became the epicentre of the world fashion industry.",
|
||||||
|
"image_url": "/media/decks_media/barron/epicenter.jpg",
|
||||||
|
"audio_tts_text": "epicenter",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "financial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. related to money",
|
||||||
|
"example": "financial transactions\nfinancial assistance\na financial advisor.\nOrganic farmers should be encouraged with financial incentives.",
|
||||||
|
"image_url": "/media/decks_media/barron/financial.jpg",
|
||||||
|
"audio_tts_text": "financial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "firm",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a company; business",
|
||||||
|
"example": "Kevin is with a firm of accountants in Birmingham.\nShe works for an electronics firm.",
|
||||||
|
"image_url": "/media/decks_media/barron/firm.jpg",
|
||||||
|
"audio_tts_text": "firm",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "fleeting",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. lasting for only a short time; brief; ending quickly",
|
||||||
|
"example": "For one fleeting moment, Paula allowed herself to forget her troubles.\na fleeting smile\nCarol was paying a fleeting visit to Paris.",
|
||||||
|
"image_url": "/media/decks_media/barron/fleeting.jpg",
|
||||||
|
"audio_tts_text": "fleeting",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ignore",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to not pay attention to",
|
||||||
|
"example": "The phone rang, but she ignored it.\nYou can’t ignore the fact that many criminals never go to prison.",
|
||||||
|
"image_url": "/media/decks_media/barron/ignore_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "ignore",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inevitably",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. certainly; to be expected",
|
||||||
|
"example": "Inevitably, the situation did not please everyone.\nThe decision will inevitably lead to political tensions.",
|
||||||
|
"image_url": "/media/decks_media/barron/inevitably.jpg",
|
||||||
|
"audio_tts_text": "inevitably",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "initial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj.[only before noun] happening at the beginning",
|
||||||
|
"example": "the initial stages of the disease.\nan initial investment of £5,000.",
|
||||||
|
"image_url": "/media/decks_media/barron/initial.jpg",
|
||||||
|
"audio_tts_text": "initial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "looming",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. nearing, usually said of a threat or difficulty",
|
||||||
|
"example": "The two countries believe that a crisis is looming.",
|
||||||
|
"image_url": "/media/decks_media/barron/looming.jpg",
|
||||||
|
"audio_tts_text": "looming",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "loyalty",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. faithfulness; belief in something",
|
||||||
|
"example": "In the rural areas, family and tribal loyalties continue to be important.\nElizabeth understood her husband’s loyalty to his sister.",
|
||||||
|
"image_url": "/media/decks_media/barron/loyalty.jpg",
|
||||||
|
"audio_tts_text": "loyalty",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "motivation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] reason for doing something",
|
||||||
|
"example": "Jack is an intelligent pupil, but he lacks motivation\na high level of motivation\nefforts to improve employees’ motivation",
|
||||||
|
"image_url": "/media/decks_media/barron/motivation.jpg",
|
||||||
|
"audio_tts_text": "motivation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "niche",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] position or place that is very suitable, specialized market",
|
||||||
|
"example": "He’s managed to create a niche for himself in local politics.\nAmanda soon found her niche at the club.",
|
||||||
|
"image_url": "/media/decks_media/barron/niche.jpg",
|
||||||
|
"audio_tts_text": "niche",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "opponent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] someone who disagrees and speaks out",
|
||||||
|
"example": "Graf’s opponent in today’s final will be Sukova.",
|
||||||
|
"image_url": "/media/decks_media/barron/opponent.jpg",
|
||||||
|
"audio_tts_text": "opponent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "outperform",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to perform better than",
|
||||||
|
"example": "Stocks generally outperform other investments.",
|
||||||
|
"image_url": "/media/decks_media/barron/outperform.jpg",
|
||||||
|
"audio_tts_text": "outperform",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "particular",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. specific",
|
||||||
|
"example": "Most students choose one particular area for research.\na particular type of food\nIn this particular case, no one else was involved.",
|
||||||
|
"image_url": "/media/decks_media/barron/particular (1).jpg",
|
||||||
|
"audio_tts_text": "particular",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "passion",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a strong feeling or interest in",
|
||||||
|
"example": "His eyes were burning with passion.\nhis passion for football.",
|
||||||
|
"image_url": "/media/decks_media/barron/passion.jpg",
|
||||||
|
"audio_tts_text": "passion",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "personalized",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. made or done especially for a certain person",
|
||||||
|
"example": "a personalized number plate.",
|
||||||
|
"image_url": "/media/decks_media/barron/personalized (1).jpg",
|
||||||
|
"audio_tts_text": "personalized",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "phenomenon",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] something unusual that happens, a fact",
|
||||||
|
"example": "Homelessness is not a new phenomenon.\nthe growing phenomenon of telecommuting.",
|
||||||
|
"image_url": "/media/decks_media/barron/phenomenon.jpg",
|
||||||
|
"audio_tts_text": "phenomenon",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "point",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to call attention to",
|
||||||
|
"example": "‘Look!’ she said and pointed.\nShe was pointing to a small boat that was approaching the shore.",
|
||||||
|
"image_url": "/media/decks_media/barron/point (1).jpg",
|
||||||
|
"audio_tts_text": "point",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "potential",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj.[only before noun] possible",
|
||||||
|
"example": "the potential benefitsof the new system\n(potential benefit/problem)the potential risks to health associated with the drug\n(potential danger/threat/risk)\nthe potential risks to health associated with the drug",
|
||||||
|
"image_url": "/media/decks_media/barron/potential.jpg",
|
||||||
|
"audio_tts_text": "potential",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "preponderance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.(formal) the largest amount",
|
||||||
|
"example": "There is a preponderance of female students in the music department\n(a preponderance of something)",
|
||||||
|
"image_url": "/media/decks_media/barron/preponderance.jpg",
|
||||||
|
"audio_tts_text": "preponderance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prevail",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to be common among certain groups; be stronger",
|
||||||
|
"example": "It is hard for logic to prevail over emotion.\nThe economic conditions which prevail in England and Wales.",
|
||||||
|
"image_url": "/media/decks_media/barron/prevail.jpg",
|
||||||
|
"audio_tts_text": "prevail",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "product",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. something that is made",
|
||||||
|
"example": "He works in marketing and product development.\nThe London factory assembles the finished product.",
|
||||||
|
"image_url": "/media/decks_media/barron/product.jpg",
|
||||||
|
"audio_tts_text": "product",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "profit",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable, uncountable] money earned after paying costs",
|
||||||
|
"example": "She sold the business and bought a farm with the profits.\nThey sold their house at a healthy profit.\nThe shop’s daily profit is usually around $500.",
|
||||||
|
"image_url": "/media/decks_media/barron/profit_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "profit",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "project",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to estimate; calculate a future amount",
|
||||||
|
"example": "projected sales figures.\nTotal expenditure is projected to rise by 25%\n(be projected to do something)\nThe company projected an annual growth rate of 3%.",
|
||||||
|
"image_url": "/media/decks_media/barron/project.jpg",
|
||||||
|
"audio_tts_text": "project",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "promote",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to advertise",
|
||||||
|
"example": "She’s in London to promote her new book.\na meeting to promote trade between Taiwan and the UK.",
|
||||||
|
"image_url": "/media/decks_media/barron/promote.jpg",
|
||||||
|
"audio_tts_text": "promote",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "proponent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a supporter; someone who supports something or persuades people to do something",
|
||||||
|
"example": "Dr George is one of the leading proponents of this view\n(leading/main/major proponent)\nSteinem has always been a strong proponent of women’s rights.",
|
||||||
|
"image_url": "/media/decks_media/barron/proponent.jpg",
|
||||||
|
"audio_tts_text": "proponent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "remainder",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the rest; what is left",
|
||||||
|
"example": "He spent the remainder of his police career behind a desk.\nThe remainder must be paid by the end of June.",
|
||||||
|
"image_url": "/media/decks_media/barron/remainder.jpg",
|
||||||
|
"audio_tts_text": "remainder",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reputation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] the general opinion about something or somebody",
|
||||||
|
"example": "In her last job she gained a reputation as a hard worker.\nJudge Kelso has a reputation for being strict but fair.",
|
||||||
|
"image_url": "/media/decks_media/barron/reputation.jpg",
|
||||||
|
"audio_tts_text": "reputation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "reverse",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to turn around; change to its opposite",
|
||||||
|
"example": "More changes are required to reverse the trend towards centralised power.\nThe decision was reversed on appeal.",
|
||||||
|
"image_url": "/media/decks_media/barron/reverse.jpg",
|
||||||
|
"audio_tts_text": "reverse",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "routinely",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. on a regular basis",
|
||||||
|
"example": "The staff routinely ignored my requests.",
|
||||||
|
"image_url": "/media/decks_media/barron/routinely.png",
|
||||||
|
"audio_tts_text": "routinely",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "selective",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. intentionally choosing some things and not others",
|
||||||
|
"example": "He has a very selective memory (=he chooses what he wants to remember and what to forget).\nWe’re very selective about what we let the children watch.",
|
||||||
|
"image_url": "/media/decks_media/barron/selective.jpg",
|
||||||
|
"audio_tts_text": "selective",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "shift",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] period of work time",
|
||||||
|
"example": "The thought of working night shifts put her off becoming a nurse.\nDave had to work a 12-hour shift yesterday.",
|
||||||
|
"image_url": "/media/decks_media/barron/shift.jpg",
|
||||||
|
"audio_tts_text": "shift",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "sound",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. healthy; without financial risk",
|
||||||
|
"example": "safe and sound.",
|
||||||
|
"image_url": "/media/decks_media/barron/sound.jpg",
|
||||||
|
"audio_tts_text": "sound",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "staple",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a basic household item",
|
||||||
|
"example": "bread, milk, and other staples\nBananas and sugar are the staples of Jamaica.\nTortillas are a staple of Mexican cooking.\nstaples like flour and rice.",
|
||||||
|
"image_url": "/media/decks_media/barron/staple.jpg",
|
||||||
|
"audio_tts_text": "staple",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "status",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a social position",
|
||||||
|
"example": "These documents have no legal status in Britain.\nWhat is your marital status?",
|
||||||
|
"image_url": "/media/decks_media/barron/status.jpg",
|
||||||
|
"audio_tts_text": "status",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "thirst",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. strong desire",
|
||||||
|
"example": "European fashion companies are taking advantage of Chinese consumers' thirst for designer labels.",
|
||||||
|
"image_url": "/media/decks_media/barron/thirst.jpg",
|
||||||
|
"audio_tts_text": "thirst",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "tip",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a piece of advice",
|
||||||
|
"example": "handy tips for decorating a small flat\n[handy tip (=useful tip)]\nPerhaps she could give us a few tips.",
|
||||||
|
"image_url": "/media/decks_media/barron/tip.jpg",
|
||||||
|
"audio_tts_text": "tip",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "turnover",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[singular, uncountable] the rate at which employees leave and are replaced",
|
||||||
|
"example": "Turnover rose 9%.",
|
||||||
|
"image_url": "/media/decks_media/barron/turnover.jpg",
|
||||||
|
"audio_tts_text": "turnover",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "unique",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. special; different from all others",
|
||||||
|
"example": "Each person’s fingerprints are unique.",
|
||||||
|
"image_url": "/media/decks_media/barron/unique.jpg",
|
||||||
|
"audio_tts_text": "unique",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "vital",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. very important, necessary for success",
|
||||||
|
"example": "Regular exercise is vital for your health\n(vital for)\nIt is vital that you keep accurate records\n(it is vital (that))",
|
||||||
|
"image_url": "/media/decks_media/barron/vital (1).jpg",
|
||||||
|
"audio_tts_text": "vital",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "wealthy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. rich",
|
||||||
|
"example": "the wealthy nations of the world\nHe left as a poor, working class boy and returned as a wealthy man.",
|
||||||
|
"image_url": "/media/decks_media/barron/wealthy.jpg",
|
||||||
|
"audio_tts_text": "wealthy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 59
|
||||||
|
}
|
||||||
|
]
|
||||||
662
supabase/data/terms/flashcard_terms_338.json
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "abound",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to exist in large numbers",
|
||||||
|
"example": "Rumours abound as to the reasons for his resignation.",
|
||||||
|
"image_url": "/media/decks_media/barron/abound.jpg",
|
||||||
|
"audio_tts_text": "abound",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "absence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. not being present; time away",
|
||||||
|
"example": "The absence of certain species of flora and fauna\nIn the absence of any evidence, the police had to let Myers go.",
|
||||||
|
"image_url": "/media/decks_media/barron/absence.jpg",
|
||||||
|
"audio_tts_text": "absence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "academic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. related to school, especially the university",
|
||||||
|
"example": "a program to raise academic standards",
|
||||||
|
"image_url": "/media/decks_media/barron/academic.jpg",
|
||||||
|
"audio_tts_text": "academic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "account for",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to be responsible for; be the cause of",
|
||||||
|
"example": "The transportation sector accounts for 27 percent of all greenhouse gases produced in Canada.\nAccidents account for most disabilities.\nRecent pressure at work may account for his behavior.",
|
||||||
|
"image_url": "/media/decks_media/barron/Account for (2).jpg",
|
||||||
|
"audio_tts_text": "account for",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "acquaintance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a friend you do not know well",
|
||||||
|
"example": "She was a casual acquaintance of my family in Vienna.",
|
||||||
|
"image_url": "/media/decks_media/barron/acquaintance.jpg",
|
||||||
|
"audio_tts_text": "acquaintance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "adolescent",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a person between the ages of thirteen and nineteen",
|
||||||
|
"example": "adolescent girls",
|
||||||
|
"image_url": "/media/decks_media/barron/adolescent.jpg",
|
||||||
|
"audio_tts_text": "adolescent",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "apparently",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. seemingly",
|
||||||
|
"example": "Apparently the company is losing a lot of money.",
|
||||||
|
"image_url": "/media/decks_media/barron/apparently.jpg",
|
||||||
|
"audio_tts_text": "apparently",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "approximately",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. close but not exactly",
|
||||||
|
"example": "The plane will be landing in approximately 20 minutes.",
|
||||||
|
"image_url": "/media/decks_media/barron/approximately.jpg",
|
||||||
|
"audio_tts_text": "approximately",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "bear",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to carry; have responsibilities for",
|
||||||
|
"example": "She was afraid she wouldn’t be able to bear the pain.\nMake the water as hot as you can bear.",
|
||||||
|
"image_url": "/media/decks_media/barron/bear.jpg",
|
||||||
|
"audio_tts_text": "bear",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "capable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. able to do something",
|
||||||
|
"example": "I'm perfectly capable of taking care of myself",
|
||||||
|
"image_url": "/media/decks_media/barron/capable.jpg",
|
||||||
|
"audio_tts_text": "capable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "carry out",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to do; perform",
|
||||||
|
"example": "We need to carry out more research.",
|
||||||
|
"image_url": "/media/decks_media/barron/Carry out.jpg",
|
||||||
|
"audio_tts_text": "carry out",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "commensurate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. similar in size or amount",
|
||||||
|
"example": "Salary will be commensurate with age and experience.",
|
||||||
|
"image_url": "/media/decks_media/barron/commensurate.jpg",
|
||||||
|
"audio_tts_text": "commensurate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "community",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a social group",
|
||||||
|
"example": "different ethnic communities\nThe new arts centre will serve the whole community.",
|
||||||
|
"image_url": "/media/decks_media/barron/community_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "community",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "compact",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. small",
|
||||||
|
"example": "The students’ rooms were compact, with a desk, bed, and closet built in.",
|
||||||
|
"image_url": "/media/decks_media/barron/compact.jpg",
|
||||||
|
"audio_tts_text": "compact",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "consequence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. result",
|
||||||
|
"example": "Many believe that poverty is a direct consequence of overpopulation.",
|
||||||
|
"image_url": "/media/decks_media/barron/consequence_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "consequence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "contact",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. communication; connection",
|
||||||
|
"example": "We stay in contact by email.",
|
||||||
|
"image_url": "/media/decks_media/barron/contact.jpg",
|
||||||
|
"audio_tts_text": "contact",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "corridor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] hallway",
|
||||||
|
"example": "Go down here and the bathroom’s at the end of the corridor.",
|
||||||
|
"image_url": "/media/decks_media/barron/corridor.jpg",
|
||||||
|
"audio_tts_text": "corridor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "curb",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the raised edge of the street",
|
||||||
|
"example": "A car was parked at the curb.",
|
||||||
|
"image_url": "/media/decks_media/barron/curb.jpg",
|
||||||
|
"audio_tts_text": "curb",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "devote",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. give; commit;",
|
||||||
|
"example": "He devoted his energies to writing films.\nShe devoted herself full-time to her business.",
|
||||||
|
"image_url": "/media/decks_media/barron/devote.jpg",
|
||||||
|
"audio_tts_text": "devote",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "dire",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. very serious or extreme; very bad",
|
||||||
|
"example": "The country is in dire need of food aid.",
|
||||||
|
"image_url": "/media/decks_media/barron/dire.jpg",
|
||||||
|
"audio_tts_text": "dire",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "disability",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a physical or mental condition that makes it difficult to do things other people do",
|
||||||
|
"example": "Public places are becoming more accessible to people with disabilities.",
|
||||||
|
"image_url": "/media/decks_media/barron/disability.jpg",
|
||||||
|
"audio_tts_text": "disability",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "discrepancy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable, uncountable] a difference between two amounts, details, reports etc that should be the same",
|
||||||
|
"example": "There is a large discrepancy between the ideal image of motherhood and the reality.\nPolice found discrepancies in the two men’s reports.",
|
||||||
|
"image_url": "/media/decks_media/barron/discrepancy (1).jpg",
|
||||||
|
"audio_tts_text": "discrepancy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "equality",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. being the same; having the same rights and opportunities",
|
||||||
|
"example": "equality between men and women\nAll people have the right to equality of opportunity.",
|
||||||
|
"image_url": "/media/decks_media/barron/equality.jpg",
|
||||||
|
"audio_tts_text": "equality",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "eradicate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to remove completely",
|
||||||
|
"example": "We can eradicate this disease from the world.",
|
||||||
|
"image_url": "/media/decks_media/barron/eradicate.jpg",
|
||||||
|
"audio_tts_text": "eradicate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "exchange",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to trade something",
|
||||||
|
"example": "He exchanged the black jacket for a blue one\n(exchange something for something)\nWe exchange gifts at Christmas.",
|
||||||
|
"image_url": "/media/decks_media/barron/exchange.jpg",
|
||||||
|
"audio_tts_text": "exchange",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "explode",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to grow suddenly and rapidly; to suddenly increase greatly in number, amount, or degree",
|
||||||
|
"example": "Florida’s population exploded after World War II.",
|
||||||
|
"image_url": "/media/decks_media/barron/explode.jpg",
|
||||||
|
"audio_tts_text": "explode",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "exterior",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the outside of something",
|
||||||
|
"example": "the exterior of the building.",
|
||||||
|
"image_url": "/media/decks_media/barron/exterior.jpg",
|
||||||
|
"audio_tts_text": "exterior",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "frustration",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. lack of satisfaction; inability to reach goals",
|
||||||
|
"example": "People often feel a sense of frustration that they are not being promoted quickly enough.\nI was practically screaming with frustration.",
|
||||||
|
"image_url": "/media/decks_media/barron/frustration.jpg",
|
||||||
|
"audio_tts_text": "frustration",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "funding",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. money provided, especially by an organization or government, for a particular purpose.",
|
||||||
|
"example": "The money will also be used to provide funding for an ethnic officer to work in the community",
|
||||||
|
"image_url": "/media/decks_media/barron/funding.jpg",
|
||||||
|
"audio_tts_text": "funding",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "guidance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] advice; assistance",
|
||||||
|
"example": "Children need moral guidance.\nI went to a counselor for guidance on my career\n(guidance on/about)",
|
||||||
|
"image_url": "/media/decks_media/barron/guidance (1).jpg",
|
||||||
|
"audio_tts_text": "guidance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immense",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. very big; huge",
|
||||||
|
"example": "People who travel by rail still read an immense amount.",
|
||||||
|
"image_url": "/media/decks_media/barron/immense_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "immense",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "impose",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to force",
|
||||||
|
"example": "parents who impose their own moral values on their children\nThe government imposed a ban on the sale of ivory.",
|
||||||
|
"image_url": "/media/decks_media/barron/impose.jpg",
|
||||||
|
"audio_tts_text": "impose",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "incapacitated",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. unable to do things normally",
|
||||||
|
"example": "Richard was temporarily incapacitated",
|
||||||
|
"image_url": "/media/decks_media/barron/incapacitated.jpg",
|
||||||
|
"audio_tts_text": "incapacitated",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inordinate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. far more than you would reasonably or normally expect",
|
||||||
|
"example": "Testing is taking up an inordinate amount of teachers’ time.",
|
||||||
|
"image_url": "/media/decks_media/barron/inordinate.jpg",
|
||||||
|
"audio_tts_text": "inordinate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "interact",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to communicate with",
|
||||||
|
"example": "Lucy interacts well with other children in the class.",
|
||||||
|
"image_url": "/media/decks_media/barron/interact.jpg",
|
||||||
|
"audio_tts_text": "interact",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "interior",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the inside of something",
|
||||||
|
"example": "The interior of the church was dark.",
|
||||||
|
"image_url": "/media/decks_media/barron/interior (1).jpg",
|
||||||
|
"audio_tts_text": "interior",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "invaluable",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. very valuable; extremely useful",
|
||||||
|
"example": "Your advice has been invaluable to us\n(invaluable to/for)\nThis help was invaluable in focussing my ideas\n(be invaluable in/for (doing) something)",
|
||||||
|
"image_url": "/media/decks_media/barron/invaluable.jpg",
|
||||||
|
"audio_tts_text": "invaluable",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "mentor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a person who gives help and advice",
|
||||||
|
"example": "Auden later became a friend and mentor.\nyou need a mentor to guide you along the way.",
|
||||||
|
"image_url": "/media/decks_media/barron/mentor_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "mentor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "necessitate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to make necessary",
|
||||||
|
"example": "This would necessitate interviewing all the staff.\nLack of money necessitated a change of plan.",
|
||||||
|
"image_url": "/media/decks_media/barron/necessitate.jpg",
|
||||||
|
"audio_tts_text": "necessitate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "persist",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to continue",
|
||||||
|
"example": "She persisted in asking the question.\n(persist in (doing) something)",
|
||||||
|
"image_url": "/media/decks_media/barron/persist.jpg",
|
||||||
|
"audio_tts_text": "persist",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "post",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to display information in a public place",
|
||||||
|
"example": "There was post after post criticizing the Minister.",
|
||||||
|
"image_url": "/media/decks_media/barron/post.jpg",
|
||||||
|
"audio_tts_text": "post",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "poverty",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] the condition of being poor",
|
||||||
|
"example": "Millions of elderly people live in poverty.",
|
||||||
|
"image_url": "/media/decks_media/barron/poverty.jpg",
|
||||||
|
"audio_tts_text": "poverty",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pressure",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] demands; responsibilities",
|
||||||
|
"example": "the pressure of his hand on my arm.\nThe minister was under pressure to resign\n(be/come under pressure to do something)",
|
||||||
|
"image_url": "/media/decks_media/barron/pressure.jpg",
|
||||||
|
"audio_tts_text": "pressure",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "progress",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to move forward",
|
||||||
|
"example": "The police are disappointed by the slow progress of the investigation.",
|
||||||
|
"image_url": "/media/decks_media/barron/progress (1).jpg",
|
||||||
|
"audio_tts_text": "progress",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pursue",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to hunt for; seek",
|
||||||
|
"example": "She plans to pursue a career in politics.",
|
||||||
|
"image_url": "/media/decks_media/barron/pursue.jpg",
|
||||||
|
"audio_tts_text": "pursue",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "ramp",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a smooth surface that allows access between levels",
|
||||||
|
"example": "Ramps are needed at exits and entrances for wheelchair users.",
|
||||||
|
"image_url": "/media/decks_media/barron/ramp_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "ramp",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recreation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. leisure activities",
|
||||||
|
"example": "His only recreations are drinking beer and watching football.",
|
||||||
|
"image_url": "/media/decks_media/barron/recreation.jpg",
|
||||||
|
"audio_tts_text": "recreation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slippery",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. causing things to slide or slip; difficult to hold or stand on",
|
||||||
|
"example": "In places, the path can be wet and slippery.",
|
||||||
|
"image_url": "/media/decks_media/barron/slippery (1).jpg",
|
||||||
|
"audio_tts_text": "slippery",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slope",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a surface at an angle; with the top higher than the bottom",
|
||||||
|
"example": "a slope of 30 degrees",
|
||||||
|
"image_url": "/media/decks_media/barron/slope (1).jpg",
|
||||||
|
"audio_tts_text": "slope",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "statistics",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[plural] information in the form of numbers; data",
|
||||||
|
"example": "statistics for injuries at work.\nthe official crime statistics.",
|
||||||
|
"image_url": "/media/decks_media/barron/statistics.jpg",
|
||||||
|
"audio_tts_text": "statistics",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "struggle",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to fight",
|
||||||
|
"example": "She struggled to free herself.\nShe’s struggling to bring up a family alone.",
|
||||||
|
"image_url": "/media/decks_media/barron/struggle (1).jpg",
|
||||||
|
"audio_tts_text": "struggle",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "susceptible",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. easily affected",
|
||||||
|
"example": "She was very susceptible to flattery.\nA lot of TV advertising is aimed at susceptible young children.",
|
||||||
|
"image_url": "/media/decks_media/barron/susceptible.jpg",
|
||||||
|
"audio_tts_text": "susceptible",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "switch",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a button used to turn on lights or machines",
|
||||||
|
"example": "Where’s the light switch?",
|
||||||
|
"image_url": "/media/decks_media/barron/switch.jpg",
|
||||||
|
"audio_tts_text": "switch",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "terrain",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable, uncountable] a particular type of land",
|
||||||
|
"example": "rocky terrain",
|
||||||
|
"image_url": "/media/decks_media/barron/terrain.jpg",
|
||||||
|
"audio_tts_text": "terrain",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "trend",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] movement in a certain direction; popular fashion",
|
||||||
|
"example": "Lately there has been a trend towards hiring younger, cheaper employees.",
|
||||||
|
"image_url": "/media/decks_media/barron/trend.jpg",
|
||||||
|
"audio_tts_text": "trend",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "undergo",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to experience; suffer",
|
||||||
|
"example": "The country has undergone massive changes recently.",
|
||||||
|
"image_url": "/media/decks_media/barron/undergo.jpg",
|
||||||
|
"audio_tts_text": "undergo",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "unfold",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to develop; open up",
|
||||||
|
"example": "As the story unfolds, we learn more about Max’s childhood.\nHe unfolded the map.",
|
||||||
|
"image_url": "/media/decks_media/barron/unfold.jpg",
|
||||||
|
"audio_tts_text": "unfold",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "unwieldy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. difficult to manage;",
|
||||||
|
"example": "the first mechanical clocks were large and unwieldy",
|
||||||
|
"image_url": "/media/decks_media/barron/unwieldy.jpg",
|
||||||
|
"audio_tts_text": "unwieldy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "update",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to modernize; improve",
|
||||||
|
"example": "security measures are continually updated and improved.",
|
||||||
|
"image_url": "/media/decks_media/barron/update.jpg",
|
||||||
|
"audio_tts_text": "update",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "validate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to confirm; make a person feel valued",
|
||||||
|
"example": "Many scientists plan to wait until the results of the study are validated by future research.",
|
||||||
|
"image_url": "/media/decks_media/barron/validate.jpg",
|
||||||
|
"audio_tts_text": "validate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 59
|
||||||
|
}
|
||||||
|
]
|
||||||
662
supabase/data/terms/flashcard_terms_339.json
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "address",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to deal with a problem or issue",
|
||||||
|
"example": "a fundamental problem has still to be addressed",
|
||||||
|
"image_url": "/media/decks_media/barron/address.jpg",
|
||||||
|
"audio_tts_text": "address",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "adequately",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. Well enough",
|
||||||
|
"example": "Students need to be adequately prepared for the world of work.",
|
||||||
|
"image_url": "/media/decks_media/barron/adequately.jpg",
|
||||||
|
"audio_tts_text": "adequately",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alternative",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] something you can choose to do or use instead of something else",
|
||||||
|
"example": "I had no alternative but to report him to the police.",
|
||||||
|
"image_url": "/media/decks_media/barron/alternative.jpg",
|
||||||
|
"audio_tts_text": "alternative",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "approach",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] method",
|
||||||
|
"example": "a new approach to teaching languages",
|
||||||
|
"image_url": "/media/decks_media/barron/approach (1).jpg",
|
||||||
|
"audio_tts_text": "approach",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "assess",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to measure; to evaluate",
|
||||||
|
"example": "a report to assess the impact of advertising on children",
|
||||||
|
"image_url": "/media/decks_media/barron/assess_1470756627987.jpg",
|
||||||
|
"audio_tts_text": "assess",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "auditory",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj.[only before noun] related to hearing",
|
||||||
|
"example": "the auditory nerves",
|
||||||
|
"image_url": "/media/decks_media/barron/auditory.jpg",
|
||||||
|
"audio_tts_text": "auditory",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "blend",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a mixture; combination",
|
||||||
|
"example": "an excellent team, with a nice blend of experience and youthful enthusiasm",
|
||||||
|
"image_url": "/media/decks_media/barron/blend.jpg",
|
||||||
|
"audio_tts_text": "blend",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "circumstance",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. situation",
|
||||||
|
"example": "I can’t imagine a circumstance in which I would be willing to steal.",
|
||||||
|
"image_url": "/media/decks_media/barron/circumstance.jpg",
|
||||||
|
"audio_tts_text": "circumstance",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "compulsory",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. required",
|
||||||
|
"example": "Car insurance is compulsory.\n11 years of compulsory education.",
|
||||||
|
"image_url": "/media/decks_media/barron/compulsory.png",
|
||||||
|
"audio_tts_text": "compulsory",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "concerned",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. worried",
|
||||||
|
"example": "He called the police because he was concerned for Gemma’s safety.\nShe is concerned about how little food I eat.",
|
||||||
|
"image_url": "/media/decks_media/barron/concerned.jpg",
|
||||||
|
"audio_tts_text": "concerned",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "confidence",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] belief in one's abilities",
|
||||||
|
"example": "She had complete confidence in the doctors.",
|
||||||
|
"image_url": "/media/decks_media/barron/confidence.jpg",
|
||||||
|
"audio_tts_text": "confidence",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "constructive",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. positive; beneficial",
|
||||||
|
"example": "We welcome any constructive criticism.\nThe meeting was very constructive.",
|
||||||
|
"image_url": "/media/decks_media/barron/constructive.jpg",
|
||||||
|
"audio_tts_text": "constructive",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "conventional",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. normal; traditional",
|
||||||
|
"example": "Internet connections through conventional phone lines are fairly slow.",
|
||||||
|
"image_url": "/media/decks_media/barron/conventional (1).jpg",
|
||||||
|
"audio_tts_text": "conventional",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "curriculum",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] the set of subjects taught at a school",
|
||||||
|
"example": "Languages are an essential part of the school curriculum.",
|
||||||
|
"image_url": "/media/decks_media/barron/curriculum.jpg",
|
||||||
|
"audio_tts_text": "curriculum",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "dedicate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to give; devote; to give all your attention and effort to one particular thing",
|
||||||
|
"example": "The actress now dedicates herself to children’s charity work.",
|
||||||
|
"image_url": "/media/decks_media/barron/dedicate (1).jpg",
|
||||||
|
"audio_tts_text": "dedicate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "diagram",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a simple drawing to explain how something works",
|
||||||
|
"example": "a diagram of the heating system",
|
||||||
|
"image_url": "/media/decks_media/barron/diagram.jpg",
|
||||||
|
"audio_tts_text": "diagram",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "discipline",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] the ability to control your own behaviour, so that you do what you are expected to do",
|
||||||
|
"example": "Working from home requires a good deal of discipline.\nThe book gives parents advice on discipline.",
|
||||||
|
"image_url": "/media/decks_media/barron/discipline.jpg",
|
||||||
|
"audio_tts_text": "discipline",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "dissatisfied",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. not pleased",
|
||||||
|
"example": "If you are dissatisfied with this product, please return it.",
|
||||||
|
"image_url": "/media/decks_media/barron/dissatisfied.jpg",
|
||||||
|
"audio_tts_text": "dissatisfied",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "dominant",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. most important, powerful, or influential.",
|
||||||
|
"example": "its dominant position within the group",
|
||||||
|
"image_url": "/media/decks_media/barron/dominant.png",
|
||||||
|
"audio_tts_text": "dominant",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "encouragement",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. praise; support to keep going",
|
||||||
|
"example": "With encouragement, Sally is starting to play with the other children.",
|
||||||
|
"image_url": "/media/decks_media/barron/Encouragement.jpg",
|
||||||
|
"audio_tts_text": "encouragement",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "enriched",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. improved; describing something of higher quality;",
|
||||||
|
"example": "enriched uranium",
|
||||||
|
"image_url": "/media/decks_media/barron/enriched.png",
|
||||||
|
"audio_tts_text": "enriched",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "exceptional",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. unusual; not typical, special, above average",
|
||||||
|
"example": "This is an exceptional case; I’ve never seen anything like it before.\nan exceptional student\n(good )",
|
||||||
|
"image_url": "/media/decks_media/barron/exceptional.jpg",
|
||||||
|
"audio_tts_text": "exceptional",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "expose",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to give an opportunity to experience or learn new things",
|
||||||
|
"example": "He lifted his T-shirt to expose a jagged scar across his chest.",
|
||||||
|
"image_url": "/media/decks_media/barron/expose.jpg",
|
||||||
|
"audio_tts_text": "expose",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "extraordinary",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. very much greater or more impressive than usual",
|
||||||
|
"example": "an extraordinary talent.\na woman of extraordinary beauty.",
|
||||||
|
"image_url": "/media/decks_media/barron/extraordinary.jpg",
|
||||||
|
"audio_tts_text": "extraordinary",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "facial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. related to the face",
|
||||||
|
"example": "facial expressions\nfacial hair",
|
||||||
|
"image_url": "/media/decks_media/barron/facial (1).jpg",
|
||||||
|
"audio_tts_text": "facial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "fidget",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to move constantly in a nervous manner",
|
||||||
|
"example": "Stop fidgeting with your pens!\nThe kids had started to fidget.",
|
||||||
|
"image_url": "/media/decks_media/barron/fidget (1).jpg",
|
||||||
|
"audio_tts_text": "fidget",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "gifted",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. having special talents or abilities; having a natural ability to do one or more things extremely well",
|
||||||
|
"example": "She was an extremely gifted poet.",
|
||||||
|
"image_url": "/media/decks_media/barron/gifted.jpg",
|
||||||
|
"audio_tts_text": "gifted",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "hinder",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to prevent; et in the way",
|
||||||
|
"example": "Higher interest rates could hinder economic growth.\nHis career has been hindered by injury.",
|
||||||
|
"image_url": "/media/decks_media/barron/hinder.jpg",
|
||||||
|
"audio_tts_text": "hinder",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "incorporate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to add in; bring together",
|
||||||
|
"example": "Which activities would you like to incorporate in your life?",
|
||||||
|
"image_url": "/media/decks_media/barron/incorporate.jpg",
|
||||||
|
"audio_tts_text": "incorporate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "inquisitiveness",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[uncountable] desire for knowledge",
|
||||||
|
"example": "Children have an inquisitiveness about the world.",
|
||||||
|
"image_url": "/media/decks_media/barron/inquisitiveness.jpg",
|
||||||
|
"audio_tts_text": "inquisitiveness",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "instruction",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. teaching; a statement telling someone what they must do",
|
||||||
|
"example": "Make sure you carry out the doctor’s instructions.",
|
||||||
|
"image_url": "/media/decks_media/barron/instruction.jpg",
|
||||||
|
"audio_tts_text": "instruction",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "interpretation",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. the way in which someone explains or understands an event, information, someone’s actions etc",
|
||||||
|
"example": "One possible interpretation is that they want you to resign.",
|
||||||
|
"image_url": "/media/decks_media/barron/interpretation.jpg",
|
||||||
|
"audio_tts_text": "interpretation",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "kinesthetic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. related to body motion",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/kinesthetic.jpg",
|
||||||
|
"audio_tts_text": "kinesthetic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "latter",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj.[only before noun] near the end",
|
||||||
|
"example": "the latter half of 1989\nIn the latter case, buyers pay a 15% commission.",
|
||||||
|
"image_url": "/media/decks_media/barron/latter.jpg",
|
||||||
|
"audio_tts_text": "latter",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "majority",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[singular] the larger part; most; most of the people or things in a group",
|
||||||
|
"example": "An overwhelming majority of the members were against the idea.\nIn the vast majority of cases the disease is fatal.",
|
||||||
|
"image_url": "/media/decks_media/barron/majority.jpg",
|
||||||
|
"audio_tts_text": "majority",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "mandate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to order officially; require",
|
||||||
|
"example": "Justice mandates that we should treat all candidates equally.",
|
||||||
|
"image_url": "/media/decks_media/barron/mandate.jpg",
|
||||||
|
"audio_tts_text": "mandate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "manipulate",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to move things around with the hands",
|
||||||
|
"example": "The workmen manipulated some knobs and levers.\nYou can integrate text with graphics and manipulate graphic images.\nsoftware designed to store and manipulate data",
|
||||||
|
"image_url": "/media/decks_media/barron/manipulate.jpg",
|
||||||
|
"audio_tts_text": "manipulate",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "moderately",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. somewhat; fairly, but not very",
|
||||||
|
"example": "He did moderately well in the exams.",
|
||||||
|
"image_url": "/media/decks_media/barron/moderately.jpg",
|
||||||
|
"audio_tts_text": "moderately",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "novel",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. new and unusual",
|
||||||
|
"example": null,
|
||||||
|
"image_url": "/media/decks_media/barron/Novel.jpg",
|
||||||
|
"audio_tts_text": "novel",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "obligatory",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. required",
|
||||||
|
"example": "it is obligatory for somebody (to do something)\nIt is obligatory for companies to provide details of their industrial processes.",
|
||||||
|
"image_url": "/media/decks_media/barron/obligatory.jpg",
|
||||||
|
"audio_tts_text": "obligatory",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "obstruction",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. something that blocks or stands in the way",
|
||||||
|
"example": "Police can remove a vehicle that is causing an obstruction.",
|
||||||
|
"image_url": "/media/decks_media/barron/obstruction.jpg",
|
||||||
|
"audio_tts_text": "obstruction",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "peer",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n.[countable] a person at an equal level with another",
|
||||||
|
"example": "Staff members are trained by their peers.\nAmerican children did less well in math than their peers in Japan.",
|
||||||
|
"image_url": "/media/decks_media/barron/peer.jpg",
|
||||||
|
"audio_tts_text": "peer",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "periodic",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. repeated regularly",
|
||||||
|
"example": "the periodic visits she made to her father",
|
||||||
|
"image_url": "/media/decks_media/barron/periodic.jpg",
|
||||||
|
"audio_tts_text": "periodic",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "philosophy",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. set of beliefs and values",
|
||||||
|
"example": "the philosophy of science",
|
||||||
|
"image_url": "/media/decks_media/barron/philosophy.jpg",
|
||||||
|
"audio_tts_text": "philosophy",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prior",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. existing or arranged before something else or before the present situation",
|
||||||
|
"example": "Some prior experience with the software is needed.\nYou do not need any prior knowledge of the subject.",
|
||||||
|
"image_url": "/media/decks_media/barron/prior.png",
|
||||||
|
"audio_tts_text": "prior",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "profoundly",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. Greatly; extremely",
|
||||||
|
"example": "He was profoundly affected by his time in the army.",
|
||||||
|
"image_url": "/media/decks_media/barron/profoundly.jpg",
|
||||||
|
"audio_tts_text": "profoundly",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recite",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to say or repeat out loud",
|
||||||
|
"example": "She recited a poem that she had learnt at school.",
|
||||||
|
"image_url": "/media/decks_media/barron/recite.jpg",
|
||||||
|
"audio_tts_text": "recite",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recognize",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to identify",
|
||||||
|
"example": "I didn’t recognize you in your uniform.",
|
||||||
|
"image_url": "/media/decks_media/barron/recognize.jpg",
|
||||||
|
"audio_tts_text": "recognize",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "relatively",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adv. something that is relatively small, easy etc is fairly small, easy etc compared to other things",
|
||||||
|
"example": "E-commerce is a relatively recent phenomenon.\nThe system is relatively easy to use.",
|
||||||
|
"image_url": "/media/decks_media/barron/relatively.png",
|
||||||
|
"audio_tts_text": "relatively",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "remedial",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. a special course etc that helps students who have difficulty learning something",
|
||||||
|
"example": "remedial course/class/teacher etc\nAbout one quarter of entering college students now take at least one remedial course.",
|
||||||
|
"image_url": "/media/decks_media/barron/remedial.jpg",
|
||||||
|
"audio_tts_text": "remedial",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "simultaneous",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. happening at the same time",
|
||||||
|
"example": "a simultaneous withdrawal of all troops",
|
||||||
|
"image_url": "/media/decks_media/barron/simultaneous.jpg",
|
||||||
|
"audio_tts_text": "simultaneous",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "solitary",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. done alone; independent",
|
||||||
|
"example": "a long, solitary walk\nthe solitary goal of the match",
|
||||||
|
"image_url": "/media/decks_media/barron/solitary.jpg",
|
||||||
|
"audio_tts_text": "solitary",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "sophisticated",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. advanced, complex",
|
||||||
|
"example": "a highly sophisticated weapons system",
|
||||||
|
"image_url": "/media/decks_media/barron/sophisticated.jpg",
|
||||||
|
"audio_tts_text": "sophisticated",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transfer",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to move from one place to another",
|
||||||
|
"example": "a transfer of wealth to the poorer nations",
|
||||||
|
"image_url": "/media/decks_media/barron/transfer.jpg",
|
||||||
|
"audio_tts_text": "transfer",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "turn into",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "v. to become something different, or to make someone or something do this",
|
||||||
|
"example": "(turn (somebody/something) into something)\nA few weeks later, winter had turned into spring.\nThe sofa turns into a bed.",
|
||||||
|
"image_url": "/media/decks_media/barron/turn-into.jpg",
|
||||||
|
"audio_tts_text": "turn into",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "tutor",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. a private teacher",
|
||||||
|
"example": "She was my tutor at Durham.",
|
||||||
|
"image_url": "/media/decks_media/barron/tutor.jpg",
|
||||||
|
"audio_tts_text": "tutor",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "vast",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. extremely large",
|
||||||
|
"example": "vast areas of rainforest\nThe government will have to borrow vast amounts of money.",
|
||||||
|
"image_url": "/media/decks_media/barron/vast.jpg",
|
||||||
|
"audio_tts_text": "vast",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "verbal",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. related to words",
|
||||||
|
"example": "verbal abuse (=cruel words) from other kids on the street\nverbal skills",
|
||||||
|
"image_url": "/media/decks_media/barron/verbal.png",
|
||||||
|
"audio_tts_text": "verbal",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "widespread",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "Adj. common; happening in a lot of places or done by a lot of people",
|
||||||
|
"example": "the widespread use of chemicals in agriculture.\nThe report claimed that the problem of police brutality was widespread.",
|
||||||
|
"image_url": "/media/decks_media/barron/widespread.jpg",
|
||||||
|
"audio_tts_text": "widespread",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "withdrawal",
|
||||||
|
"part_of_speech": null,
|
||||||
|
"phonetic": "",
|
||||||
|
"definition": "n. not wanting to participate; the act of no longer taking part in an activity or being a member of an organization",
|
||||||
|
"example": "the Russian withdrawal from Afghanistan.\nGermany’s withdrawal from the talks.",
|
||||||
|
"image_url": "/media/decks_media/barron/withdrawal.jpg",
|
||||||
|
"audio_tts_text": "withdrawal",
|
||||||
|
"audio_lang": "en-US",
|
||||||
|
"display_order": 59
|
||||||
|
}
|
||||||
|
]
|
||||||
4402
supabase/data/terms/flashcard_terms_352.json
Normal file
5150
supabase/data/terms/flashcard_terms_354.json
Normal file
16139
supabase/data/terms/flashcard_terms_355.json
Normal file
1
supabase/data/terms/flashcard_terms_361.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||