feat: initial english-front setup

This commit is contained in:
hyoseung930 2026-05-12 15:06:28 +09:00
commit 834f3fc737
23 changed files with 2256 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.DS_Store

1
dist/assets/GrammarView-dvqX1h4-.js vendored Normal file
View File

@ -0,0 +1 @@
import{o as b,l as h,c as a,a as e,F as u,r as g,b as p,t as l,n as y,d,e as s}from"./index-BcYnNRd_.js";const k={class:"container"},f={key:0},x={key:0,class:"loading"},w={key:1},C=["onClick"],B={style:{display:"flex","justify-content":"space-between","align-items":"center"}},z={class:"grammar-title"},V={class:"grammar-explanation",style:{"margin-bottom":"0","-webkit-line-clamp":"2",display:"-webkit-box","-webkit-box-orient":"vertical",overflow:"hidden"}},j={key:0,class:"empty"},A={key:1},F={class:"card"},N={style:{display:"flex","justify-content":"space-between","align-items":"center","margin-bottom":"1rem"}},$={style:{"font-size":"1.3rem","font-weight":"700"}},D={class:"grammar-explanation"},E={key:0,style:{"margin-top":"1rem"}},G={class:"en"},L={class:"ko"},q={__name:"GrammarView",setup(M){const r=d([]),n=d(null),c=d(!0),m=i=>({beginner:"badge-green",intermediate:"badge-blue",advanced:"badge-purple"})[i]||"badge-orange";return b(async()=>{const i=await h.getAll();r.value=i.data,c.value=!1}),(i,o)=>{var v;return s(),a("div",k,[o[2]||(o[2]=e("h1",{class:"page-title"},"문법 레슨",-1)),n.value?(s(),a("div",A,[e("button",{class:"btn btn-secondary",style:{"margin-bottom":"1.5rem"},onClick:o[0]||(o[0]=t=>n.value=null)},"← 목록으로"),e("div",F,[e("div",N,[e("h2",$,l(n.value.title),1),e("span",{class:y(["badge",m(n.value.level)])},l(n.value.level),3)]),e("div",D,l(n.value.explanation),1),(v=n.value.examples)!=null&&v.length?(s(),a("div",E,[o[1]||(o[1]=e("div",{style:{"font-weight":"600","margin-bottom":"0.5rem","font-size":"0.9rem",color:"#4a5568"}},"예문",-1)),(s(!0),a(u,null,g(n.value.examples,(t,_)=>(s(),a("div",{key:_,class:"grammar-example"},[e("div",G,l(t.en),1),e("div",L,l(t.ko),1)]))),128))])):p("",!0)])])):(s(),a("div",f,[c.value?(s(),a("div",x,"불러오는 중...")):(s(),a("div",w,[(s(!0),a(u,null,g(r.value,t=>(s(),a("div",{key:t.id,class:"card grammar-card",onClick:_=>n.value=t},[e("div",B,[e("div",z,l(t.order_num)+". "+l(t.title),1),e("span",{class:y(["badge",m(t.level)])},l(t.level),3)]),e("div",V,l(t.explanation),1)],8,C))),128)),r.value.length?p("",!0):(s(),a("div",j,"문법 레슨이 없습니다"))]))]))])}}};export{q as default};

1
dist/assets/PhrasesView-Cr7ZmGzr.js vendored Normal file
View File

