feat: initial commit
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user