feat(phase10C): 온보딩 + 프론트 재구성 — 수학 수능 4단원 + PS 트랙

- lib/api.ts: GoalType('math-suneung'|'ps'|...), MathUnit, MATH_UNIT_LABEL,
  MeUser{focusUnits,bojHandle}, ps.* helper (search/bookmark/sync/getProblem)
- onboarding/page.tsx: 5개 GoalType 옵션, math-suneung 단원 멀티셀렉트,
  ps 인풋 + 즉시 동기화 토글, focusUnits/bojHandle PATCH
- profile/page.tsx: 동일 컨트롤 노출 + 검증
- ps/{page,bookmarks/page,sync/page}.tsx: 검색·필터·북마크·동기화 신규 섹션
- components/ps/PsTabs.tsx, layout/{SideNav,BottomNav}.tsx, ui/Icon.tsx:
  PS 진입점 + Phosphor code/bookmark-simple 아이콘
- exam/shared.ts: getExamDurationSeconds → 수학 100분 고정
- 비수학 하드코딩 전면 제거: dashboard/exams/study/subjects/school/assignment
- 카피 갱신: landing/pricing/layout metadata + README + docs(user/admin/pipeline)
- .eslintrc.json: Next core lint 결정성 확보

Phase 10C DoD 통과 — pnpm build + lint 0 에러, /ps /ps/bookmarks /ps/sync 라우트 생성,
grep -rE '국어|영어|한국사|생활과 윤리|사회탐구' frontend/src docs = 0
This commit is contained in:
reloop
2026-04-14 19:38:16 +09:00
parent 6ceeee4f3f
commit 78371cacf8
26 changed files with 1590 additions and 153 deletions

View File

@@ -9,10 +9,10 @@
| 기능 | 설명 |
|------|------|
| **적응형 복습 스케줄링** | 페르소나 + 문제 난이도에 따라 "정답률이 기준 아래로 떨어지는 시점"을 자동 계산해 복습 큐에 넣습니다 |
| **KICE 기출 문제집** | 2025/2026 수능 기출 (국어/수학/영어/한국사/생활과 윤리) 320문항을 PDF에서 자동 크롭한 이미지로 제공 |
| **KICE 기출 문제집** | 2025/2026 수능 수학 4단원(공통/확률과 통계/미적분/기하) 92문항을 PDF에서 자동 크롭한 이미지로 제공 |
| **모의고사 모드** | 실제 시험처럼 타이머 + OMR 답안 선택 + 자동 채점 + 학습 기록 일괄 생성 |
| **영어 듣기 오디오** | 영어 듣기평가 MP3를 내장 오디오 플레이어로 재생 |
| **통계 대시보드** | 학습 히트맵, 과목별 정답률, 숙련도 성장 곡선을 시각화 |
| **Solved.ac PS 연동** | Solved.ac 계정에서 최신 백준 풀이 이력을 동기화해 복습 큐에 추가 |
| **통계 대시보드** | 학습 히트맵, 단원별·PS 정답률, 숙련도 성장 곡선을 시각화 |
| **PWA 지원** | 모바일 홈 화면에 앱으로 설치 가능 |
## 기술 스택

View File