@ -0,0 +1 @@
import{u as h,o as f,p as u,q as k,c as t,a as o,n as y,F as _,r as p,b as v,d as l,e as s,t as r}from"./index-BcYnNRd_.js";const w={class:"container"},C={style:{display:"flex",gap:"0.5rem","flex-wrap":"wrap","margin-bottom":"1.5rem"}},x=["onClick"],A={key:0,class:"loading"},z={key:1,class:"card-grid"},B={style:{"font-size":"1.1rem","font-weight":"700",color:"#2b6cb0","margin-bottom":"0.4rem"}},S={style:{color:"#2d3748","margin-bottom":"0.4rem"}},V={key:0,style:{"font-size":"0.8rem",color:"#718096"}},q={class:"badge badge-purple",style:{"margin-top":"0.5rem"}},F={key:0,class:"empty"},D={__name:"PhrasesView",setup(N){const b=h(),i=l([]),m=l([]),c=l(""),d=l(!1),g=async n=>{c.value=n,d.value=!0;const a=await u.getAll(n||void 0);i.value=a.data,d.value=!1};return f(async()=>{const[n,a]=await Promise.all([u.getAll(),u.getCategories()]);i.value=n.data,m.value=a.data,k.complete({session_id:b.id,type:"phrase"})}),(n,a)=>(s(),t("div",w,[a[1]||(a[1]=o("h1",{class:"page-title"},"회화 표현",-1)),o("div",C,[o("button",{class:y(["btn",c.value===""?"btn-primary":"btn-secondary"]),onClick:a[0]||(a[0]=e=>g(""))},"전체",2),(s(!0),t(_,null,p(m.value,e=>(s(),t("button",{key:e.category,class:y(["btn",c.value===e.category?"btn-primary":"btn-secondary"]),onClick:P=>g(e.category)},r(e.category),11,x))),128))]),d.value?(s(),t("div",A,"불러오는 중...")):(s(),t("div",z,[(s(!0),t(_,null,p(i.value,e=>(s(),t("div",{key:e.id,class:"card"},[o("div",B,r(e.english),1),o("div",S,r(e.korean),1),e.situation?(s(),t("div",V,"💬 "+r(e.situation),1)):v("",!0),o("span",q,r(e.category),1)]))),128)),i.value.length?v("",!0):(s(),t("div",F,"표현이 없습니다"))]))]))}};export{D as default};

1
dist/assets/QuizView-WGudUYtA.js vendored Normal file
View File

@ -0,0 +1 @@
import{u as C,c as o,a as t,g as b,t as a,b as f,F as V,r as N,h as S,i as B,j as R,R as j,d as r,k as z,q as A,e as i,n as F,f as L}from"./index-BcYnNRd_.js";const Q={class:"container",style:{"max-width":"600px"}},D={key:0,class:"card",style:{"text-align":"center",padding:"3rem"}},E={key:1},M={class:"quiz-progress"},T={style:{"text-align":"right","font-size":"0.85rem",color:"#718096","margin-bottom":"0.5rem"}},$={class:"card"},G={class:"quiz-question"},H={key:0,class:"quiz-sub"},I={class:"quiz-choices"},J=["disabled","onClick"],K={key:1,style:{"text-align":"center","margin-top":"1.5rem"}},O={key:2,class:"card",style:{"text-align":"center",padding:"3rem"}},P={style:{"font-size":"3rem","margin-bottom":"1rem"}},U={style:{"margin-bottom":"0.5rem"}},W={style:{color:"#718096","margin-bottom":"2rem"}},X={style:{display:"flex",gap:"0.75rem","justify-content":"center"}},te={__name:"QuizView",setup(Y){const _=C(),m=r(!1),g=r(!1),e=r([]),s=r(0),u=r(null),v=r(!1),n=r(0),k=async()=>{const d=await z.generate(5);e.value=d.data,m.value=!0},w=d=>{u.value=d,v.value=!0,d===e.value[s.value].answer&&n.value++},q=()=>{s.value<e.value.length-1?(s.value++,v.value=!1,u.value=null):(g.value=!0,z.saveResult({session_id:_.id,score:n.value,total:e.value.length}),A.complete({session_id:_.id,type:"quiz",score:n.value}))},x=()=>{m.value=!1,g.value=!1,s.value=0,n.value=0,v.value=!1,u.value=null};return(d,l)=>{var y,p,h;return i(),o("div",Q,[l[4]||(l[4]=t("h1",{class:"page-title"},"퀴즈",-1)),m.value?g.value?(i(),o("div",O,[t("div",P,a(n.value===e.value.length?"🏆":n.value>=e.value.length/2?"👏":"💪"),1),t("h2",U,a(n.value)+" / "+a(e.value.length)+" 정답",1),t("p",W,"정확도 "+a(Math.round(n.value/e.value.length*100))+"%",1),t("div",X,[t("button",{class:"btn btn-secondary",onClick:x},"다시 풀기"),S(R(j),{to:"/",class:"btn btn-primary"},{default:B(()=>[...l[3]||(l[3]=[L("홈으로",-1)])]),_:1})])])):(i(),o("div",E,[t("div",M,[t("div",{class:"quiz-progress-bar",style:b({width:s.value/e.value.length*100+"%"})},null,4)]),t("div",T,a(s.value+1)+" / "+a(e.value.length),1),t("div",$,[t("div",G,a((y=e.value[s.value])==null?void 0:y.english),1),(p=e.value[s.value])!=null&&p.pronunciation?(i(),o("div",H,"["+a(e.value[s.value].pronunciation)+"]",1)):f("",!0),t("div",I,[(i(!0),o(V,null,N((h=e.value[s.value])==null?void 0:h.choices,c=>(i(),o("button",{key:c,class:F(["quiz-choice",v.value?c===e.value[s.value].answer?"correct":c===u.value?"wrong":"":""]),disabled:v.value,onClick:Z=>w(c)},a(c),11,J))),128))]),v.value?(i(),o("div",K,[t("div",{style:b([{"margin-bottom":"1rem","font-weight":"600"},{color:u.value===e.value[s.value].answer?"#38a169":"#e53e3e"}])},a(u.value===e.value[s.value].answer?"✅ 정답!":"❌ 오답"),5),t("button",{class:"btn btn-primary",onClick:q},a(s.value<e.value.length-1?"다음":"결과 보기"),1)])):f("",!0)])])):(i(),o("div",D,[l[0]||(l[0]=t("div",{style:{"font-size":"3rem","margin-bottom":"1rem"}},"🎯",-1)),l[1]||(l[1]=t("h2",{style:{"margin-bottom":"0.5rem"}},"단어 퀴즈",-1)),l[2]||(l[2]=t("p",{style:{color:"#718096","margin-bottom":"2rem"}},"영어 단어의 뜻을 맞춰보세요!",-1)),t("button",{class:"btn btn-primary",style:{"font-size":"1rem",padding:"0.8rem 2rem"},onClick:k},"시작하기")]))])}}};export{te as default};

