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:
@@ -9,10 +9,10 @@
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| **적응형 복습 스케줄링** | 페르소나 + 문제 난이도에 따라 "정답률이 기준 아래로 떨어지는 시점"을 자동 계산해 복습 큐에 넣습니다 |
|
||||
| **KICE 기출 문제집** | 2025/2026 수능 기출 (국어/수학/영어/한국사/생활과 윤리) 320문항을 PDF에서 자동 크롭한 이미지로 제공 |
|
||||
| **KICE 기출 문제집** | 2025/2026 수능 수학 4단원(공통/확률과 통계/미적분/기하) 92문항을 PDF에서 자동 크롭한 이미지로 제공 |
|
||||
| **모의고사 모드** | 실제 시험처럼 타이머 + OMR 답안 선택 + 자동 채점 + 학습 기록 일괄 생성 |
|
||||
| **영어 듣기 오디오** | 영어 듣기평가 MP3를 내장 오디오 플레이어로 재생 |
|
||||
| **통계 대시보드** | 학습 히트맵, 과목별 정답률, 숙련도 성장 곡선을 시각화 |
|
||||
| **Solved.ac PS 연동** | Solved.ac 계정에서 최신 백준 풀이 이력을 동기화해 복습 큐에 추가 |
|
||||
| **통계 대시보드** | 학습 히트맵, 단원별·PS 정답률, 숙련도 성장 곡선을 시각화 |
|
||||
| **PWA 지원** | 모바일 홈 화면에 앱으로 설치 가능 |
|
||||
|
||||
## 기술 스택
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
3
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -76,7 +76,7 @@ const tiers: Tier[] = [
|
||||
'기출 문제집 연동',
|
||||
'마스터리 경로 뷰와 예상 정답률 예측',
|
||||
'PDF 문제/자료 이미지 업로드',
|
||||
'과목별 상세 분석 리포트',
|
||||
'단원별 상세 분석 리포트',
|
||||
'데이터 내보내기와 알림 위젯',
|
||||
],
|
||||
},
|
||||
@@ -236,7 +236,7 @@ const faqItems = [
|
||||
{
|
||||
question: 'Free에서 Pro로 올리면 기존 데이터가 유지되나요?',
|
||||
answer:
|
||||
'유지됩니다. 과목, 복습 이력, 태그, 학습 통계가 그대로 이어지며 새로 가져오거나 다시 설정할 필요가 없습니다.',
|
||||
'유지됩니다. 단원 설정, 복습 이력, 태그, 학습 통계가 그대로 이어지며 새로 가져오거나 다시 설정할 필요가 없습니다.',
|
||||
},
|
||||
{
|
||||
question: 'School 플랜은 어떤 팀에 적합한가요?',
|
||||
|
||||
@@ -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;
|
||||
|
||||
231
frontend/src/app/ps/bookmarks/page.tsx
Normal file
231
frontend/src/app/ps/bookmarks/page.tsx
Normal 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;
|
||||
`;
|
||||
532
frontend/src/app/ps/page.tsx
Normal file
532
frontend/src/app/ps/page.tsx
Normal 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};
|
||||
`;
|
||||
261
frontend/src/app/ps/sync/page.tsx
Normal file
261
frontend/src/app/ps/sync/page.tsx
Normal 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;
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -785,7 +785,7 @@ function SubjectModal({
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, name: event.target.value })
|
||||
}
|
||||
placeholder="예) 국어, 수학, 한국사"
|
||||
placeholder="예) 공통 수학, 미적분"
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: '통계',
|
||||
|
||||
48
frontend/src/components/ps/PsTabs.tsx
Normal file
48
frontend/src/components/ps/PsTabs.tsx
Normal 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;
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user