Initial commit

This commit is contained in:
rl544
2026-06-25 07:43:27 +09:00
commit c0814f4315
4494 changed files with 11587 additions and 0 deletions
+146
View File
@@ -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>
+12
View File
@@ -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')
+50
View File
@@ -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
+18
View File
@@ -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))
}
}
})
+18
View File
@@ -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;
}
+173
View File
@@ -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>
+56
View File
@@ -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>
+58
View File
@@ -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
+251
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />