Initial commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="drawer lg:drawer-open" data-theme="dark">
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content flex flex-col h-screen overflow-hidden bg-base-100 text-base-content">
|
||||
<!-- Mobile Navbar -->
|
||||
<div class="w-full navbar bg-base-200 lg:hidden border-b border-base-300 shrink-0">
|
||||
<div class="flex-none">
|
||||
<label for="main-drawer" class="btn btn-square btn-ghost">
|
||||
<MenuIcon :size="24" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 px-2 mx-2 font-black text-xl">CBT 마스터</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-hidden relative">
|
||||
<div class="absolute inset-0 overflow-y-auto">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side z-50">
|
||||
<label for="main-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<aside class="w-72 min-h-full bg-base-200 border-r border-base-300 flex flex-col shrink-0">
|
||||
<div class="p-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary p-2 rounded-xl shadow-lg shadow-primary/20 text-primary-content">
|
||||
<TrophyIcon :size="24" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-black tracking-tight">CBT 마스터</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-4 space-y-2 overflow-y-auto">
|
||||
<router-link to="/" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group" active-class="bg-primary text-primary-content shadow-lg shadow-primary/20" @click="closeDrawer">
|
||||
<LayoutDashboardIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
현황판
|
||||
</router-link>
|
||||
<router-link to="/categories" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group" active-class="bg-primary text-primary-content shadow-lg shadow-primary/20" @click="closeDrawer">
|
||||
<BookOpenIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
카테고리
|
||||
</router-link>
|
||||
<router-link to="/bookmarks" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group" active-class="bg-primary text-primary-content shadow-lg shadow-primary/20" @click="closeDrawer">
|
||||
<BookmarkIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
책갈피
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="p-6 m-4 bg-base-300 rounded-3xl border border-base-content/5 space-y-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3 px-2">
|
||||
<KeyIcon :size="16" class="text-primary" />
|
||||
<span class="text-xs font-black uppercase tracking-widest opacity-60">Gemini API Key</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
v-model="geminiKey"
|
||||
@change="updateKey"
|
||||
placeholder="Paste key here..."
|
||||
class="input input-bordered input-sm w-full bg-base-100 font-mono text-xs focus:input-primary"
|
||||
/>
|
||||
<p class="text-[10px] mt-3 px-2 opacity-40 font-medium">상세 해설시 사용</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-base-content/5 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3 px-2">
|
||||
<ShuffleIcon :size="16" class="text-primary" />
|
||||
<span class="text-xs font-black uppercase tracking-widest opacity-60">설정</span>
|
||||
</div>
|
||||
<label class="label cursor-pointer justify-between px-2 py-0">
|
||||
<span class="text-xs font-bold text-base-content/80">랜덤 문제 출제</span>
|
||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="randomizeQuestions" />
|
||||
</label>
|
||||
<p class="text-[10px] mt-2 px-2 opacity-40 font-medium">문제 순서 무작위 배치</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { LayoutDashboardIcon, BookOpenIcon, KeyIcon, TrophyIcon, MenuIcon, ShuffleIcon, BookmarkIcon } from 'lucide-vue-next'
|
||||
import { useQuizStore } from './store/quiz'
|
||||
import { SetGeminiKey } from '../bindings/changeme/QuizService'
|
||||
|
||||
const store = useQuizStore()
|
||||
const geminiKey = ref(store.geminiKey)
|
||||
|
||||
const randomizeQuestions = computed({
|
||||
get: () => store.randomizeQuestions,
|
||||
set: (val) => store.setRandomizeQuestions(val)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (geminiKey.value) {
|
||||
SetGeminiKey(geminiKey.value)
|
||||
}
|
||||
})
|
||||
|
||||
function updateKey() {
|
||||
store.setGeminiKey(geminiKey.value)
|
||||
SetGeminiKey(geminiKey.value)
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
const drawer = document.getElementById('main-drawer') as HTMLInputElement
|
||||
if (drawer) drawer.checked = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
/* Scrollbar Customization */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import CategoryListView from '../views/CategoryListView.vue'
|
||||
import SubjectListView from '../views/SubjectListView.vue'
|
||||
import QuizView from '../views/QuizView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: '/categories',
|
||||
name: 'categories',
|
||||
component: CategoryListView
|
||||
},
|
||||
{
|
||||
path: '/subjects/:category',
|
||||
name: 'subjects',
|
||||
component: SubjectListView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/quiz/:category/:subject/:difficulty',
|
||||
name: 'quiz',
|
||||
component: QuizView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/bookmarks',
|
||||
name: 'bookmarks',
|
||||
component: () => import('../views/BookmarkView.vue')
|
||||
},
|
||||
{
|
||||
path: '/bookmarks/quiz/:category/:difficulty',
|
||||
name: 'bookmark-quiz',
|
||||
component: QuizView,
|
||||
props: route => ({
|
||||
category: route.params.category,
|
||||
difficulty: route.params.difficulty,
|
||||
bookmarkOnly: true
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useQuizStore = defineStore('quiz', {
|
||||
state: () => ({
|
||||
geminiKey: localStorage.getItem('geminiKey') || '',
|
||||
randomizeQuestions: localStorage.getItem('randomizeQuestions') === 'true',
|
||||
}),
|
||||
actions: {
|
||||
setGeminiKey(key: string) {
|
||||
this.geminiKey = key
|
||||
localStorage.setItem('geminiKey', key)
|
||||
},
|
||||
setRandomizeQuestions(randomize: boolean) {
|
||||
this.randomizeQuestions = randomize
|
||||
localStorage.setItem('randomizeQuestions', String(randomize))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #1b2636;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Fix: Prevent drawer overlay from turning white in dark theme */
|
||||
.drawer-toggle:checked ~ .drawer-side > .drawer-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="p-6 md:p-10 max-w-4xl mx-auto flex flex-col min-h-screen text-base-content">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-8 shrink-0">
|
||||
<button @click="router.back()" class="btn btn-ghost btn-circle shrink-0">
|
||||
<ArrowLeftIcon :size="24" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-black tracking-tight flex items-center gap-2">
|
||||
책갈피
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm opacity-50 font-medium">담아놓은 문제를 모아 풀 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center py-20 gap-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-slate-400 text-sm">로딩 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="categories.length === 0" class="flex-1 flex flex-col items-center justify-center py-20">
|
||||
<div class="card bg-base-200 border border-base-300 shadow-xl max-w-md w-full p-8 text-center items-center gap-4">
|
||||
<div class="bg-base-300 p-4 rounded-full text-slate-500">
|
||||
<BookmarkXIcon :size="48" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">책갈피한 문제가 없습니다</h2>
|
||||
<p class="text-xs sm:text-sm text-slate-400">퀴즈를 풀면서 책갈피 단추를 누르면 이곳에 모여 보여집니다.</p>
|
||||
<button @click="router.push('/categories')" class="btn btn-primary btn-sm sm:btn-md mt-2 gap-2">
|
||||
<BookOpenIcon :size="18" />
|
||||
문제 풀러 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
|
||||
<!-- Step 1: Select Category -->
|
||||
<div class="card bg-base-200 border border-base-300 shadow-xl overflow-hidden">
|
||||
<div class="card-body p-6">
|
||||
<h2 class="card-title text-lg font-black flex items-center gap-2 text-primary mb-4">
|
||||
<span class="badge badge-primary badge-sm">1</span>
|
||||
카테고리 선택
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
@click="selectCategory(cat.name)"
|
||||
class="w-full btn btn-outline justify-between text-left h-auto py-2.5 sm:py-3 rounded-xl border-base-content/10 hover:border-primary transition-all duration-200 text-xs sm:text-sm"
|
||||
:class="selectedCategory === cat.name ? 'border-primary bg-primary/10 text-primary font-bold shadow-md' : 'text-base-content font-medium'"
|
||||
>
|
||||
<span class="truncate pr-2">{{ cat.name }}</span>
|
||||
<span class="badge badge-xs sm:badge-sm" :class="selectedCategory === cat.name ? 'badge-primary' : 'badge-ghost opacity-60'">
|
||||
{{ cat.count }}개
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Select Difficulty / Target -->
|
||||
<div class="card bg-base-200 border border-base-300 shadow-xl overflow-hidden transition-all duration-300">
|
||||
<div class="card-body p-6">
|
||||
<h2 class="card-title text-lg font-black flex items-center gap-2 text-primary mb-4">
|
||||
<span class="badge badge-primary badge-sm">2</span>
|
||||
난이도 선택
|
||||
</h2>
|
||||
|
||||
<div v-if="!selectedCategory" class="flex flex-col items-center justify-center py-12 text-slate-500">
|
||||
<HelpCircleIcon :size="36" class="opacity-40 mb-2" />
|
||||
<p class="text-xs sm:text-sm font-medium">카테고리를 먼저 선택해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="targetsLoading" class="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<span class="loading loading-dots loading-md text-primary"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="targets.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-500">
|
||||
<BookmarkXIcon :size="36" class="opacity-40 mb-2" />
|
||||
<p class="text-xs sm:text-sm font-medium">선택된 카테고리에 난이도가 존재하지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<button
|
||||
v-for="t in targets"
|
||||
:key="t.name"
|
||||
@click="selectTarget(t.name)"
|
||||
class="w-full btn btn-outline justify-between text-left h-auto py-2.5 sm:py-3 rounded-xl border-base-content/10 hover:border-primary transition-all duration-200 text-xs sm:text-sm"
|
||||
:class="selectedTarget === t.name ? 'border-primary bg-primary/10 text-primary font-bold shadow-md' : 'text-base-content font-medium'"
|
||||
>
|
||||
<span>{{ t.name }}</span>
|
||||
<span class="badge badge-xs sm:badge-sm" :class="selectedTarget === t.name ? 'badge-primary' : 'badge-ghost opacity-60'">
|
||||
{{ t.count }}개
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Start Button -->
|
||||
<button
|
||||
v-if="selectedTarget"
|
||||
@click="startBookmarkedQuiz"
|
||||
class="w-full btn btn-primary mt-6 py-4 h-auto rounded-xl gap-2 font-bold shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<PlayIcon :size="20" />
|
||||
즐겨찾기 퀴즈 풀기 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeftIcon, BookmarkIcon, BookOpenIcon, HelpCircleIcon, PlayIcon } from 'lucide-vue-next'
|
||||
// Custom Bookmark icons because lucide-vue-next might have variations
|
||||
import { BookmarkXIcon } from 'lucide-vue-next'
|
||||
import { QuizService, CategoryInfo, TargetInfo } from '../../bindings/changeme'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const targetsLoading = ref(false)
|
||||
const categories = ref<CategoryInfo[]>([])
|
||||
const targets = ref<TargetInfo[]>([])
|
||||
|
||||
const selectedCategory = ref('')
|
||||
const selectedTarget = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const fetched = await QuizService.GetBookmarkedCategories()
|
||||
categories.value = fetched || []
|
||||
} catch (e) {
|
||||
console.error("Failed to load bookmarked categories:", e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function selectCategory(catName: string) {
|
||||
selectedCategory.value = catName
|
||||
selectedTarget.value = ''
|
||||
targets.value = []
|
||||
targetsLoading.value = true
|
||||
try {
|
||||
const fetched = await QuizService.GetBookmarkedTargets(catName)
|
||||
targets.value = fetched || []
|
||||
} catch (e) {
|
||||
console.error("Failed to load bookmarked targets:", e)
|
||||
} finally {
|
||||
targetsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectTarget(targetName: string) {
|
||||
selectedTarget.value = targetName
|
||||
}
|
||||
|
||||
function startBookmarkedQuiz() {
|
||||
if (!selectedCategory.value || !selectedTarget.value) return
|
||||
// Move to bookmarked quiz path
|
||||
router.push(`/bookmarks/quiz/${encodeURIComponent(selectedCategory.value)}/${encodeURIComponent(selectedTarget.value)}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
text-transform: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="p-4 sm:p-8 max-w-6xl mx-auto">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl font-black text-base-content">카테고리</h2>
|
||||
<p class="text-slate-500 font-medium text-xs sm:text-sm mt-1">카테고리와 세부 과목을 선택합니다.</p>
|
||||
</div>
|
||||
<div class="badge badge-primary badge-lg self-start sm:self-auto">{{ categories.length }} 카테고리</div>
|
||||
</div>
|
||||
|
||||
<div v-if="categories.length === 0" class="flex flex-col items-center justify-center p-12 sm:p-20 bg-base-200 rounded-3xl border border-base-300 italic text-slate-500">
|
||||
<span class="loading loading-ring loading-md text-primary mb-2"></span>
|
||||
카테고리 불러오는 중...
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2 sm:gap-3">
|
||||
<div
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
@click="goToSubjects(cat.name)"
|
||||
class="flex items-center justify-between p-2 sm:p-2.5 bg-base-200 hover:bg-base-300 border border-base-300/60 rounded-lg cursor-pointer hover:border-primary/40 active:scale-[0.98] transition-all group shadow-sm min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="bg-primary/10 text-primary rounded-md w-7 h-7 flex items-center justify-center shrink-0 text-xs font-bold">
|
||||
{{ cat.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-xs sm:text-sm font-bold group-hover:text-primary transition-colors text-base-content truncate pr-0.5">{{ cat.name }}</h3>
|
||||
<p class="text-[9px] sm:text-[10px] text-slate-500 font-semibold mt-0.5">{{ cat.count }} 문제</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { QuizService, CategoryInfo } from '../../bindings/changeme'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<CategoryInfo[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
categories.value = await QuizService.GetCategories()
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch categories", e)
|
||||
}
|
||||
})
|
||||
|
||||
function goToSubjects(category: string) {
|
||||
router.push(`/subjects/${encodeURIComponent(category)}`)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="p-4 sm:p-8 max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl sm:text-3xl font-black mb-6 sm:mb-8 text-base-content">현황판</h2>
|
||||
|
||||
<div class="stats stats-vertical sm:stats-horizontal shadow bg-base-200 w-full lg:w-2/3 mb-6 sm:mb-10">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<BookOpenIcon :size="28" />
|
||||
</div>
|
||||
<div class="stat-title text-xs sm:text-sm">카테고리</div>
|
||||
<div class="stat-value text-2xl sm:text-3xl text-primary">{{ categories.length }}</div>
|
||||
<div class="stat-desc text-slate-500 font-medium text-xs">공부할 수 있는 과목</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<HelpCircleIcon :size="28" />
|
||||
</div>
|
||||
<div class="stat-title text-xs sm:text-sm">전체 문제</div>
|
||||
<div class="stat-value text-2xl sm:text-3xl text-secondary">{{ totalQuestions }}</div>
|
||||
<div class="stat-desc text-slate-500 font-medium text-xs">DB 기록</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 w-full">
|
||||
<div class="card-body p-6 sm:p-8">
|
||||
<h3 class="card-title text-lg sm:text-xl mb-3 sm:mb-4">우리 함께 공부해봐요!</h3>
|
||||
<p class="text-sm sm:text-base text-slate-400 leading-relaxed mb-6">
|
||||
이 앱은 SQLite 데이터베이스에 저장된 퀴즈를 풀고, Gemini AI의 상세한 해설을 들을 수 있는 학습 도구입니다.
|
||||
좌측 메뉴에서 <strong>카테고리</strong>를 선택하여 원하는 주제의 퀴즈를 시작해 보세요.
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<router-link to="/categories" class="btn btn-primary w-full sm:w-auto">시작하기</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { BookOpenIcon, HelpCircleIcon } from 'lucide-vue-next'
|
||||
import { QuizService, CategoryInfo } from '../../bindings/changeme'
|
||||
|
||||
const categories = ref<CategoryInfo[]>([])
|
||||
|
||||
const totalQuestions = computed(() => {
|
||||
return categories.value.reduce((acc, cat) => acc + cat.count, 0)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
categories.value = await QuizService.GetCategories()
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch categories", e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@touchcancel="handleTouchEnd"
|
||||
class="p-4 sm:p-8 max-w-6xl mx-auto select-none min-h-full flex flex-col"
|
||||
>
|
||||
<div class="flex items-center gap-4 sm:gap-6 mb-6 sm:mb-10">
|
||||
<button @click="activeTarget ? backToTargets() : router.back()" class="btn btn-ghost btn-circle text-base-content shrink-0">
|
||||
<ArrowLeftIcon :size="24" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl font-black text-base-content break-words leading-tight">
|
||||
{{ activeTarget ? `${category} - ${activeTarget}` : category }}
|
||||
</h2>
|
||||
<p class="text-slate-500 font-medium text-xs sm:text-sm mt-0.5">
|
||||
{{ activeTarget ? '문제를 풀 세부 과목을 선택합니다.' : '문제를 풀 회차/과목을 선택합니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz Settings Options -->
|
||||
<div v-if="!loading && !activeTarget" class="card bg-base-200 border border-base-300 mb-6 sm:mb-8 shadow-sm">
|
||||
<div class="card-body p-4 sm:p-5 flex flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 text-primary p-2.5 rounded-xl">
|
||||
<ShuffleIcon :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-sm sm:text-base text-base-content">랜덤 문제</h4>
|
||||
<p class="text-xs text-slate-500 font-medium mt-0.5">문제를 무작위 순서로 출제합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-md"
|
||||
v-model="randomizeQuestions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center p-12 sm:p-20 gap-4 italic text-slate-500 bg-base-200 rounded-3xl border border-base-300">
|
||||
<span class="loading loading-ring loading-lg text-primary"></span>
|
||||
과목 불러오는 중
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Sub-subject selection list -->
|
||||
<div v-if="activeTarget" class="space-y-4">
|
||||
<div v-if="loadingSubjects" class="flex flex-col items-center justify-center p-12 gap-3 italic text-slate-500">
|
||||
<span class="loading loading-spinner loading-md text-primary"></span>
|
||||
세부 과목 불러오는 중...
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<!-- "All" option -->
|
||||
<div
|
||||
@click="startQuiz(activeTarget, 'All')"
|
||||
class="flex items-center justify-between p-4 bg-primary/5 hover:bg-primary/10 border border-primary/20 rounded-xl cursor-pointer active:scale-[0.98] transition-all group shadow-sm font-bold text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/20 text-primary rounded-lg w-8 h-8 flex items-center justify-center">
|
||||
<BookOpenIcon :size="16" />
|
||||
</div>
|
||||
<span class="text-primary font-bold">전체 세부과목 풀기</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-xs font-bold px-3 rounded-md">시작</button>
|
||||
</div>
|
||||
|
||||
<!-- Subject items -->
|
||||
<div
|
||||
v-for="sub in subjects"
|
||||
:key="sub"
|
||||
@click="startQuiz(activeTarget, sub)"
|
||||
class="flex items-center justify-between p-4 bg-base-200 hover:bg-base-300 border border-base-300/60 rounded-xl cursor-pointer hover:border-primary/40 active:scale-[0.98] transition-all group shadow-sm min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="bg-secondary/10 text-secondary rounded-lg w-8 h-8 flex items-center justify-center shrink-0">
|
||||
<TargetIcon :size="16" />
|
||||
</div>
|
||||
<span class="font-bold text-xs sm:text-sm text-base-content truncate" :title="sub">{{ sub }}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-xs font-bold px-3 rounded-md shrink-0 ml-2">시작</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex">
|
||||
<button @click="backToTargets" class="btn btn-ghost btn-sm whitespace-nowrap text-slate-400 font-bold gap-2">
|
||||
<ArrowLeftIcon :size="16" />
|
||||
이전 단계로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rounds selection list -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2.5 sm:gap-3">
|
||||
<div
|
||||
v-for="t in targets"
|
||||
:key="t.name"
|
||||
@click="selectTarget(t.name)"
|
||||
class="flex items-center justify-between p-3 bg-base-200 hover:bg-base-300 border border-base-300/60 rounded-xl cursor-pointer hover:border-primary/40 active:scale-[0.98] transition-all group shadow-sm min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div class="bg-secondary/10 text-secondary rounded-lg w-8 h-8 flex items-center justify-center shrink-0">
|
||||
<TargetIcon :size="16" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-xs sm:text-sm font-bold group-hover:text-primary transition-colors text-base-content truncate pr-1" :title="t.name">{{ t.name }}</h3>
|
||||
<p class="text-[10px] text-slate-500 font-semibold mt-0.5">{{ t.count }} 문제</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center shrink-0 ml-2">
|
||||
<button class="btn btn-primary btn-xs font-bold px-2.5 rounded-md opacity-90 group-hover:opacity-100 transition-opacity">
|
||||
선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeftIcon, TargetIcon, ShuffleIcon, BookOpenIcon } from 'lucide-vue-next'
|
||||
import { QuizService, TargetInfo } from '../../bindings/changeme'
|
||||
import { useQuizStore } from '../store/quiz'
|
||||
|
||||
const props = defineProps<{ category: string }>()
|
||||
const router = useRouter()
|
||||
const store = useQuizStore()
|
||||
|
||||
const targets = ref<TargetInfo[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Subject selection state
|
||||
const activeTarget = ref<string | null>(null)
|
||||
const subjects = ref<string[]>([])
|
||||
const loadingSubjects = ref(false)
|
||||
|
||||
const randomizeQuestions = computed({
|
||||
get: () => store.randomizeQuestions,
|
||||
set: (val) => store.setRandomizeQuestions(val)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
targets.value = await QuizService.GetTargets(props.category)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch targets", e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function selectTarget(target: string) {
|
||||
loadingSubjects.value = true
|
||||
activeTarget.value = target
|
||||
try {
|
||||
// Fetch unique subjects for this category and round (target)
|
||||
const list = await QuizService.GetSubjects(props.category, target)
|
||||
if (list && list.length > 1) {
|
||||
subjects.value = list
|
||||
} else if (list && list.length === 1) {
|
||||
// If there is exactly one sub-subject, bypass choice and start quiz immediately
|
||||
startQuiz(target, list[0])
|
||||
} else {
|
||||
// If no subjects found, proceed directly to quiz with "dummy"
|
||||
startQuiz(target, 'dummy')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch subjects", e)
|
||||
startQuiz(target, 'dummy')
|
||||
} finally {
|
||||
loadingSubjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startQuiz(target: string, subject: string) {
|
||||
router.push(`/quiz/${encodeURIComponent(props.category)}/${encodeURIComponent(subject)}/${encodeURIComponent(target)}`)
|
||||
}
|
||||
|
||||
function backToTargets() {
|
||||
activeTarget.value = null
|
||||
subjects.value = []
|
||||
}
|
||||
|
||||
// 터치 스와이프 제스처 핸들러
|
||||
const touchStartX = ref(0)
|
||||
const touchStartY = ref(0)
|
||||
const touchThreshold = 50 // 더 민감하고 쫀득하게 반응하도록 75에서 50으로 하향 조정
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
if (e.touches.length > 0) {
|
||||
touchStartX.value = e.touches[0].clientX
|
||||
touchStartY.value = e.touches[0].clientY
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (e.touches.length > 0) {
|
||||
const currentX = e.touches[0].clientX
|
||||
const currentY = e.touches[0].clientY
|
||||
|
||||
const diffX = currentX - touchStartX.value
|
||||
const diffY = currentY - touchStartY.value
|
||||
|
||||
// 수평 스와이프 움직임이 우세한 경우 화면 상하 스크롤 등 브라우저 기본 동작을 차단하여
|
||||
// 터치 이벤트가 중단(touchcancel)되는 것을 예방합니다.
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 10) {
|
||||
if (e.cancelable) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (e.changedTouches.length > 0) {
|
||||
const endX = e.changedTouches[0].clientX
|
||||
const endY = e.changedTouches[0].clientY
|
||||
|
||||
const diffX = endX - touchStartX.value
|
||||
const diffY = endY - touchStartY.value
|
||||
|
||||
// 대각선 드래그도 부드럽게 수용하도록 판정 조건 완화 (수직 대비 수평 이동 비율 0.7 이상)
|
||||
if (Math.abs(diffX) > Math.abs(diffY) * 0.7 && Math.abs(diffX) > touchThreshold) {
|
||||
if (diffX > 0) {
|
||||
// Swipe Right (왼쪽에서 오른쪽으로 드래그) -> 뒤로가기 동작
|
||||
if (activeTarget.value) {
|
||||
// 소과목 선택 화면 -> Difficulty 화면으로
|
||||
backToTargets()
|
||||
} else {
|
||||
// Difficulty 화면 -> 카테고리 메뉴로
|
||||
router.push('/categories')
|
||||
}
|
||||
} else {
|
||||
// Swipe Left (오른쪽에서 왼쪽으로 드래그) -> 바로 문제풀기 동작
|
||||
if (activeTarget.value) {
|
||||
// 소과목 선택 화면 -> 해당 회차의 전체 세부과목 문제 풀기
|
||||
startQuiz(activeTarget.value, 'All')
|
||||
} else {
|
||||
// Difficulty 화면 -> 해당 카테고리의 모든 난이도/전체 문제 풀기
|
||||
startQuiz('All', 'All')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user