1
dist/assets/WordsView-Cjev8bPi.js vendored Normal file
View File

@ -0,0 +1 @@
import{u as f,o as k,w as u,q as h,c as t,a as n,n as v,F as m,r as g,b as _,d as r,e as a,t as o,f as x}from"./index-BcYnNRd_.js";const w={class:"container"},C={style:{display:"flex",gap:"0.5rem","flex-wrap":"wrap","margin-bottom":"1.5rem"}},A=["onClick"],V={key:0,class:"loading"},B={key:1,class:"card-grid"},N={style:{display:"flex","justify-content":"space-between","align-items":"flex-start"}},S={class:"word-en"},q={class:"badge badge-blue"},F={key:0,class:"word-pron"},$={class:"word-ko"},j={key:1,class:"word-example"},z={style:{color:"#a0aec0"}},D={key:0,class:"empty"},P={__name:"WordsView",setup(E){const b=f(),i=r([]),p=r([]),c=r(""),d=r(!1),y=async l=>{c.value=l,d.value=!0;const s=await u.getAll(l||void 0);i.value=s.data,d.value=!1};return k(async()=>{const[l,s]=await Promise.all([u.getAll(),u.getCategories()]);i.value=l.data,p.value=s.data,h.complete({session_id:b.id,type:"word"})}),(l,s)=>(a(),t("div",w,[s[2]||(s[2]=n("h1",{class:"page-title"},"단어장",-1)),n("div",C,[n("button",{class:v(["btn",c.value===""?"btn-primary":"btn-secondary"]),onClick:s[0]||(s[0]=e=>y(""))},"전체",2),(a(!0),t(m,null,g(p.value,e=>(a(),t("button",{key:e.category,class:v(["btn",c.value===e.category?"btn-primary":"btn-secondary"]),onClick:L=>y(e.category)},o(e.category),11,A))),128))]),d.value?(a(),t("div",V,"불러오는 중...")):(a(),t("div",B,[(a(!0),t(m,null,g(i.value,e=>(a(),t("div",{key:e.id,class:"card word-card"},[n("div",N,[n("div",S,o(e.english),1),n("span",q,o(e.difficulty),1)]),e.pronunciation?(a(),t("div",F,"["+o(e.pronunciation)+"]",1)):_("",!0),n("div",$,o(e.korean),1),e.example_en?(a(),t("div",j,[x(o(e.example_en),1),s[1]||(s[1]=n("br",null,null,-1)),n("span",z,o(e.example_ko),1)])):_("",!0)]))),128)),i.value.length?_("",!0):(a(),t("div",D,"단어가 없습니다"))]))]))}};export{P as default};

37
dist/assets/index-BcYnNRd_.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-C5kTBbUm.css vendored Normal file
View File

