From 28e866a64e0712a2ece653c9b76475ffe577b517 Mon Sep 17 00:00:00 2001 From: renolation Date: Sun, 12 Apr 2026 01:20:57 +0700 Subject: [PATCH] feat: initial commit --- .env.example | 2 + .gitignore | 3 + components.json | 25 ++++++ eslint.config.js | 28 +++++++ index.html | 13 +++ package.json | 45 ++++++++++ src/components/FlashCard.tsx | 31 +++++++ src/components/ProgressBar.tsx | 26 ++++++ src/components/QuestionCard.tsx | 29 +++++++ src/components/Timer.tsx | 14 ++++ src/components/WritingFeedback.tsx | 44 ++++++++++ src/components/ui/button.tsx | 58 +++++++++++++ src/hooks/use-questions.ts | 18 ++++ src/hooks/use-vocab.ts | 16 ++++ src/hooks/use-writing-check.ts | 28 +++++++ src/index.css | 130 +++++++++++++++++++++++++++++ src/lib/query-client.ts | 10 +++ src/lib/supabase.ts | 15 ++++ src/lib/utils.ts | 6 ++ src/main.tsx | 23 +++++ src/pages/Home.tsx | 32 +++++++ src/pages/TestResult.tsx | 8 ++ src/pages/TestSession.tsx | 8 ++ src/pages/ToeicPractice.tsx | 26 ++++++ src/pages/Vocabulary.tsx | 17 ++++ src/pages/WritingChecker.tsx | 16 ++++ src/routes/__root.tsx | 31 +++++++ src/routes/index.tsx | 6 ++ src/routes/toeic.index.tsx | 6 ++ src/routes/toeic.part.$partId.tsx | 15 ++++ src/routes/toeic.result.tsx | 6 ++ src/routes/toeic.session.tsx | 6 ++ src/routes/toeic.tsx | 9 ++ src/routes/vocab.tsx | 6 ++ src/routes/writing.tsx | 6 ++ src/store/test-store.ts | 27 ++++++ src/store/vocab-store.ts | 28 +++++++ src/utils/rate-limiter.ts | 49 +++++++++++ src/vite-env.d.ts | 10 +++ tsconfig.app.json | 25 ++++++ tsconfig.json | 12 +++ tsconfig.node.json | 20 +++++ vite.config.ts | 21 +++++ 43 files changed, 954 insertions(+) create mode 100644 .env.example create mode 100644 components.json create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 src/components/FlashCard.tsx create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/components/QuestionCard.tsx create mode 100644 src/components/Timer.tsx create mode 100644 src/components/WritingFeedback.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/hooks/use-questions.ts create mode 100644 src/hooks/use-vocab.ts create mode 100644 src/hooks/use-writing-check.ts create mode 100644 src/index.css create mode 100644 src/lib/query-client.ts create mode 100644 src/lib/supabase.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/pages/Home.tsx create mode 100644 src/pages/TestResult.tsx create mode 100644 src/pages/TestSession.tsx create mode 100644 src/pages/ToeicPractice.tsx create mode 100644 src/pages/Vocabulary.tsx create mode 100644 src/pages/WritingChecker.tsx create mode 100644 src/routes/__root.tsx create mode 100644 src/routes/index.tsx create mode 100644 src/routes/toeic.index.tsx create mode 100644 src/routes/toeic.part.$partId.tsx create mode 100644 src/routes/toeic.result.tsx create mode 100644 src/routes/toeic.session.tsx create mode 100644 src/routes/toeic.tsx create mode 100644 src/routes/vocab.tsx create mode 100644 src/routes/writing.tsx create mode 100644 src/store/test-store.ts create mode 100644 src/store/vocab-store.ts create mode 100644 src/utils/rate-limiter.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0839f12 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key-here diff --git a/.gitignore b/.gitignore index 1663e65..398c5c8 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/components.json b/components.json new file mode 100644 index 0000000..15addee --- /dev/null +++ b/components.json @@ -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": {} +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f6e0f68 --- /dev/null +++ b/eslint.config.js @@ -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 }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..c385bae --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + English Learning App + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8028b5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/components/FlashCard.tsx b/src/components/FlashCard.tsx new file mode 100644 index 0000000..aa3f280 --- /dev/null +++ b/src/components/FlashCard.tsx @@ -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 ( +
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 ? ( +
+

{word || "word"}

+ {phonetic &&

{phonetic}

} +
+ ) : ( +
+

{meaningVi || "nghĩa tiếng Việt"}

+ {example &&

{example}

} +
+ )} +
+ ) +} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..317f0aa --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -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 ( +
+ {label && ( +
+ {label} + {value}/{max} +
+ )} +
+
+
+
+ ) +} diff --git a/src/components/QuestionCard.tsx b/src/components/QuestionCard.tsx new file mode 100644 index 0000000..c3a9bf3 --- /dev/null +++ b/src/components/QuestionCard.tsx @@ -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 ( +
+

{question || "Question placeholder"}

+ {options && ( +
    + {options.map((opt) => ( +
  • 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} +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/components/Timer.tsx b/src/components/Timer.tsx new file mode 100644 index 0000000..ad41f16 --- /dev/null +++ b/src/components/Timer.tsx @@ -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 ( +
+ {String(mins).padStart(2, "0")}:{String(secs).padStart(2, "0")} +
+ ) +} diff --git a/src/components/WritingFeedback.tsx b/src/components/WritingFeedback.tsx new file mode 100644 index 0000000..ad34567 --- /dev/null +++ b/src/components/WritingFeedback.tsx @@ -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 ( +
+
+ Band score: + {score} +
+ {summary &&

{summary}

} + {grammar && grammar.length > 0 && ( +
+

Ngữ pháp:

+
    + {grammar.map((item, i) =>
  • {item}
  • )} +
+
+ )} + {vocabulary && vocabulary.length > 0 && ( +
+

Từ vựng:

+
    + {vocabulary.map((item, i) =>
  • {item}
  • )} +
+
+ )} + {structure && ( +
+

Bố cục:

+

{structure}

+
+ )} +
+ ) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..09df753 --- /dev/null +++ b/src/components/ui/button.tsx @@ -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) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/hooks/use-questions.ts b/src/hooks/use-questions.ts new file mode 100644 index 0000000..91a0b69 --- /dev/null +++ b/src/hooks/use-questions.ts @@ -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 + }) +} diff --git a/src/hooks/use-vocab.ts b/src/hooks/use-vocab.ts new file mode 100644 index 0000000..904b6b4 --- /dev/null +++ b/src/hooks/use-vocab.ts @@ -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 + }) +} diff --git a/src/hooks/use-writing-check.ts b/src/hooks/use-writing-check.ts new file mode 100644 index 0000000..fc4aacf --- /dev/null +++ b/src/hooks/use-writing-check.ts @@ -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 => { + 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 + }, + }) +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..fb3c7e9 --- /dev/null +++ b/src/index.css @@ -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; + } +} \ No newline at end of file diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts new file mode 100644 index 0000000..2609f6b --- /dev/null +++ b/src/lib/query-client.ts @@ -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, + }, + }, +}) diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..8b9434c --- /dev/null +++ b/src/lib/supabase.ts @@ -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", +) diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..74d657c --- /dev/null +++ b/src/main.tsx @@ -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( + + + + + , +) diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..93c54b7 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,32 @@ +export function Home() { + return ( +
+
+

