feat(prototype): 수학 단일 과목 잠금 + 샘플 ebook 데모 + B2B QR 비전 문서
- 백엔드 read-side 필터: subjects/stats/problem-sets findMany 에 name='수학' / subjectName='수학' 고정 - 백엔드 write-side 가드: subjects.controller @IsIn(['수학']) for create/update - 프론트 subjects: 클라이언트 검증으로 수학 외 입력 차단 - 프론트 exams: 카탈로그 맨 앞에 [샘플] 수능 수학 미리보기 ebook 데모 카드 항상 노출 (4분기 모두) - 신규 페이지 /exams/sample-ebook: 종이책 펼침 two-page spread, 5문항 (이차함수/등차/극한/확률/정적분), 키보드 ←→ nav, 정답 확인+해설, B2B 비전 안내 박스 - docs/b2b-qr-physical-book.md: 종이책 QR → 앱 ebook + 망각곡선 복습 큐 자동 연동, 출판사 라이선스 모델 미래 스펙 데이터는 DB 보존 (soft filter), '수학' 한 단어 sed 로 풀 수 있음. 백엔드 tsc build / 프론트 tsc + lint 클린.
This commit is contained in:
@@ -6,9 +6,10 @@ export class ProblemSetsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(opts: { subjectName?: string; year?: number }) {
|
||||
// 프로토타입: 수학 ProblemSet만 노출 (soft filter — DB 데이터는 유지)
|
||||
return this.prisma.problemSet.findMany({
|
||||
where: {
|
||||
...(opts.subjectName && { subjectName: opts.subjectName }),
|
||||
subjectName: '수학',
|
||||
...(opts.year && { year: opts.year }),
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -193,8 +193,9 @@ export class StatsService {
|
||||
}
|
||||
|
||||
async bySubject(userId: number) {
|
||||
// 프로토타입: 수학 과목만 노출 (soft filter — DB 데이터는 유지)
|
||||
const subjects = await this.prisma.subject.findMany({
|
||||
where: { userId },
|
||||
where: { userId, name: '수학' },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
|
||||
@@ -9,16 +9,20 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { SubjectsService } from './subjects.service';
|
||||
|
||||
// 프로토타입: 수학만 허용 — 나중에 sed 한 방으로 해제 가능
|
||||
const ALLOWED_SUBJECT_NAMES = ['수학'] as const;
|
||||
|
||||
class CreateSubjectDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
@IsIn(ALLOWED_SUBJECT_NAMES)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -31,6 +35,7 @@ class UpdateSubjectDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
@IsIn(ALLOWED_SUBJECT_NAMES)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,16 +6,18 @@ export class SubjectsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(userId: number) {
|
||||
// 프로토타입: 수학 과목만 노출 (soft filter — DB 데이터는 유지)
|
||||
return this.prisma.subject.findMany({
|
||||
where: { userId },
|
||||
where: { userId, name: '수학' },
|
||||
include: { tags: { orderBy: { name: 'asc' } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getOne(userId: number, id: number) {
|
||||
// 프로토타입: 수학 과목만 노출 (soft filter — DB 데이터는 유지)
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id, userId },
|
||||
where: { id, userId, name: '수학' },
|
||||
include: { tags: { orderBy: { name: 'asc' } } },
|
||||
});
|
||||
if (!subject) throw new NotFoundException();
|
||||
|
||||
45
docs/b2b-qr-physical-book.md
Normal file
45
docs/b2b-qr-physical-book.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# B2B — 종이책 QR 연동 (Future Spec)
|
||||
|
||||
> 상태: **비전 / 미실행**. 사업 확장 단계에서 검토. 현재 프로토타입(수학 단일 과목)에는 포함되지 않음.
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
이용자가 출판사로부터 종이 문제집을 구매하면, **표지 또는 책 뒤의 QR 코드**를 ReLoop 앱에서 스캔해 해당 문제집을 ebook 형태로 즉시 열람하고, 동일 문제집의 **틀린 문제·복습 큐**까지 앱 안에서 자동 연동한다.
|
||||
|
||||
## 사용자 시나리오
|
||||
|
||||
1. 학생이 서점/학교에서 ReLoop 제휴 문제집을 구매
|
||||
2. 책 뒤 QR 코드를 ReLoop 앱 카메라로 스캔
|
||||
3. 앱에서 해당 문제집(ProblemSet)이 자동으로 사용자 라이브러리에 추가
|
||||
4. 디지털 ebook 뷰어로 문제 본문을 그대로 열람 가능 (이미지 또는 텍스트 렌더)
|
||||
5. 풀이 결과를 ReLoop가 기록 → 틀린 문제는 망각곡선 큐에 자동 진입
|
||||
6. 다음 학습 시 앱 대시보드에 "OO문제집 복습할 문제 N개" 형태로 노출
|
||||
|
||||
## B2B 모델 가설
|
||||
|
||||
- **출판사 파트너십**: 문제집 단위로 라이선스 계약, ReLoop ProblemSet 카탈로그에 등록
|
||||
- **권리 관리**: QR = (publisherId, productCode, oneTimeRedeemToken). 이용자 1인 1라이선스로 매핑하여 무단 공유 차단
|
||||
- **수익 모델**: B2C 구독료 + B2B 출판사 라이선스 수수료(Per-redeemed-book) 이중 구조 가능
|
||||
- **차별점**: 종이책의 학습 경험 + 디지털 망각곡선 복습이라는 두 강점 결합
|
||||
|
||||
## 기술 스케치 (요구사항만, 구현 X)
|
||||
|
||||
| 영역 | 내용 |
|
||||
|---|---|
|
||||
| 데이터 모델 | `Publisher`, `LicensedProblemSet`, `RedemptionCode (one-time)`, `UserBookLicense` |
|
||||
| QR 스킴 | `reloop://redeem?code=<jwt>` 또는 `https://reloop.app/r/<token>` (앱 미설치 시 다운로드 유도) |
|
||||
| 콘텐츠 형식 | 텍스트형(국어/영어 등): 본문+선택지 DB 저장 / 수식·도표형(수학 등): 페이지 이미지 + 좌표 매핑 |
|
||||
| 동기화 | 서버에서 문제집 풀이 진도/오답 → ReLoop 코어의 StudyLog/ReviewSchedule 와 동일 구조로 정규화 |
|
||||
| 보안 | redemption 토큰 1회성, 디바이스 fingerprint, 동일 책 재등록 차단 |
|
||||
| 오프라인 | 한 번 등록한 문제집은 로컬 캐시로 오프라인 풀이 지원 |
|
||||
|
||||
## 의존 사전 작업
|
||||
|
||||
- **현재 단계 (수학 단일 과목 프로토타입)** 가 사용자 검증을 통과해 망각곡선 + 복습 큐의 가치가 입증된 후 진행
|
||||
- ProblemSet 카탈로그가 다과목·다출판사 구조를 받을 수 있도록 schema 확장 필요 (현재는 KICE 단일 출처)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 종이책 문제 풀이 결과를 어떻게 입력받나? (스캔, 카메라 OCR, 수동 체크)
|
||||
- ebook 뷰어를 자체 개발 vs 기존 PDF/EPUB 라이브러리 활용
|
||||
- 출판사 입장에서의 인센티브 구조 (단순 라이선스 vs 학습 데이터 리포트 제공)
|
||||
@@ -102,34 +102,50 @@ function ExamsBody() {
|
||||
</FilterCard>
|
||||
|
||||
{error ? (
|
||||
<StateCard role="alert">
|
||||
<StateIcon>
|
||||
<Icon name="info" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>불러오기에 실패했어</StateTitle>
|
||||
<StateText>{error}</StateText>
|
||||
<Button type="button" $variant="secondary" onClick={() => setReloadKey((prev) => prev + 1)}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</StateCard>
|
||||
<>
|
||||
<Grid>
|
||||
<SampleEbookCard router={router} />
|
||||
</Grid>
|
||||
<StateCard role="alert">
|
||||
<StateIcon>
|
||||
<Icon name="info" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>불러오기에 실패했어</StateTitle>
|
||||
<StateText>{error}</StateText>
|
||||
<Button type="button" $variant="secondary" onClick={() => setReloadKey((prev) => prev + 1)}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</StateCard>
|
||||
</>
|
||||
) : problemSets === null ? (
|
||||
<StateCard>
|
||||
<StateIcon>
|
||||
<Icon name="clock" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>문제집을 준비하는 중</StateTitle>
|
||||
<StateText>필터에 맞는 평가원 문제집을 불러오고 있어.</StateText>
|
||||
</StateCard>
|
||||
<>
|
||||
<Grid>
|
||||
<SampleEbookCard router={router} />
|
||||
</Grid>
|
||||
<StateCard>
|
||||
<StateIcon>
|
||||
<Icon name="clock" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>문제집을 준비하는 중</StateTitle>
|
||||
<StateText>필터에 맞는 평가원 문제집을 불러오고 있어.</StateText>
|
||||
</StateCard>
|
||||
</>
|
||||
) : problemSets.length === 0 ? (
|
||||
<StateCard>
|
||||
<StateIcon>
|
||||
<Icon name="book-open-text" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>조건에 맞는 문제집이 아직 없어</StateTitle>
|
||||
<StateText>연도나 과목 필터를 바꿔서 다시 찾아봐.</StateText>
|
||||
</StateCard>
|
||||
<>
|
||||
<Grid>
|
||||
<SampleEbookCard router={router} />
|
||||
</Grid>
|
||||
<StateCard>
|
||||
<StateIcon>
|
||||
<Icon name="book-open-text" size={24} />
|
||||
</StateIcon>
|
||||
<StateTitle>조건에 맞는 문제집이 아직 없어</StateTitle>
|
||||
<StateText>연도나 과목 필터를 바꿔서 다시 찾아봐.</StateText>
|
||||
</StateCard>
|
||||
</>
|
||||
) : (
|
||||
<Grid>
|
||||
<SampleEbookCard router={router} />
|
||||
{problemSets.map((problemSet) => {
|
||||
const isAutoGradeUnavailable =
|
||||
(problemSet.problems?.length ?? 0) > 0 &&
|
||||
@@ -313,3 +329,74 @@ const StateTitle = styled.h2`
|
||||
const StateText = styled.p`
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
// ─── 샘플 ebook 카드 ─────────────────────────────────────────────────────────
|
||||
|
||||
function SampleEbookCard({ router }: { router: ReturnType<typeof useRouter> }) {
|
||||
return (
|
||||
<SampleCard>
|
||||
<SampleCardHeader>
|
||||
<CardTitle>[샘플] 수능 수학 미리보기 ebook</CardTitle>
|
||||
<SampleBadge $variant="default">
|
||||
<Icon name="book-open-text" size={11} />
|
||||
샘플 · 미리보기
|
||||
</SampleBadge>
|
||||
</SampleCardHeader>
|
||||
|
||||
<CardMeta>2026 · 수학 · 5문항</CardMeta>
|
||||
|
||||
<CardFooter>
|
||||
<InfoLine>
|
||||
<Icon name="book-open-text" size={16} />
|
||||
종이책처럼 넘겨보기
|
||||
</InfoLine>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="primary"
|
||||
onClick={() => router.push('/exams/sample-ebook')}
|
||||
>
|
||||
열어보기
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</SampleCard>
|
||||
);
|
||||
}
|
||||
|
||||
const SampleCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 220px;
|
||||
justify-content: space-between;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(124, 58, 237, 0.18), transparent 40%),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(124, 58, 237, 0.35);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.5), rgba(124, 58, 237, 0.5));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SampleCardHeader = styled(CardHeader)`
|
||||
align-items: flex-start;
|
||||
`;
|
||||
|
||||
const SampleBadge = styled(MutedBadge)`
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
color: ${theme.color.accent2};
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
623
frontend/src/app/exams/sample-ebook/page.tsx
Normal file
623
frontend/src/app/exams/sample-ebook/page.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
// ─── 샘플 문제 데이터 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface Problem {
|
||||
number: number;
|
||||
question: string;
|
||||
choices: string[];
|
||||
answer: number; // 1-based
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
const SAMPLE_PROBLEMS: Problem[] = [
|
||||
{
|
||||
number: 1,
|
||||
question:
|
||||
'함수 f(x) = x² − 4x + 3 의 최솟값을 구하시오.',
|
||||
choices: ['−3', '−2', '−1', '0', '1'],
|
||||
answer: 3,
|
||||
explanation:
|
||||
'꼭짓점 공식: f(x) = (x − 2)² − 1. 꼭짓점 x = 2 에서 최솟값 f(2) = 4 − 8 + 3 = −1.',
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
question:
|
||||
'등차수열 {aₙ} 에서 a₁ = 3, 공차 d = 4 일 때, a₁₀ 의 값은?',
|
||||
choices: ['35', '38', '39', '40', '43'],
|
||||
answer: 3,
|
||||
explanation:
|
||||
'aₙ = a₁ + (n − 1)d. a₁₀ = 3 + 9 × 4 = 3 + 36 = 39.',
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
question:
|
||||
'lim(x→2) (x² − 4) / (x − 2) 의 값은?',
|
||||
choices: ['0', '2', '4', '6', '수렴하지 않음'],
|
||||
answer: 3,
|
||||
explanation:
|
||||
'분자를 인수분해: (x² − 4) = (x − 2)(x + 2). 약분하면 lim(x→2) (x + 2) = 4.',
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
question:
|
||||
'주머니 속에 빨간 공 3개, 파란 공 2개가 있다. 한 개를 꺼낼 때 빨간 공일 확률은?',
|
||||
choices: ['1/5', '2/5', '3/5', '4/5', '1'],
|
||||
answer: 3,
|
||||
explanation:
|
||||
'전체 5개 중 빨간 공 3개. 확률 = 3/5.',
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
question:
|
||||
'∫₀² (2x + 1) dx 의 값은?',
|
||||
choices: ['4', '5', '6', '7', '8'],
|
||||
answer: 3,
|
||||
explanation:
|
||||
'∫(2x + 1) dx = x² + x + C. [x² + x]₀² = (4 + 2) − 0 = 6.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── 페이지 컴포넌트 ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SampleEbookPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<EbookViewer />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EbookViewer() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<Record<number, number>>({});
|
||||
const [revealed, setRevealed] = useState<Record<number, boolean>>({});
|
||||
const [fading, setFading] = useState(false);
|
||||
|
||||
const total = SAMPLE_PROBLEMS.length;
|
||||
const problem = SAMPLE_PROBLEMS[pageIndex];
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= total || fading) return;
|
||||
setFading(true);
|
||||
setTimeout(() => {
|
||||
setPageIndex(index);
|
||||
setFading(false);
|
||||
}, 160);
|
||||
},
|
||||
[total, fading],
|
||||
);
|
||||
|
||||
// 키보드 ← / → 지원
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') goTo(pageIndex - 1);
|
||||
if (e.key === 'ArrowRight') goTo(pageIndex + 1);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [pageIndex, goTo]);
|
||||
|
||||
const selectAnswer = (choiceIndex: number) => {
|
||||
setSelectedAnswers((prev) => ({ ...prev, [problem.number]: choiceIndex }));
|
||||
// 답 바꾸면 해설 숨김
|
||||
setRevealed((prev) => ({ ...prev, [problem.number]: false }));
|
||||
};
|
||||
|
||||
const revealAnswer = () => {
|
||||
setRevealed((prev) => ({ ...prev, [problem.number]: true }));
|
||||
};
|
||||
|
||||
const chosen = selectedAnswers[problem.number]; // undefined | number (1-based)
|
||||
const isRevealed = revealed[problem.number] ?? false;
|
||||
const isCorrect = chosen !== undefined && chosen === problem.answer;
|
||||
|
||||
return (
|
||||
<ViewerWrap>
|
||||
{/* 상단 헤더 */}
|
||||
<ViewerHeader>
|
||||
<BackLink href="/exams">
|
||||
<Icon name="arrow-left" size={16} />
|
||||
문제집으로 돌아가기
|
||||
</BackLink>
|
||||
<HeaderRight>
|
||||
<PreviewBadge $variant="default">
|
||||
<Icon name="book-open-text" size={11} />
|
||||
샘플 미리보기
|
||||
</PreviewBadge>
|
||||
</HeaderRight>
|
||||
</ViewerHeader>
|
||||
|
||||
<ViewerTitle>[샘플] 수능 수학 미리보기 ebook</ViewerTitle>
|
||||
<ViewerDivider />
|
||||
|
||||
{/* 책 펼침 영역 */}
|
||||
<BookSpread $fading={fading}>
|
||||
{/* 왼쪽 페이지 — 문제 본문 */}
|
||||
<BookPage $side="left">
|
||||
<PageLabel>문제 {problem.number}</PageLabel>
|
||||
<QuestionText>{problem.question}</QuestionText>
|
||||
</BookPage>
|
||||
|
||||
{/* 책등 */}
|
||||
<BookSpine />
|
||||
|
||||
{/* 오른쪽 페이지 — 선택지 + 답 확인 */}
|
||||
<BookPage $side="right">
|
||||
<PageLabel>선택지</PageLabel>
|
||||
<ChoiceList>
|
||||
{problem.choices.map((choice, idx) => {
|
||||
const choiceNum = idx + 1; // 1-based
|
||||
const isSelected = chosen === choiceNum;
|
||||
const isAnswerChoice = problem.answer === choiceNum;
|
||||
|
||||
let choiceState: 'default' | 'selected' | 'correct' | 'wrong' = 'default';
|
||||
if (isRevealed) {
|
||||
if (isAnswerChoice) choiceState = 'correct';
|
||||
else if (isSelected) choiceState = 'wrong';
|
||||
} else if (isSelected) {
|
||||
choiceState = 'selected';
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceItem
|
||||
key={choiceNum}
|
||||
$state={choiceState}
|
||||
onClick={() => !isRevealed && selectAnswer(choiceNum)}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') selectAnswer(choiceNum);
|
||||
}}
|
||||
>
|
||||
<ChoiceCircle $state={choiceState}>
|
||||
{CIRCLE_NUMS[idx]}
|
||||
</ChoiceCircle>
|
||||
<ChoiceText>{choice}</ChoiceText>
|
||||
{isRevealed && isAnswerChoice && (
|
||||
<CorrectMark>
|
||||
<Icon name="check" size={14} />
|
||||
</CorrectMark>
|
||||
)}
|
||||
</ChoiceItem>
|
||||
);
|
||||
})}
|
||||
</ChoiceList>
|
||||
|
||||
{/* 답 선택 상태 표시 */}
|
||||
<AnswerStatusRow>
|
||||
{chosen !== undefined ? (
|
||||
<AnswerChosen>
|
||||
내 답: {CIRCLE_NUMS[chosen - 1]}
|
||||
</AnswerChosen>
|
||||
) : (
|
||||
<AnswerChosen $muted>선택 전</AnswerChosen>
|
||||
)}
|
||||
{!isRevealed && (
|
||||
<Button
|
||||
type="button"
|
||||
$variant="secondary"
|
||||
$size="sm"
|
||||
onClick={revealAnswer}
|
||||
disabled={chosen === undefined}
|
||||
>
|
||||
정답 확인
|
||||
</Button>
|
||||
)}
|
||||
</AnswerStatusRow>
|
||||
|
||||
{/* 해설 */}
|
||||
{isRevealed && (
|
||||
<ExplanationBox $correct={isCorrect}>
|
||||
<ExplanationLabel>
|
||||
{isCorrect ? '정답이야! 👍' : '아쉽게 틀렸어.'}
|
||||
</ExplanationLabel>
|
||||
<ExplanationText>{problem.explanation}</ExplanationText>
|
||||
</ExplanationBox>
|
||||
)}
|
||||
</BookPage>
|
||||
</BookSpread>
|
||||
|
||||
{/* 페이지 네비게이션 */}
|
||||
<PageNav>
|
||||
<NavButton
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
$size="sm"
|
||||
onClick={() => goTo(pageIndex - 1)}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label="이전 페이지"
|
||||
>
|
||||
<Icon name="caret-left" size={16} />
|
||||
이전 페이지
|
||||
</NavButton>
|
||||
<PageIndicator>
|
||||
{pageIndex + 1} / {total}
|
||||
</PageIndicator>
|
||||
<NavButton
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
$size="sm"
|
||||
onClick={() => goTo(pageIndex + 1)}
|
||||
disabled={pageIndex === total - 1}
|
||||
aria-label="다음 페이지"
|
||||
>
|
||||
다음 페이지
|
||||
<Icon name="caret-right" size={16} />
|
||||
</NavButton>
|
||||
</PageNav>
|
||||
|
||||
<ViewerDivider />
|
||||
|
||||
{/* B2B 비전 안내 */}
|
||||
<InfoBox>
|
||||
<InfoIcon>
|
||||
<Icon name="info" size={18} />
|
||||
</InfoIcon>
|
||||
<InfoContent>
|
||||
<InfoTitle>미리보기 안내</InfoTitle>
|
||||
<InfoDesc>
|
||||
이 ebook 은 향후 B2B 종이책 QR 연동 기능의 미리보기야. 실제 출시 시
|
||||
종이책 표지 QR 을 스캔하면 이렇게 펼쳐지고, 틀린 문제는 자동으로
|
||||
망각곡선 복습 큐에 등록돼.
|
||||
</InfoDesc>
|
||||
</InfoContent>
|
||||
</InfoBox>
|
||||
</ViewerWrap>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 유틸 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const CIRCLE_NUMS = ['①', '②', '③', '④', '⑤'];
|
||||
|
||||
// ─── 스타일 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
`;
|
||||
|
||||
const ViewerWrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: ${theme.space.lg};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
padding: ${theme.space.md};
|
||||
}
|
||||
`;
|
||||
|
||||
const ViewerHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const BackLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.textMain};
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const PreviewBadge = styled(Badge)`
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
color: ${theme.color.accent2};
|
||||
`;
|
||||
|
||||
const ViewerTitle = styled.h1`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textBright};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ViewerDivider = styled.hr`
|
||||
border: none;
|
||||
border-top: 1px solid ${theme.color.borderSoftAlpha};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
// 책 펼침 레이아웃
|
||||
const BookSpread = styled.div<{ $fading: boolean }>`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2px 1fr;
|
||||
gap: 0;
|
||||
min-height: 400px;
|
||||
opacity: ${({ $fading }) => ($fading ? 0 : 1)};
|
||||
transition: opacity 0.16s ease;
|
||||
border-radius: ${theme.radius.lg};
|
||||
overflow: hidden;
|
||||
box-shadow: ${theme.shadow.lg};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const BookPage = styled(Card)<{ $side: 'left' | 'right' }>`
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: rgba(22, 20, 36, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.md};
|
||||
padding: ${theme.space.xl};
|
||||
min-height: 380px;
|
||||
|
||||
/* cream tint */
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
rgba(20, 18, 40, 0.98) 0%,
|
||||
rgba(22, 20, 38, 0.96) 100%
|
||||
);
|
||||
|
||||
${({ $side }) =>
|
||||
$side === 'left'
|
||||
? css`
|
||||
border-right: none;
|
||||
`
|
||||
: css`
|
||||
border-left: none;
|
||||
`}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
border-radius: 0;
|
||||
min-height: auto;
|
||||
padding: ${theme.space.lg};
|
||||
|
||||
${({ $side }) =>
|
||||
$side === 'right' &&
|
||||
css`
|
||||
border-top: 1px solid ${theme.color.borderSoftAlpha};
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
const BookSpine = styled.div`
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0.35),
|
||||
rgba(124, 58, 237, 0.15),
|
||||
rgba(0, 0, 0, 0.35)
|
||||
);
|
||||
width: 2px;
|
||||
align-self: stretch;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const PageLabel = styled.span`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: ${theme.color.accent};
|
||||
`;
|
||||
|
||||
const QuestionText = styled.p`
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
color: ${theme.color.textBright};
|
||||
font-family: 'Georgia', 'Batang', serif;
|
||||
white-space: pre-wrap;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
// 선택지
|
||||
const ChoiceList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type ChoiceState = 'default' | 'selected' | 'correct' | 'wrong';
|
||||
|
||||
const choiceStateStyles = {
|
||||
default: css`
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: ${theme.color.borderBrightAlpha};
|
||||
}
|
||||
`,
|
||||
selected: css`
|
||||
background: rgba(79, 70, 229, 0.12);
|
||||
border-color: rgba(79, 70, 229, 0.5);
|
||||
cursor: pointer;
|
||||
`,
|
||||
correct: css`
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
cursor: default;
|
||||
`,
|
||||
wrong: css`
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
cursor: default;
|
||||
`,
|
||||
};
|
||||
|
||||
const ChoiceItem = styled.div<{ $state: ChoiceState }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
user-select: none;
|
||||
outline-offset: 2px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.color.accent};
|
||||
}
|
||||
|
||||
${({ $state }) => choiceStateStyles[$state]}
|
||||
`;
|
||||
|
||||
const circleStateStyles: Record<ChoiceState, ReturnType<typeof css>> = {
|
||||
default: css`
|
||||
color: ${theme.color.textSub};
|
||||
`,
|
||||
selected: css`
|
||||
color: ${theme.color.accent};
|
||||
font-weight: 700;
|
||||
`,
|
||||
correct: css`
|
||||
color: ${theme.color.success};
|
||||
font-weight: 700;
|
||||
`,
|
||||
wrong: css`
|
||||
color: ${theme.color.danger};
|
||||
font-weight: 700;
|
||||
`,
|
||||
};
|
||||
|
||||
const ChoiceCircle = styled.span<{ $state: ChoiceState }>`
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
${({ $state }) => circleStateStyles[$state]}
|
||||
`;
|
||||
|
||||
const ChoiceText = styled.span`
|
||||
font-size: 15px;
|
||||
color: ${theme.color.textMain};
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const CorrectMark = styled.span`
|
||||
color: ${theme.color.success};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
animation: ${fadeIn} 0.2s ease;
|
||||
`;
|
||||
|
||||
// 답 선택 상태
|
||||
const AnswerStatusRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const AnswerChosen = styled.span<{ $muted?: boolean }>`
|
||||
font-size: 14px;
|
||||
color: ${({ $muted }) => ($muted ? theme.color.textMute : theme.color.textSub)};
|
||||
`;
|
||||
|
||||
// 해설 박스
|
||||
const ExplanationBox = styled.div<{ $correct: boolean }>`
|
||||
padding: ${theme.space.md};
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid
|
||||
${({ $correct }) =>
|
||||
$correct ? 'rgba(34, 197, 94, 0.35)' : 'rgba(239, 68, 68, 0.35)'};
|
||||
background: ${({ $correct }) =>
|
||||
$correct ? 'rgba(34, 197, 94, 0.07)' : 'rgba(239, 68, 68, 0.07)'};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
animation: ${fadeIn} 0.25s ease;
|
||||
`;
|
||||
|
||||
const ExplanationLabel = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const ExplanationText = styled.p`
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
// 페이지 네비게이션
|
||||
const PageNav = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const NavButton = styled(Button)`
|
||||
min-width: 110px;
|
||||
`;
|
||||
|
||||
const PageIndicator = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textSub};
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
// B2B 안내 박스
|
||||
const InfoBox = styled(Card)`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: ${theme.space.md};
|
||||
background: rgba(56, 189, 248, 0.06);
|
||||
border-color: rgba(56, 189, 248, 0.25);
|
||||
`;
|
||||
|
||||
const InfoIcon = styled.div`
|
||||
color: ${theme.color.info};
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
`;
|
||||
|
||||
const InfoContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const InfoTitle = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const InfoDesc = styled.p`
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: ${theme.color.textSub};
|
||||
margin: 0;
|
||||
`;
|
||||
@@ -180,6 +180,11 @@ function SubjectsBody() {
|
||||
setSubjectModalError('과목 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
// 프로토타입: 수학 단일 과목 잠금 — 추후 sed 한 방으로 해제
|
||||
if (name !== '수학') {
|
||||
setSubjectModalError('현재는 수학 과목만 추가할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubjectModalSaving(true);
|
||||
setSubjectModalError(null);
|
||||
|
||||
Reference in New Issue
Block a user