@ -0,0 +1 @@
*{margin:0;padding:0;box-sizing:border-box}body{font-family:Pretendard,-apple-system,BlinkMacSystemFont,sans-serif;background:#f0f4f8;color:#1a202c;min-height:100vh}#app{min-height:100vh}.nav{background:#2d3748;padding:0 1.5rem;display:flex;align-items:center;gap:.5rem;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px #0003}.nav-logo{color:#68d391;font-weight:700;font-size:1.2rem;padding:1rem 0;margin-right:auto}.nav a{color:#cbd5e0;text-decoration:none;padding:1rem .75rem;font-size:.9rem;border-bottom:3px solid transparent;transition:all .2s}.nav a:hover,.nav a.active{color:#68d391;border-bottom-color:#68d391}.container{max-width:900px;margin:0 auto;padding:2rem 1rem}.page-title{font-size:1.6rem;font-weight:700;margin-bottom:1.5rem;color:#2d3748}.card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px #0000000f;margin-bottom:1rem}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}.badge{display:inline-block;padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:600}.badge-green{background:#c6f6d5;color:#276749}.badge-blue{background:#bee3f8;color:#2b6cb0}.badge-purple{background:#e9d8fd;color:#553c9a}.badge-orange{background:#feebc8;color:#c05621}.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-size:.9rem;font-weight:600;transition:all .2s}.btn-primary{background:#48bb78;color:#fff}.btn-primary:hover{background:#38a169}.btn-secondary{background:#e2e8f0;color:#4a5568}.btn-secondary:hover{background:#cbd5e0}.word-card{transition:transform .2s}.word-card:hover{transform:translateY(-2px)}.word-en{font-size:1.4rem;font-weight:700;color:#2b6cb0}.word-pron{font-size:.85rem;color:#718096;margin:.2rem 0}.word-ko{font-size:1rem;color:#2d3748;margin:.4rem 0}.word-example{font-size:.82rem;color:#718096;border-left:3px solid #bee3f8;padding-left:.6rem;margin-top:.5rem}.streak-box{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border-radius:12px;padding:1.5rem;text-align:center;margin-bottom:1.5rem}.streak-num{font-size:3rem;font-weight:800}.streak-label{font-size:.9rem;opacity:.85}.quest-item{display:flex;align-items:center;gap:1rem;padding:.8rem 0;border-bottom:1px solid #e2e8f0}.quest-item:last-child{border-bottom:none}.quest-check{width:24px;height:24px;border-radius:50%;border:2px solid #cbd5e0;display:flex;align-items:center;justify-content:center;font-size:.8rem}.quest-check.done{background:#48bb78;border-color:#48bb78;color:#fff}.quiz-question{font-size:2rem;font-weight:800;text-align:center;color:#2b6cb0;margin:1.5rem 0}.quiz-sub{text-align:center;color:#718096;margin-bottom:1.5rem}.quiz-choices{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.quiz-choice{padding:1rem;border:2px solid #e2e8f0;border-radius:10px;cursor:pointer;font-size:1rem;background:#fff;transition:all .15s;text-align:center}.quiz-choice:hover:not(:disabled){border-color:#4299e1;background:#ebf8ff}.quiz-choice.correct{border-color:#48bb78;background:#f0fff4;color:#276749}.quiz-choice.wrong{border-color:#fc8181;background:#fff5f5;color:#c53030}.quiz-progress{height:6px;background:#e2e8f0;border-radius:3px;margin-bottom:1.5rem}.quiz-progress-bar{height:100%;background:#48bb78;border-radius:3px;transition:width .3s}.grammar-card{cursor:pointer}.grammar-card:hover{box-shadow:0 4px 16px #0000001a}.grammar-title{font-size:1.1rem;font-weight:700;margin-bottom:.4rem}.grammar-explanation{color:#4a5568;font-size:.9rem;line-height:1.6;margin:.8rem 0}.grammar-example{background:#f7fafc;border-radius:8px;padding:.6rem .8rem;margin:.4rem 0;font-size:.88rem}.grammar-example .en{color:#2b6cb0;font-weight:600}.grammar-example .ko{color:#718096}.empty{text-align:center;padding:3rem;color:#a0aec0}.loading{text-align:center;padding:2rem;color:#718096}

13
dist/index.html vendored Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>English Study</title>
<script type="module" crossorigin src="/assets/index-BcYnNRd_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5kTBbUm.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>English Study</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1600
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "english-front",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}

15
src/App.vue Normal file
View File

@ -0,0 +1,15 @@
<template>
<nav class="nav">
<span class="nav-logo">📚 English Study</span>
<RouterLink to="/"></RouterLink>
<RouterLink to="/words">단어</RouterLink>
<RouterLink to="/phrases">회화</RouterLink>
<RouterLink to="/quiz">퀴즈</RouterLink>
<RouterLink to="/grammar">문법</RouterLink>
</nav>
<RouterView />
</template>
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

