feat: initial english-front setup
This commit is contained in:
commit
834f3fc737
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
1
dist/assets/GrammarView-dvqX1h4-.js
vendored
Normal file
1
dist/assets/GrammarView-dvqX1h4-.js
vendored
Normal 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
1
dist/assets/PhrasesView-Cr7ZmGzr.js
vendored
Normal 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
1
dist/assets/QuizView-WGudUYtA.js
vendored
Normal 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
1
dist/assets/WordsView-Cjev8bPi.js
vendored
Normal 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
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
1
dist/assets/index-C5kTBbUm.css
vendored
Normal 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
13
dist/index.html
vendored
Normal 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
12
index.html
Normal 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
1600
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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
15
src/App.vue
Normal 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
32
src/api/index.js
Normal 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
10
src/main.js
Normal 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
13
src/router/index.js
Normal 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
11
src/stores/session.js
Normal 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
139
src/style.css
Normal 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
60
src/views/GrammarView.vue
Normal 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
76
src/views/HomeView.vue
Normal 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
46
src/views/PhrasesView.vue
Normal 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
97
src/views/QuizView.vue
Normal 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
54
src/views/WordsView.vue
Normal 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
13
vite.config.js
Normal 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' },
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user