@@ -65,16 +65,12 @@ curl -s -o /dev/null -w "%{http_code}\n" https://reloop.nabomhalang.co.kr/
```
data/kice/
├── 2025/
── 국어/
├── (25)국어영역_문제지_홀수형.pdf
── (25)국어영역_정답표.pdf
├── 수학/
├── 영어/
── (25)영어영역_문제지_홀수형.pdf
│ │ ├── (25)영어영역_정답표.pdf
│ │ └── 영어영역듣기평가음원/*.mp3
│ ├── 한국사/
│ └── 사회탐구/사회탐구영역_문제지/01 생활과 윤리_문제지.pdf
── 수학/
├── (25)수학_공통_문제지.pdf
── (25)수학_확통_문제지.pdf
├── (25)수학_미적분_문제지.pdf
├── (25)수학_기하_문제지.pdf
── (25)수학_정답표.pdf
└── 2026/
└── (같은 구조)
```
@@ -84,8 +80,8 @@ data/kice/
```bash
cd backend
# 특정 과목
pnpm cli:kice-import --year=2026 --subject=국어
# 특정 단원 (subject=math)
pnpm cli:kice-import --year=2026 --subject=math
# 특정 연도 전체
pnpm cli:kice-import --year=2026

View File

@@ -169,8 +169,8 @@ pdftotext 결과에는 페이지 헤더/푸터가 포함됩니다. 이를 제거
| 필드 | 값 | 설명 |
|------|---|------|
| `imageUrl` | `/uploads/problems/2026/국어/001.png` | 문제 크롭 이미지 (주 표시) |
| `pageImageUrl` | `/uploads/problems/2026/국어/page-1.png` | 전체 페이지 (폴백) |
| `imageUrl` | `/uploads/problems/2026/math/common/001.png` | 문제 크롭 이미지 (주 표시) |
| `pageImageUrl` | `/uploads/problems/2026/math/common/page-1.png` | 전체 페이지 (폴백) |
| `bodyText` | `윗글의 내용과 일치하지 않는 것은?` | 텍스트 추출 (검색/접근성) |
| `choices` | `{"1":"...","2":"...","3":"...","4":"...","5":"..."}` | 선택지 (보조) |
| `answerNumber` | `3` | 정답 (자동 채점용) |
@@ -180,7 +180,7 @@ pdftotext 결과에는 페이지 헤더/푸터가 포함됩니다. 이를 제거
| 필드 | 값 |
|------|---|
| `imageUrl` | `/uploads/problems/2026/국어/passage-1-3.png` |
| `imageUrl` | `/uploads/problems/2026/math/common/passage-1-3.png` |
| `bodyText` | `[1~3] 다음 글을 읽고 물음에 답하시오. ...` |
| `startNumber` | `1` |
| `endNumber` | `3` |
@@ -192,14 +192,14 @@ pdftotext 결과에는 페이지 헤더/푸터가 포함됩니다. 이를 제거
pnpm cli:kice-import
# 특정 대상
pnpm cli:kice-import --year=2026 --subject=국어
pnpm cli:kice-import --year=2026 --subject=math
# 드라이 런
pnpm cli:kice-import --dry-run
# OCR 보정
pnpm cli:ocr-fix --year=2025 --answers-only
pnpm cli:ocr-fix --year=2025 --subject=수학
pnpm cli:ocr-fix --year=2025 --subject=math
# 검증
pnpm exec ts-node -e "

View File

@@ -13,9 +13,9 @@
회원가입 직후 4단계 온보딩이 진행됩니다:
1. **목표 설정** — 닉네임 확인 + 목표 시험 선택 (수/자격증/어학/없음)
1. **목표 설정** — 닉네임 확인 + 목표 시험 선택 (수학 수능/PS/자격증/어학/없음)
2. **현재 실력** — 현재 등급과 목표 등급 설정 (1-9등급 카드 선택)
3. **중점 과목**학습에 집중할 과목 2-4개 선택
3. **집중 단원**수학 4단원(공통/확통/미적/기하) 중 집중할 영역 선택
4. **학습 리듬** — 페르소나 선택 (뒤에 자세히 설명)
## 핵심 기능
@@ -37,7 +37,7 @@
- 연도별, 과목별 필터로 원하는 시험지 검색
- "시작" 버튼을 누르면 모의고사 모드로 진입
- 현재 제공 중인 문제집: 2025/2026 수능 국어, 수학, 영어, 한국사, 생활과 윤리
- 현재 제공 중인 문제집: 2025/2026 수능 수학 4단원(공통/확통/미적/기하)
### 모의고사 모드
@@ -52,7 +52,6 @@
- 이전 / 건너뛰기 / 다음 / 항목이동 (4개 버튼)
- 문제 이미지 클릭 → 확대 보기
- 키보드 단축키: `1-5`(답 선택), `←/→`(문제 이동), `F`(표시), `Enter`(다음)
- 영어 듣기: 오디오 플레이어가 상단에 표시 (자동 트랙 매칭)
- 시험 종료: 우상단 `⋮` 메뉴에서 "시험 종료(제출)" 선택
**집중 모드**:
@@ -81,8 +80,8 @@
모든 학습 기록을 테이블(데스크톱) 또는 카드 리스트(모바일)로 확인합니다.
- 과목, 날짜, 결과로 필터링
- 검색창에서 제목/과목/태그로 검색
- 단원, 날짜, 결과로 필터링
- 검색창에서 제목/단원/태그로 검색
- 각 항목 클릭 → 상세 페이지에서:
- 문제 이미지 + 내 답 vs 정답
- 복습 스케줄 타임라인
@@ -93,17 +92,17 @@
학습 패턴과 성장을 시각적으로 확인합니다.
- **학습 히트맵**: 최근 18주간 날짜별 학습량 (색상 농도)
- **과목별 정답률**: 각 과목의 정답/오답 비율 바 차트
- **단원/PS 정답률**: 각 단원 및 PS 태그의 정답/오답 비율 바 차트
- **숙련도 성장 곡선**: 태그별 숙련도가 시간에 따라 어떻게 변했는지 선 그래프
### 과목 및 태그 (`/subjects`)
### 단원 및 태그 (`/subjects`)
과목과 태그를 관리합니다.
수학 단원과 태그를 관리합니다.
- 과목별로 하위 태그 목록 + 각 태그의 현재 숙련도
- 단원별로 하위 태그 목록 + 각 태그의 현재 숙련도
- 태그 이름 수정 (인라인 편집)
- 태그 보관 (소프트 삭제 + 실행 취소)
-과목/태그 추가
-단원/태그 추가
### 프로필 (`/profile`)

3
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

View File

@@ -55,7 +55,7 @@ interface MasteryPreviewItem {
currentP: number;
daysToTarget: number | null;
iterationsToTarget: number | null;
iconName: 'function' | 'dna' | 'book-bookmark' | 'book-open';
iconName: 'function' | 'dna' | 'book-bookmark' | 'book-open' | 'code';
}
const COACH_DISMISS_KEY = 'reloop.dashboard.coach.dismissed';
@@ -693,12 +693,8 @@ function resultIcon(result: StudyLog['result']) {
function iconForSubject(subjectName: string): MasteryPreviewItem['iconName'] {
const name = subjectName.toLowerCase();
if (name.includes('수학')) return 'function';
if (name.includes('생명') || name.includes('과학') || name.includes('물리') || name.includes('화학')) {
return 'dna';
}
if (name.includes('국어') || name.includes('독서')) return 'book-bookmark';
return 'book-open';
if (name.includes('ps')) return 'code';
return 'function';
}
function capitalizePersona(persona: Persona): string {

View File

@@ -11,7 +11,7 @@ import { api, type ProblemSetSummary } from '@/lib/api';
import { theme } from '@/styles/theme';
const YEAR_FILTERS = [2026, 2025] as const;
const SUBJECT_FILTERS = ['전체', '국어', '수학', '영어', '한국사', '생활과 윤리'] as const;
const SUBJECT_FILTERS = ['전체', '수학'] as const;
type YearFilter = number | 'all';
type SubjectFilter = (typeof SUBJECT_FILTERS)[number];

View File

@@ -27,7 +27,7 @@ export const metadata: Metadata = {
},
description: '잊기 직전에 딱 한 번 더. 페르소나 기반 망각곡선 복습 시스템.',
applicationName: 'ReLoop',
keywords: ['복습', '망각곡선', '페르소나', '기출 문제집', '자격증', '어학', 'KICE'],
keywords: ['복습', '망각곡선', '페르소나', '수능 수학', 'Solved.ac', 'PS', 'KICE'],
authors: [{ name: 'ReLoop' }],
creator: 'ReLoop',
manifest: '/manifest.webmanifest',

View File

@@ -7,26 +7,44 @@ import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import GradeCardCarousel from '@/components/ui/GradeCardCarousel';
import { useToast } from '@/components/ui/Toast';
import { api, type MeUser, type Persona } from '@/lib/api';
import { api, MATH_UNIT_LABEL, ps } from '@/lib/api';
import type { GoalType, MathUnit, MeUser, Persona } from '@/lib/api';
import { PERSONA_META } from '@/lib/constants';
import { getDefaultTargetYear } from '@/lib/exam-date';
import { theme, PERSONA_ORDER } from '@/styles/theme';
type Step = 1 | 2 | 3 | 4;
type GoalType = 'suneung' | 'certificate' | 'language' | 'none';
const STEP_COUNT = 4;
const SUBJECT_OPTIONS = ['국어', '수학', '영어', '한국사', '탐구'] as const;
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
const GOAL_TYPE_OPTIONS: Array<{
value: GoalType;
label: string;
desc: string;
}> = [
{ value: 'suneung', label: '수능', desc: '수능과 모의고사를 목표로 학습합니다.' },
{ value: 'certificate', label: '자격증 시험', desc: '자격증이나 직무 시험을 준비합니다.' },
{ value: 'language', label: '시험', desc: '영어/제2외국어 등 언어 학습이 목표입니다.' },
{
value: 'math-suneung',
label: '수능',
desc: '수능 수학 단원별 복습 + 4단원 전략',
},
{
value: 'ps',
label: 'PS (Solved.ac)',
desc: '백준 문제 풀이 이력을 자동으로 복습 큐에 편입',
},
{
value: 'certificate',
label: '자격증 시험',
desc: '기사/공인중개사 등 직무 시험을 준비합니다.',
},
{
value: 'language',
label: '어학 시험',
desc: '토익/토플/텝스 등 어학 시험을 준비합니다.',
},
{ value: 'none', label: '없음(취미)', desc: '시험 없이 꾸준한 학습 루틴을 만듭니다.' },
] as const;
const MATH_UNIT_OPTIONS = Object.entries(MATH_UNIT_LABEL) as Array<[MathUnit, string]>;
const PERSONA_REVIEW_GUIDE: Record<Persona, string> = {
senior: '복습 간격이 가장 넓습니다. 주 2회 리듬으로도 유지력이 높은 편입니다.',
mid: '균형형 리듬입니다. 주 3회 정도의 반복을 권장합니다.',
@@ -52,13 +70,21 @@ function OnboardingBody() {
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [nickname, setNickname] = useState('');
const [goalType, setGoalType] = useState<GoalType>('suneung');
const [goalType, setGoalType] = useState<GoalType>('math-suneung');
const [targetExamYear, setTargetExamYear] = useState(upcomingExamYear);
const [currentGrade, setCurrentGrade] = useState(4);
const [targetGrade, setTargetGrade] = useState(2);
const [focusSubjects, setFocusSubjects] = useState<string[]>([]);
const [focusUnits, setFocusUnits] = useState<MathUnit[]>(['common']);
const [bojHandle, setBojHandle] = useState('');
const [syncAfterSave, setSyncAfterSave] = useState(false);
const [persona, setPersona] = useState<Persona>('mid');
useEffect(() => {
if (goalType !== 'ps' && syncAfterSave) {
setSyncAfterSave(false);
}
}, [goalType, syncAfterSave]);
useEffect(() => {
let cancelled = false;
@@ -68,11 +94,24 @@ function OnboardingBody() {
if (cancelled) return;
const me = response.data;
setNickname(me.nickname);
setGoalType(me.targetExamYear === null ? 'none' : 'suneung');
const nextGoal: GoalType =
me.goalType ??
(me.focusUnits && me.focusUnits.length > 0
? 'math-suneung'
: me.bojHandle
? 'ps'
: me.targetExamYear === null
? 'none'
: 'math-suneung');
setGoalType(nextGoal);
setTargetExamYear(me.targetExamYear ?? upcomingExamYear);
setCurrentGrade(me.currentGrade ?? 4);
setTargetGrade(me.targetGrade ?? 2);
setFocusSubjects(me.focusSubjects ?? []);
const nextUnits =
me.focusUnits && me.focusUnits.length > 0 ? me.focusUnits : (['common'] as MathUnit[]);
setFocusUnits(nextUnits);
setBojHandle(me.bojHandle ?? '');
setSyncAfterSave(false);
setPersona(me.persona);
})
.catch(() => {
@@ -120,9 +159,22 @@ function OnboardingBody() {
return;
}
if (step === 3 && (focusSubjects.length < 2 || focusSubjects.length > 4)) {
setErr('중점 과목은 2개에서 4개 사이로 선택해 주세요.');
return;
if (step === 3) {
if (goalType === 'math-suneung' && focusUnits.length === 0) {
setErr('최소 1개 이상의 단원을 선택해 주세요.');
return;
}
if (goalType === 'ps') {
const trimmedHandle = bojHandle.trim();
if (trimmedHandle && !BOJ_HANDLE_REGEX.test(trimmedHandle)) {
setErr('BOJ 핸들을 다시 확인해 주세요.');
return;
}
if (!trimmedHandle && syncAfterSave) {
setErr('동기화를 하려면 BOJ 핸들을 입력해야 합니다.');
return;
}
}
}
setStep((current) => Math.min(current + 1, STEP_COUNT) as Step);
@@ -139,35 +191,47 @@ function OnboardingBody() {
setStep((current) => Math.max(current - 1, 1) as Step);
}
function toggleSubject(subject: string) {
function toggleUnit(unit: MathUnit) {
setErr(null);
setFocusSubjects((current) => {
if (current.includes(subject)) {
return current.filter((item) => item !== subject);
}
if (current.length >= 4) {
showToast({ message: '중점 과목은 최대 4개까지 선택할 수 있습니다', variant: 'warning' });
return current;
}
return [...current, subject];
});
setFocusUnits((current) =>
current.includes(unit) ? current.filter((item) => item !== unit) : [...current, unit],
);
}
async function finish(skipCurrentStep = false) {
async function finish() {
setSaving(true);
setErr(null);
const trimmedHandle = bojHandle.trim();
const payload = {
nickname: nickname.trim(),
goalType,
targetExamYear: goalType === 'math-suneung' ? targetExamYear : null,
currentGrade: goalType === 'math-suneung' ? currentGrade : null,
targetGrade: goalType === 'math-suneung' ? targetGrade : null,
focusSubjects: goalType === 'none' ? [] : ['수학'],
focusUnits: goalType === 'math-suneung' ? focusUnits : [],
persona,
onboarded: true,
bojHandle: goalType === 'ps' ? trimmedHandle || null : null,
};
try {
await api.patch('/me/profile', {
nickname: nickname.trim(),
targetExamYear: goalType === 'none' ? null : targetExamYear,
currentGrade: goalType === 'none' ? null : currentGrade,
targetGrade: goalType === 'none' ? null : targetGrade,
focusSubjects:
step === 3 && skipCurrentStep ? [] : focusSubjects,
persona,
onboarded: true,
});
await api.patch('/auth/me', payload);
if (goalType === 'ps' && syncAfterSave) {
try {
const result = await ps.sync(trimmedHandle || undefined);
showToast({
message: `동기화 완료 · ${result.importedCount}건 추가`,
variant: 'success',
});
} catch (syncError) {
showToast({
message: 'Solved.ac 동기화에 실패했어요. 나중에 다시 시도해 주세요.',
variant: 'danger',
});
}
}
showToast({ message: '온보딩 설정을 저장했습니다', variant: 'success' });
router.replace('/dashboard');
} catch {
@@ -181,13 +245,19 @@ function OnboardingBody() {
setErr(null);
if (step === 3) {
setFocusSubjects([]);
if (goalType === 'math-suneung' && focusUnits.length === 0) {
setFocusUnits(['common']);
}
if (goalType === 'ps') {
setBojHandle('');
setSyncAfterSave(false);
}
setStep(4);
return;
}
if (step === 4) {
void finish(true);
void finish();
}
}
@@ -254,7 +324,7 @@ function OnboardingBody() {
);
})}
</GoalTypeGrid>
{goalType !== 'none' ? (
{goalType === 'math-suneung' ? (
<>
<NumberInput
type="number"
@@ -268,10 +338,16 @@ function OnboardingBody() {
.
</FieldHint>
</>
) : (
) : goalType === 'none' ? (
<FieldHint>
D-day와 .
</FieldHint>
) : (
<FieldHint>
{goalType === 'ps'
? 'PS 트랙은 D-day 없이 Solved.ac 동기화만 설정하면 됩니다.'
: '해당 GoalType 전용 콘텐츠는 준비 중입니다. 기본 루틴으로 먼저 시작할 수 있어요.'}
</FieldHint>
)}
</Field>
</FieldGrid>
@@ -304,31 +380,77 @@ function OnboardingBody() {
{step === 3 ? (
<>
<StepLabel>STEP 3</StepLabel>
<StepTitle> .</StepTitle>
<StepDesc>2 4 . .</StepDesc>
{goalType === 'math-suneung' ? (
<>
<StepTitle> .</StepTitle>
<StepDesc> 1 . .</StepDesc>
<PillGrid>
{SUBJECT_OPTIONS.map((subject) => {
const active = focusSubjects.includes(subject);
return (
<SubjectPill
key={subject}
type="button"
$active={active}
onClick={() => toggleSubject(subject)}
>
<Icon
name={active ? 'check-circle' : 'circle'}
size={16}
color={active ? theme.color.textBright : theme.color.textSub}
<UnitGrid>
{MATH_UNIT_OPTIONS.map(([value, label]) => {
const active = focusUnits.includes(value);
return (
<UnitChip
key={value}
type="button"
$active={active}
onClick={() => toggleUnit(value)}
>
<Icon
name={active ? 'check-circle' : 'circle'}
size={16}
color={active ? theme.color.textBright : theme.color.textSub}
/>
{label}
</UnitChip>
);
})}
</UnitGrid>
<SelectionMeta>{focusUnits.length} </SelectionMeta>
</>
) : goalType === 'ps' ? (
<>
<StepTitle> .</StepTitle>
<StepDesc>Solved.ac .</StepDesc>
<Field>
<FieldName>BOJ </FieldName>
<TextInput
value={bojHandle}
onChange={(event) => setBojHandle(event.target.value)}
placeholder="예) reloop_dev"
maxLength={20}
/>
<FieldHint>//-/_ 3~20. .</FieldHint>
</Field>
<ToggleRow>
<ToggleControl>
<ToggleCheckbox
type="checkbox"
checked={syncAfterSave}
onChange={(event) => setSyncAfterSave(event.target.checked)}
/>
{subject}
</SubjectPill>
);
})}
</PillGrid>
<SelectionMeta>{focusSubjects.length} </SelectionMeta>
<ToggleVisual $active={syncAfterSave}>
<span />
</ToggleVisual>
<ToggleLabel> </ToggleLabel>
</ToggleControl>
<ToggleHint> 100 .</ToggleHint>
</ToggleRow>
</>
) : (
<>
<StepTitle> · PS .</StepTitle>
<StepDesc> GoalType은 . .</StepDesc>
<PlaceholderCard>
<PlaceholderText>
GoalType을 {goalType === 'none' ? '없음' : '다른 시험'}
. .
</PlaceholderText>
</PlaceholderCard>
</>
)}
</>
) : null}
@@ -646,13 +768,13 @@ const SliderSection = styled.div`
gap: 16px;
`;
const PillGrid = styled.div`
const UnitGrid = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
`;
const SubjectPill = styled.button<{ $active: boolean }>`
const UnitChip = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
gap: 8px;
@@ -683,6 +805,75 @@ const SelectionMeta = styled.div`
font-family: ${theme.font.mono};
`;
const ToggleRow = styled.div`
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
`;
const ToggleControl = styled.label`
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
width: fit-content;
`;
const ToggleCheckbox = styled.input`
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
`;
const ToggleVisual = styled.span<{ $active: boolean }>`
width: 42px;
height: 24px;
border-radius: 999px;
background: ${({ $active }) => ($active ? theme.color.brandIndigo : 'rgba(255, 255, 255, 0.15)')};
padding: 3px;
display: inline-flex;
align-items: center;
span {
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
transform: translateX(${({ $active }) => ($active ? '18px' : '0')});
transition: transform 0.2s ease;
}
`;
const ToggleLabel = styled.span`
color: ${theme.color.textBright};
font-weight: 600;
font-size: 14px;
`;
const ToggleHint = styled.span`
color: ${theme.color.textSub};
font-size: 12px;
`;
const PlaceholderCard = styled.div`
border-radius: 18px;
border: 1px dashed ${theme.color.borderSoftAlpha};
padding: 20px;
background: rgba(255, 255, 255, 0.02);
`;
const PlaceholderText = styled.p`
margin: 0;
color: ${theme.color.textSub};
line-height: 1.6;
font-size: 14px;
`;
const PersonaGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -106,7 +106,7 @@ const faqItems = [
{
question: '어떤 공부에 적용할 수 있나요?',
answer:
'수능과 자격증, 어학, 취미 학습까지 모두 적용할 수 있습니다. 개념 암기, 오답 노트, 단어 복습, 문제 풀이처럼 반복이 필요한 학습이면 같은 루프로 관리할 수 있습니다.',
'2025/2026 수능 수학 4단원(공통·확통·미적·기하) 복습과 Solved.ac PS 루틴을 동시에 지원합니다. 단원별 기출 복습과 백준 풀이 이력을 한 큐로 엮어 필요한 문제만 다시 만나게 도와줍니다.',
},
{
question: '학습 페르소나는 중간에 바꿀 수 있나요?',
@@ -122,7 +122,7 @@ const faqItems = [
const heroPhrases = [
'잊기 직전에, 딱 한 번 더.',
'수능도, 자격증도, 언어도. 반복은 하나야.',
'수학 수능 + PS, 두 트랙을 한 루프로.',
'네 학습 리듬에 맞춘 복습.',
'까먹기 전에 다시 만나자.',
'공부는 기억과의 싸움이야.',
@@ -181,7 +181,7 @@ export default function LandingPage() {
<HeroSection>
<HeroBadge>
<StatusDot />
{dDay.label} · , ,
{dDay.label} · 4 + Solved.ac PS
</HeroBadge>
<HeroTitle>
@@ -281,8 +281,8 @@ export default function LandingPage() {
, .
</SectionBody>
<SectionBody>
, , ,
.
4 Solved.ac PS
.
</SectionBody>
</SectionHeader>
</ScrollReveal>

View File

@@ -76,7 +76,7 @@ const tiers: Tier[] = [
'기출 문제집 연동',
'마스터리 경로 뷰와 예상 정답률 예측',
'PDF 문제/자료 이미지 업로드',
'과목별 상세 분석 리포트',
'단원별 상세 분석 리포트',
'데이터 내보내기와 알림 위젯',
],
},
@@ -236,7 +236,7 @@ const faqItems = [
{
question: 'Free에서 Pro로 올리면 기존 데이터가 유지되나요?',
answer:
'유지됩니다. 과목, 복습 이력, 태그, 학습 통계가 그대로 이어지며 새로 가져오거나 다시 설정할 필요가 없습니다.',
'유지됩니다. 단원 설정, 복습 이력, 태그, 학습 통계가 그대로 이어지며 새로 가져오거나 다시 설정할 필요가 없습니다.',
},
{
question: 'School 플랜은 어떤 팀에 적합한가요?',

View File

@@ -11,7 +11,9 @@ import { Button, Card } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import {
api,
MATH_UNIT_LABEL,
resolveAssetUrl,
type MathUnit,
type MeUser,
type Persona,
type ReviewIntensity,
@@ -41,14 +43,17 @@ type ProfilePatch = Partial<
| 'targetGrade'
| 'targetExamYear'
| 'focusSubjects'
| 'focusUnits'
| 'persona'
| 'reviewIntensity'
| 'bojHandle'
> & { onboarded?: boolean }
>;
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
const NOTIFICATION_STORAGE_KEY = 'reloop:profile-notifications';
const FETCH_LIMIT = 200;
const SUBJECT_OPTIONS = ['국어', '수학', '영어', '한국사', '탐구'] as const;
const MATH_UNIT_OPTIONS = Object.entries(MATH_UNIT_LABEL) as Array<[MathUnit, string]>;
const DEFAULT_NOTIFICATIONS: NotificationPrefs = {
pushDailyReview: true,
weeklyReport: true,
@@ -105,6 +110,7 @@ function ProfileBody() {
const [currentGradeDraft, setCurrentGradeDraft] = useState(5);
const [targetGradeDraft, setTargetGradeDraft] = useState(3);
const [targetExamYearDraft, setTargetExamYearDraft] = useState(new Date().getFullYear() + 1);
const [bojHandleDraft, setBojHandleDraft] = useState('');
const [notifications, setNotifications] =
useState<NotificationPrefs>(DEFAULT_NOTIFICATIONS);
const [studyLogs, setStudyLogs] = useState<StudyLog[]>([]);
@@ -181,13 +187,17 @@ function ProfileBody() {
fetchAllStudyLogs().catch(() => []),
]);
const nextUser = meResponse.data;
const nextUser: MeUser = {
...meResponse.data,
focusUnits: meResponse.data.focusUnits ?? [],
};
userRef.current = nextUser;
setUser(nextUser);
setNicknameDraft(nextUser.nickname);
setCurrentGradeDraft(nextUser.currentGrade ?? 5);
setTargetGradeDraft(nextUser.targetGrade ?? 3);
setTargetExamYearDraft(nextUser.targetExamYear ?? new Date().getFullYear() + 1);
setBojHandleDraft(nextUser.bojHandle ?? '');
setAvatarPreviewUrl(null);
setStudyLogs(logs);
} finally {
@@ -202,13 +212,17 @@ function ProfileBody() {
try {
const response = await api.patch<MeUser>('/me/profile', patch);
const nextUser = response.data;
const nextUser: MeUser = {
...response.data,
focusUnits: response.data.focusUnits ?? [],
};
userRef.current = nextUser;
setUser(nextUser);
setNicknameDraft(nextUser.nickname);
setCurrentGradeDraft(nextUser.currentGrade ?? currentGradeRef.current);
setTargetGradeDraft(nextUser.targetGrade ?? targetGradeRef.current);
setTargetExamYearDraft(nextUser.targetExamYear ?? targetExamYearDraft);
setBojHandleDraft(nextUser.bojHandle ?? '');
showToast({ message: successMessage, variant: 'success' });
} catch {
const currentUser = userRef.current;
@@ -216,6 +230,7 @@ function ProfileBody() {
setCurrentGradeDraft(currentUser?.currentGrade ?? 5);
setTargetGradeDraft(currentUser?.targetGrade ?? 3);
setTargetExamYearDraft(currentUser?.targetExamYear ?? new Date().getFullYear() + 1);
setBojHandleDraft(currentUser?.bojHandle ?? '');
showToast({ message: '저장에 실패했습니다', variant: 'danger' });
} finally {
setSavingKey((current) => (current === key ? null : current));
@@ -303,25 +318,39 @@ function ProfileBody() {
void patchProfile({ targetExamYear: targetExamYearDraft }, 'target-year');
}
function toggleFocusSubject(subject: string) {
function handleBojHandleSave() {
if (!user) return;
const trimmed = bojHandleDraft.trim();
const currentHandle = user.bojHandle ?? '';
if (trimmed === currentHandle) return;
if (trimmed && !BOJ_HANDLE_REGEX.test(trimmed)) {
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
return;
}
void patchProfile(
{ bojHandle: trimmed ? trimmed : null },
'boj-handle',
trimmed ? 'BOJ 핸들을 저장했어요' : 'BOJ 핸들을 제거했어요',
);
}
function toggleFocusUnit(unit: MathUnit) {
if (!user) return;
const current = new Set(user.focusSubjects ?? []);
if (current.has(subject)) {
current.delete(subject);
} else {
if (current.size >= 4) {
showToast({ message: '집중 과목은 최대 4개까지 선택할 수 있습니다', variant: 'warning' });
const current = new Set(user.focusUnits ?? []);
if (current.has(unit)) {
if (current.size === 1) {
showToast({ message: '최소 1개 단원은 유지해야 합니다', variant: 'warning' });
return;
}
current.add(subject);
current.delete(unit);
} else {
current.add(unit);
}
void patchProfile(
{ focusSubjects: Array.from(current) },
'focus-subjects',
'중점 과목을 업데이트했습니다',
);
const next = Array.from(current);
setUser((prev) => (prev ? { ...prev, focusUnits: next } : prev));
void patchProfile({ focusUnits: next }, 'focus-units', '집중 단원을 업데이트했습니다');
}
function handleNotificationToggle(key: keyof NotificationPrefs) {
@@ -654,7 +683,7 @@ function ProfileBody() {
<CardSection>
<SectionHeader>
<SectionTitle> / </SectionTitle>
<SectionTitle> / </SectionTitle>
</SectionHeader>
<FieldRow>
<FieldLabel> </FieldLabel>
@@ -670,23 +699,38 @@ function ProfileBody() {
</InlineField>
</FieldRow>
<FieldRow>
<FieldLabel> </FieldLabel>
<SubjectGrid>
{SUBJECT_OPTIONS.map((subject) => {
const active = user.focusSubjects.includes(subject);
<FieldLabel> </FieldLabel>
<UnitGrid>
{MATH_UNIT_OPTIONS.map(([value, label]) => {
const active = (user.focusUnits ?? []).includes(value);
return (
<SubjectPill
key={subject}
<UnitPill
key={value}
type="button"
$active={active}
onClick={() => toggleFocusSubject(subject)}
onClick={() => toggleFocusUnit(value)}
>
<Icon name={active ? 'check-circle' : 'circle'} size={15} />
{subject}
</SubjectPill>
{label}
</UnitPill>
);
})}
</SubjectGrid>
</UnitGrid>
</FieldRow>
<FieldRow>
<FieldLabel>BOJ </FieldLabel>
<InlineField>
<HandleInput
value={bojHandleDraft}
onChange={(event) => setBojHandleDraft(event.target.value)}
placeholder="예) reloop_ps"
maxLength={20}
/>
<Button type="button" onClick={handleBojHandleSave}>
</Button>
</InlineField>
<FieldNote>//-/_ 3~20. PS .</FieldNote>
</FieldRow>
</CardSection>
</Grid>
@@ -1198,13 +1242,13 @@ const YearInput = styled.input`
color: ${theme.color.textBright};
`;
const SubjectGrid = styled.div`
const UnitGrid = styled.div`
display: flex;
gap: 10px;
flex-wrap: wrap;
`;
const SubjectPill = styled.button<{ $active: boolean }>`
const UnitPill = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
gap: 8px;
@@ -1218,6 +1262,22 @@ const SubjectPill = styled.button<{ $active: boolean }>`
cursor: pointer;
`;
const HandleInput = styled.input`
width: 220px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textBright};
`;
const FieldNote = styled.span`
display: block;
margin-top: 6px;
color: ${theme.color.textSub};
font-size: 12px;
`;
const NotificationList = styled.div`
display: grid;
gap: 14px;

View File

@@ -0,0 +1,231 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card, PageHeader } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { ps, type PsBookmark } from '@/lib/api';
import { theme } from '@/styles/theme';
export default function PsBookmarksPage() {
return (
<AppShell>
<BookmarksBody />
</AppShell>
);
}
function BookmarksBody() {
const { showToast } = useToast();
const [bookmarks, setBookmarks] = useState<PsBookmark[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<number | null>(null);
useEffect(() => {
void loadBookmarks();
}, []);
async function loadBookmarks() {
setLoading(true);
setError(null);
try {
const items = await ps.listBookmarks();
setBookmarks(items);
} catch {
setError('북마크 목록을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.');
} finally {
setLoading(false);
}
}
async function handleRemove(bojId: number) {
setRemovingId(bojId);
try {
await ps.removeBookmark(bojId);
setBookmarks((prev) => prev.filter((bookmark) => bookmark.psProblem.bojId !== bojId));
showToast({ message: '북마크를 삭제했습니다', variant: 'success' });
} catch {
showToast({ message: '삭제에 실패했습니다', variant: 'danger' });
} finally {
setRemovingId(null);
}
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="북마크"
subtitle="중요한 PS 문제를 저장해 두고 다시 풀어보세요."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
{error ? (
<StateCard>
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle>{error}</StateTitle>
</StateCard>
) : loading ? (
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
</StateCard>
) : bookmarks.length === 0 ? (
<StateCard>
<StateIcon>
<Icon name="bookmark-simple" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
) : (
<BookmarkCard>
<BookmarkList>
{bookmarks.map((bookmark) => (
<BookmarkRow key={bookmark.id}>
<BookmarkInfo>
<BookmarkTitle>{bookmark.psProblem.titleKo ?? bookmark.psProblem.title}</BookmarkTitle>
<BookmarkMeta>
<Badge>Lv {bookmark.psProblem.level}</Badge>
<Badge>BOJ {bookmark.psProblem.bojId}</Badge>
</BookmarkMeta>
{bookmark.memo ? <BookmarkMemo>{bookmark.memo}</BookmarkMemo> : null}
</BookmarkInfo>
<BookmarkActions>
<Button
as="a"
href={`https://www.acmicpc.net/problem/${bookmark.psProblem.bojId}`}
target="_blank"
rel="noopener noreferrer"
$variant="secondary"
>
</Button>
<Button
type="button"
$variant="ghost"
onClick={() => handleRemove(bookmark.psProblem.bojId)}
disabled={removingId === bookmark.psProblem.bojId}
>
</Button>
</BookmarkActions>
</BookmarkRow>
))}
</BookmarkList>
</BookmarkCard>
)}
</PageWrap>
);
}
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const BookmarkCard = styled(Card)`
border-color: ${theme.color.borderSoftAlpha};
`;
const BookmarkList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const BookmarkRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: 16px;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
`;
const BookmarkInfo = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
min-width: 260px;
`;
const BookmarkTitle = styled.h3`
margin: 0;
font-size: 16px;
color: ${theme.color.textBright};
`;
const BookmarkMeta = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
color: ${theme.color.textSub};
`;
const BookmarkMemo = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 13px;
line-height: 1.5;
`;
const BookmarkActions = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
border-color: ${theme.color.borderSoftAlpha};
`;
const StateIcon = styled.div`
color: ${theme.color.textSub};
`;
const StateTitle = styled.div`
color: ${theme.color.textBright};
font-weight: 600;
`;
const StateText = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;

View File

@@ -0,0 +1,532 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card, Label, PageHeader } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { ps, type PsSearchProblem, type PsSearchResponse } from '@/lib/api';
import { theme } from '@/styles/theme';
const SEARCH_PAGE_SIZE = 20;
const LEVEL_PRESETS: Array<{ label: string; value: number | null }> = [
{ label: '전체', value: null },
{ label: '쉬움', value: 5 },
{ label: '중간', value: 15 },
{ label: '어려움', value: 25 },
];
export default function PsSearchPage() {
return (
<AppShell>
<SearchBody />
</AppShell>
);
}
function SearchBody() {
const { showToast } = useToast();
const [queryInput, setQueryInput] = useState('');
const [submittedQuery, setSubmittedQuery] = useState('');
const [level, setLevel] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [result, setResult] = useState<PsSearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bookmarkedIds, setBookmarkedIds] = useState<Set<number>>(new Set());
const [bookmarkingId, setBookmarkingId] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
ps
.listBookmarks()
.then((items) => {
if (cancelled) return;
setBookmarkedIds(new Set(items.map((bookmark) => bookmark.psProblem.bojId)));
})
.catch(() => {
/* ignore */
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
ps
.search({
q: submittedQuery || undefined,
level: level ?? undefined,
page,
})
.then((data) => {
if (!cancelled) {
setResult(data);
}
})
.catch(() => {
if (!cancelled) {
setError('Solved.ac 문제를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [submittedQuery, level, page]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmittedQuery(queryInput.trim());
setPage(1);
}
function handlePresetSelect(value: number | null) {
setLevel(value);
setPage(1);
}
function handleSliderChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextValue = Number(event.target.value);
setLevel(nextValue === 0 ? null : nextValue);
setPage(1);
}
const totalPages = useMemo(() => {
if (!result) return 1;
return Math.max(1, Math.ceil(result.count / SEARCH_PAGE_SIZE));
}, [result]);
const currentItems = result?.items ?? [];
async function toggleBookmark(bojId: number) {
setBookmarkingId(bojId);
const isBookmarked = bookmarkedIds.has(bojId);
try {
if (isBookmarked) {
await ps.removeBookmark(bojId);
setBookmarkedIds((prev) => {
const next = new Set(prev);
next.delete(bojId);
return next;
});
showToast({ message: '북마크에서 제거했어요', variant: 'success' });
} else {
await ps.bookmark(bojId);
setBookmarkedIds((prev) => {
const next = new Set(prev);
next.add(bojId);
return next;
});
showToast({ message: '북마크에 추가했어요', variant: 'success' });
}
} catch {
showToast({ message: '북마크 업데이트에 실패했습니다', variant: 'danger' });
} finally {
setBookmarkingId((current) => (current === bojId ? null : current));
}
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="PS 문제 검색"
subtitle="레벨과 태그를 걸러 Solved.ac 문제를 빠르게 찾고 북마크하세요."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
<FilterCard as="form" onSubmit={handleSubmit}>
<FilterField>
<Label htmlFor="ps-query"></Label>
<SearchInput
id="ps-query"
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder="문제 번호, 태그, 키워드를 입력하세요"
/>
</FilterField>
<FilterField>
<Label></Label>
<PresetRow>
{LEVEL_PRESETS.map((preset) => (
<PresetButton
key={preset.label}
type="button"
$active={preset.value === level}
onClick={() => handlePresetSelect(preset.value)}
>
{preset.label}
</PresetButton>
))}
</PresetRow>
<SliderRow>
<LevelValue>{level ? `Lv ${level}` : '전체'}</LevelValue>
<LevelSlider min={0} max={30} value={level ?? 0} onChange={handleSliderChange} />
</SliderRow>
</FilterField>
<FilterActions>
<Button type="submit"></Button>
</FilterActions>
</FilterCard>
{error ? (
<StateCard>
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>{error}</StateText>
</StateCard>
) : loading ? (
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>Solved.ac .</StateText>
</StateCard>
) : currentItems.length === 0 ? (
<StateCard>
<StateIcon>
<Icon name="magnifying-glass" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
) : (
<ResultCard>
<ResultHeader>
<strong>{result?.count.toLocaleString()}</strong> .
</ResultHeader>
<ProblemList>
{currentItems.map((problem) => (
<ProblemRow key={problem.problemId}>
<ProblemInfo>
<ProblemTitle>{displayTitle(problem)}</ProblemTitle>
<ProblemMeta>
<TierBadge $color={tierColor(problem.level)}>{tierLabel(problem.level)}</TierBadge>
<Badge>BOJ {problem.problemId}</Badge>
</ProblemMeta>
<TagChips>
{extractTags(problem).map((tag) => (
<TagChip key={`${problem.problemId}-${tag}`}>{tag}</TagChip>
))}
</TagChips>
</ProblemInfo>
<ProblemActions>
<BookmarkButton
type="button"
disabled={bookmarkingId === problem.problemId}
$active={bookmarkedIds.has(problem.problemId)}
onClick={() => toggleBookmark(problem.problemId)}
>
<Icon
name="bookmark-simple"
size={16}
weight={bookmarkedIds.has(problem.problemId) ? 'fill' : 'regular'}
/>
{bookmarkedIds.has(problem.problemId) ? '북마크됨' : '북마크'}
</BookmarkButton>
<Button
as="a"
href={`https://www.acmicpc.net/problem/${problem.problemId}`}
target="_blank"
rel="noopener noreferrer"
$variant="secondary"
>
</Button>
</ProblemActions>
</ProblemRow>
))}
</ProblemList>
<Pagination>
<PaginationButton type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
</PaginationButton>
<PaginationStatus>
{page} / {totalPages}
</PaginationStatus>
<PaginationButton
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
</PaginationButton>
</Pagination>
</ResultCard>
)}
</PageWrap>
);
}
function displayTitle(problem: PsSearchProblem) {
return problem.titleKo ?? problem.title ?? `BOJ ${problem.problemId}`;
}
function extractTags(problem: PsSearchProblem) {
const tags = problem.tags ?? [];
return tags
.map((tag) => tag.displayNames?.[0]?.name ?? tag.key)
.filter(Boolean)
.slice(0, 4);
}
function tierLabel(level: number) {
const meta = tierMeta(level);
return meta.label;
}
function tierColor(level: number) {
const meta = tierMeta(level);
return meta.color;
}
function tierMeta(level: number) {
const tierNames = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Ruby'];
const division = ['V', 'IV', 'III', 'II', 'I'];
if (level <= 0) {
return { label: 'Unrated', color: theme.color.border };
}
const tierIndex = Math.min(tierNames.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
const divisionIndex = Math.max(0, (level - 1) % 5);
return {
label: `${tierNames[tierIndex]} ${division[divisionIndex]}`,
color: TIER_COLORS[tierIndex] ?? theme.color.brandIndigo,
};
}
const TIER_COLORS = ['#cd7f32', '#b0bec5', '#fbbf24', '#38bdf8', '#60a5fa', '#f472b6'];
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const FilterCard = styled(Card)`
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: end;
border-color: ${theme.color.borderSoftAlpha};
`;
const FilterField = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const SearchInput = styled.input`
padding: 12px 16px;
border-radius: 14px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
`;
const PresetRow = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
`;
const PresetButton = styled.button<{ $active: boolean }>`
border-radius: 12px;
padding: 6px 12px;
border: 1px solid
${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79, 70, 229, 0.2)' : 'transparent')};
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
font-size: 13px;
`;
const SliderRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const LevelValue = styled.span`
min-width: 64px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const LevelSlider = styled.input.attrs({ type: 'range' })`
flex: 1;
`;
const FilterActions = styled.div`
display: flex;
justify-content: flex-end;
grid-column: 1 / -1;
`;
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
border-color: ${theme.color.borderSoftAlpha};
`;
const StateIcon = styled.div`
color: ${theme.color.textSub};
`;
const StateTitle = styled.div`
color: ${theme.color.textBright};
font-weight: 600;
`;
const StateText = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;
const ResultCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.color.borderSoftAlpha};
`;
const ResultHeader = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;
const ProblemList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const ProblemRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: 16px;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
`;
const ProblemInfo = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
min-width: 260px;
`;
const ProblemTitle = styled.h3`
margin: 0;
font-size: 16px;
color: ${theme.color.textBright};
`;
const ProblemMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
`;
const TierBadge = styled(Badge)<{ $color: string }>`
background: ${({ $color }) => `${$color}33`};
color: ${({ $color }) => $color};
`;
const TagChips = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const TagChip = styled.span`
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: ${theme.color.textSub};
font-size: 12px;
`;
const ProblemActions = styled.div`
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
`;
const BookmarkButton = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid
${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79, 70, 229, 0.14)' : 'transparent')};
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
`;
const Pagination = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
`;
const PaginationButton = styled.button<{ disabled?: boolean }>`
padding: 8px 16px;
border-radius: 12px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: transparent;
color: ${theme.color.textBright};
opacity: ${({ disabled }) => (disabled ? 0.4 : 1)};
`;
const PaginationStatus = styled.span`
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;

View File

@@ -0,0 +1,261 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import PsTabs from '@/components/ps/PsTabs';
import { Icon } from '@/components/ui/Icon';
import { Button, Card, Label, PageHeader } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { api, ps, type MeUser, type PsSyncResult } from '@/lib/api';
import { theme } from '@/styles/theme';
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
export default function PsSyncPage() {
return (
<AppShell>
<SyncBody />
</AppShell>
);
}
function SyncBody() {
const { showToast } = useToast();
const [user, setUser] = useState<MeUser | null>(null);
const [handleInput, setHandleInput] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<PsSyncResult | null>(null);
useEffect(() => {
api
.get<MeUser>('/auth/me')
.then((response) => {
setUser(response.data);
setHandleInput(response.data.bojHandle ?? '');
})
.catch(() => {
setError('계정 정보를 불러오지 못했습니다. 다시 로그인해 주세요.');
})
.finally(() => setLoading(false));
}, []);
async function saveHandle() {
if (!user) return;
const trimmed = handleInput.trim();
if (trimmed && !BOJ_HANDLE_REGEX.test(trimmed)) {
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
return;
}
if ((user.bojHandle ?? '') === trimmed) {
showToast({ message: '이미 저장된 핸들입니다', variant: 'info' });
return;
}
setSaving(true);
try {
const response = await api.patch<MeUser>('/auth/me', {
bojHandle: trimmed || null,
});
setUser(response.data);
showToast({ message: '핸들을 저장했습니다', variant: 'success' });
} catch {
showToast({ message: '핸들을 저장하지 못했습니다', variant: 'danger' });
} finally {
setSaving(false);
}
}
async function runSync() {
if (!user) return;
const activeHandle = handleInput.trim() || user.bojHandle || '';
if (!activeHandle) {
showToast({ message: '먼저 BOJ 핸들을 입력해 주세요.', variant: 'warning' });
return;
}
if (!BOJ_HANDLE_REGEX.test(activeHandle)) {
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
return;
}
setSyncing(true);
setError(null);
try {
const result = await ps.sync(activeHandle);
setSyncResult(result);
showToast({ message: `${result.importedCount}건을 새로 가져왔어요`, variant: 'success' });
} catch (syncError) {
setSyncResult(null);
showToast({ message: '동기화에 실패했습니다. 잠시 후 다시 시도해 주세요.', variant: 'danger' });
} finally {
setSyncing(false);
}
}
if (loading) {
return (
<PageWrap>
<StateCard>
<Icon name="clock" size={24} />
<StateText> ...</StateText>
</StateCard>
</PageWrap>
);
}
if (error) {
return (
<PageWrap>
<StateCard>
<Icon name="info" size={24} />
<StateText>{error}</StateText>
</StateCard>
</PageWrap>
);
}
return (
<PageWrap>
<HeaderCard>
<PageHeader
eyebrow="Solved.ac"
title="BOJ 동기화"
subtitle="핸들을 저장하고 최신 풀이 기록을 복습 큐에 자동으로 추가합니다."
/>
<TabsWrap>
<PsTabs />
</TabsWrap>
</HeaderCard>
<SyncCard>
<FieldGroup>
<Label htmlFor="boj-handle">BOJ </Label>
<HandleInput
id="boj-handle"
value={handleInput}
onChange={(event) => setHandleInput(event.target.value)}
placeholder="예) reloop_ps"
maxLength={20}
/>
<FieldHint>//-/_ 3~20</FieldHint>
</FieldGroup>
<ActionRow>
<Button type="button" onClick={saveHandle} disabled={saving}>
{saving ? '저장 중...' : '핸들 저장'}
</Button>
<Button type="button" onClick={runSync} disabled={syncing} $variant="secondary">
{syncing ? '동기화 중...' : '지금 동기화'}
</Button>
</ActionRow>
<ResultGrid>
<ResultCardItem>
<ResultValue>{syncResult ? syncResult.importedCount : '-'}</ResultValue>
<ResultLabel> </ResultLabel>
</ResultCardItem>
<ResultCardItem>
<ResultValue>{syncResult ? syncResult.skippedCount : '-'}</ResultValue>
<ResultLabel> </ResultLabel>
</ResultCardItem>
<ResultCardItem>
<ResultValue>{syncResult ? syncResult.totalSolved : '-'}</ResultValue>
<ResultLabel> </ResultLabel>
</ResultCardItem>
</ResultGrid>
</SyncCard>
</PageWrap>
);
}
const PageWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const HeaderCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-color: ${theme.color.borderSoftAlpha};
`;
const TabsWrap = styled.div`
margin-top: 8px;
`;
const SyncCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.color.borderSoftAlpha};
`;
const FieldGroup = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const HandleInput = styled.input`
width: 260px;
max-width: 100%;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
`;
const FieldHint = styled.span`
color: ${theme.color.textSub};
font-size: 12px;
`;
const ActionRow = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const ResultGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
`;
const ResultCardItem = styled.div`
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
`;
const ResultValue = styled.div`
font-size: 24px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const ResultLabel = styled.div`
color: ${theme.color.textSub};
font-size: 12px;
`;
const StateCard = styled(Card)`
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-color: ${theme.color.borderSoftAlpha};
`;
const StateText = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;

View File

@@ -163,7 +163,7 @@ export default function NewAssignmentPage() {
id="assignment-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="예: 6월 전국연합 영어 과제"
placeholder="예: 6월 전국연합 수학 과제"
required
/>
</Field>

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
@@ -277,7 +278,7 @@ export default function ExamPage() {
}, [selectedTrack]);
useEffect(() => {
if (!currentProblem || problemSet?.subjectName !== '영어') return;
if (!currentProblem || !hasAudioTracks) return;
if (currentProblem.number < 1 || currentProblem.number > ENGLISH_LISTENING_PROBLEM_MAX) return;
const matchedIndex = audioTracks.findIndex(
@@ -286,7 +287,7 @@ export default function ExamPage() {
if (matchedIndex >= 0) {
setSelectedTrackIndex(matchedIndex);
}
}, [audioTracks, currentProblem, problemSet?.subjectName]);
}, [audioTracks, currentProblem, hasAudioTracks]);
useEffect(() => {
if (!problemSet || submitting || submittedRef.current) return;
@@ -642,7 +643,7 @@ export default function ExamPage() {
<AudioMeta>
<AudioLabelRow>
<AudioTitle> </AudioTitle>
<AudioTitle> </AudioTitle>
<AudioTime>
{formatAudioClock(audioCurrentTime)} / {formatAudioClock(audioDuration)}
</AudioTime>

View File

@@ -180,7 +180,7 @@ function StudyBody() {
<FieldInput
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="예) 2026 6월 국어 18번"
placeholder="예) 2026 6월 수학 18번"
required
/>
</FieldFrame>

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
'use client';
import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -785,7 +785,7 @@ function SubjectModal({
onChange={(event) =>
onChange({ ...value, name: event.target.value })
}
placeholder="예) 국어, 수학, 한국사"
placeholder="예) 공통 수학, 미적분"
maxLength={40}
/>
</div>

View File

@@ -13,12 +13,8 @@ export interface StoredExamResult {
summary: ProblemSetSubmissionResponse;
}
export function getExamDurationSeconds(subjectName: string): number {
if (subjectName === '국어') return 80 * 60;
if (subjectName === '영어') return 70 * 60;
if (subjectName === '한국사') return 30 * 60;
if (subjectName.includes('윤리') || subjectName.includes('탐구')) return 30 * 60;
return 60 * 60;
export function getExamDurationSeconds(_subjectName: string): number {
return 100 * 60;
}
export function formatClock(totalSeconds: number): string {

View File

@@ -12,6 +12,7 @@ import { theme } from '@/styles/theme';
const BASE_TABS = [
{ href: '/dashboard', label: '대시보드', icon: 'squares-four' as const },
{ href: '/review', label: '복습', icon: 'arrows-clockwise' as const },
{ href: '/ps', label: 'PS', icon: 'code' as const },
{ href: '/exams', label: '문제집', icon: 'books' as const },
{ href: '/subjects', label: '과목', icon: 'folders' as const },
{ href: '/stats', label: '통계', icon: 'trend-up' as const },

View File

@@ -42,6 +42,12 @@ const BASE_NAV_ITEMS: Array<{
match: (pathname) => pathname.startsWith('/review'),
showBadge: true,
},
{
href: '/ps',
label: 'PS',
icon: 'code',
match: (pathname) => pathname === '/ps' || pathname.startsWith('/ps/'),
},
{
href: '/stats',
label: '통계',

View File

@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
const TABS = [
{ href: '/ps', label: '검색' },
{ href: '/ps/bookmarks', label: '북마크' },
{ href: '/ps/sync', label: '동기화' },
];
export default function PsTabs() {
const pathname = usePathname();
return (
<Tabs>
{TABS.map((tab) => {
const active = tab.href === '/ps' ? pathname === '/ps' : pathname.startsWith(tab.href);
return (
<Tab key={tab.href} href={tab.href} $active={active}>
{tab.label}
</Tab>
);
})}
</Tabs>
);
}
const Tabs = styled.div`
display: inline-flex;
border-radius: 999px;
padding: 4px;
gap: 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid ${theme.color.borderSoftAlpha};
`;
const Tab = styled(Link)<{ $active: boolean }>`
padding: 8px 18px;
border-radius: 999px;
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
font-weight: 600;
font-size: 14px;
background: ${({ $active }) => ($active ? 'rgba(99, 102, 241, 0.18)' : 'transparent')};
transition: background 0.15s ease, color 0.15s ease;
`;

View File

@@ -10,7 +10,7 @@ import {
Users, ChalkboardTeacher, Sliders, FilePdf, MoonStars, Devices, Keyboard, Brain, Funnel,
MagnifyingGlass, DownloadSimple, CalendarBlank, CalendarCheck, BellRinging, Bell, Sparkle,
Minus, DotsThree, DotsThreeVertical, Translate, BookOpenText, SkipForward, Info, SignOut, Camera, Crown,
Export, Gear, ArrowRight, Pause, type IconProps as PhIconProps, type IconWeight,
Export, Gear, ArrowRight, Pause, BookmarkSimple, Code, type IconProps as PhIconProps, type IconWeight,
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft, User, Books, Medal,
} from '@phosphor-icons/react';
@@ -40,6 +40,7 @@ const ICON_MAP = {
'caret-right': CaretRight,
'caret-left': CaretLeft,
'book-bookmark': BookBookmark,
'bookmark-simple': BookmarkSimple,
'function': FunctionIcon,
'dna': Dna,
'clock-counter-clockwise': ClockCounterClockwise,
@@ -85,6 +86,7 @@ const ICON_MAP = {
'user': User,
'books': Books,
'medal': Medal,
'code': Code,
} as const;
export type IconName = keyof typeof ICON_MAP;

View File

@@ -40,6 +40,15 @@ export type StudyResult = 'correct' | 'incorrect' | 'partial';
export type ReviewStatus = 'pending' | 'done' | 'skipped' | 'expired';
export type SubscriptionTier = 'free' | 'pro' | 'school';
export type OrganizationRole = 'admin' | 'teacher' | 'student';
export type GoalType = 'math-suneung' | 'ps' | 'certificate' | 'language' | 'none';
export type MathUnit = 'common' | 'prob_stat' | 'calculus' | 'geometry';
export const MATH_UNIT_LABEL: Record<MathUnit, string> = {
common: '공통 수학',
prob_stat: '확률과 통계',
calculus: '미적분',
geometry: '기하',
};
export interface MeUser {
id: number;
@@ -51,6 +60,9 @@ export interface MeUser {
targetGrade: number | null;
targetExamYear: number | null;
focusSubjects: string[];
focusUnits: MathUnit[] | null;
bojHandle: string | null;
goalType?: GoalType | null;
reviewIntensity: ReviewIntensity;
onboarded: boolean;
subscriptionTier: SubscriptionTier;
@@ -259,6 +271,67 @@ export interface ProblemSetDetail extends ProblemSetSummary {
passages: Passage[];
}
export interface PsTagDisplayName {
language: string;
name: string;
}
export interface PsTag {
key: string;
displayNames?: PsTagDisplayName[];
}
export interface PsSearchProblem {
problemId: number;
title?: string | null;
titleKo?: string | null;
level: number;
tags?: PsTag[];
acceptedUserCount?: number | null;
averageTries?: number | null;
}
export interface PsSearchResponse {
count: number;
items: PsSearchProblem[];
}
export interface PsProblem {
id: number;
bojId: number;
title: string;
titleKo: string | null;
level: number;
tags?: PsTag[] | null;
acceptedUserCount?: number | null;
averageTries?: number | null;
solvedacUpdatedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface PsBookmark {
id: number;
psProblemId: number;
memo: string | null;
createdAt: string;
psProblem: PsProblem;
}
export interface PsSyncResult {
handle: string;
importedCount: number;
skippedCount: number;
totalSolved: number;
}
export interface PsSearchParams {
q?: string;
level?: number;
tag?: string;
page?: number;
}
export interface Subject {
id: number;
name: string;
@@ -653,3 +726,43 @@ export async function getAssignmentSubmissions(id: number) {
const response = await api.get<SubmissionDetail[]>(`/assignments/${id}/submissions`);
return response.data;
}
export const ps = {
async search(params: PsSearchParams = {}) {
const query: Record<string, string | number> = {};
if (params.q?.trim()) query.q = params.q.trim();
if (typeof params.level === 'number') query.level = params.level;
if (params.tag?.trim()) query.tag = params.tag.trim();
if (typeof params.page === 'number' && params.page > 0) query.page = params.page;
const response = await api.get<PsSearchResponse>('/ps/search', {
params: query,
});
return response.data;
},
async bookmark(bojId: number, memo?: string) {
const payload = memo ? { bojId, memo } : { bojId };
const response = await api.post<PsBookmark>('/ps/bookmarks', payload);
return response.data;
},
async removeBookmark(bojId: number) {
await api.delete(`/ps/bookmarks/${bojId}`);
},
async listBookmarks() {
const response = await api.get<PsBookmark[]>('/ps/bookmarks');
return response.data;
},
async sync(bojHandle?: string) {
const payload = bojHandle?.trim() ? { bojHandle: bojHandle.trim() } : {};
const response = await api.post<PsSyncResult>('/ps/sync', payload);
return response.data;
},
async getProblem(bojId: number) {
const response = await api.get<PsProblem>(`/ps/problems/${bojId}`);
return response.data;
},
};