32
src/api/index.js Normal file
View File

@ -0,0 +1,32 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export const wordsApi = {
getAll: (category) => api.get('/words', { params: { category } }),
getDaily: () => api.get('/words/daily'),
getQuiz: (count = 5) => api.get('/words/quiz', { params: { count } }),
getCategories: () => api.get('/words/categories'),
}
export const phrasesApi = {
getAll: (category) => api.get('/phrases', { params: { category } }),
getDaily: () => api.get('/phrases/daily'),
getCategories: () => api.get('/phrases/categories'),
}
export const grammarApi = {
getAll: () => api.get('/grammar'),
getOne: (id) => api.get(`/grammar/${id}`),
}
export const quizApi = {
generate: (count = 5) => api.get('/quiz/generate', { params: { count } }),
saveResult: (data) => api.post('/quiz/result', data),
}
export const questApi = {
getToday: (session) => api.get('/quest/today', { params: { session } }),
complete: (data) => api.post('/quest/complete', data),
getStreak: (session) => api.get('/quest/streak', { params: { session } }),
}

10
src/main.js Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

13
src/router/index.js Normal file
View File

@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomeView },
{ path: '/words', component: () => import('../views/WordsView.vue') },
{ path: '/phrases', component: () => import('../views/PhrasesView.vue') },
{ path: '/quiz', component: () => import('../views/QuizView.vue') },
{ path: '/grammar', component: () => import('../views/GrammarView.vue') },
],
})

11
src/stores/session.js Normal file
View File

@ -0,0 +1,11 @@
import { defineStore } from 'pinia'
export const useSessionStore = defineStore('session', {
state: () => ({
id: localStorage.getItem('session_id') || (() => {
const id = 'sess_' + Math.random().toString(36).slice(2)
localStorage.setItem('session_id', id)
return id
})(),
}),
})

139
src/style.css Normal file
View File

