import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
// --- Utility: Local Storage Hook ---
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch {}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// --- TOEIC-style Sentence Generator (Creates 500+) ---
function generateToeicSentences(count = 500) {
const templates = [
"Please submit the {doc} by {day}.",
"The {dept} meeting has been {status} to {time}.",
"Our company will {verb} a new {item} next {day}.",
"Employees are required to {action} before {deadline}.",
"The maintenance team will check the {place} this {day}.",
"We appreciate your {noun} during the {event}.",
"The {role} is responsible for {gerund} the {doc}.",
"Please be advised that the {item} is {status}.",
"Customer feedback should be {verb_past} to the {dept}.",
"The shipment is expected to {verb} on {day}.",
"Please make sure to {action} the {item}.",
"Due to {event}, the {dept} office will be {status}.",
"Kindly {action} your {doc} for review.",
"The {role} will provide an update by {time}.",
"There will be a {event} in the main {place} at {time}.",
"We regret to inform you that the {item} has been {status}.",
"All staff must {action} their {doc} by {deadline}.",
"The {dept} is conducting a survey on {topic}.",
"Please {action} to the new {policy}.",
"The {role} will be {verb_ger} the project next {day}.",
];
const pools = {
doc: ["expense report", "timesheet", "proposal", "application", "invoice", "contract", "presentation", "travel request"],
day: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "weekend"],
dept: ["marketing", "sales", "human resources", "finance", "IT", "logistics", "customer service"],
status: ["rescheduled", "postponed", "moved", "canceled", "confirmed", "under review"],
time: ["9 a.m.", "noon", "3 p.m.", "5 p.m.", "end of day"],
verb: ["launch", "announce", "introduce", "deliver", "ship", "release"],
item: ["policy", "product", "service", "newsletter", "training program", "software update"],
action: ["complete", "sign", "attach", "review", "confirm", "submit", "reset"],
deadline: ["Friday", "tomorrow", "this afternoon", "next week", "the end of the month"],
place: ["conference room", "cafeteria", "warehouse", "lobby", "parking lot", "server room"],
noun: ["patience", "cooperation", "understanding", "participation", "support"],
event: ["system upgrade", "power outage", "renovation", "holiday", "annual audit", "weather conditions"],
role: ["manager", "supervisor", "director", "team leader", "coordinator", "assistant"],
gerund: ["preparing", "reviewing", "finalizing", "submitting", "approving"],
verb_past: ["forwarded", "submitted", "reported", "sent", "delivered"],
topic: ["workplace safety", "remote work", "customer satisfaction", "training needs", "product quality"],
policy: ["policy", "guideline", "schedule", "seating arrangement", "security procedure"],
verb_ger: ["overseeing", "leading", "managing", "coordinating", "facilitating"],
};
function fill(template) {
return template.replace(/\{(.*?)\}/g, (_, key) => {
const arr = pools[key] || [key];
return arr[Math.floor(Math.random() * arr.length)];
});
}
const set = new Set();
let guard = 0;
while (set.size < count && guard < count * 20) {
guard++;
const t = templates[Math.floor(Math.random() * templates.length)];
set.add(fill(t));
}
// Add a few fixed authentic TOEIC-like sentences for variety and length control
const fixed = [
"Please be reminded that the deadline has been extended to next Friday.",
"If you have any questions regarding the itinerary, feel free to contact our travel desk.",
"Participants are encouraged to arrive at least ten minutes before the seminar begins.",
"Due to unforeseen circumstances, the elevator will be out of service until further notice.",
"Please note that all orders placed after 4 p.m. will be processed the following business day.",
"To enhance security, employees must display their ID badges at all times.",
];
return [...fixed, ...Array.from(set)].slice(0, count);
}
// --- Helper: caret positioning for input ---
function setCaretToEnd(el) {
const value = el.value;
el.value = "";
el.value = value;
}
export default function App() {
const [sentences, setSentences] = useLocalStorage("toeic.sentences", []);
const [countSetting, setCountSetting] = useLocalStorage("toeic.count", 500);
const [shuffle, setShuffle] = useLocalStorage("toeic.shuffle", true);
const [currentIndex, setCurrentIndex] = useLocalStorage("toeic.index", 0);
const [input, setInput] = useState("");
const [startedAt, setStartedAt] = useState(null);
const [finishedCount, setFinishedCount] = useLocalStorage("toeic.finished", 0);
const [keystrokes, setKeystrokes] = useLocalStorage("toeic.keys", 0);
const [errors, setErrors] = useLocalStorage("toeic.errors", 0);
const [goalWPM, setGoalWPM] = useLocalStorage("toeic.goalWPM", 50);
const [sessionId, setSessionId] = useState(() => Date.now());
const inputRef = useRef(null);
// Initialize sentences on first load or when user regenerates
useEffect(() => {
if (!sentences || sentences.length === 0) {
const gen = generateToeicSentences(countSetting);
const arr = shuffle ? shuffleArray(gen) : gen;
setSentences(arr);
setCurrentIndex(0);
setFinishedCount(0);
setKeystrokes(0);
setErrors(0);
}
}, []); // eslint-disable-line
function shuffleArray(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const current = sentences[currentIndex] || "Click '새로 만들기' to generate sentences.";
// Stats
const elapsedMinutes = useMemo(() => {
if (!startedAt) return 0;
const diffMs = Date.now() - startedAt;
return diffMs / 60000;
}, [startedAt, sessionId]);
const grossWPM = useMemo(() => {
if (!startedAt || elapsedMinutes <= 0) return 0;
return Math.round(((keystrokes / 5) / elapsedMinutes) * 10) / 10;
}, [keystrokes, elapsedMinutes, startedAt]);
const accuracy = useMemo(() => {
const total = keystrokes || 1;
const acc = ((total - errors) / total) * 100;
return Math.max(0, Math.min(100, Math.round(acc * 10) / 10));
}, [keystrokes, errors]);
useEffect(() => {
if (inputRef.current) {
setCaretToEnd(inputRef.current);
inputRef.current.focus();
}
}, [currentIndex]);
function handleChange(e) {
const v = e.target.value;
const expected = current.slice(0, v.length);
const lastChar = v.slice(-1);
setInput(v);
// Keystroke + error detection (per char)
if (startedAt === null) setStartedAt(Date.now());
setKeystrokes((k) => k + 1);
if (v !== expected) {
setErrors((er) => er + 1);
}
// Completed sentence
if (v === current) {
setFinishedCount((n) => n + 1);
setTimeout(() => nextSentence(), 80);
}
}
function nextSentence() {
setInput("");
setCurrentIndex((i) => Math.min(i + 1, sentences.length));
setSessionId(Date.now());
}
function prevSentence() {
setInput("");
setCurrentIndex((i) => Math.max(0, i - 1));
}
function regenerate() {
const gen = generateToeicSentences(countSetting);
const arr = shuffle ? shuffleArray(gen) : gen;
setSentences(arr);
setCurrentIndex(0);
setInput("");
setFinishedCount(0);
setKeystrokes(0);
setErrors(0);
setStartedAt(null);
setSessionId(Date.now());
}
function importList(text) {
const lines = text
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
if (lines.length > 0) {
setSentences(lines);
setCurrentIndex(0);
setInput("");
setFinishedCount(0);
setKeystrokes(0);
setErrors(0);
setStartedAt(null);
setSessionId(Date.now());
}
}
const progress = sentences.length ? Math.min(100, Math.round(((currentIndex) / sentences.length) * 100)) : 0;
function coloredChars() {
const out = [];
const tgt = current;
for (let i = 0; i < tgt.length; i++) {
const ch = tgt[i];
const typed = input[i];
let cls = "";
if (typed == null) cls = "text-gray-400";
else if (typed === ch) cls = "text-green-600";
else cls = "text-red-600 bg-red-50";
out.push(
{ch === " " ? "\u00A0" : ch}
);
}
return out;
}
return (
{/* Controls */}
문장 가져오기 / 내보내기
원하는 TOEIC 예문 500개를 직접 넣어 커스텀 연습이 가능합니다.
세션
완료 문장: {finishedCount} / {sentences.length}
Enter 키 없이, 문장을 정확히 모두 치면 자동으로 다음 문장으로 넘어갑니다.
{/* Typing Panel */}
문장 {currentIndex + 1} / {sentences.length}
= goalWPM ? "bg-green-50 text-green-700" : "bg-amber-50 text-amber-700"}`}>
목표 WPM {goalWPM} {grossWPM >= goalWPM ? "달성!" : "도전"}
);
}
function Stat({ label, value }) {
return (
);
}
function Tips() {
return (
사용 팁
- 오타가 나면 해당 글자가 붉게 표시됩니다. 문장을 정확히 모두 입력하면 자동으로 다음 문장으로 이동합니다.
- 상단의 새로 만들기 버튼으로 500개의 TOEIC 스타일 예문을 즉시 생성할 수 있습니다.
- 원하는 예문이 있다면 ‘문장 가져오기’에 붙여넣어 맞춤형 연습을 하세요.
- 모든 기록은 브라우저(LocalStorage)에 저장되며, 새로고침해도 유지됩니다.
);
}