feat: initial commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
25
components.json
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
45
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
src/components/FlashCard.tsx
Normal file
31
src/components/FlashCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/ProgressBar.tsx
Normal file
26
src/components/ProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/components/QuestionCard.tsx
Normal file
29
src/components/QuestionCard.tsx
Normal 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
14
src/components/Timer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/WritingFeedback.tsx
Normal file
44
src/components/WritingFeedback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal 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 }
|
||||
18
src/hooks/use-questions.ts
Normal file
18
src/hooks/use-questions.ts
Normal 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
16
src/hooks/use-vocab.ts
Normal 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
|
||||
})
|
||||
}
|
||||
28
src/hooks/use-writing-check.ts
Normal file
28
src/hooks/use-writing-check.ts
Normal 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
130
src/index.css
Normal 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
10
src/lib/query-client.ts
Normal 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
15
src/lib/supabase.ts
Normal 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
6
src/lib/utils.ts
Normal 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
23
src/main.tsx
Normal 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
32
src/pages/Home.tsx
Normal 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, và 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 1–7 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 và 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
8
src/pages/TestResult.tsx
Normal 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ả và đáp án — placeholder</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/pages/TestSession.tsx
Normal file
8
src/pages/TestSession.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/pages/ToeicPractice.tsx
Normal file
26
src/pages/ToeicPractice.tsx
Normal 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
17
src/pages/Vocabulary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/pages/WritingChecker.tsx
Normal file
16
src/pages/WritingChecker.tsx
Normal 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
31
src/routes/__root.tsx
Normal 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
6
src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Home } from "@/pages/Home"
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Home,
|
||||
})
|
||||
6
src/routes/toeic.index.tsx
Normal file
6
src/routes/toeic.index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { ToeicPractice } from "@/pages/ToeicPractice"
|
||||
|
||||
export const Route = createFileRoute("/toeic/")({
|
||||
component: ToeicPractice,
|
||||
})
|
||||
15
src/routes/toeic.part.$partId.tsx
Normal file
15
src/routes/toeic.part.$partId.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/routes/toeic.result.tsx
Normal file
6
src/routes/toeic.result.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { TestResult } from "@/pages/TestResult"
|
||||
|
||||
export const Route = createFileRoute("/toeic/result")({
|
||||
component: TestResult,
|
||||
})
|
||||
6
src/routes/toeic.session.tsx
Normal file
6
src/routes/toeic.session.tsx
Normal 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
9
src/routes/toeic.tsx
Normal 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
6
src/routes/vocab.tsx
Normal 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
6
src/routes/writing.tsx
Normal 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
27
src/store/test-store.ts
Normal 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
28
src/store/vocab-store.ts
Normal 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
49
src/utils/rate-limiter.ts
Normal 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
10
src/vite-env.d.ts
vendored
Normal 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
25
tsconfig.app.json
Normal 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
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tsconfig.node.json
Normal file
20
tsconfig.node.json
Normal 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
21
vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user