@ -0,0 +1,139 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f0f4f8;
color: #1a202c;
min-height: 100vh;
}
#app { min-height: 100vh; }
.nav {
background: #2d3748;
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.nav-logo { color: #68d391; font-weight: 700; font-size: 1.2rem; padding: 1rem 0; margin-right: auto; }
.nav a {
color: #cbd5e0;
text-decoration: none;
padding: 1rem 0.75rem;
font-size: 0.9rem;
border-bottom: 3px solid transparent;
transition: all 0.2s;
}
.nav a:hover, .nav a.active { color: #68d391; border-bottom-color: #68d391; }
.container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
.page-title { font-size: 1.6rem; font-weight: 700; margin-bottom: 1.5rem; color: #2d3748; }
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 1rem;
}
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-green { background: #c6f6d5; color: #276749; }
.badge-blue { background: #bee3f8; color: #2b6cb0; }
.badge-purple { background: #e9d8fd; color: #553c9a; }
.badge-orange { background: #feebc8; color: #c05621; }
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.2rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary { background: #48bb78; color: white; }
.btn-primary:hover { background: #38a169; }
.btn-secondary { background: #e2e8f0; color: #4a5568; }
.btn-secondary:hover { background: #cbd5e0; }
.word-card { transition: transform 0.2s; }
.word-card:hover { transform: translateY(-2px); }
.word-en { font-size: 1.4rem; font-weight: 700; color: #2b6cb0; }
.word-pron { font-size: 0.85rem; color: #718096; margin: 0.2rem 0; }
.word-ko { font-size: 1rem; color: #2d3748; margin: 0.4rem 0; }
.word-example { font-size: 0.82rem; color: #718096; border-left: 3px solid #bee3f8; padding-left: 0.6rem; margin-top: 0.5rem; }
.streak-box {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border-radius: 12px;
padding: 1.5rem;
text-align: center;
margin-bottom: 1.5rem;
}
.streak-num { font-size: 3rem; font-weight: 800; }
.streak-label { font-size: 0.9rem; opacity: 0.85; }
.quest-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.8rem 0;
border-bottom: 1px solid #e2e8f0;
}
.quest-item:last-child { border-bottom: none; }
.quest-check { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #cbd5e0; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; }
.quest-check.done { background: #48bb78; border-color: #48bb78; color: white; }
.quiz-question { font-size: 2rem; font-weight: 800; text-align: center; color: #2b6cb0; margin: 1.5rem 0; }
.quiz-sub { text-align: center; color: #718096; margin-bottom: 1.5rem; }
.quiz-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.quiz-choice {
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
font-size: 1rem;
background: white;
transition: all 0.15s;
text-align: center;
}
.quiz-choice:hover:not(:disabled) { border-color: #4299e1; background: #ebf8ff; }
.quiz-choice.correct { border-color: #48bb78; background: #f0fff4; color: #276749; }
.quiz-choice.wrong { border-color: #fc8181; background: #fff5f5; color: #c53030; }
.quiz-progress { height: 6px; background: #e2e8f0; border-radius: 3px; margin-bottom: 1.5rem; }
.quiz-progress-bar { height: 100%; background: #48bb78; border-radius: 3px; transition: width 0.3s; }
.grammar-card { cursor: pointer; }
.grammar-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
.grammar-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.4rem; }
.grammar-explanation { color: #4a5568; font-size: 0.9rem; line-height: 1.6; margin: 0.8rem 0; }
.grammar-example { background: #f7fafc; border-radius: 8px; padding: 0.6rem 0.8rem; margin: 0.4rem 0; font-size: 0.88rem; }
.grammar-example .en { color: #2b6cb0; font-weight: 600; }
.grammar-example .ko { color: #718096; }
.empty { text-align: center; padding: 3rem; color: #a0aec0; }
.loading { text-align: center; padding: 2rem; color: #718096; }

60
src/views/GrammarView.vue Normal file
View File

@ -0,0 +1,60 @@
<template>
<div class="container">
<h1 class="page-title">문법 레슨</h1>
<div v-if="!selected">
<div v-if="loading" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="lesson in lessons" :key="lesson.id" class="card grammar-card" @click="selected = lesson">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="grammar-title">{{ lesson.order_num }}. {{ lesson.title }}</div>
<span class="badge" :class="levelBadge(lesson.level)">{{ lesson.level }}</span>
</div>
<div class="grammar-explanation" style="margin-bottom:0;-webkit-line-clamp:2;display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden">
{{ lesson.explanation }}
</div>
</div>
<div v-if="!lessons.length" class="empty">문법 레슨이 없습니다</div>
</div>
</div>
<div v-else>
<button class="btn btn-secondary" style="margin-bottom:1.5rem" @click="selected = null"> 목록으로</button>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h2 style="font-size:1.3rem;font-weight:700">{{ selected.title }}</h2>
<span class="badge" :class="levelBadge(selected.level)">{{ selected.level }}</span>
</div>
<div class="grammar-explanation">{{ selected.explanation }}</div>
<div v-if="selected.examples?.length" style="margin-top:1rem">
<div style="font-weight:600;margin-bottom:0.5rem;font-size:0.9rem;color:#4a5568">예문</div>
<div v-for="(ex, i) in selected.examples" :key="i" class="grammar-example">
<div class="en">{{ ex.en }}</div>
<div class="ko">{{ ex.ko }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { grammarApi } from '../api'
const lessons = ref([])
const selected = ref(null)
const loading = ref(true)
const levelBadge = (level) => ({
beginner: 'badge-green',
intermediate: 'badge-blue',
advanced: 'badge-purple',
}[level] || 'badge-orange')
onMounted(async () => {
const res = await grammarApi.getAll()
lessons.value = res.data
loading.value = false
})
</script>

76
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,76 @@
<template>
<div class="container">
<div class="streak-box">
<div class="streak-num">🔥 {{ streak.current_streak }}</div>
<div class="streak-label"> 연속 학습 (최고 {{ streak.max_streak }})</div>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568">오늘의 퀘스트</h3>
<div class="quest-item">
<div class="quest-check" :class="{ done: quest.word_done }"></div>
<div>
<div style="font-weight:600">단어 학습</div>
<div style="font-size:0.82rem;color:#718096">오늘의 단어 5 확인하기</div>
</div>
<RouterLink v-if="!quest.word_done" to="/words" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink>
<span v-else class="badge badge-green" style="margin-left:auto">완료</span>
</div>
<div class="quest-item">
<div class="quest-check" :class="{ done: quest.phrase_done }"></div>
<div>
<div style="font-weight:600">회화 표현</div>
<div style="font-size:0.82rem;color:#718096">오늘의 표현 3 확인하기</div>
</div>
<RouterLink v-if="!quest.phrase_done" to="/phrases" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink>
<span v-else class="badge badge-green" style="margin-left:auto">완료</span>
</div>
<div class="quest-item">
<div class="quest-check" :class="{ done: quest.quiz_done }"></div>
<div>
<div style="font-weight:600">퀴즈</div>
<div style="font-size:0.82rem;color:#718096">5문제 풀기 <span v-if="quest.quiz_score !== null">({{ quest.quiz_score }})</span></div>
</div>
<RouterLink v-if="!quest.quiz_done" to="/quiz" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink>
<span v-else class="badge badge-green" style="margin-left:auto">완료</span>
</div>
</div>
<h2 class="page-title">오늘의 단어</h2>
<div class="card-grid">
<div v-for="word in dailyWords" :key="word.id" class="card word-card">
<div class="word-en">{{ word.english }}</div>
<div v-if="word.pronunciation" class="word-pron">[{{ word.pronunciation }}]</div>
<div class="word-ko">{{ word.korean }}</div>
<div v-if="word.example_en" class="word-example">
{{ word.example_en }}<br/>
<span style="color:#a0aec0">{{ word.example_ko }}</span>
</div>
</div>
<div v-if="!dailyWords.length" class="empty">단어 데이터가 없습니다</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { wordsApi, questApi } from '../api'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const dailyWords = ref([])
const quest = ref({ word_done: false, phrase_done: false, quiz_done: false, quiz_score: null })
const streak = ref({ current_streak: 0, max_streak: 0 })
onMounted(async () => {
const [w, q, s] = await Promise.all([
wordsApi.getDaily(),
questApi.getToday(session.id),
questApi.getStreak(session.id),
])
dailyWords.value = w.data
quest.value = q.data
streak.value = s.data
})
</script>

46
src/views/PhrasesView.vue Normal file
View File

@ -0,0 +1,46 @@
<template>
<div class="container">
<h1 class="page-title">회화 표현</h1>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1.5rem">
<button class="btn" :class="selected === '' ? 'btn-primary' : 'btn-secondary'" @click="select('')">전체</button>
<button v-for="c in categories" :key="c.category" class="btn" :class="selected === c.category ? 'btn-primary' : 'btn-secondary'" @click="select(c.category)">{{ c.category }}</button>
</div>
<div v-if="loading" class="loading">불러오는 중...</div>
<div v-else class="card-grid">
<div v-for="p in phrases" :key="p.id" class="card">
<div style="font-size:1.1rem;font-weight:700;color:#2b6cb0;margin-bottom:0.4rem">{{ p.english }}</div>
<div style="color:#2d3748;margin-bottom:0.4rem">{{ p.korean }}</div>
<div v-if="p.situation" style="font-size:0.8rem;color:#718096">💬 {{ p.situation }}</div>
<span class="badge badge-purple" style="margin-top:0.5rem">{{ p.category }}</span>
</div>
<div v-if="!phrases.length" class="empty">표현이 없습니다</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { phrasesApi, questApi } from '../api'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const phrases = ref([])
const categories = ref([])
const selected = ref('')
const loading = ref(false)
const select = async (cat) => {
selected.value = cat
loading.value = true
const res = await phrasesApi.getAll(cat || undefined)
phrases.value = res.data
loading.value = false
}
onMounted(async () => {
const [p, c] = await Promise.all([phrasesApi.getAll(), phrasesApi.getCategories()])
phrases.value = p.data
categories.value = c.data
questApi.complete({ session_id: session.id, type: 'phrase' })
})
</script>

97
src/views/QuizView.vue Normal file
View File

@ -0,0 +1,97 @@
<template>
<div class="container" style="max-width:600px">
<h1 class="page-title">퀴즈</h1>
<div v-if="!started" class="card" style="text-align:center;padding:3rem">
<div style="font-size:3rem;margin-bottom:1rem">🎯</div>
<h2 style="margin-bottom:0.5rem">단어 퀴즈</h2>
<p style="color:#718096;margin-bottom:2rem">영어 단어의 뜻을 맞춰보세요!</p>
<button class="btn btn-primary" style="font-size:1rem;padding:0.8rem 2rem" @click="startQuiz">시작하기</button>
</div>
<div v-else-if="!finished">
<div class="quiz-progress">
<div class="quiz-progress-bar" :style="{ width: ((current) / questions.length * 100) + '%' }"></div>
</div>
<div style="text-align:right;font-size:0.85rem;color:#718096;margin-bottom:0.5rem">{{ current + 1 }} / {{ questions.length }}</div>
<div class="card">
<div class="quiz-question">{{ questions[current]?.english }}</div>
<div v-if="questions[current]?.pronunciation" class="quiz-sub">[{{ questions[current].pronunciation }}]</div>
<div class="quiz-choices">
<button v-for="choice in questions[current]?.choices" :key="choice"
class="quiz-choice"
:class="answered ? (choice === questions[current].answer ? 'correct' : (choice === selected ? 'wrong' : '')) : ''"
:disabled="answered"
@click="answer(choice)">
{{ choice }}
</button>
</div>
<div v-if="answered" style="text-align:center;margin-top:1.5rem">
<div style="margin-bottom:1rem;font-weight:600" :style="{ color: selected === questions[current].answer ? '#38a169' : '#e53e3e' }">
{{ selected === questions[current].answer ? '✅ 정답!' : '❌ 오답' }}
</div>
<button class="btn btn-primary" @click="next">{{ current < questions.length - 1 ? '다음' : '결과 보기' }}</button>
</div>
</div>
</div>
<div v-else class="card" style="text-align:center;padding:3rem">
<div style="font-size:3rem;margin-bottom:1rem">{{ score === questions.length ? '🏆' : score >= questions.length / 2 ? '👏' : '💪' }}</div>
<h2 style="margin-bottom:0.5rem">{{ score }} / {{ questions.length }} 정답</h2>
<p style="color:#718096;margin-bottom:2rem">정확도 {{ Math.round(score / questions.length * 100) }}%</p>
<div style="display:flex;gap:0.75rem;justify-content:center">
<button class="btn btn-secondary" @click="reset">다시 풀기</button>
<RouterLink to="/" class="btn btn-primary">홈으로</RouterLink>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { quizApi, questApi } from '../api'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const started = ref(false)
const finished = ref(false)
const questions = ref([])
const current = ref(0)
const selected = ref(null)
const answered = ref(false)
const score = ref(0)
const startQuiz = async () => {
const res = await quizApi.generate(5)
questions.value = res.data
started.value = true
}
const answer = (choice) => {
selected.value = choice
answered.value = true
if (choice === questions.value[current.value].answer) score.value++
}
const next = () => {
if (current.value < questions.value.length - 1) {
current.value++
answered.value = false
selected.value = null
} else {
finished.value = true
quizApi.saveResult({ session_id: session.id, score: score.value, total: questions.value.length })
questApi.complete({ session_id: session.id, type: 'quiz', score: score.value })
}
}
const reset = () => {
started.value = false
finished.value = false
current.value = 0
score.value = 0
answered.value = false
selected.value = null
}
</script>

54
src/views/WordsView.vue Normal file
View File

@ -0,0 +1,54 @@
<template>
<div class="container">
<h1 class="page-title">단어장</h1>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1.5rem">
<button class="btn" :class="selected === '' ? 'btn-primary' : 'btn-secondary'" @click="select('')">전체</button>
<button v-for="c in categories" :key="c.category" class="btn" :class="selected === c.category ? 'btn-primary' : 'btn-secondary'" @click="select(c.category)">
{{ c.category }}
</button>
</div>
<div v-if="loading" class="loading">불러오는 중...</div>
<div v-else class="card-grid">
<div v-for="word in words" :key="word.id" class="card word-card">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div class="word-en">{{ word.english }}</div>
<span class="badge badge-blue">{{ word.difficulty }}</span>
</div>
<div v-if="word.pronunciation" class="word-pron">[{{ word.pronunciation }}]</div>
<div class="word-ko">{{ word.korean }}</div>
<div v-if="word.example_en" class="word-example">
{{ word.example_en }}<br/>
<span style="color:#a0aec0">{{ word.example_ko }}</span>
</div>
</div>
<div v-if="!words.length" class="empty">단어가 없습니다</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { wordsApi, questApi } from '../api'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const words = ref([])
const categories = ref([])
const selected = ref('')
const loading = ref(false)
const select = async (cat) => {
selected.value = cat
loading.value = true
const res = await wordsApi.getAll(cat || undefined)
words.value = res.data
loading.value = false
}
onMounted(async () => {
const [w, c] = await Promise.all([wordsApi.getAll(), wordsApi.getCategories()])
words.value = w.data
categories.value = c.data
questApi.complete({ session_id: session.id, type: 'word' })
})
</script>

13
vite.config.js Normal file
View File

@ -0,0 +1,13 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
proxy: { '/api': 'http://localhost:3011' },
},
})