feat: initial commit

This commit is contained in:
2026-04-12 01:20:57 +07:00
parent 10d660cbcb
commit 28e866a64e
43 changed files with 954 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here

3
.gitignore vendored
View File

@@ -70,6 +70,9 @@ __pycache__
prompt.md
ck.md
# TanStack Router generated
src/routeTree.gen.ts
# Generated runtime layout for release/install smoke tests
/.claude/

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
export default tseslint.config(
{ ignores: ["dist", "src/routeTree.gen.ts"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>English Learning App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "english-learning-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@supabase/supabase-js": "^2.103.0",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-router": "^1.168.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "^1.167.15",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.1",
"vite": "^8.0.8"
}
}

View File

@@ -0,0 +1,31 @@
import { useState } from "react"
interface FlashCardProps {
word?: string
phonetic?: string
meaningVi?: string
example?: string
}
export function FlashCard({ word, phonetic, meaningVi, example }: FlashCardProps) {
const [flipped, setFlipped] = useState(false)
return (
<div
onClick={() => setFlipped((f) => !f)}
className="rounded-lg border p-6 text-center cursor-pointer min-h-[160px] flex flex-col justify-center select-none hover:bg-gray-50"
>
{!flipped ? (
<div>
<p className="text-2xl font-bold">{word || "word"}</p>
{phonetic && <p className="text-gray-400 mt-1">{phonetic}</p>}
</div>
) : (
<div className="space-y-2">
<p className="text-lg font-semibold text-blue-600">{meaningVi || "nghĩa tiếng Việt"}</p>
{example && <p className="text-sm text-gray-500 italic">{example}</p>}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,26 @@
interface ProgressBarProps {
value?: number
max?: number
label?: string
}
export function ProgressBar({ value = 0, max = 100, label }: ProgressBarProps) {
const pct = Math.min(100, Math.round((value / max) * 100))
return (
<div className="space-y-1">
{label && (
<div className="flex justify-between text-sm text-gray-600">
<span>{label}</span>
<span>{value}/{max}</span>
</div>
)}
<div className="h-2 w-full rounded-full bg-gray-200">
<div
className="h-2 rounded-full bg-blue-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
interface QuestionCardProps {
question?: string
options?: string[]
selectedAnswer?: string
onSelect?: (answer: string) => void
}
export function QuestionCard({ question, options, selectedAnswer, onSelect }: QuestionCardProps) {
return (
<div className="rounded-lg border p-4 space-y-3">
<p className="font-medium">{question || "Question placeholder"}</p>
{options && (
<ul className="space-y-2">
{options.map((opt) => (
<li
key={opt}
onClick={() => onSelect?.(opt[0])}
className={`rounded border p-2 text-sm cursor-pointer hover:bg-gray-50 ${
selectedAnswer === opt[0] ? "border-blue-500 bg-blue-50" : ""
}`}
>
{opt}
</li>
))}
</ul>
)}
</div>
)
}

14
src/components/Timer.tsx Normal file
View File

@@ -0,0 +1,14 @@
interface TimerProps {
seconds?: number
}
export function Timer({ seconds = 0 }: TimerProps) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return (
<div className="font-mono text-lg font-semibold tabular-nums">
{String(mins).padStart(2, "0")}:{String(secs).padStart(2, "0")}
</div>
)
}

View File

@@ -0,0 +1,44 @@
interface WritingFeedbackProps {
score?: string
grammar?: string[]
vocabulary?: string[]
structure?: string
summary?: string
improvedVersion?: string
}
export function WritingFeedback({ score, grammar, vocabulary, structure, summary }: WritingFeedbackProps) {
if (!score) return null
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center gap-2">
<span className="font-semibold">Band score:</span>
<span className="text-lg font-bold text-blue-600">{score}</span>
</div>
{summary && <p className="text-sm text-gray-700">{summary}</p>}
{grammar && grammar.length > 0 && (
<div>
<p className="font-medium text-sm">Ngữ pháp:</p>
<ul className="mt-1 space-y-1 text-sm text-gray-600 list-disc list-inside">
{grammar.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
)}
{vocabulary && vocabulary.length > 0 && (
<div>
<p className="font-medium text-sm">Từ vựng:</p>
<ul className="mt-1 space-y-1 text-sm text-gray-600 list-disc list-inside">
{vocabulary.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
)}
{structure && (
<div>
<p className="font-medium text-sm">Bố cục:</p>
<p className="text-sm text-gray-600">{structure}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
export function useQuestions(part: number, limit = 10) {
return useQuery({
queryKey: ["questions", part, limit],
queryFn: async () => {
const { data, error } = await supabase
.from("questions")
.select("*")
.eq("part", part)
.limit(limit)
if (error) throw error
return data
},
enabled: false, // Enabled during feature implementation
})
}

16
src/hooks/use-vocab.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
export function useVocab(topic?: string) {
return useQuery({
queryKey: ["vocab", topic],
queryFn: async () => {
let query = supabase.from("vocab").select("*")
if (topic) query = query.eq("topic", topic.toLowerCase())
const { data, error } = await query
if (error) throw error
return data
},
enabled: false, // Enabled during feature implementation
})
}

View File

@@ -0,0 +1,28 @@
import { useMutation } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
interface WritingFeedback {
score: string
grammar: string[]
vocabulary: string[]
structure: string
improved_version: string
summary: string
}
export function useWritingCheck() {
return useMutation({
mutationFn: async (content: string): Promise<WritingFeedback> => {
if (!canUseWritingCheck()) {
throw new Error("Bạn đã dùng hết 3 lần kiểm tra hôm nay. Quay lại vào ngày mai!")
}
const { data, error } = await supabase.functions.invoke("writing-check", {
body: { content },
})
if (error) throw error
recordWritingCheckUsage()
return data as WritingFeedback
},
})
}

130
src/index.css Normal file
View File

@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

10
src/lib/query-client.ts Normal file
View File

@@ -0,0 +1,10 @@
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})

15
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createClient } from "@supabase/supabase-js"
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
console.warn(
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in .env",
)
}
export const supabase = createClient(
supabaseUrl || "https://placeholder.supabase.co",
supabaseAnonKey || "placeholder-key",
)

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

23
src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { QueryClientProvider } from "@tanstack/react-query"
import { routeTree } from "./routeTree.gen"
import { queryClient } from "./lib/query-client"
import "./index.css"
const router = createRouter({ routeTree })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
)

32
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,32 @@
export function Home() {
return (
<div className="space-y-6">
<div className="text-center py-12">
<h1 className="text-3xl font-bold">Luyện tiếng Anh TOEIC</h1>
<p className="mt-3 text-lg text-gray-600">
Luyện đ, kiểm tra writing, học từ vựng TOEIC miễn phí
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-6">
<h2 className="font-semibold text-lg">Luyện đ TOEIC</h2>
<p className="mt-2 text-sm text-gray-500">
Luyện tập từng Part 17 với đ thật
</p>
</div>
<div className="rounded-lg border p-6">
<h2 className="font-semibold text-lg">AI Writing Checker</h2>
<p className="mt-2 text-sm text-gray-500">
Chấm điểm sửa bài writing bằng AI
</p>
</div>
<div className="rounded-lg border p-6">
<h2 className="font-semibold text-lg">Từ vựng TOEIC</h2>
<p className="mt-2 text-sm text-gray-500">
Flashcard 6 chủ đề: Business, Finance, HR...
</p>
</div>
</div>
</div>
)
}

8
src/pages/TestResult.tsx Normal file
View File

@@ -0,0 +1,8 @@
export function TestResult() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Kết quả</h1>
<p className="text-gray-500">Kết quả đáp án placeholder</p>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export function TestSession() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Làm bài</h1>
<p className="text-gray-500">Trang làm bài placeholder</p>
</div>
)
}

View File

@@ -0,0 +1,26 @@
const PARTS = [
{ id: 1, name: "Part 1", desc: "Photographs" },
{ id: 2, name: "Part 2", desc: "Question-Response" },
{ id: 3, name: "Part 3", desc: "Conversations" },
{ id: 4, name: "Part 4", desc: "Short Talks" },
{ id: 5, name: "Part 5", desc: "Incomplete Sentences" },
{ id: 6, name: "Part 6", desc: "Text Completion" },
{ id: 7, name: "Part 7", desc: "Reading Comprehension" },
]
export function ToeicPractice() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Luyện đ TOEIC</h1>
<p className="text-gray-600">Chọn Part đ bắt đu luyện tập</p>
<div className="grid gap-3 sm:grid-cols-2">
{PARTS.map((part) => (
<div key={part.id} className="rounded-lg border p-4 cursor-pointer hover:bg-gray-50">
<div className="font-semibold">{part.name}</div>
<div className="text-sm text-gray-500">{part.desc}</div>
</div>
))}
</div>
</div>
)
}

17
src/pages/Vocabulary.tsx Normal file
View File

@@ -0,0 +1,17 @@
const TOPICS = ["Business", "Office", "Travel", "Finance", "HR", "Marketing"]
export function Vocabulary() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Từ vựng TOEIC</h1>
<p className="text-gray-600">Chọn chủ đ đ học flashcard</p>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
{TOPICS.map((topic) => (
<div key={topic} className="rounded-lg border p-4 text-center cursor-pointer hover:bg-gray-50">
<span className="font-medium">{topic}</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
export function WritingChecker() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">AI Writing Checker</h1>
<p className="text-gray-600">
Nhập bài writing đ nhận phản hồi từ AI
</p>
<textarea
className="w-full h-48 rounded-lg border p-3 text-sm resize-none"
placeholder="Nhập bài writing của bạn tại đây..."
disabled
/>
<p className="text-sm text-gray-400">Tính năng đang đưc phát triển</p>
</div>
)
}

31
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
return (
<div className="min-h-screen bg-white text-gray-900">
<header className="border-b px-4 py-3">
<nav className="mx-auto max-w-5xl flex items-center gap-6">
<Link to="/" className="text-lg font-bold">
English App
</Link>
<Link to="/toeic" className="text-sm text-gray-600 hover:text-gray-900">
Luyện đ
</Link>
<Link to="/writing" className="text-sm text-gray-600 hover:text-gray-900">
Writing
</Link>
<Link to="/vocab" className="text-sm text-gray-600 hover:text-gray-900">
Từ vựng
</Link>
</nav>
</header>
<main className="mx-auto max-w-5xl px-4 py-6">
<Outlet />
</main>
</div>
)
}

6
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { Home } from "@/pages/Home"
export const Route = createFileRoute("/")({
component: Home,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { ToeicPractice } from "@/pages/ToeicPractice"
export const Route = createFileRoute("/toeic/")({
component: ToeicPractice,
})

View File

@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/toeic/part/$partId")({
component: ToeicPartConfig,
})
function ToeicPartConfig() {
const { partId } = Route.useParams()
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">TOEIC Part {partId}</h1>
<p className="text-gray-500">Cấu hình bài thi placeholder</p>
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { TestResult } from "@/pages/TestResult"
export const Route = createFileRoute("/toeic/result")({
component: TestResult,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { TestSession } from "@/pages/TestSession"
export const Route = createFileRoute("/toeic/session")({
component: TestSession,
})

9
src/routes/toeic.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/toeic")({
component: ToeicLayout,
})
function ToeicLayout() {
return <Outlet />
}

6
src/routes/vocab.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { Vocabulary } from "@/pages/Vocabulary"
export const Route = createFileRoute("/vocab")({
component: Vocabulary,
})

6
src/routes/writing.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { WritingChecker } from "@/pages/WritingChecker"
export const Route = createFileRoute("/writing")({
component: WritingChecker,
})

27
src/store/test-store.ts Normal file
View File

@@ -0,0 +1,27 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface TestState {
// Shell — filled during TOEIC feature implementation
currentPart: number | null
answers: Record<string, string>
setCurrentPart: (part: number | null) => void
setAnswer: (questionId: string, answer: string) => void
reset: () => void
}
export const useTestStore = create<TestState>()(
persist(
(set) => ({
currentPart: null,
answers: {},
setCurrentPart: (part) => set({ currentPart: part }),
setAnswer: (questionId, answer) =>
set((state) => ({
answers: { ...state.answers, [questionId]: answer },
})),
reset: () => set({ currentPart: null, answers: {} }),
}),
{ name: "test-store" },
),
)

28
src/store/vocab-store.ts Normal file
View File

@@ -0,0 +1,28 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface VocabState {
// Shell — filled during Vocab feature implementation
knownWords: string[]
markKnown: (wordId: string) => void
markUnknown: (wordId: string) => void
reset: () => void
}
export const useVocabStore = create<VocabState>()(
persist(
(set) => ({
knownWords: [],
markKnown: (wordId) =>
set((state) => ({
knownWords: [...new Set([...state.knownWords, wordId])],
})),
markUnknown: (wordId) =>
set((state) => ({
knownWords: state.knownWords.filter((id) => id !== wordId),
})),
reset: () => set({ knownWords: [] }),
}),
{ name: "vocab-store" },
),
)

49
src/utils/rate-limiter.ts Normal file
View File

@@ -0,0 +1,49 @@
const DAILY_LIMIT = 3
const STORAGE_KEY = "writing-check-usage"
interface UsageRecord {
date: string
count: number
}
export function canUseWritingCheck(): boolean {
const record = getUsageRecord()
const today = getToday()
if (record.date !== today) return true
return record.count < DAILY_LIMIT
}
export function recordWritingCheckUsage(): void {
const today = getToday()
const record = getUsageRecord()
if (record.date !== today) {
setUsageRecord({ date: today, count: 1 })
} else {
setUsageRecord({ date: today, count: record.count + 1 })
}
}
export function getRemainingChecks(): number {
const record = getUsageRecord()
const today = getToday()
if (record.date !== today) return DAILY_LIMIT
return Math.max(0, DAILY_LIMIT - record.count)
}
function getToday(): string {
return new Date().toISOString().split("T")[0]
}
function getUsageRecord(): UsageRecord {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { date: "", count: 0 }
return JSON.parse(raw) as UsageRecord
} catch {
return { date: "", count: 0 }
}
}
function setUsageRecord(record: UsageRecord): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
}

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string
readonly VITE_SUPABASE_ANON_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

25
tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

20
tsconfig.node.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
TanStackRouterVite({
target: "react",
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})