Luyện tiếng Anh TOEIC

+

+ Luyện đề, kiểm tra writing, và học từ vựng TOEIC miễn phí +

+
+
+
+

Luyện đề TOEIC

+

+ Luyện tập từng Part 1–7 với đề thật +

+
+
+

AI Writing Checker

+

+ Chấm điểm và sửa bài writing bằng AI +

+
+
+

Từ vựng TOEIC

+

+ Flashcard 6 chủ đề: Business, Finance, HR... +

+
+
+
+ ) +} diff --git a/src/pages/TestResult.tsx b/src/pages/TestResult.tsx new file mode 100644 index 0000000..f49c0a5 --- /dev/null +++ b/src/pages/TestResult.tsx @@ -0,0 +1,8 @@ +export function TestResult() { + return ( +
+

Kết quả

+

Kết quả và đáp án — placeholder

+
+ ) +} diff --git a/src/pages/TestSession.tsx b/src/pages/TestSession.tsx new file mode 100644 index 0000000..7922f43 --- /dev/null +++ b/src/pages/TestSession.tsx @@ -0,0 +1,8 @@ +export function TestSession() { + return ( +
+

Làm bài

+

Trang làm bài — placeholder

+
+ ) +} diff --git a/src/pages/ToeicPractice.tsx b/src/pages/ToeicPractice.tsx new file mode 100644 index 0000000..5b61e43 --- /dev/null +++ b/src/pages/ToeicPractice.tsx @@ -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 ( +
+

Luyện đề TOEIC

+

Chọn Part để bắt đầu luyện tập

+
+ {PARTS.map((part) => ( +
+
{part.name}
+
{part.desc}
+
+ ))} +
+
+ ) +} diff --git a/src/pages/Vocabulary.tsx b/src/pages/Vocabulary.tsx new file mode 100644 index 0000000..0971431 --- /dev/null +++ b/src/pages/Vocabulary.tsx @@ -0,0 +1,17 @@ +const TOPICS = ["Business", "Office", "Travel", "Finance", "HR", "Marketing"] + +export function Vocabulary() { + return ( +
+

Từ vựng TOEIC

+

Chọn chủ đề để học flashcard

+
+ {TOPICS.map((topic) => ( +
+ {topic} +
+ ))} +
+
+ ) +} diff --git a/src/pages/WritingChecker.tsx b/src/pages/WritingChecker.tsx new file mode 100644 index 0000000..30eba38 --- /dev/null +++ b/src/pages/WritingChecker.tsx @@ -0,0 +1,16 @@ +export function WritingChecker() { + return ( +
+

AI Writing Checker

+

+ Nhập bài writing để nhận phản hồi từ AI +

+