chore: reloop-v2 initial import for harness breezing QA
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
.turbo
|
||||
backend/node_modules
|
||||
frontend/node_modules
|
||||
frontend/.next
|
||||
backend/dist
|
||||
15
backend/.env.example
Normal file
15
backend/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# ReLoop backend env
|
||||
# Copy to .env and fill in.
|
||||
|
||||
DATABASE_URL="mysql://reloop:CHANGE_ME@localhost:3306/reloop"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="change-me-in-prod-really-long-random-string"
|
||||
JWT_EXPIRES_IN="30d"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS="https://reloop.nabomhalang.co.kr,http://localhost:3000"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=production
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
63
backend/package.json
Normal file
63
backend/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "reloop-backend",
|
||||
"version": "0.2.0",
|
||||
"description": "ReLoop Backend API (v2 — persona forgetting curve)",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
156
backend/prisma/schema.prisma
Normal file
156
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,156 @@
|
||||
// ReLoop v2 schema — persona forgetting curve model
|
||||
// Generated fresh; old FSRS fields dropped. DB reset via `prisma migrate reset`.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────────────
|
||||
|
||||
enum Persona {
|
||||
senior
|
||||
mid
|
||||
junior
|
||||
crammer
|
||||
}
|
||||
|
||||
enum ReviewIntensity {
|
||||
strict
|
||||
moderate
|
||||
relaxed
|
||||
}
|
||||
|
||||
enum StudyResult {
|
||||
correct
|
||||
incorrect
|
||||
partial
|
||||
}
|
||||
|
||||
enum ReviewStatus {
|
||||
pending
|
||||
done
|
||||
skipped
|
||||
expired
|
||||
}
|
||||
|
||||
// ─── Models ───────────────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
nickname String
|
||||
persona Persona @default(mid)
|
||||
currentGrade Int?
|
||||
targetGrade Int?
|
||||
reviewIntensity ReviewIntensity @default(moderate)
|
||||
onboardedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
subjects Subject[]
|
||||
studyLogs StudyLog[]
|
||||
reviewSchedules ReviewSchedule[]
|
||||
skillSnapshots SkillSnapshot[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Subject {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
color String @default("#6366f1")
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tags Tag[]
|
||||
studyLogs StudyLog[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@map("subjects")
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
subjectId Int
|
||||
subject Subject @relation(fields: [subjectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
studyLogs StudyLog[]
|
||||
skillSnapshots SkillSnapshot[]
|
||||
|
||||
@@unique([subjectId, name])
|
||||
@@index([subjectId])
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
model StudyLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subjectId Int
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id])
|
||||
|
||||
title String
|
||||
difficulty Float
|
||||
baseCorrectRate Float?
|
||||
result StudyResult
|
||||
memo String? @db.Text
|
||||
studiedAt DateTime @default(now())
|
||||
timeSpent Int?
|
||||
|
||||
reviewSchedules ReviewSchedule[]
|
||||
|
||||
@@index([userId, studiedAt])
|
||||
@@index([subjectId])
|
||||
@@index([tagId])
|
||||
@@map("study_logs")
|
||||
}
|
||||
|
||||
model ReviewSchedule {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
studyLogId Int
|
||||
studyLog StudyLog @relation(fields: [studyLogId], references: [id], onDelete: Cascade)
|
||||
|
||||
scheduledAt DateTime
|
||||
reviewedAt DateTime?
|
||||
result StudyResult?
|
||||
iteration Int @default(0)
|
||||
predictedP Float?
|
||||
status ReviewStatus @default(pending)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, status, scheduledAt])
|
||||
@@index([studyLogId])
|
||||
@@map("review_schedules")
|
||||
}
|
||||
|
||||
model SkillSnapshot {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tagId Int
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
s0 Float
|
||||
lastUpdatedAt DateTime @default(now())
|
||||
sampleCount Int @default(0)
|
||||
|
||||
@@unique([userId, tagId])
|
||||
@@index([userId])
|
||||
@@map("skill_snapshots")
|
||||
}
|
||||
98
backend/prisma/seed.ts
Normal file
98
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Dev seed — creates a default demo user with 수능 과목/태그 preset.
|
||||
* Run via: `pnpm prisma:seed` (or `ts-node prisma/seed.ts`).
|
||||
*
|
||||
* Idempotent: re-running just upserts.
|
||||
*/
|
||||
|
||||
import { PrismaClient, Persona, ReviewIntensity } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEMO_EMAIL = 'demo@reloop.local';
|
||||
const DEMO_PASSWORD = 'demo1234';
|
||||
|
||||
interface SubjectSeed {
|
||||
name: string;
|
||||
color: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const SUBJECTS: SubjectSeed[] = [
|
||||
{
|
||||
name: '국어',
|
||||
color: '#ef4444',
|
||||
tags: ['문학', '독서(비문학)', '화법과작문', '언어와매체', '고전시가'],
|
||||
},
|
||||
{
|
||||
name: '수학',
|
||||
color: '#3b82f6',
|
||||
tags: ['미적분', '확률과통계', '기하', '수1 지수로그', '수1 삼각함수', '수2 미분', '수2 적분'],
|
||||
},
|
||||
{
|
||||
name: '영어',
|
||||
color: '#22c55e',
|
||||
tags: ['문법/어법', '어휘', '빈칸추론', '순서배열', '삽입', '주제/제목', '함축의미'],
|
||||
},
|
||||
{
|
||||
name: '사회탐구',
|
||||
color: '#f59e0b',
|
||||
tags: ['생활과윤리', '사회문화', '한국지리', '세계사'],
|
||||
},
|
||||
{
|
||||
name: '과학탐구',
|
||||
color: '#8b5cf6',
|
||||
tags: ['물리1', '화학1', '생명과학1', '지구과학1'],
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 ReLoop seed start');
|
||||
|
||||
// Demo user
|
||||
const hash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: DEMO_EMAIL },
|
||||
update: {},
|
||||
create: {
|
||||
email: DEMO_EMAIL,
|
||||
password: hash,
|
||||
nickname: '데모',
|
||||
persona: Persona.mid,
|
||||
currentGrade: 4,
|
||||
targetGrade: 2,
|
||||
reviewIntensity: ReviewIntensity.moderate,
|
||||
onboardedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(` user: ${user.email} (id=${user.id}) password=${DEMO_PASSWORD}`);
|
||||
|
||||
for (const s of SUBJECTS) {
|
||||
const subject = await prisma.subject.upsert({
|
||||
where: { userId_name: { userId: user.id, name: s.name } },
|
||||
update: { color: s.color },
|
||||
create: { name: s.name, color: s.color, userId: user.id },
|
||||
});
|
||||
console.log(` subject: ${subject.name}`);
|
||||
|
||||
for (const tagName of s.tags) {
|
||||
await prisma.tag.upsert({
|
||||
where: { subjectId_name: { subjectId: subject.id, name: tagName } },
|
||||
update: {},
|
||||
create: { name: tagName, subjectId: subject.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ seed done');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
33
backend/src/app.module.ts
Normal file
33
backend/src/app.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { MeModule } from './me/me.module';
|
||||
import { SubjectsModule } from './subjects/subjects.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { StudyLogsModule } from './study-logs/study-logs.module';
|
||||
import { ReviewsModule } from './reviews/reviews.module';
|
||||
import { ForgetModule } from './forget/forget.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { StatsModule } from './stats/stats.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
MeModule,
|
||||
SubjectsModule,
|
||||
TagsModule,
|
||||
StudyLogsModule,
|
||||
ReviewsModule,
|
||||
ForgetModule,
|
||||
DashboardModule,
|
||||
StatsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
33
backend/src/auth/auth.controller.ts
Normal file
33
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, RegisterDto } from './dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
import { AuthUser } from './jwt.strategy';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.auth.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user.id);
|
||||
}
|
||||
}
|
||||
25
backend/src/auth/auth.module.ts
Normal file
25
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) => ({
|
||||
secret: cfg.get<string>('JWT_SECRET') ?? 'dev-reloop-secret-change-me',
|
||||
signOptions: { expiresIn: cfg.get<string>('JWT_EXPIRES_IN') ?? '30d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
88
backend/src/auth/auth.service.ts
Normal file
88
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async register(input: { email: string; password: string; nickname: string }) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
if (existing) throw new ConflictException('already registered');
|
||||
|
||||
const hash = await bcrypt.hash(input.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
password: hash,
|
||||
nickname: input.nickname,
|
||||
},
|
||||
});
|
||||
const accessToken = this.sign(user.id, user.email);
|
||||
return { accessToken, user: this.safeUser(user) };
|
||||
}
|
||||
|
||||
async login(input: { email: string; password: string }) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('invalid credentials');
|
||||
|
||||
const ok = await bcrypt.compare(input.password, user.password);
|
||||
if (!ok) throw new UnauthorizedException('invalid credentials');
|
||||
|
||||
const accessToken = this.sign(user.id, user.email);
|
||||
return { accessToken, user: this.safeUser(user) };
|
||||
}
|
||||
|
||||
async me(userId: number) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
return this.safeUser(user);
|
||||
}
|
||||
|
||||
private sign(id: number, email: string): string {
|
||||
const payload: JwtPayload = { sub: id, email };
|
||||
return this.jwt.sign(payload);
|
||||
}
|
||||
|
||||
private safeUser(u: {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
persona: string;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
reviewIntensity: string;
|
||||
onboardedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nickname: u.nickname,
|
||||
persona: u.persona,
|
||||
currentGrade: u.currentGrade,
|
||||
targetGrade: u.targetGrade,
|
||||
reviewIntensity: u.reviewIntensity,
|
||||
onboarded: u.onboardedAt !== null,
|
||||
createdAt: u.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
backend/src/auth/current-user.decorator.ts
Normal file
9
backend/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthUser } from './jwt.strategy';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
return req.user as AuthUser;
|
||||
},
|
||||
);
|
||||
24
backend/src/auth/dto.ts
Normal file
24
backend/src/auth/dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(32)
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
5
backend/src/auth/jwt-auth.guard.ts
Normal file
5
backend/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
26
backend/src/auth/jwt.strategy.ts
Normal file
26
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from './auth.service';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(cfg: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey:
|
||||
cfg.get<string>('JWT_SECRET') ?? 'dev-reloop-secret-change-me',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): AuthUser {
|
||||
return { id: payload.sub, email: payload.email };
|
||||
}
|
||||
}
|
||||
16
backend/src/dashboard/dashboard.controller.ts
Normal file
16
backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Controller('dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DashboardController {
|
||||
constructor(private readonly svc: DashboardService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.svc.summary(user.id);
|
||||
}
|
||||
}
|
||||
9
backend/src/dashboard/dashboard.module.ts
Normal file
9
backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
101
backend/src/dashboard/dashboard.service.ts
Normal file
101
backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ReviewStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async summary(userId: number) {
|
||||
const now = new Date();
|
||||
const todayEnd = new Date(now);
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
const weekStart = new Date(now.getTime() - 6 * 86_400_000);
|
||||
|
||||
const [
|
||||
user,
|
||||
pendingNow,
|
||||
pendingSoon,
|
||||
recentLogs,
|
||||
weeklyLogs,
|
||||
topSkills,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.findUniqueOrThrow({ where: { id: userId } }),
|
||||
this.prisma.reviewSchedule.count({
|
||||
where: {
|
||||
userId,
|
||||
status: ReviewStatus.pending,
|
||||
scheduledAt: { lte: now },
|
||||
},
|
||||
}),
|
||||
this.prisma.reviewSchedule.count({
|
||||
where: {
|
||||
userId,
|
||||
status: ReviewStatus.pending,
|
||||
scheduledAt: { gt: now, lte: todayEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.studyLog.findMany({
|
||||
where: { userId },
|
||||
orderBy: { studiedAt: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.studyLog.groupBy({
|
||||
by: ['result'],
|
||||
where: {
|
||||
userId,
|
||||
studiedAt: { gte: weekStart },
|
||||
},
|
||||
_count: { result: true },
|
||||
}),
|
||||
this.prisma.skillSnapshot.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { s0: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
|
||||
const weekly = {
|
||||
correct: 0,
|
||||
incorrect: 0,
|
||||
partial: 0,
|
||||
};
|
||||
for (const row of weeklyLogs) {
|
||||
if (row.result === 'correct') weekly.correct = row._count.result;
|
||||
if (row.result === 'incorrect') weekly.incorrect = row._count.result;
|
||||
if (row.result === 'partial') weekly.partial = row._count.result;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
nickname: user.nickname,
|
||||
persona: user.persona,
|
||||
currentGrade: user.currentGrade,
|
||||
targetGrade: user.targetGrade,
|
||||
reviewIntensity: user.reviewIntensity,
|
||||
onboarded: user.onboardedAt !== null,
|
||||
},
|
||||
queue: {
|
||||
overdue: pendingNow,
|
||||
soon: pendingSoon,
|
||||
total: pendingNow + pendingSoon,
|
||||
},
|
||||
weekly,
|
||||
recentLogs,
|
||||
topSkills,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
backend/src/forget/forget.module.ts
Normal file
9
backend/src/forget/forget.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PersonaForgetService } from './persona-forget.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PersonaForgetService],
|
||||
exports: [PersonaForgetService],
|
||||
})
|
||||
export class ForgetModule {}
|
||||
211
backend/src/forget/persona-forget.service.spec.ts
Normal file
211
backend/src/forget/persona-forget.service.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { PersonaForgetService } from './persona-forget.service';
|
||||
import { Persona, ReviewIntensity, StudyResult } from '@prisma/client';
|
||||
|
||||
const NOW = new Date('2026-04-11T00:00:00Z');
|
||||
const T0 = new Date('2026-04-01T00:00:00Z');
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
describe('PersonaForgetService', () => {
|
||||
let svc: PersonaForgetService;
|
||||
|
||||
beforeEach(() => {
|
||||
svc = new PersonaForgetService();
|
||||
});
|
||||
|
||||
describe('updateS0', () => {
|
||||
it('correct pushes low s0 upward', () => {
|
||||
expect(svc.updateS0({ previousS0: 0.2, result: 'correct' })).toBeCloseTo(0.7);
|
||||
});
|
||||
it('incorrect halves s0', () => {
|
||||
expect(svc.updateS0({ previousS0: 0.8, result: 'incorrect' })).toBeCloseTo(0.4);
|
||||
});
|
||||
it('partial gives a moderate boost', () => {
|
||||
expect(svc.updateS0({ previousS0: 0.4, result: 'partial' })).toBeCloseTo(0.58);
|
||||
});
|
||||
it('correct from 0 goes to 0.6', () => {
|
||||
expect(svc.updateS0({ previousS0: 0, result: 'correct' })).toBeCloseTo(0.6);
|
||||
});
|
||||
it('clamps to [0,1]', () => {
|
||||
expect(svc.updateS0({ previousS0: 1, result: 'correct' })).toBeCloseTo(1);
|
||||
expect(svc.updateS0({ previousS0: 0, result: 'incorrect' })).toBeCloseTo(0);
|
||||
});
|
||||
it('null prev uses initial default 0.3', () => {
|
||||
const v = svc.updateS0({ previousS0: null, result: 'correct' });
|
||||
expect(v).toBeCloseTo(0.3 * 0.5 + 0.6); // 0.75
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule', () => {
|
||||
it('higher λ → sooner review (crammer earlier than senior)', () => {
|
||||
const base = {
|
||||
s0: 0.9,
|
||||
intensity: 'moderate' as ReviewIntensity,
|
||||
difficulty: 0.4,
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
};
|
||||
const senior = svc.schedule({ ...base, persona: 'senior' as Persona });
|
||||
const crammer = svc.schedule({ ...base, persona: 'crammer' as Persona });
|
||||
expect(crammer.scheduledAt.getTime()).toBeLessThan(senior.scheduledAt.getTime());
|
||||
});
|
||||
|
||||
it('stricter intensity → sooner review', () => {
|
||||
const base = {
|
||||
s0: 0.9,
|
||||
persona: 'mid' as Persona,
|
||||
difficulty: 0.4,
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
};
|
||||
const strict = svc.schedule({ ...base, intensity: 'strict' as ReviewIntensity });
|
||||
const moderate = svc.schedule({ ...base, intensity: 'moderate' as ReviewIntensity });
|
||||
const relaxed = svc.schedule({ ...base, intensity: 'relaxed' as ReviewIntensity });
|
||||
expect(strict.scheduledAt.getTime()).toBeLessThan(moderate.scheduledAt.getTime());
|
||||
expect(moderate.scheduledAt.getTime()).toBeLessThan(relaxed.scheduledAt.getTime());
|
||||
});
|
||||
|
||||
it('harder difficulty → sooner review', () => {
|
||||
const base = {
|
||||
s0: 0.9,
|
||||
persona: 'mid' as Persona,
|
||||
intensity: 'moderate' as ReviewIntensity,
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
};
|
||||
const easy = svc.schedule({ ...base, difficulty: 0.3 });
|
||||
const hard = svc.schedule({ ...base, difficulty: 0.7 });
|
||||
expect(hard.scheduledAt.getTime()).toBeLessThan(easy.scheduledAt.getTime());
|
||||
});
|
||||
|
||||
it('s0=0 schedules immediate review (within 2h)', () => {
|
||||
const r = svc.schedule({
|
||||
s0: 0,
|
||||
persona: 'mid',
|
||||
intensity: 'moderate',
|
||||
difficulty: 0.5,
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
});
|
||||
expect(r.scheduledAt.getTime() - NOW.getTime()).toBeLessThanOrEqual(2 * HOUR);
|
||||
});
|
||||
|
||||
it('very easy problem + high s0 → capped at 60 days', () => {
|
||||
const r = svc.schedule({
|
||||
s0: 0.99,
|
||||
persona: 'senior',
|
||||
intensity: 'relaxed',
|
||||
difficulty: 0.05,
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
});
|
||||
const diff = r.scheduledAt.getTime() - T0.getTime();
|
||||
expect(diff).toBeLessThanOrEqual(60 * DAY + HOUR); // cap + epsilon
|
||||
});
|
||||
|
||||
it('already-below-threshold schedules within 2h', () => {
|
||||
const r = svc.schedule({
|
||||
s0: 0.3,
|
||||
persona: 'junior',
|
||||
intensity: 'strict', // threshold 0.7
|
||||
difficulty: 0.5, // P = σ(4·(0.3-0.5))=σ(-0.8)≈0.31 → below 0.7
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
});
|
||||
expect(r.scheduledAt.getTime() - NOW.getTime()).toBeLessThanOrEqual(2 * HOUR);
|
||||
});
|
||||
|
||||
it('predictedP is at or below threshold', () => {
|
||||
const r = svc.schedule({
|
||||
s0: 0.9,
|
||||
persona: 'mid',
|
||||
intensity: 'moderate',
|
||||
difficulty: 0.4,
|
||||
lastUpdatedAt: T0,
|
||||
now: NOW,
|
||||
});
|
||||
expect(r.predictedP).toBeLessThanOrEqual(0.5 + 1e-6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('predictP', () => {
|
||||
it('decays over time', () => {
|
||||
const args = {
|
||||
s0: 1,
|
||||
persona: 'mid' as Persona,
|
||||
difficulty: 0.5,
|
||||
lastUpdatedAt: T0,
|
||||
};
|
||||
const p0 = svc.predictP({ ...args, at: T0 });
|
||||
const p1d = svc.predictP({ ...args, at: new Date(T0.getTime() + 1 * DAY) });
|
||||
const p10d = svc.predictP({ ...args, at: new Date(T0.getTime() + 10 * DAY) });
|
||||
expect(p0).toBeGreaterThan(p1d);
|
||||
expect(p1d).toBeGreaterThan(p10d);
|
||||
});
|
||||
|
||||
it('at t=0, P equals sigmoid(k(s0-D))', () => {
|
||||
const p = svc.predictP({
|
||||
s0: 0.6,
|
||||
persona: 'mid',
|
||||
difficulty: 0.4,
|
||||
lastUpdatedAt: T0,
|
||||
at: T0,
|
||||
});
|
||||
const expected = svc.sigmoid(4 * (0.6 - 0.4));
|
||||
expect(p).toBeCloseTo(expected, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampleCurve', () => {
|
||||
it('returns steps+1 points that monotonically decrease', () => {
|
||||
const points = svc.sampleCurve({
|
||||
s0: 0.9,
|
||||
persona: 'mid',
|
||||
difficulty: 0.5,
|
||||
lastUpdatedAt: T0,
|
||||
days: 30,
|
||||
steps: 30,
|
||||
});
|
||||
expect(points.length).toBe(31);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
expect(points[i].s).toBeLessThanOrEqual(points[i - 1].s);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sigmoid', () => {
|
||||
it('σ(0) = 0.5', () => {
|
||||
expect(svc.sigmoid(0)).toBeCloseTo(0.5);
|
||||
});
|
||||
it('σ(+∞) → 1', () => {
|
||||
expect(svc.sigmoid(100)).toBeCloseTo(1);
|
||||
});
|
||||
it('σ(-∞) → 0', () => {
|
||||
expect(svc.sigmoid(-100)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration-ish: StudyResult sequence should yield a sensible trajectory.
|
||||
*/
|
||||
describe('PersonaForgetService — end-to-end sanity', () => {
|
||||
it('repeated correct reviews grow s0 toward 1', () => {
|
||||
const svc = new PersonaForgetService();
|
||||
let s0: number = 0.3;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s0 = svc.updateS0({ previousS0: s0, result: 'correct' });
|
||||
}
|
||||
expect(s0).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it('repeated incorrect drags s0 toward 0', () => {
|
||||
const svc = new PersonaForgetService();
|
||||
let s0: number = 0.8;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s0 = svc.updateS0({ previousS0: s0, result: 'incorrect' });
|
||||
}
|
||||
expect(s0).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
200
backend/src/forget/persona-forget.service.ts
Normal file
200
backend/src/forget/persona-forget.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Persona, ReviewIntensity, StudyResult } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Persona-based forgetting curve.
|
||||
*
|
||||
* S(t) = S₀ · exp(-λ · Δt_days) // 실력
|
||||
* P(correct) = σ(k · (S(t) - D)) // 정답 확률
|
||||
* σ(x) = 1 / (1 + exp(-x)) // sigmoid
|
||||
*
|
||||
* 변수
|
||||
* S₀ : 0..1 마지막 업데이트 시점의 태그 실력
|
||||
* λ (lambda) : 페르소나별 망각 속도 (day⁻¹)
|
||||
* D : 0..1 문제 난이도 (baseCorrectRate 있으면 1-baseCorrectRate)
|
||||
* k : sigmoid 가파름 상수 (기본 4.0)
|
||||
* P_threshold : reviewIntensity 에 따라 결정되는 복습 trigger 기준
|
||||
*
|
||||
* 스케줄링
|
||||
* "P(correct) 가 P_threshold 아래로 떨어지는 시점" 을 next review 로 삼는다.
|
||||
* 방정식을 t 에 대해 풀면:
|
||||
*
|
||||
* σ(k(S₀·e^(-λt) - D)) = P_threshold
|
||||
* ⇒ k(S₀·e^(-λt) - D) = logit(P_threshold)
|
||||
* ⇒ S₀·e^(-λt) = D + logit(P_threshold) / k
|
||||
* ⇒ t = -(1/λ) · ln( (D + logit(P_threshold)/k) / S₀ )
|
||||
*
|
||||
* 경계 조건
|
||||
* • t < 0 (이미 threshold 이하) → 지금 당장 복습, 1 시간 후
|
||||
* • target(= D + logit/k) ≤ 0 → 난이도가 0 이하라 영원히 안 까먹음 → 60 일 cap
|
||||
* • target > S₀ → 이미 threshold 이하 (로그 도메인 오류) → 1 시간 후
|
||||
* • 계산된 t > 60 일 → 60 일 cap (무한 대기 방지)
|
||||
*/
|
||||
|
||||
export const PERSONA_LAMBDA: Record<Persona, number> = {
|
||||
senior: 0.1,
|
||||
mid: 0.2,
|
||||
junior: 0.4,
|
||||
crammer: 0.6,
|
||||
};
|
||||
|
||||
export const INTENSITY_THRESHOLD: Record<ReviewIntensity, number> = {
|
||||
strict: 0.7,
|
||||
moderate: 0.5,
|
||||
relaxed: 0.35,
|
||||
};
|
||||
|
||||
export const DEFAULT_K = 4.0;
|
||||
export const DEFAULT_INITIAL_S0 = 0.3;
|
||||
export const MAX_INTERVAL_DAYS = 60;
|
||||
|
||||
export interface UpdateInput {
|
||||
previousS0: number | null;
|
||||
result: StudyResult;
|
||||
}
|
||||
|
||||
export interface ScheduleInput {
|
||||
s0: number;
|
||||
persona: Persona;
|
||||
intensity: ReviewIntensity;
|
||||
difficulty: number;
|
||||
lastUpdatedAt: Date;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface ScheduleOutput {
|
||||
scheduledAt: Date;
|
||||
predictedP: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PersonaForgetService {
|
||||
readonly k = DEFAULT_K;
|
||||
|
||||
/**
|
||||
* Apply a study result to the previous S₀ and return the updated value.
|
||||
* Clamped to [0, 1].
|
||||
*/
|
||||
updateS0(input: UpdateInput): number {
|
||||
const prev = input.previousS0 ?? DEFAULT_INITIAL_S0;
|
||||
switch (input.result) {
|
||||
case 'correct':
|
||||
return clamp(prev * 0.5 + 0.6, 0, 1);
|
||||
case 'partial':
|
||||
return clamp(prev * 0.7 + 0.3, 0, 1);
|
||||
case 'incorrect':
|
||||
return clamp(prev * 0.5, 0, 1);
|
||||
default:
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next scheduled review time at which P(correct) hits the
|
||||
* persona's threshold.
|
||||
*/
|
||||
schedule(input: ScheduleInput): ScheduleOutput {
|
||||
const lambda = PERSONA_LAMBDA[input.persona];
|
||||
const pThreshold = INTENSITY_THRESHOLD[input.intensity];
|
||||
const now = input.now ?? new Date();
|
||||
const { s0, difficulty } = input;
|
||||
|
||||
// ── degenerate cases ─────────────────────────────────────────
|
||||
if (s0 <= 0) {
|
||||
// 실력이 0 이면 이미 모르는 상태 → 즉시 복습 (1 시간 후)
|
||||
return {
|
||||
scheduledAt: addHours(now, 1),
|
||||
predictedP: this.sigmoid(this.k * (s0 - difficulty)),
|
||||
};
|
||||
}
|
||||
|
||||
const logitThreshold = logit(pThreshold);
|
||||
const target = difficulty + logitThreshold / this.k;
|
||||
|
||||
if (target <= 0) {
|
||||
// 난이도가 워낙 쉬워서 P(correct) 가 영원히 threshold 이상 → cap
|
||||
return {
|
||||
scheduledAt: addDays(now, MAX_INTERVAL_DAYS),
|
||||
predictedP: pThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
if (target >= s0) {
|
||||
// 이미 threshold 이하 → 바로 복습 (1 시간 버퍼)
|
||||
return {
|
||||
scheduledAt: addHours(now, 1),
|
||||
predictedP: this.sigmoid(this.k * (s0 - difficulty)),
|
||||
};
|
||||
}
|
||||
|
||||
// 본 공식
|
||||
const tDays = -(1 / lambda) * Math.log(target / s0);
|
||||
const cappedDays = Math.min(tDays, MAX_INTERVAL_DAYS);
|
||||
return {
|
||||
scheduledAt: addDays(input.lastUpdatedAt, cappedDays),
|
||||
predictedP: pThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Current P(correct) at an arbitrary point in time (for charts / stats).
|
||||
*/
|
||||
predictP(opts: {
|
||||
s0: number;
|
||||
persona: Persona;
|
||||
difficulty: number;
|
||||
lastUpdatedAt: Date;
|
||||
at: Date;
|
||||
}): number {
|
||||
const lambda = PERSONA_LAMBDA[opts.persona];
|
||||
const dtDays = Math.max(
|
||||
0,
|
||||
(opts.at.getTime() - opts.lastUpdatedAt.getTime()) / 86_400_000,
|
||||
);
|
||||
const s = opts.s0 * Math.exp(-lambda * dtDays);
|
||||
return this.sigmoid(this.k * (s - opts.difficulty));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample S(t) over a range — used by /stats/forget-curve endpoint to
|
||||
* draw a line chart.
|
||||
*/
|
||||
sampleCurve(opts: {
|
||||
s0: number;
|
||||
persona: Persona;
|
||||
difficulty: number;
|
||||
lastUpdatedAt: Date;
|
||||
days: number;
|
||||
steps: number;
|
||||
}): Array<{ t: number; s: number; p: number }> {
|
||||
const lambda = PERSONA_LAMBDA[opts.persona];
|
||||
const points: Array<{ t: number; s: number; p: number }> = [];
|
||||
for (let i = 0; i <= opts.steps; i++) {
|
||||
const tDays = (i / opts.steps) * opts.days;
|
||||
const s = opts.s0 * Math.exp(-lambda * tDays);
|
||||
const p = this.sigmoid(this.k * (s - opts.difficulty));
|
||||
points.push({ t: tDays, s, p });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
sigmoid(x: number): number {
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
function logit(p: number): number {
|
||||
const eps = 1e-9;
|
||||
const q = Math.max(eps, Math.min(1 - eps, p));
|
||||
return Math.log(q / (1 - q));
|
||||
}
|
||||
function addDays(d: Date, days: number): Date {
|
||||
return new Date(d.getTime() + days * 86_400_000);
|
||||
}
|
||||
function addHours(d: Date, hours: number): Date {
|
||||
return new Date(d.getTime() + hours * 3_600_000);
|
||||
}
|
||||
9
backend/src/health/health.controller.ts
Normal file
9
backend/src/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
health() {
|
||||
return { status: 'ok', service: 'reloop-backend', version: '0.2.0' };
|
||||
}
|
||||
}
|
||||
5
backend/src/health/health.module.ts
Normal file
5
backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
40
backend/src/main.ts
Normal file
40
backend/src/main.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule, { cors: false });
|
||||
|
||||
// CORS — allow the frontend origin explicitly (covers prod + dev)
|
||||
const allowedOrigins = (
|
||||
process.env.CORS_ORIGINS ??
|
||||
'https://reloop.nabomhalang.co.kr,http://localhost:3000'
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const port = parseInt(process.env.PORT ?? '3001', 10);
|
||||
await app.listen(port);
|
||||
logger.log(`🚀 ReLoop API listening on :${port}`);
|
||||
logger.log(`📡 CORS origins: ${allowedOrigins.join(', ')}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
74
backend/src/me/me.controller.ts
Normal file
74
backend/src/me/me.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Persona, ReviewIntensity } from '@prisma/client';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { MeService } from './me.service';
|
||||
|
||||
class OnboardingDto {
|
||||
@IsEnum(Persona)
|
||||
persona: Persona;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(9)
|
||||
currentGrade?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(9)
|
||||
targetGrade?: number;
|
||||
|
||||
@IsEnum(ReviewIntensity)
|
||||
reviewIntensity: ReviewIntensity;
|
||||
}
|
||||
|
||||
class ProfilePatchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Persona)
|
||||
persona?: Persona;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(9)
|
||||
currentGrade?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(9)
|
||||
targetGrade?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ReviewIntensity)
|
||||
reviewIntensity?: ReviewIntensity;
|
||||
}
|
||||
|
||||
@Controller('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MeController {
|
||||
constructor(private readonly me: MeService) {}
|
||||
|
||||
@Patch('onboarding')
|
||||
onboarding(@CurrentUser() user: AuthUser, @Body() dto: OnboardingDto) {
|
||||
return this.me.updateOnboarding(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
profile(@CurrentUser() user: AuthUser, @Body() dto: ProfilePatchDto) {
|
||||
return this.me.updateProfile(user.id, dto);
|
||||
}
|
||||
}
|
||||
9
backend/src/me/me.module.ts
Normal file
9
backend/src/me/me.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeController } from './me.controller';
|
||||
import { MeService } from './me.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MeController],
|
||||
providers: [MeService],
|
||||
})
|
||||
export class MeModule {}
|
||||
70
backend/src/me/me.service.ts
Normal file
70
backend/src/me/me.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Persona, ReviewIntensity } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface OnboardingInput {
|
||||
persona: Persona;
|
||||
currentGrade?: number;
|
||||
targetGrade?: number;
|
||||
reviewIntensity: ReviewIntensity;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async updateOnboarding(userId: number, input: OnboardingInput) {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
persona: input.persona,
|
||||
currentGrade: input.currentGrade ?? null,
|
||||
targetGrade: input.targetGrade ?? null,
|
||||
reviewIntensity: input.reviewIntensity,
|
||||
onboardedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return this.toView(user);
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: number,
|
||||
input: Partial<{
|
||||
nickname: string;
|
||||
persona: Persona;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
reviewIntensity: ReviewIntensity;
|
||||
}>,
|
||||
) {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: input,
|
||||
});
|
||||
return this.toView(user);
|
||||
}
|
||||
|
||||
private toView(u: {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
persona: string;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
reviewIntensity: string;
|
||||
onboardedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nickname: u.nickname,
|
||||
persona: u.persona,
|
||||
currentGrade: u.currentGrade,
|
||||
targetGrade: u.targetGrade,
|
||||
reviewIntensity: u.reviewIntensity,
|
||||
onboarded: u.onboardedAt !== null,
|
||||
createdAt: u.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
backend/src/prisma/prisma.service.ts
Normal file
16
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
64
backend/src/reviews/reviews.controller.ts
Normal file
64
backend/src/reviews/reviews.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { StudyResult } from '@prisma/client';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
class SubmitDto {
|
||||
@IsEnum(StudyResult)
|
||||
result: StudyResult;
|
||||
}
|
||||
|
||||
class HistoryQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Controller('reviews')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewsController {
|
||||
constructor(private readonly svc: ReviewsService) {}
|
||||
|
||||
@Get('queue')
|
||||
queue(@CurrentUser() user: AuthUser) {
|
||||
return this.svc.queue(user.id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
submit(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: SubmitDto,
|
||||
) {
|
||||
return this.svc.submit(user.id, id, dto.result);
|
||||
}
|
||||
|
||||
@Post(':id/skip')
|
||||
skip(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.svc.skip(user.id, id);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
history(@CurrentUser() user: AuthUser, @Query() q: HistoryQuery) {
|
||||
return this.svc.history(user.id, q.limit);
|
||||
}
|
||||
}
|
||||
9
backend/src/reviews/reviews.module.ts
Normal file
9
backend/src/reviews/reviews.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReviewsController } from './reviews.controller';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReviewsController],
|
||||
providers: [ReviewsService],
|
||||
})
|
||||
export class ReviewsModule {}
|
||||
165
backend/src/reviews/reviews.service.ts
Normal file
165
backend/src/reviews/reviews.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { StudyResult, ReviewStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly forget: PersonaForgetService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue of reviews that should be done around "now". Includes anything
|
||||
* whose scheduledAt is in the past OR within the next 24h so the UI can
|
||||
* show "곧 해야 할 것들" too.
|
||||
*/
|
||||
async queue(userId: number) {
|
||||
const now = new Date();
|
||||
const soon = new Date(now.getTime() + 24 * 3_600_000);
|
||||
|
||||
return this.prisma.reviewSchedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: ReviewStatus.pending,
|
||||
scheduledAt: { lte: soon },
|
||||
},
|
||||
include: {
|
||||
studyLog: {
|
||||
include: {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async submit(userId: number, reviewId: number, result: StudyResult) {
|
||||
const review = await this.prisma.reviewSchedule.findUnique({
|
||||
where: { id: reviewId },
|
||||
include: {
|
||||
studyLog: { include: { subject: true } },
|
||||
},
|
||||
});
|
||||
if (!review) throw new NotFoundException();
|
||||
if (review.userId !== userId) throw new ForbiddenException();
|
||||
if (review.status !== 'pending') {
|
||||
throw new ForbiddenException('review already processed');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Update snapshot
|
||||
let newS0 = 0.3;
|
||||
if (review.studyLog.tagId) {
|
||||
const existing = await this.prisma.skillSnapshot.findUnique({
|
||||
where: {
|
||||
userId_tagId: { userId, tagId: review.studyLog.tagId },
|
||||
},
|
||||
});
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: existing?.s0 ?? null,
|
||||
result,
|
||||
});
|
||||
await this.prisma.skillSnapshot.upsert({
|
||||
where: {
|
||||
userId_tagId: { userId, tagId: review.studyLog.tagId },
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tagId: review.studyLog.tagId,
|
||||
s0: newS0,
|
||||
lastUpdatedAt: new Date(),
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0: newS0,
|
||||
lastUpdatedAt: new Date(),
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newS0 = this.forget.updateS0({ previousS0: null, result });
|
||||
}
|
||||
|
||||
// Compute the next schedule
|
||||
const D =
|
||||
review.studyLog.baseCorrectRate !== null &&
|
||||
review.studyLog.baseCorrectRate !== undefined
|
||||
? 1 - review.studyLog.baseCorrectRate
|
||||
: review.studyLog.difficulty;
|
||||
const next = this.forget.schedule({
|
||||
s0: newS0,
|
||||
persona: user.persona,
|
||||
intensity: user.reviewIntensity,
|
||||
difficulty: D,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Transaction: mark done + insert next
|
||||
const [updated, nextRow] = await this.prisma.$transaction([
|
||||
this.prisma.reviewSchedule.update({
|
||||
where: { id: reviewId },
|
||||
data: {
|
||||
status: 'done',
|
||||
reviewedAt: new Date(),
|
||||
result,
|
||||
},
|
||||
}),
|
||||
this.prisma.reviewSchedule.create({
|
||||
data: {
|
||||
userId,
|
||||
studyLogId: review.studyLogId,
|
||||
scheduledAt: next.scheduledAt,
|
||||
predictedP: next.predictedP,
|
||||
iteration: review.iteration + 1,
|
||||
status: 'pending',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { updated, nextReview: nextRow, s0: newS0 };
|
||||
}
|
||||
|
||||
async skip(userId: number, reviewId: number) {
|
||||
const review = await this.prisma.reviewSchedule.findUnique({
|
||||
where: { id: reviewId },
|
||||
});
|
||||
if (!review || review.userId !== userId) throw new NotFoundException();
|
||||
if (review.status !== 'pending') throw new ForbiddenException();
|
||||
|
||||
return this.prisma.reviewSchedule.update({
|
||||
where: { id: reviewId },
|
||||
data: { status: 'skipped' },
|
||||
});
|
||||
}
|
||||
|
||||
async history(userId: number, limit = 50) {
|
||||
return this.prisma.reviewSchedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { in: [ReviewStatus.done, ReviewStatus.skipped] },
|
||||
},
|
||||
include: {
|
||||
studyLog: {
|
||||
include: {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { reviewedAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
49
backend/src/stats/stats.controller.ts
Normal file
49
backend/src/stats/stats.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { StatsService } from './stats.service';
|
||||
|
||||
class ForgetCurveQuery {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(180)
|
||||
days?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(120)
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
@Controller('stats')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StatsController {
|
||||
constructor(private readonly svc: StatsService) {}
|
||||
|
||||
@Get('forget-curve')
|
||||
forgetCurve(@CurrentUser() user: AuthUser, @Query() q: ForgetCurveQuery) {
|
||||
return this.svc.forgetCurve(user.id, q.tagId, q.days, q.steps);
|
||||
}
|
||||
|
||||
@Get('subjects')
|
||||
subjects(@CurrentUser() user: AuthUser) {
|
||||
return this.svc.bySubject(user.id);
|
||||
}
|
||||
}
|
||||
9
backend/src/stats/stats.module.ts
Normal file
9
backend/src/stats/stats.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StatsController } from './stats.controller';
|
||||
import { StatsService } from './stats.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StatsController],
|
||||
providers: [StatsService],
|
||||
})
|
||||
export class StatsModule {}
|
||||
101
backend/src/stats/stats.service.ts
Normal file
101
backend/src/stats/stats.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
|
||||
@Injectable()
|
||||
export class StatsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly forget: PersonaForgetService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sample S(t) / P(correct) over the next N days for a given tag.
|
||||
* Used by the frontend to draw the forget curve chart.
|
||||
*/
|
||||
async forgetCurve(userId: number, tagId: number, days = 30, steps = 30) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
const snapshot = await this.prisma.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId } },
|
||||
include: {
|
||||
tag: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!snapshot) throw new NotFoundException('no snapshot for this tag');
|
||||
|
||||
// Use average difficulty from recent logs for this tag
|
||||
const recentLog = await this.prisma.studyLog.findFirst({
|
||||
where: { userId, tagId },
|
||||
orderBy: { studiedAt: 'desc' },
|
||||
});
|
||||
const D =
|
||||
recentLog?.baseCorrectRate !== null &&
|
||||
recentLog?.baseCorrectRate !== undefined
|
||||
? 1 - recentLog.baseCorrectRate
|
||||
: recentLog?.difficulty ?? 0.5;
|
||||
|
||||
const points = this.forget.sampleCurve({
|
||||
s0: snapshot.s0,
|
||||
persona: user.persona,
|
||||
difficulty: D,
|
||||
lastUpdatedAt: snapshot.lastUpdatedAt,
|
||||
days,
|
||||
steps,
|
||||
});
|
||||
|
||||
return {
|
||||
tag: snapshot.tag,
|
||||
snapshot: {
|
||||
s0: snapshot.s0,
|
||||
lastUpdatedAt: snapshot.lastUpdatedAt,
|
||||
sampleCount: snapshot.sampleCount,
|
||||
},
|
||||
persona: user.persona,
|
||||
intensity: user.reviewIntensity,
|
||||
difficulty: D,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
async bySubject(userId: number) {
|
||||
const subjects = await this.prisma.subject.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
skillSnapshots: { where: { userId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return subjects.map((s) => {
|
||||
const tagSkills = s.tags.map((t) => ({
|
||||
tagId: t.id,
|
||||
name: t.name,
|
||||
s0: t.skillSnapshots[0]?.s0 ?? null,
|
||||
sampleCount: t.skillSnapshots[0]?.sampleCount ?? 0,
|
||||
}));
|
||||
const withData = tagSkills.filter((t) => t.s0 !== null);
|
||||
const avgS0 =
|
||||
withData.length === 0
|
||||
? null
|
||||
: withData.reduce((acc, t) => acc + (t.s0 ?? 0), 0) / withData.length;
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
avgS0,
|
||||
tags: tagSkills,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
106
backend/src/study-logs/study-logs.controller.ts
Normal file
106
backend/src/study-logs/study-logs.controller.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { StudyResult } from '@prisma/client';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { StudyLogsService } from './study-logs.service';
|
||||
|
||||
class CreateStudyLogDto {
|
||||
@IsInt()
|
||||
subjectId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
difficulty: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
baseCorrectRate?: number;
|
||||
|
||||
@IsEnum(StudyResult)
|
||||
result: StudyResult;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
class ListStudyLogQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
subjectId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Controller('study-logs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StudyLogsController {
|
||||
constructor(private readonly svc: StudyLogsService) {}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStudyLogDto) {
|
||||
return this.svc.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser, @Query() q: ListStudyLogQuery) {
|
||||
return this.svc.list(user.id, q);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
one(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
}
|
||||
9
backend/src/study-logs/study-logs.module.ts
Normal file
9
backend/src/study-logs/study-logs.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StudyLogsController } from './study-logs.controller';
|
||||
import { StudyLogsService } from './study-logs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StudyLogsController],
|
||||
providers: [StudyLogsService],
|
||||
})
|
||||
export class StudyLogsModule {}
|
||||
173
backend/src/study-logs/study-logs.service.ts
Normal file
173
backend/src/study-logs/study-logs.service.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { StudyResult } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
|
||||
export interface CreateStudyLogInput {
|
||||
subjectId: number;
|
||||
tagId?: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
baseCorrectRate?: number | null;
|
||||
result: StudyResult;
|
||||
memo?: string;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating a study log has two side-effects:
|
||||
* 1. Update (or create) the SkillSnapshot for (user, tag).
|
||||
* 2. Create a ReviewSchedule row at the time P(correct) drops below
|
||||
* the persona's threshold.
|
||||
*
|
||||
* Both happen in a single transaction so a failure leaves no orphan
|
||||
* schedule or stale snapshot.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StudyLogsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly forget: PersonaForgetService,
|
||||
) {}
|
||||
|
||||
async create(userId: number, input: CreateStudyLogInput) {
|
||||
// ── ownership checks ──
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id: input.subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new ForbiddenException('subject');
|
||||
|
||||
if (input.tagId) {
|
||||
const tag = await this.prisma.tag.findFirst({
|
||||
where: { id: input.tagId, subjectId: input.subjectId },
|
||||
});
|
||||
if (!tag) throw new NotFoundException('tag');
|
||||
}
|
||||
|
||||
// ── persona / intensity ──
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Difficulty: user provides 0..1; if baseCorrectRate given, blend.
|
||||
const D =
|
||||
input.baseCorrectRate !== undefined && input.baseCorrectRate !== null
|
||||
? 1 - input.baseCorrectRate
|
||||
: clamp01(input.difficulty);
|
||||
|
||||
// ── transaction ──
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 1. create the study log
|
||||
const log = await tx.studyLog.create({
|
||||
data: {
|
||||
userId,
|
||||
subjectId: input.subjectId,
|
||||
tagId: input.tagId ?? null,
|
||||
title: input.title,
|
||||
difficulty: clamp01(input.difficulty),
|
||||
baseCorrectRate: input.baseCorrectRate ?? null,
|
||||
result: input.result,
|
||||
memo: input.memo ?? null,
|
||||
timeSpent: input.timeSpent ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. upsert skill snapshot (if tagId present)
|
||||
let s0 = 0.3;
|
||||
if (input.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
});
|
||||
const prevS0 = existing?.s0 ?? null;
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: prevS0,
|
||||
result: input.result,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
create: {
|
||||
userId,
|
||||
tagId: input.tagId,
|
||||
s0,
|
||||
lastUpdatedAt: new Date(),
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0,
|
||||
lastUpdatedAt: new Date(),
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
s0 = this.forget.updateS0({ previousS0: null, result: input.result });
|
||||
}
|
||||
|
||||
// 3. schedule next review
|
||||
const schedule = this.forget.schedule({
|
||||
s0,
|
||||
persona: user.persona,
|
||||
intensity: user.reviewIntensity,
|
||||
difficulty: D,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
|
||||
const reviewRow = await tx.reviewSchedule.create({
|
||||
data: {
|
||||
userId,
|
||||
studyLogId: log.id,
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
predictedP: schedule.predictedP,
|
||||
iteration: 0,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
return { studyLog: log, nextReview: reviewRow, s0 };
|
||||
});
|
||||
}
|
||||
|
||||
list(
|
||||
userId: number,
|
||||
opts: { subjectId?: number; tagId?: number; limit?: number; offset?: number },
|
||||
) {
|
||||
return this.prisma.studyLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(opts.subjectId && { subjectId: opts.subjectId }),
|
||||
...(opts.tagId && { tagId: opts.tagId }),
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
reviewSchedules: {
|
||||
orderBy: { scheduledAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: { studiedAt: 'desc' },
|
||||
take: opts.limit ?? 50,
|
||||
skip: opts.offset ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
async getOne(userId: number, id: number) {
|
||||
const log = await this.prisma.studyLog.findFirst({
|
||||
where: { id, userId },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
reviewSchedules: { orderBy: { scheduledAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!log) throw new NotFoundException();
|
||||
return log;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
74
backend/src/subjects/subjects.controller.ts
Normal file
74
backend/src/subjects/subjects.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { SubjectsService } from './subjects.service';
|
||||
|
||||
class CreateSubjectDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
}
|
||||
|
||||
class UpdateSubjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
}
|
||||
|
||||
@Controller('subjects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SubjectsController {
|
||||
constructor(private readonly svc: SubjectsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.svc.list(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
one(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateSubjectDto) {
|
||||
return this.svc.create(user.id, dto.name, dto.color ?? '#6366f1');
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateSubjectDto,
|
||||
) {
|
||||
return this.svc.update(user.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.svc.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
9
backend/src/subjects/subjects.module.ts
Normal file
9
backend/src/subjects/subjects.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SubjectsController } from './subjects.controller';
|
||||
import { SubjectsService } from './subjects.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SubjectsController],
|
||||
providers: [SubjectsService],
|
||||
})
|
||||
export class SubjectsModule {}
|
||||
43
backend/src/subjects/subjects.service.ts
Normal file
43
backend/src/subjects/subjects.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SubjectsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(userId: number) {
|
||||
return this.prisma.subject.findMany({
|
||||
where: { userId },
|
||||
include: { tags: { orderBy: { name: 'asc' } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getOne(userId: number, id: number) {
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id, userId },
|
||||
include: { tags: { orderBy: { name: 'asc' } } },
|
||||
});
|
||||
if (!subject) throw new NotFoundException();
|
||||
return subject;
|
||||
}
|
||||
|
||||
create(userId: number, name: string, color: string) {
|
||||
return this.prisma.subject.create({
|
||||
data: { userId, name, color },
|
||||
});
|
||||
}
|
||||
|
||||
async update(userId: number, id: number, data: { name?: string; color?: string }) {
|
||||
const owned = await this.prisma.subject.findFirst({ where: { id, userId } });
|
||||
if (!owned) throw new NotFoundException();
|
||||
return this.prisma.subject.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async remove(userId: number, id: number) {
|
||||
const owned = await this.prisma.subject.findFirst({ where: { id, userId } });
|
||||
if (!owned) throw new NotFoundException();
|
||||
await this.prisma.subject.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
52
backend/src/tags/tags.controller.ts
Normal file
52
backend/src/tags/tags.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsInt, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
class CreateTagDto {
|
||||
@IsInt()
|
||||
subjectId: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Controller('tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagsController {
|
||||
constructor(private readonly svc: TagsService) {}
|
||||
|
||||
@Get('by-subject/:subjectId')
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('subjectId', ParseIntPipe) subjectId: number,
|
||||
) {
|
||||
return this.svc.listForSubject(user.id, subjectId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateTagDto) {
|
||||
return this.svc.create(user.id, dto.subjectId, dto.name);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.svc.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
9
backend/src/tags/tags.module.ts
Normal file
9
backend/src/tags/tags.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
36
backend/src/tags/tags.service.ts
Normal file
36
backend/src/tags/tags.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listForSubject(userId: number, subjectId: number) {
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id: subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new NotFoundException('subject');
|
||||
return this.prisma.tag.findMany({
|
||||
where: { subjectId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: number, subjectId: number, name: string) {
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id: subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new ForbiddenException();
|
||||
return this.prisma.tag.create({ data: { subjectId, name } });
|
||||
}
|
||||
|
||||
async remove(userId: number, id: number) {
|
||||
const tag = await this.prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: { subject: true },
|
||||
});
|
||||
if (!tag || tag.subject.userId !== userId) throw new NotFoundException();
|
||||
await this.prisma.tag.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
24
backend/tsconfig.json
Normal file
24
backend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
2
frontend/next-env.d.ts
vendored
Normal file
2
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
9
frontend/next.config.js
Normal file
9
frontend/next.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
compiler: {
|
||||
styledComponents: true,
|
||||
},
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "reloop-frontend",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\" --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"recharts": "^2.12.7",
|
||||
"styled-components": "^6.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5.1.34",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
377
frontend/src/app/dashboard/page.tsx
Normal file
377
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type DashboardSummary } from '@/lib/api';
|
||||
import { PERSONA_META } from '@/lib/constants';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
SectionTitle,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<DashboardBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardBody() {
|
||||
const [data, setData] = useState<DashboardSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DashboardSummary>('/dashboard/summary').then((r) => setData(r.data));
|
||||
}, []);
|
||||
|
||||
if (!data) return <Loading>로딩 중...</Loading>;
|
||||
|
||||
const persona = PERSONA_META[data.user.persona];
|
||||
const totalWeek = data.weekly.correct + data.weekly.incorrect + data.weekly.partial;
|
||||
const correctRate = totalWeek === 0 ? 0 : data.weekly.correct / totalWeek;
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Greeting>
|
||||
<HiEmoji>{persona.emoji}</HiEmoji>
|
||||
<div>
|
||||
<HelloText>
|
||||
{data.user.nickname}, 오늘도 복습 시작해볼까?
|
||||
</HelloText>
|
||||
<PersonaLine>
|
||||
<PersonaTag $color={persona.color}>{persona.label}</PersonaTag>
|
||||
<MetaSep>·</MetaSep>
|
||||
<span>
|
||||
{data.user.currentGrade}등급 → {data.user.targetGrade}등급
|
||||
</span>
|
||||
</PersonaLine>
|
||||
</div>
|
||||
</Greeting>
|
||||
</Header>
|
||||
|
||||
{/* Queue cards */}
|
||||
<Grid3>
|
||||
<StatCard $accent={theme.color.danger}>
|
||||
<StatLabel>지금 복습할 것</StatLabel>
|
||||
<StatValue>{data.queue.overdue}</StatValue>
|
||||
<StatHint>지난 일정 포함</StatHint>
|
||||
</StatCard>
|
||||
<StatCard $accent={theme.color.warning}>
|
||||
<StatLabel>오늘 안에</StatLabel>
|
||||
<StatValue>{data.queue.soon}</StatValue>
|
||||
<StatHint>곧 떨어질 예정</StatHint>
|
||||
</StatCard>
|
||||
<StatCard $accent={theme.color.success}>
|
||||
<StatLabel>주간 정답률</StatLabel>
|
||||
<StatValue>{totalWeek === 0 ? '—' : `${Math.round(correctRate * 100)}%`}</StatValue>
|
||||
<StatHint>{totalWeek}문제 풀었어</StatHint>
|
||||
</StatCard>
|
||||
</Grid3>
|
||||
|
||||
{/* CTA */}
|
||||
<CTACard>
|
||||
<CTALeft>
|
||||
<CTATitle>
|
||||
{data.queue.total > 0
|
||||
? `복습할 문제가 ${data.queue.total}개 있어`
|
||||
: '오늘은 복습할 게 없어. 새 문제 기록해 볼래?'}
|
||||
</CTATitle>
|
||||
<CTASub>
|
||||
{data.queue.total > 0
|
||||
? '오래된 것부터 순서대로 풀어봐.'
|
||||
: '학습 기록을 추가하면 자동으로 다음 복습이 잡혀.'}
|
||||
</CTASub>
|
||||
</CTALeft>
|
||||
<CTARight>
|
||||
{data.queue.total > 0 ? (
|
||||
<Link href="/review">
|
||||
<Button $size="lg">복습 시작 →</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/study">
|
||||
<Button $size="lg">학습 기록 +</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CTARight>
|
||||
</CTACard>
|
||||
|
||||
{/* Top skills */}
|
||||
{data.topSkills.length > 0 && (
|
||||
<Section>
|
||||
<SectionTitle>실력 상위 영역</SectionTitle>
|
||||
<SkillGrid>
|
||||
{data.topSkills.map((s) => (
|
||||
<SkillCard key={s.id}>
|
||||
<SkillHeader>
|
||||
<SubjectDot $color={s.tag.subject.color} />
|
||||
<SkillSubject>{s.tag.subject.name}</SkillSubject>
|
||||
</SkillHeader>
|
||||
<SkillTag>{s.tag.name}</SkillTag>
|
||||
<SkillBar>
|
||||
<SkillFill style={{ width: `${Math.round(s.s0 * 100)}%` }} />
|
||||
</SkillBar>
|
||||
<SkillMeta>
|
||||
<span>{Math.round(s.s0 * 100)}%</span>
|
||||
<span>{s.sampleCount}회</span>
|
||||
</SkillMeta>
|
||||
</SkillCard>
|
||||
))}
|
||||
</SkillGrid>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Recent logs */}
|
||||
{data.recentLogs.length > 0 && (
|
||||
<Section>
|
||||
<SectionTitle>최근 학습 기록</SectionTitle>
|
||||
<Stack $gap={theme.space.sm}>
|
||||
{data.recentLogs.map((l) => (
|
||||
<RecentRow key={l.id}>
|
||||
<SubjectDot $color={l.subject?.color ?? '#6366f1'} />
|
||||
<RecentMain>
|
||||
<RecentTitle>{l.title}</RecentTitle>
|
||||
<RecentMeta>
|
||||
{l.subject?.name} {l.tag && `· ${l.tag.name}`}
|
||||
</RecentMeta>
|
||||
</RecentMain>
|
||||
<ResultBadge $result={l.result}>
|
||||
{l.result === 'correct' ? '정답' : l.result === 'partial' ? '부분' : '오답'}
|
||||
</ResultBadge>
|
||||
</RecentRow>
|
||||
))}
|
||||
</Stack>
|
||||
</Section>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
// ── styled ──
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.xl};
|
||||
`;
|
||||
|
||||
const Header = styled.header``;
|
||||
|
||||
const Greeting = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const HiEmoji = styled.div`
|
||||
font-size: 36px;
|
||||
`;
|
||||
|
||||
const HelloText = styled.h1`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const PersonaLine = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const PersonaTag = styled.span<{ $color: string }>`
|
||||
color: ${({ $color }) => $color};
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const MetaSep = styled.span`
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
|
||||
const Grid3 = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: ${theme.space.md};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const StatCard = styled(Card)<{ $accent: string }>`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: ${({ $accent }) => $accent};
|
||||
}
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const StatValue = styled.div`
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const StatHint = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const CTACard = styled(Card)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.space.lg};
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
${theme.color.surface} 0%,
|
||||
${theme.color.surface2} 100%
|
||||
);
|
||||
border: 1px solid ${theme.color.accent}55;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
const CTALeft = styled.div``;
|
||||
const CTATitle = styled.h3`
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
const CTASub = styled.p`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
const CTARight = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Section = styled.section``;
|
||||
|
||||
const SkillGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const SkillCard = styled(Card)`
|
||||
padding: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const SkillHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const SubjectDot = styled.span<{ $color: string }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const SkillSubject = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
const SkillTag = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const SkillBar = styled.div`
|
||||
height: 6px;
|
||||
background: ${theme.color.surface2};
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const SkillFill = styled.div`
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, ${theme.color.accent}, ${theme.color.accent2});
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
const SkillMeta = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const RecentRow = styled(Card)`
|
||||
padding: ${theme.space.md};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const RecentMain = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const RecentTitle = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const RecentMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const ResultBadge = styled(Badge)<{ $result: string }>`
|
||||
${({ $result }) => {
|
||||
if ($result === 'correct') return `background: rgba(34,197,94,0.15); color: ${theme.color.success}; border-color: rgba(34,197,94,0.35);`;
|
||||
if ($result === 'partial') return `background: rgba(245,158,11,0.15); color: ${theme.color.warning}; border-color: rgba(245,158,11,0.35);`;
|
||||
return `background: rgba(239,68,68,0.15); color: ${theme.color.danger}; border-color: rgba(239,68,68,0.35);`;
|
||||
}}
|
||||
`;
|
||||
31
frontend/src/app/layout.tsx
Normal file
31
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import React from 'react';
|
||||
import StyledComponentsRegistry from '@/styles/registry';
|
||||
import GlobalStyle from '@/styles/GlobalStyle';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ReLoop — 적응형 복습 스케줄링',
|
||||
description: '페르소나 망각 곡선 기반 수능 학습 최적화 플랫폼',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
themeColor: '#0b1020',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>
|
||||
<StyledComponentsRegistry>
|
||||
<GlobalStyle />
|
||||
{children}
|
||||
</StyledComponentsRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
140
frontend/src/app/login/page.tsx
Normal file
140
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api } from '@/lib/api';
|
||||
import { setToken } from '@/lib/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.post('/auth/login', { email, password });
|
||||
setToken(r.data.accessToken);
|
||||
router.replace(r.data.user.onboarded ? '/dashboard' : '/onboarding');
|
||||
} catch (e) {
|
||||
setErr('이메일 또는 비밀번호가 맞지 않아');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Hero>
|
||||
<HeroTitle>ReLoop</HeroTitle>
|
||||
<HeroSub>적응형 복습 스케줄링 · 페르소나 망각 곡선 기반</HeroSub>
|
||||
</Hero>
|
||||
<FormCard>
|
||||
<FormTitle>로그인</FormTitle>
|
||||
<form onSubmit={submit}>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>비밀번호</Label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<Button type="submit" $block $size="lg" disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<SubRow>
|
||||
<span>계정이 없어?</span>
|
||||
<Link href="/register">회원가입</Link>
|
||||
</SubRow>
|
||||
</FormCard>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${theme.space.lg};
|
||||
gap: ${theme.space.xl};
|
||||
`;
|
||||
|
||||
const Hero = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HeroTitle = styled.h1`
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, #818cf8, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const HeroSub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const FormCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const FormTitle = styled.h2`
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const SubRow = styled.div`
|
||||
margin-top: ${theme.space.lg};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
|
||||
a {
|
||||
color: ${theme.color.accent};
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
295
frontend/src/app/onboarding/page.tsx
Normal file
295
frontend/src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme, PERSONA_ORDER } from '@/styles/theme';
|
||||
import { api, type Persona, type ReviewIntensity } from '@/lib/api';
|
||||
import { hasToken } from '@/lib/auth';
|
||||
import { PERSONA_META, INTENSITY_META } from '@/lib/constants';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Select,
|
||||
Label,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
type Step = 1 | 2 | 3;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [currentGrade, setCurrentGrade] = useState<number>(4);
|
||||
const [targetGrade, setTargetGrade] = useState<number>(2);
|
||||
const [persona, setPersona] = useState<Persona>('mid');
|
||||
const [intensity, setIntensity] = useState<ReviewIntensity>('moderate');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasToken()) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const finish = async () => {
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.patch('/me/onboarding', {
|
||||
currentGrade,
|
||||
targetGrade,
|
||||
persona,
|
||||
reviewIntensity: intensity,
|
||||
});
|
||||
router.replace('/dashboard');
|
||||
} catch (e) {
|
||||
setErr('저장에 실패했어. 다시 시도해 줘.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Progress>
|
||||
<Dot $active={step >= 1} />
|
||||
<Dot $active={step >= 2} />
|
||||
<Dot $active={step >= 3} />
|
||||
</Progress>
|
||||
|
||||
{step === 1 && (
|
||||
<StepCard>
|
||||
<StepTitle>목표 등급 설정</StepTitle>
|
||||
<StepSub>현재 등급과 목표 등급을 알려줘.</StepSub>
|
||||
<Stack $gap={theme.space.lg}>
|
||||
<div>
|
||||
<Label>현재 등급</Label>
|
||||
<Select
|
||||
value={currentGrade}
|
||||
onChange={(e) => setCurrentGrade(Number(e.target.value))}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}등급
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>목표 등급</Label>
|
||||
<Select
|
||||
value={targetGrade}
|
||||
onChange={(e) => setTargetGrade(Number(e.target.value))}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}등급
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Button $block $size="lg" onClick={() => setStep(2)}>
|
||||
다음
|
||||
</Button>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<StepCard>
|
||||
<StepTitle>학습 유형 선택</StepTitle>
|
||||
<StepSub>본인에게 가장 가까운 유형을 골라. 나중에 바꿀 수 있어.</StepSub>
|
||||
<PersonaGrid>
|
||||
{PERSONA_ORDER.map((key) => {
|
||||
const meta = PERSONA_META[key];
|
||||
const active = persona === key;
|
||||
return (
|
||||
<PersonaOption
|
||||
key={key}
|
||||
$active={active}
|
||||
$color={meta.color}
|
||||
onClick={() => setPersona(key)}
|
||||
>
|
||||
<PersonaEmoji>{meta.emoji}</PersonaEmoji>
|
||||
<PersonaLabel>{meta.label}</PersonaLabel>
|
||||
<PersonaDesc>{meta.desc}</PersonaDesc>
|
||||
</PersonaOption>
|
||||
);
|
||||
})}
|
||||
</PersonaGrid>
|
||||
<ButtonRow>
|
||||
<Button $variant="ghost" onClick={() => setStep(1)}>
|
||||
이전
|
||||
</Button>
|
||||
<Button $block $size="lg" onClick={() => setStep(3)}>
|
||||
다음
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</StepCard>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<StepCard>
|
||||
<StepTitle>복습 강도</StepTitle>
|
||||
<StepSub>얼마나 자주 복습할지 정해. 언제든 바꿀 수 있어.</StepSub>
|
||||
<IntensityGrid>
|
||||
{(['strict', 'moderate', 'relaxed'] as ReviewIntensity[]).map((key) => {
|
||||
const meta = INTENSITY_META[key];
|
||||
const active = intensity === key;
|
||||
return (
|
||||
<IntensityOption
|
||||
key={key}
|
||||
$active={active}
|
||||
onClick={() => setIntensity(key)}
|
||||
>
|
||||
<IntensityLabel>{meta.label}</IntensityLabel>
|
||||
<IntensityDesc>{meta.desc}</IntensityDesc>
|
||||
<IntensityThreshold>
|
||||
P ≤ {(meta.threshold * 100).toFixed(0)}%
|
||||
</IntensityThreshold>
|
||||
</IntensityOption>
|
||||
);
|
||||
})}
|
||||
</IntensityGrid>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<ButtonRow>
|
||||
<Button $variant="ghost" onClick={() => setStep(2)}>
|
||||
이전
|
||||
</Button>
|
||||
<Button $block $size="lg" onClick={finish} disabled={saving}>
|
||||
{saving ? '저장 중...' : '시작하기'}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</StepCard>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${theme.space.lg};
|
||||
gap: ${theme.space.xl};
|
||||
`;
|
||||
|
||||
const Progress = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const Dot = styled.div<{ $active: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
|
||||
transition: background 0.2s;
|
||||
`;
|
||||
|
||||
const StepCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
`;
|
||||
|
||||
const StepTitle = styled.h2`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const StepSub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const PersonaGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: ${theme.space.md};
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const PersonaOption = styled.button<{ $active: boolean; $color: string }>`
|
||||
text-align: left;
|
||||
padding: ${theme.space.md};
|
||||
border-radius: ${theme.radius.md};
|
||||
background: ${({ $active, $color }) =>
|
||||
$active ? `${$color}15` : theme.color.surface2};
|
||||
border: 2px solid
|
||||
${({ $active, $color }) => ($active ? $color : theme.color.border)};
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $color }) => $color};
|
||||
}
|
||||
`;
|
||||
|
||||
const PersonaEmoji = styled.div`
|
||||
font-size: 28px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const PersonaLabel = styled.div`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textMain};
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const PersonaDesc = styled.div`
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const IntensityGrid = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.sm};
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const IntensityOption = styled.button<{ $active: boolean }>`
|
||||
text-align: left;
|
||||
padding: ${theme.space.md};
|
||||
border-radius: ${theme.radius.md};
|
||||
background: ${({ $active }) =>
|
||||
$active ? `${theme.color.accent}15` : theme.color.surface2};
|
||||
border: 2px solid
|
||||
${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const IntensityLabel = styled.div`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const IntensityDesc = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const IntensityThreshold = styled.div`
|
||||
font-size: 10px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.space.sm};
|
||||
align-items: center;
|
||||
`;
|
||||
13
frontend/src/app/page.tsx
Normal file
13
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { hasToken } from '@/lib/auth';
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace(hasToken() ? '/dashboard' : '/login');
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
372
frontend/src/app/profile/page.tsx
Normal file
372
frontend/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import {
|
||||
api,
|
||||
type MeUser,
|
||||
type Persona,
|
||||
type ReviewIntensity,
|
||||
} from '@/lib/api';
|
||||
import { clearToken } from '@/lib/auth';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
SectionTitle,
|
||||
Select,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
const PERSONAS: Persona[] = ['senior', 'mid', 'junior', 'crammer'];
|
||||
const INTENSITIES: ReviewIntensity[] = ['strict', 'moderate', 'relaxed'];
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ProfileBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileBody() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<MeUser | null>(null);
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [persona, setPersona] = useState<Persona>('mid');
|
||||
const [intensity, setIntensity] = useState<ReviewIntensity>('moderate');
|
||||
const [currentGrade, setCurrentGrade] = useState<number | ''>('');
|
||||
const [targetGrade, setTargetGrade] = useState<number | ''>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<MeUser>('/auth/me').then((r) => {
|
||||
const u = r.data;
|
||||
setUser(u);
|
||||
setNickname(u.nickname);
|
||||
setPersona(u.persona);
|
||||
setIntensity(u.reviewIntensity);
|
||||
setCurrentGrade(u.currentGrade ?? '');
|
||||
setTargetGrade(u.targetGrade ?? '');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setSaved(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await api.patch<MeUser>('/me/profile', {
|
||||
nickname,
|
||||
persona,
|
||||
reviewIntensity: intensity,
|
||||
currentGrade: currentGrade === '' ? undefined : currentGrade,
|
||||
targetGrade: targetGrade === '' ? undefined : targetGrade,
|
||||
});
|
||||
setUser(r.data);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch {
|
||||
setErr('저장에 실패했어.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
if (user === null) return <Loading>로딩 중...</Loading>;
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Title>프로필</Title>
|
||||
<Sub>계정과 학습 스타일 설정.</Sub>
|
||||
</Header>
|
||||
|
||||
<Card>
|
||||
<SectionTitle>계정</SectionTitle>
|
||||
<InfoRow>
|
||||
<InfoLabel>이메일</InfoLabel>
|
||||
<InfoValue>{user.email}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>가입일</InfoLabel>
|
||||
<InfoValue>
|
||||
{new Date(user.createdAt).toLocaleDateString('ko-KR')}
|
||||
</InfoValue>
|
||||
</InfoRow>
|
||||
</Card>
|
||||
|
||||
<form onSubmit={save}>
|
||||
<Card>
|
||||
<SectionTitle>기본 정보</SectionTitle>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>닉네임</Label>
|
||||
<Input
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<Row>
|
||||
<div>
|
||||
<Label>현재 등급</Label>
|
||||
<Select
|
||||
value={currentGrade}
|
||||
onChange={(e) =>
|
||||
setCurrentGrade(
|
||||
e.target.value === '' ? '' : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{Array.from({ length: 9 }, (_, i) => i + 1).map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}등급
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>목표 등급</Label>
|
||||
<Select
|
||||
value={targetGrade}
|
||||
onChange={(e) =>
|
||||
setTargetGrade(
|
||||
e.target.value === '' ? '' : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{Array.from({ length: 9 }, (_, i) => i + 1).map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}등급
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Label>페르소나</Label>
|
||||
<PersonaGrid>
|
||||
{PERSONAS.map((p) => (
|
||||
<PersonaCard
|
||||
key={p}
|
||||
type="button"
|
||||
$active={persona === p}
|
||||
$color={theme.persona[p].color}
|
||||
onClick={() => setPersona(p)}
|
||||
>
|
||||
<PersonaEmoji>{theme.persona[p].emoji}</PersonaEmoji>
|
||||
<PersonaLabel>{theme.persona[p].label}</PersonaLabel>
|
||||
<PersonaDesc>{personaDesc(p)}</PersonaDesc>
|
||||
</PersonaCard>
|
||||
))}
|
||||
</PersonaGrid>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>복습 강도</Label>
|
||||
<IntensityGrid>
|
||||
{INTENSITIES.map((i) => (
|
||||
<IntensityCard
|
||||
key={i}
|
||||
type="button"
|
||||
$active={intensity === i}
|
||||
onClick={() => setIntensity(i)}
|
||||
>
|
||||
<IntensityLabel>{intensityLabel(i)}</IntensityLabel>
|
||||
<IntensityDesc>{intensityDesc(i)}</IntensityDesc>
|
||||
</IntensityCard>
|
||||
))}
|
||||
</IntensityGrid>
|
||||
</div>
|
||||
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
{saved && <Saved>저장했어 ✓</Saved>}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" $size="md" disabled={saving}>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Stack>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
<Card>
|
||||
<SectionTitle>세션</SectionTitle>
|
||||
<Button $variant="danger" $size="md" onClick={logout}>
|
||||
로그아웃
|
||||
</Button>
|
||||
</Card>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function personaDesc(p: Persona): string {
|
||||
return p === 'senior'
|
||||
? 'λ=0.1 · 잘 안 잊음'
|
||||
: p === 'mid'
|
||||
? 'λ=0.2 · 표준'
|
||||
: p === 'junior'
|
||||
? 'λ=0.4 · 빨리 잊음'
|
||||
: 'λ=0.6 · 벼락치기형';
|
||||
}
|
||||
function intensityLabel(i: ReviewIntensity): string {
|
||||
return i === 'strict' ? '엄격' : i === 'relaxed' ? '느슨' : '보통';
|
||||
}
|
||||
function intensityDesc(i: ReviewIntensity): string {
|
||||
return i === 'strict'
|
||||
? '높은 P 기준, 더 자주'
|
||||
: i === 'relaxed'
|
||||
? '낮은 P 기준, 덜 자주'
|
||||
: '표준 P 기준';
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
const Header = styled.header``;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
const InfoRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid ${theme.color.borderSoft};
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
const InfoLabel = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
const InfoValue = styled.span`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textMain};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const Row = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: ${theme.space.md};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
const PersonaGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: ${theme.space.sm};
|
||||
|
||||
@media (min-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
`;
|
||||
const PersonaCard = styled.button<{ $active: boolean; $color: string }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: ${theme.space.md};
|
||||
gap: 4px;
|
||||
border-radius: ${theme.radius.md};
|
||||
background: ${({ $active }) =>
|
||||
$active ? theme.color.surfaceHover : theme.color.surface2};
|
||||
border: 1px solid
|
||||
${({ $active, $color }) => ($active ? $color : theme.color.border)};
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $color }) => $color};
|
||||
}
|
||||
`;
|
||||
const PersonaEmoji = styled.span`
|
||||
font-size: 24px;
|
||||
`;
|
||||
const PersonaLabel = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const PersonaDesc = styled.span`
|
||||
font-size: 10px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const IntensityGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
const IntensityCard = styled.button<{ $active: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: ${theme.space.md};
|
||||
gap: 4px;
|
||||
border-radius: ${theme.radius.md};
|
||||
background: ${({ $active }) =>
|
||||
$active ? theme.color.surfaceHover : theme.color.surface2};
|
||||
border: 1px solid
|
||||
${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.color.accent};
|
||||
}
|
||||
`;
|
||||
const IntensityLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const IntensityDesc = styled.span`
|
||||
font-size: 10px;
|
||||
color: ${theme.color.textMute};
|
||||
text-align: center;
|
||||
`;
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
const Saved = styled.p`
|
||||
color: ${theme.color.success};
|
||||
font-size: 13px;
|
||||
`;
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
129
frontend/src/app/register/page.tsx
Normal file
129
frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api } from '@/lib/api';
|
||||
import { setToken } from '@/lib/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.post('/auth/register', { email, password, nickname });
|
||||
setToken(r.data.accessToken);
|
||||
router.replace('/onboarding');
|
||||
} catch (e: any) {
|
||||
if (e.response?.status === 409) {
|
||||
setErr('이미 등록된 이메일이야');
|
||||
} else {
|
||||
setErr('회원가입 실패 — 다시 시도해 줘');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<FormCard>
|
||||
<FormTitle>회원가입</FormTitle>
|
||||
<form onSubmit={submit}>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>비밀번호 (6자 이상)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>닉네임</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
required
|
||||
maxLength={32}
|
||||
/>
|
||||
</div>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<Button type="submit" $block $size="lg" disabled={loading}>
|
||||
{loading ? '가입 중...' : '가입하기'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<SubRow>
|
||||
<span>이미 계정이 있어?</span>
|
||||
<Link href="/login">로그인</Link>
|
||||
</SubRow>
|
||||
</FormCard>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const FormCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const FormTitle = styled.h2`
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const SubRow = styled.div`
|
||||
margin-top: ${theme.space.lg};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
|
||||
a {
|
||||
color: ${theme.color.accent};
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
156
frontend/src/app/review/history/page.tsx
Normal file
156
frontend/src/app/review/history/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type QueueItem } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Badge, Card, SectionTitle, Stack } from '@/components/ui/primitives';
|
||||
|
||||
interface HistoryItem extends QueueItem {
|
||||
reviewedAt: string | null;
|
||||
result: 'correct' | 'incorrect' | 'partial' | null;
|
||||
}
|
||||
|
||||
export default function ReviewHistoryPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ReviewHistoryBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewHistoryBody() {
|
||||
const [items, setItems] = useState<HistoryItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<HistoryItem[]>('/reviews/history?limit=100')
|
||||
.then((r) => setItems(r.data));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Title>복습 히스토리</Title>
|
||||
<Sub>최근 완료하거나 스킵한 복습.</Sub>
|
||||
</Header>
|
||||
|
||||
{items === null ? (
|
||||
<Loading>로딩 중...</Loading>
|
||||
) : items.length === 0 ? (
|
||||
<Empty>아직 완료한 복습이 없어.</Empty>
|
||||
) : (
|
||||
<Stack $gap={theme.space.sm}>
|
||||
{items.map((it) => (
|
||||
<HistoryCard key={it.id}>
|
||||
<TopRow>
|
||||
<Dot $color={it.studyLog.subject.color} />
|
||||
<SubjectName>{it.studyLog.subject.name}</SubjectName>
|
||||
{it.studyLog.tag && (
|
||||
<TagName>· {it.studyLog.tag.name}</TagName>
|
||||
)}
|
||||
<Spacer />
|
||||
{it.status === 'skipped' ? (
|
||||
<Badge>스킵</Badge>
|
||||
) : it.result ? (
|
||||
<Badge $variant={resultVariant(it.result)}>
|
||||
{resultLabel(it.result)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</TopRow>
|
||||
<ProblemTitle>{it.studyLog.title}</ProblemTitle>
|
||||
<MetaRow>
|
||||
<Meta>{it.iteration + 1}회차 예정</Meta>
|
||||
<Meta>
|
||||
{it.reviewedAt
|
||||
? `${new Date(it.reviewedAt).toLocaleString('ko-KR')} 처리`
|
||||
: `${new Date(it.scheduledAt).toLocaleString('ko-KR')} 예정`}
|
||||
</Meta>
|
||||
</MetaRow>
|
||||
</HistoryCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function resultLabel(r: string): string {
|
||||
return r === 'correct' ? '맞음' : r === 'partial' ? '부분' : '틀림';
|
||||
}
|
||||
function resultVariant(r: string): 'success' | 'warning' | 'danger' {
|
||||
return r === 'correct' ? 'success' : r === 'partial' ? 'warning' : 'danger';
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
const Header = styled.header``;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
const HistoryCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`;
|
||||
const TopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
const Dot = styled.span<{ $color: string }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
const SubjectName = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
const TagName = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
const Spacer = styled.span`
|
||||
flex: 1;
|
||||
`;
|
||||
const ProblemTitle = styled.h3`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
const Meta = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textMute};
|
||||
border: 1px dashed ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
`;
|
||||
271
frontend/src/app/review/page.tsx
Normal file
271
frontend/src/app/review/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type QueueItem, type StudyResult } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
SectionTitle,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function ReviewPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ReviewBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewBody() {
|
||||
const [queue, setQueue] = useState<QueueItem[] | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.get<QueueItem[]>('/reviews/queue').then((r) => setQueue(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const submit = async (id: number, result: StudyResult) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.post(`/reviews/${id}/submit`, { result });
|
||||
setQueue((prev) => prev?.filter((q) => q.id !== id) ?? null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const skip = async (id: number) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.post(`/reviews/${id}/skip`);
|
||||
setQueue((prev) => prev?.filter((q) => q.id !== id) ?? null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (queue === null) return <Loading>로딩 중...</Loading>;
|
||||
|
||||
if (queue.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyEmoji>🎉</EmptyEmoji>
|
||||
<EmptyTitle>오늘 할 복습이 없어</EmptyTitle>
|
||||
<EmptySub>새 문제 기록을 추가하면 여기에 큐가 생겨.</EmptySub>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Title>복습 큐</Title>
|
||||
<Sub>{queue.length}개 · 오래된 것부터</Sub>
|
||||
</Header>
|
||||
|
||||
<Stack $gap={theme.space.md}>
|
||||
{queue.map((q) => (
|
||||
<ReviewCard key={q.id}>
|
||||
<CardTop>
|
||||
<TagLine>
|
||||
<SubjectDot $color={q.studyLog.subject.color} />
|
||||
<SubjectName>{q.studyLog.subject.name}</SubjectName>
|
||||
{q.studyLog.tag && <TagName>· {q.studyLog.tag.name}</TagName>}
|
||||
</TagLine>
|
||||
<Meta>
|
||||
{q.iteration > 0 && <Badge>{q.iteration + 1}회차</Badge>}
|
||||
{q.predictedP !== null && (
|
||||
<Badge $variant="info">P {Math.round(q.predictedP * 100)}%</Badge>
|
||||
)}
|
||||
</Meta>
|
||||
</CardTop>
|
||||
<ProblemTitle>{q.studyLog.title}</ProblemTitle>
|
||||
{q.studyLog.memo && <Memo>{q.studyLog.memo}</Memo>}
|
||||
<Scheduled>
|
||||
{formatRelative(new Date(q.scheduledAt))}
|
||||
</Scheduled>
|
||||
<Actions>
|
||||
<Button
|
||||
$variant="danger"
|
||||
$size="md"
|
||||
disabled={submitting}
|
||||
onClick={() => submit(q.id, 'incorrect')}
|
||||
>
|
||||
✗ 틀림
|
||||
</Button>
|
||||
<Button
|
||||
$variant="secondary"
|
||||
$size="md"
|
||||
disabled={submitting}
|
||||
onClick={() => submit(q.id, 'partial')}
|
||||
>
|
||||
△ 부분
|
||||
</Button>
|
||||
<Button
|
||||
$size="md"
|
||||
disabled={submitting}
|
||||
onClick={() => submit(q.id, 'correct')}
|
||||
>
|
||||
✓ 맞음
|
||||
</Button>
|
||||
<Button
|
||||
$variant="ghost"
|
||||
$size="md"
|
||||
disabled={submitting}
|
||||
onClick={() => skip(q.id)}
|
||||
>
|
||||
스킵
|
||||
</Button>
|
||||
</Actions>
|
||||
</ReviewCard>
|
||||
))}
|
||||
</Stack>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(d: Date): string {
|
||||
const diffMin = Math.round((Date.now() - d.getTime()) / 60_000);
|
||||
if (diffMin < 0) {
|
||||
const mins = -diffMin;
|
||||
if (mins < 60) return `${mins}분 뒤 예정`;
|
||||
const h = Math.round(mins / 60);
|
||||
return `${h}시간 뒤 예정`;
|
||||
}
|
||||
if (diffMin < 60) return `${diffMin}분 전 예정이었어`;
|
||||
const hours = Math.round(diffMin / 60);
|
||||
if (hours < 24) return `${hours}시간 지남`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}일 지남`;
|
||||
}
|
||||
|
||||
// ── styled ──
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xxl};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const EmptyEmoji = styled.div`
|
||||
font-size: 56px;
|
||||
margin-bottom: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const EmptyTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const EmptySub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const Header = styled.header``;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const ReviewCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const CardTop = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const TagLine = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const SubjectDot = styled.span<{ $color: string }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
|
||||
const SubjectName = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
const TagName = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const ProblemTitle = styled.h3`
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const Memo = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
padding: ${theme.space.sm};
|
||||
background: ${theme.color.surface2};
|
||||
border-radius: ${theme.radius.sm};
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const Scheduled = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr auto;
|
||||
gap: ${theme.space.sm};
|
||||
margin-top: ${theme.space.sm};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
button:last-child { grid-column: span 3; }
|
||||
}
|
||||
`;
|
||||
311
frontend/src/app/stats/page.tsx
Normal file
311
frontend/src/app/stats/page.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type ForgetCurveResponse } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Badge, Card, SectionTitle, Stack } from '@/components/ui/primitives';
|
||||
import ForgetCurveChart from '@/components/charts/ForgetCurveChart';
|
||||
|
||||
interface SubjectStats {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
avgS0: number | null;
|
||||
tags: Array<{
|
||||
tagId: number;
|
||||
name: string;
|
||||
s0: number | null;
|
||||
sampleCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<StatsBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsBody() {
|
||||
const [subjects, setSubjects] = useState<SubjectStats[] | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<number | null>(null);
|
||||
const [curve, setCurve] = useState<ForgetCurveResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SubjectStats[]>('/stats/subjects').then((r) => {
|
||||
setSubjects(r.data);
|
||||
const firstWithData = r.data
|
||||
.flatMap((s) => s.tags)
|
||||
.find((t) => t.s0 !== null);
|
||||
if (firstWithData) setSelectedTag(firstWithData.tagId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTag === null) return;
|
||||
setCurve(null);
|
||||
api
|
||||
.get<ForgetCurveResponse>('/stats/forget-curve', {
|
||||
params: { tagId: selectedTag, days: 30, steps: 60 },
|
||||
})
|
||||
.then((r) => setCurve(r.data));
|
||||
}, [selectedTag]);
|
||||
|
||||
if (subjects === null) return <Loading>로딩 중...</Loading>;
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Title>통계</Title>
|
||||
<Sub>페르소나 망각 곡선과 주제별 숙련도.</Sub>
|
||||
</Header>
|
||||
|
||||
<Card>
|
||||
<SectionTitle>망각 곡선</SectionTitle>
|
||||
{selectedTag === null ? (
|
||||
<Empty>
|
||||
태그에 연결된 학습 기록이 아직 없어. 학습 기록을 남기면 곡선이 그려져.
|
||||
</Empty>
|
||||
) : curve === null ? (
|
||||
<Loading>곡선 그리는 중...</Loading>
|
||||
) : (
|
||||
<>
|
||||
<CurveHeader>
|
||||
<CurveTitle>
|
||||
<Dot $color={curve.tag.subject.color} />
|
||||
<span>
|
||||
{curve.tag.subject.name} · {curve.tag.name}
|
||||
</span>
|
||||
</CurveTitle>
|
||||
<CurveMeta>
|
||||
<Badge $variant="info">
|
||||
S₀ {Math.round(curve.snapshot.s0 * 100)}%
|
||||
</Badge>
|
||||
<Badge>페르소나 {personaLabel(curve.persona)}</Badge>
|
||||
<Badge>강도 {intensityLabel(curve.intensity)}</Badge>
|
||||
<Badge>
|
||||
난이도 {Math.round(curve.difficulty * 100)}%
|
||||
</Badge>
|
||||
</CurveMeta>
|
||||
</CurveHeader>
|
||||
<ForgetCurveChart
|
||||
data={curve}
|
||||
threshold={intensityThreshold(curve.intensity)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<SectionTitle>과목별 숙련도</SectionTitle>
|
||||
<Stack $gap={theme.space.md}>
|
||||
{subjects.map((s) => (
|
||||
<SubjectCard key={s.id}>
|
||||
<SubjectTop>
|
||||
<Dot $color={s.color} />
|
||||
<SubjectName>{s.name}</SubjectName>
|
||||
<Spacer />
|
||||
<Badge $variant="info">
|
||||
평균 S₀{' '}
|
||||
{s.avgS0 === null ? '—' : `${Math.round(s.avgS0 * 100)}%`}
|
||||
</Badge>
|
||||
</SubjectTop>
|
||||
{s.tags.length === 0 ? (
|
||||
<SubNote>태그 없음</SubNote>
|
||||
) : (
|
||||
<TagList>
|
||||
{s.tags.map((t) => (
|
||||
<TagRow
|
||||
key={t.tagId}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
t.s0 !== null ? setSelectedTag(t.tagId) : null
|
||||
}
|
||||
$active={t.tagId === selectedTag}
|
||||
disabled={t.s0 === null}
|
||||
>
|
||||
<TagName>{t.name}</TagName>
|
||||
<Bar>
|
||||
<BarFill $value={t.s0 ?? 0} $color={s.color} />
|
||||
</Bar>
|
||||
<TagValue>
|
||||
{t.s0 === null
|
||||
? '—'
|
||||
: `${Math.round(t.s0 * 100)}%`}
|
||||
</TagValue>
|
||||
<SampleCount>n={t.sampleCount}</SampleCount>
|
||||
</TagRow>
|
||||
))}
|
||||
</TagList>
|
||||
)}
|
||||
</SubjectCard>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function personaLabel(p: string): string {
|
||||
return p === 'senior'
|
||||
? '상위권'
|
||||
: p === 'mid'
|
||||
? '중위권'
|
||||
: p === 'junior'
|
||||
? '하위권'
|
||||
: '벼락치기';
|
||||
}
|
||||
function intensityLabel(i: string): string {
|
||||
return i === 'strict' ? '엄격' : i === 'relaxed' ? '느슨' : '보통';
|
||||
}
|
||||
function intensityThreshold(i: string): number {
|
||||
return i === 'strict' ? 0.7 : i === 'relaxed' ? 0.35 : 0.5;
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
const Header = styled.header``;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const CurveHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.space.md};
|
||||
margin-bottom: ${theme.space.md};
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
const CurveTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const CurveMeta = styled.div`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const SubjectCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
const SubjectTop = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
const Dot = styled.span<{ $color: string }>`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
const SubjectName = styled.h3`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Spacer = styled.span`
|
||||
flex: 1;
|
||||
`;
|
||||
const SubNote = styled.p`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
const TagList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: ${theme.space.sm};
|
||||
`;
|
||||
const TagRow = styled.button<{ $active: boolean }>`
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 50px 50px;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
padding: 8px 10px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? theme.color.surfaceHover : 'transparent'};
|
||||
border: 1px solid
|
||||
${({ $active }) => ($active ? theme.color.accent : 'transparent')};
|
||||
border-radius: ${theme.radius.sm};
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${theme.color.surface2};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: 100px 1fr 44px;
|
||||
}
|
||||
`;
|
||||
const TagName = styled.span`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textMain};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
const Bar = styled.div`
|
||||
height: 6px;
|
||||
background: ${theme.color.surface2};
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
const BarFill = styled.div<{ $value: number; $color: string }>`
|
||||
height: 100%;
|
||||
width: ${({ $value }) => `${Math.round($value * 100)}%`};
|
||||
background: ${({ $color }) => $color};
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
const TagValue = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
text-align: right;
|
||||
`;
|
||||
const SampleCount = styled.span`
|
||||
font-size: 10px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
text-align: right;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 13px;
|
||||
`;
|
||||
215
frontend/src/app/study/history/page.tsx
Normal file
215
frontend/src/app/study/history/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type StudyLog, type Subject } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
SectionTitle,
|
||||
Select,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function StudyHistoryPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<HistoryBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryBody() {
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [subjectFilter, setSubjectFilter] = useState<number | ''>('');
|
||||
const [logs, setLogs] = useState<StudyLog[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Subject[]>('/subjects').then((r) => setSubjects(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params: Record<string, number> = { limit: 100 };
|
||||
if (subjectFilter !== '') params.subjectId = subjectFilter;
|
||||
api
|
||||
.get<StudyLog[]>('/study-logs', { params })
|
||||
.then((r) => setLogs(r.data));
|
||||
}, [subjectFilter]);
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<div>
|
||||
<Title>학습 기록</Title>
|
||||
<Sub>최근에 기록한 순서대로.</Sub>
|
||||
</div>
|
||||
<Link href="/study">
|
||||
<Button $size="md">+ 새 기록</Button>
|
||||
</Link>
|
||||
</Header>
|
||||
|
||||
<FilterRow>
|
||||
<Select
|
||||
value={subjectFilter}
|
||||
onChange={(e) =>
|
||||
setSubjectFilter(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<option value="">전체 과목</option>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FilterRow>
|
||||
|
||||
{logs === null ? (
|
||||
<Loading>로딩 중...</Loading>
|
||||
) : logs.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyEmoji>📝</EmptyEmoji>
|
||||
<EmptyTitle>아직 기록이 없어</EmptyTitle>
|
||||
<Link href="/study">
|
||||
<Button $size="md">지금 첫 기록 남기기</Button>
|
||||
</Link>
|
||||
</Empty>
|
||||
) : (
|
||||
<Stack $gap={theme.space.sm}>
|
||||
{logs.map((log) => (
|
||||
<LogCard key={log.id}>
|
||||
<TopRow>
|
||||
<Dot $color={log.subject?.color ?? '#6366f1'} />
|
||||
<SubjectName>{log.subject?.name}</SubjectName>
|
||||
{log.tag && <TagName>· {log.tag.name}</TagName>}
|
||||
<Spacer />
|
||||
<ResultBadge $variant={resultVariant(log.result)}>
|
||||
{resultLabel(log.result)}
|
||||
</ResultBadge>
|
||||
</TopRow>
|
||||
<LogTitle>{log.title}</LogTitle>
|
||||
{log.memo && <Memo>{log.memo}</Memo>}
|
||||
<MetaRow>
|
||||
<Meta>난이도 {Math.round(log.difficulty * 100)}%</Meta>
|
||||
{log.baseCorrectRate !== null && (
|
||||
<Meta>공식 정답률 {Math.round(log.baseCorrectRate * 100)}%</Meta>
|
||||
)}
|
||||
{log.timeSpent !== null && (
|
||||
<Meta>{Math.round((log.timeSpent ?? 0) / 60)}분 소요</Meta>
|
||||
)}
|
||||
<Meta>{new Date(log.studiedAt).toLocaleString('ko-KR')}</Meta>
|
||||
</MetaRow>
|
||||
</LogCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function resultLabel(r: string): string {
|
||||
return r === 'correct' ? '맞음' : r === 'partial' ? '부분' : '틀림';
|
||||
}
|
||||
function resultVariant(r: string): 'success' | 'warning' | 'danger' {
|
||||
return r === 'correct' ? 'success' : r === 'partial' ? 'warning' : 'danger';
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
const Header = styled.header`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
const FilterRow = styled.div`
|
||||
max-width: 280px;
|
||||
`;
|
||||
const LogCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`;
|
||||
const TopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
const Dot = styled.span<{ $color: string }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
const SubjectName = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
const TagName = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
const Spacer = styled.span`
|
||||
flex: 1;
|
||||
`;
|
||||
const ResultBadge = styled(Badge)``;
|
||||
const LogTitle = styled.h3`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Memo = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.space.md};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
const Meta = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xxl};
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
const EmptyEmoji = styled.div`
|
||||
font-size: 56px;
|
||||
`;
|
||||
const EmptyTitle = styled.h2`
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
387
frontend/src/app/study/page.tsx
Normal file
387
frontend/src/app/study/page.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type StudyResult, type Subject } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
HelpText,
|
||||
Input,
|
||||
Label,
|
||||
SectionTitle,
|
||||
Select,
|
||||
Stack,
|
||||
Textarea,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function StudyPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<StudyBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StudyBody() {
|
||||
const router = useRouter();
|
||||
const [subjects, setSubjects] = useState<Subject[] | null>(null);
|
||||
|
||||
const [subjectId, setSubjectId] = useState<number | ''>('');
|
||||
const [tagId, setTagId] = useState<number | ''>('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [difficulty, setDifficulty] = useState(0.5);
|
||||
const [baseCorrectRate, setBaseCorrectRate] = useState<string>('');
|
||||
const [result, setResult] = useState<StudyResult>('correct');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [timeSpent, setTimeSpent] = useState<string>('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Subject[]>('/subjects').then((r) => {
|
||||
setSubjects(r.data);
|
||||
if (r.data.length > 0) setSubjectId(r.data[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentSubject = useMemo(
|
||||
() => subjects?.find((s) => s.id === subjectId) ?? null,
|
||||
[subjects, subjectId],
|
||||
);
|
||||
const currentTags = currentSubject?.tags ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setTagId('');
|
||||
}, [subjectId]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!subjectId || !title.trim()) {
|
||||
setErr('과목과 제목은 필수야');
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.post('/study-logs', {
|
||||
subjectId,
|
||||
tagId: tagId || undefined,
|
||||
title: title.trim(),
|
||||
difficulty,
|
||||
baseCorrectRate: baseCorrectRate === '' ? undefined : Number(baseCorrectRate) / 100,
|
||||
result,
|
||||
memo: memo.trim() || undefined,
|
||||
timeSpent: timeSpent === '' ? undefined : Number(timeSpent),
|
||||
});
|
||||
router.push('/dashboard');
|
||||
} catch {
|
||||
setErr('저장에 실패했어. 다시 시도해줘.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (subjects === null) return <Loading>로딩 중...</Loading>;
|
||||
|
||||
if (subjects.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyEmoji>📚</EmptyEmoji>
|
||||
<EmptyTitle>먼저 과목을 만들어줘</EmptyTitle>
|
||||
<EmptySub>학습 기록을 남기려면 과목이 하나 이상 필요해.</EmptySub>
|
||||
<Button $size="md" onClick={() => router.push('/subjects')}>
|
||||
과목 만들러 가기
|
||||
</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Title>새 학습 기록</Title>
|
||||
<Sub>문제를 풀고 바로 기록하면 다음 복습이 자동으로 잡혀.</Sub>
|
||||
</Header>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<Card>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<SectionTitle>기본 정보</SectionTitle>
|
||||
|
||||
<Row>
|
||||
<Field>
|
||||
<Label>과목</Label>
|
||||
<Select
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(Number(e.target.value))}
|
||||
>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label>태그 (선택)</Label>
|
||||
<Select
|
||||
value={tagId}
|
||||
onChange={(e) =>
|
||||
setTagId(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<option value="">— 없음 —</option>
|
||||
{currentTags.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Label>문제 제목</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예) 2024 수능 수학 15번"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle>풀이 결과</SectionTitle>
|
||||
|
||||
<ResultGrid>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'correct'}
|
||||
$tone="success"
|
||||
onClick={() => setResult('correct')}
|
||||
>
|
||||
<ResultMark>✓</ResultMark>
|
||||
<ResultLabel>맞음</ResultLabel>
|
||||
</ResultChoice>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'partial'}
|
||||
$tone="warning"
|
||||
onClick={() => setResult('partial')}
|
||||
>
|
||||
<ResultMark>△</ResultMark>
|
||||
<ResultLabel>부분</ResultLabel>
|
||||
</ResultChoice>
|
||||
<ResultChoice
|
||||
type="button"
|
||||
$active={result === 'incorrect'}
|
||||
$tone="danger"
|
||||
onClick={() => setResult('incorrect')}
|
||||
>
|
||||
<ResultMark>✗</ResultMark>
|
||||
<ResultLabel>틀림</ResultLabel>
|
||||
</ResultChoice>
|
||||
</ResultGrid>
|
||||
|
||||
<div>
|
||||
<Label>체감 난이도 · {Math.round(difficulty * 100)}%</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<HelpText>0%에 가까울수록 쉬움, 100%에 가까울수록 어려움.</HelpText>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<Field>
|
||||
<Label>기준 정답률 (선택)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={baseCorrectRate}
|
||||
onChange={(e) => setBaseCorrectRate(e.target.value)}
|
||||
placeholder="예) 45"
|
||||
/>
|
||||
<HelpText>수능 기출이면 공식 정답률(%)을 넣어줘.</HelpText>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label>풀이 시간 (초, 선택)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={timeSpent}
|
||||
onChange={(e) => setTimeSpent(e.target.value)}
|
||||
placeholder="예) 180"
|
||||
/>
|
||||
</Field>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Label>메모 (선택)</Label>
|
||||
<Textarea
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="어디서 막혔는지, 무엇을 배웠는지 짧게 남겨봐."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
|
||||
<ButtonRow>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
$size="md"
|
||||
disabled={submitting}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" $size="md" disabled={submitting}>
|
||||
{submitting ? '저장 중...' : '기록하고 복습 예약'}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Stack>
|
||||
</Card>
|
||||
</form>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Header = styled.header``;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: ${theme.space.md};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const ResultGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const ResultChoice = styled.button<{
|
||||
$active: boolean;
|
||||
$tone: 'success' | 'warning' | 'danger';
|
||||
}>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: ${theme.space.md};
|
||||
border-radius: ${theme.radius.md};
|
||||
background: ${({ $active }) =>
|
||||
$active ? theme.color.surfaceHover : theme.color.surface2};
|
||||
border: 1px solid
|
||||
${({ $active, $tone }) =>
|
||||
$active
|
||||
? $tone === 'success'
|
||||
? theme.color.success
|
||||
: $tone === 'warning'
|
||||
? theme.color.warning
|
||||
: theme.color.danger
|
||||
: theme.color.border};
|
||||
color: ${({ $active, $tone }) =>
|
||||
$active
|
||||
? $tone === 'success'
|
||||
? theme.color.success
|
||||
: $tone === 'warning'
|
||||
? theme.color.warning
|
||||
: theme.color.danger
|
||||
: theme.color.textSub};
|
||||
cursor: pointer;
|
||||
min-height: 72px;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.color.accent};
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultMark = styled.span`
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const ResultLabel = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.space.sm};
|
||||
justify-content: flex-end;
|
||||
margin-top: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xxl};
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
const EmptyEmoji = styled.div`
|
||||
font-size: 56px;
|
||||
`;
|
||||
const EmptyTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const EmptySub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
205
frontend/src/app/subjects/[id]/page.tsx
Normal file
205
frontend/src/app/subjects/[id]/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type Subject, type Tag } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
SectionTitle,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
export default function SubjectDetailPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<SubjectDetailBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SubjectDetailBody() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const subjectId = Number(params.id);
|
||||
|
||||
const [subject, setSubject] = useState<Subject | null>(null);
|
||||
const [tags, setTags] = useState<Tag[] | null>(null);
|
||||
const [tagName, setTagName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.get<Subject>(`/subjects/${subjectId}`).then((r) => setSubject(r.data));
|
||||
api
|
||||
.get<Tag[]>(`/tags/by-subject/${subjectId}`)
|
||||
.then((r) => setTags(r.data));
|
||||
}, [subjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(subjectId)) return;
|
||||
load();
|
||||
}, [subjectId, load]);
|
||||
|
||||
const addTag = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!tagName.trim()) return;
|
||||
setErr(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/tags', { subjectId, name: tagName.trim() });
|
||||
setTagName('');
|
||||
load();
|
||||
} catch {
|
||||
setErr('태그 추가에 실패했어.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = async (id: number) => {
|
||||
if (!confirm('이 태그를 삭제할까? 연결된 학습기록은 태그 없는 상태가 돼.')) return;
|
||||
try {
|
||||
await api.delete(`/tags/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
if (subject === null || tags === null) return <Loading>로딩 중...</Loading>;
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<BackRow>
|
||||
<Button $variant="ghost" $size="sm" onClick={() => router.push('/subjects')}>
|
||||
← 과목 목록
|
||||
</Button>
|
||||
</BackRow>
|
||||
|
||||
<Header>
|
||||
<TitleLine>
|
||||
<Dot $color={subject.color} />
|
||||
<Title>{subject.name}</Title>
|
||||
<Badge>{tags.length} 태그</Badge>
|
||||
</TitleLine>
|
||||
<Sub>이 과목의 세부 주제를 태그로 분리하면 페르소나 망각 곡선이 주제별로 학습돼.</Sub>
|
||||
</Header>
|
||||
|
||||
<Card>
|
||||
<SectionTitle>새 태그 추가</SectionTitle>
|
||||
<form onSubmit={addTag}>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>이름</Label>
|
||||
<Input
|
||||
value={tagName}
|
||||
onChange={(e) => setTagName(e.target.value)}
|
||||
placeholder="예) 확률과 통계, 수열, 극한..."
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<div>
|
||||
<Button type="submit" $size="md" disabled={saving || !tagName.trim()}>
|
||||
{saving ? '저장 중...' : '태그 추가'}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<SectionTitle>태그 목록</SectionTitle>
|
||||
{tags.length === 0 ? (
|
||||
<Empty>아직 태그가 없어.</Empty>
|
||||
) : (
|
||||
<TagGrid>
|
||||
{tags.map((t) => (
|
||||
<TagCard key={t.id}>
|
||||
<TagName>{t.name}</TagName>
|
||||
<Button
|
||||
$variant="ghost"
|
||||
$size="sm"
|
||||
onClick={() => removeTag(t.id)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TagCard>
|
||||
))}
|
||||
</TagGrid>
|
||||
)}
|
||||
</div>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const BackRow = styled.div``;
|
||||
|
||||
const Header = styled.header``;
|
||||
const TitleLine = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
const Dot = styled.span<{ $color: string }>`
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const TagGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const TagCard = styled(Card)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const TagName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textMute};
|
||||
border: 1px dashed ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
`;
|
||||
254
frontend/src/app/subjects/page.tsx
Normal file
254
frontend/src/app/subjects/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type Subject } from '@/lib/api';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
Input,
|
||||
Label,
|
||||
SectionTitle,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
|
||||
const PALETTE = [
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#f43f5e',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#14b8a6',
|
||||
'#0ea5e9',
|
||||
];
|
||||
|
||||
export default function SubjectsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<SubjectsBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SubjectsBody() {
|
||||
const [subjects, setSubjects] = useState<Subject[] | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(PALETTE[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.get<Subject[]>('/subjects').then((r) => setSubjects(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const create = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setErr(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/subjects', { name: name.trim(), color });
|
||||
setName('');
|
||||
setColor(PALETTE[0]);
|
||||
load();
|
||||
} catch {
|
||||
setErr('과목 생성에 실패했어.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!confirm('이 과목과 연결된 태그·학습기록은 어떻게 되는지 확인하고 진행해. 정말 삭제할까?')) return;
|
||||
try {
|
||||
await api.delete(`/subjects/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
alert('삭제에 실패했어. 연결된 데이터가 있을 수 있어.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<Header>
|
||||
<Title>과목</Title>
|
||||
<Sub>학습 영역을 정의하고 태그로 세분화해.</Sub>
|
||||
</Header>
|
||||
|
||||
<Card>
|
||||
<SectionTitle>새 과목 만들기</SectionTitle>
|
||||
<form onSubmit={create}>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<div>
|
||||
<Label>이름</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예) 국어, 수학, 한국사..."
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>색상</Label>
|
||||
<Palette>
|
||||
{PALETTE.map((c) => (
|
||||
<Swatch
|
||||
key={c}
|
||||
type="button"
|
||||
$color={c}
|
||||
$active={c === color}
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</Palette>
|
||||
</div>
|
||||
{err && <ErrorText>{err}</ErrorText>}
|
||||
<div>
|
||||
<Button type="submit" $size="md" disabled={saving || !name.trim()}>
|
||||
{saving ? '저장 중...' : '과목 추가'}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<SectionTitle>내 과목 {subjects ? `(${subjects.length})` : ''}</SectionTitle>
|
||||
{subjects === null ? (
|
||||
<Loading>로딩 중...</Loading>
|
||||
) : subjects.length === 0 ? (
|
||||
<Empty>아직 과목이 없어. 위에서 하나 만들어봐.</Empty>
|
||||
) : (
|
||||
<Grid>
|
||||
{subjects.map((s) => (
|
||||
<SubjectCard key={s.id}>
|
||||
<CardTop>
|
||||
<Dot $color={s.color} />
|
||||
<SubjectName>{s.name}</SubjectName>
|
||||
<Badge>{s.tags?.length ?? 0} 태그</Badge>
|
||||
</CardTop>
|
||||
<CardActions>
|
||||
<Link href={`/subjects/${s.id}`}>
|
||||
<Button $variant="secondary" $size="sm">
|
||||
태그 관리
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
$variant="ghost"
|
||||
$size="sm"
|
||||
onClick={() => remove(s.id)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</CardActions>
|
||||
</SubjectCard>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const Header = styled.header``;
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
const Sub = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Palette = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.space.sm};
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Swatch = styled.button<{ $color: string; $active: boolean }>`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
border: 2px solid
|
||||
${({ $active }) => ($active ? 'white' : 'transparent')};
|
||||
box-shadow: ${({ $active }) =>
|
||||
$active ? '0 0 0 2px rgba(99,102,241,0.6)' : 'none'};
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const SubjectCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.md};
|
||||
`;
|
||||
|
||||
const CardTop = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const Dot = styled.span<{ $color: string }>`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
`;
|
||||
|
||||
const SubjectName = styled.h3`
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const CardActions = styled.div`
|
||||
display: flex;
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const Loading = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
padding: ${theme.space.xl};
|
||||
text-align: center;
|
||||
color: ${theme.color.textMute};
|
||||
border: 1px dashed ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
`;
|
||||
94
frontend/src/components/charts/ForgetCurveChart.tsx
Normal file
94
frontend/src/components/charts/ForgetCurveChart.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { theme } from '@/styles/theme';
|
||||
import type { ForgetCurveResponse } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
data: ForgetCurveResponse;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export default function ForgetCurveChart({ data, threshold = 0.5 }: Props) {
|
||||
const points = data.points.map((p) => ({
|
||||
day: Number(p.t.toFixed(2)),
|
||||
memory: Number((p.s * 100).toFixed(1)),
|
||||
p: Number((p.p * 100).toFixed(1)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 320 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={points} margin={{ top: 12, right: 16, left: 0, bottom: 8 }}>
|
||||
<CartesianGrid stroke={theme.color.border} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke={theme.color.textMute}
|
||||
tick={{ fontSize: 11 }}
|
||||
label={{
|
||||
value: 'days',
|
||||
position: 'insideBottomRight',
|
||||
offset: -4,
|
||||
fill: theme.color.textMute,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={theme.color.textMute}
|
||||
tick={{ fontSize: 11 }}
|
||||
domain={[0, 100]}
|
||||
unit="%"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: theme.color.surface,
|
||||
border: `1px solid ${theme.color.border}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelFormatter={(v) => `${v}일 후`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<ReferenceLine
|
||||
y={threshold * 100}
|
||||
stroke={theme.color.warning}
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: `임계값 ${Math.round(threshold * 100)}%`,
|
||||
fill: theme.color.warning,
|
||||
fontSize: 10,
|
||||
position: 'insideTopRight',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
name="기억 강도 S(t)"
|
||||
stroke={theme.color.accent}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p"
|
||||
name="정답 확률 P"
|
||||
stroke={theme.color.accent2}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/layout/AppShell.tsx
Normal file
99
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { api, type MeUser } from '@/lib/api';
|
||||
import { hasToken, clearToken } from '@/lib/auth';
|
||||
import BottomNav from './BottomNav';
|
||||
import SideNav from './SideNav';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
requireOnboarding?: boolean;
|
||||
}
|
||||
|
||||
const PUBLIC_ROUTES = ['/', '/login', '/register'];
|
||||
|
||||
export default function AppShell({ children, requireOnboarding = true }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<MeUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasToken()) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<MeUser>('/auth/me')
|
||||
.then((r) => {
|
||||
setUser(r.data);
|
||||
setLoading(false);
|
||||
if (requireOnboarding && !r.data.onboarded && pathname !== '/onboarding') {
|
||||
router.replace('/onboarding');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
});
|
||||
}, [router, pathname, requireOnboarding]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<Center>
|
||||
<LoadingMark>로딩 중...</LoadingMark>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const showNav = !PUBLIC_ROUTES.includes(pathname) && pathname !== '/onboarding';
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{showNav && <SideNav user={user} />}
|
||||
<Main $withNav={showNav}>{children}</Main>
|
||||
{showNav && <BottomNav />}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const Layout = styled.div`
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const Main = styled.main<{ $withNav: boolean }>`
|
||||
flex: 1;
|
||||
padding: ${theme.space.xl};
|
||||
padding-bottom: ${({ $withNav }) => ($withNav ? '88px' : theme.space.xl)};
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
padding: ${theme.space.md};
|
||||
padding-bottom: ${({ $withNav }) => ($withNav ? '80px' : theme.space.md)};
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoint.desktop}) {
|
||||
padding-bottom: ${theme.space.xl};
|
||||
}
|
||||
`;
|
||||
|
||||
const Center = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const LoadingMark = styled.div`
|
||||
font-family: ${theme.font.mono};
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
70
frontend/src/components/layout/BottomNav.tsx
Normal file
70
frontend/src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
const TABS = [
|
||||
{ href: '/dashboard', label: '대시', icon: '🏠' },
|
||||
{ href: '/review', label: '복습', icon: '📖' },
|
||||
{ href: '/study', label: '학습', icon: '✏️' },
|
||||
{ href: '/stats', label: '통계', icon: '📊' },
|
||||
{ href: '/profile', label: '프로필', icon: '👤' },
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Nav>
|
||||
{TABS.map((t) => {
|
||||
const active = pathname.startsWith(t.href);
|
||||
return (
|
||||
<Tab key={t.href} href={t.href} $active={active}>
|
||||
<Icon>{t.icon}</Icon>
|
||||
<Label>{t.label}</Label>
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</Nav>
|
||||
);
|
||||
}
|
||||
|
||||
const Nav = styled.nav`
|
||||
display: none;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${theme.color.surface};
|
||||
border-top: 1px solid ${theme.color.border};
|
||||
z-index: 100;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
`;
|
||||
|
||||
const Tab = styled(Link)<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
color: ${({ $active }) => ($active ? theme.color.accent : theme.color.textMute)};
|
||||
transition: color 0.15s;
|
||||
border-top: 2px solid ${({ $active }) => ($active ? theme.color.accent : 'transparent')};
|
||||
`;
|
||||
|
||||
const Icon = styled.span`
|
||||
font-size: 20px;
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
196
frontend/src/components/layout/SideNav.tsx
Normal file
196
frontend/src/components/layout/SideNav.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
import { PERSONA_META } from '@/lib/constants';
|
||||
import { clearToken } from '@/lib/auth';
|
||||
import type { MeUser } from '@/lib/api';
|
||||
|
||||
const NAV = [
|
||||
{ href: '/dashboard', label: '대시보드', icon: '🏠' },
|
||||
{ href: '/review', label: '복습', icon: '📖' },
|
||||
{ href: '/study', label: '학습 기록', icon: '✏️' },
|
||||
{ href: '/subjects', label: '과목', icon: '📚' },
|
||||
{ href: '/stats', label: '통계', icon: '📊' },
|
||||
{ href: '/profile', label: '프로필', icon: '👤' },
|
||||
];
|
||||
|
||||
export default function SideNav({ user }: { user: MeUser }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const personaMeta = PERSONA_META[user.persona];
|
||||
|
||||
return (
|
||||
<Aside>
|
||||
<Brand>
|
||||
<Logo>ReLoop</Logo>
|
||||
<BrandSub>적응형 복습</BrandSub>
|
||||
</Brand>
|
||||
|
||||
<UserCard>
|
||||
<PersonaEmoji $color={personaMeta.color}>{personaMeta.emoji}</PersonaEmoji>
|
||||
<UserInfo>
|
||||
<Nickname>{user.nickname}</Nickname>
|
||||
<UserMeta>
|
||||
{personaMeta.label} ·{' '}
|
||||
{user.currentGrade ? `${user.currentGrade}등급` : '미설정'}
|
||||
</UserMeta>
|
||||
</UserInfo>
|
||||
</UserCard>
|
||||
|
||||
<NavList>
|
||||
{NAV.map((item) => (
|
||||
<NavItem
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
$active={pathname.startsWith(item.href)}
|
||||
>
|
||||
<Icon>{item.icon}</Icon>
|
||||
<span>{item.label}</span>
|
||||
</NavItem>
|
||||
))}
|
||||
</NavList>
|
||||
|
||||
<Bottom>
|
||||
<LogoutBtn onClick={handleLogout}>로그아웃</LogoutBtn>
|
||||
</Bottom>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
const Aside = styled.aside`
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
padding: ${theme.space.xl} ${theme.space.lg};
|
||||
border-right: 1px solid ${theme.color.border};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Brand = styled.div``;
|
||||
|
||||
const Logo = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: ${theme.color.textMain};
|
||||
letter-spacing: -0.02em;
|
||||
`;
|
||||
|
||||
const BrandSub = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textMute};
|
||||
font-family: ${theme.font.mono};
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const UserCard = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.md};
|
||||
padding: ${theme.space.md};
|
||||
background: ${theme.color.surface};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
`;
|
||||
|
||||
const PersonaEmoji = styled.div<{ $color: string }>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${({ $color }) => $color}22;
|
||||
border: 1px solid ${({ $color }) => $color}66;
|
||||
border-radius: ${theme.radius.md};
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Nickname = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const UserMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const NavList = styled.nav`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const NavItem = styled(Link)<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.space.sm};
|
||||
padding: 10px 14px;
|
||||
border-radius: ${theme.radius.md};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${({ $active }) => ($active ? theme.color.textMain : theme.color.textSub)};
|
||||
background: ${({ $active }) => ($active ? theme.color.surface : 'transparent')};
|
||||
border: 1px solid ${({ $active }) => ($active ? theme.color.border : 'transparent')};
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.textMain};
|
||||
background: ${theme.color.surface};
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.span`
|
||||
font-size: 18px;
|
||||
width: 22px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Bottom = styled.div`
|
||||
margin-top: auto;
|
||||
`;
|
||||
|
||||
const LogoutBtn = styled.button`
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.danger};
|
||||
border-color: ${theme.color.danger};
|
||||
}
|
||||
`;
|
||||
259
frontend/src/components/ui/primitives.tsx
Normal file
259
frontend/src/components/ui/primitives.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import styled, { css } from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export const Card = styled.div<{ $hoverable?: boolean }>`
|
||||
background: ${theme.color.surface};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.lg};
|
||||
padding: ${theme.space.lg};
|
||||
|
||||
${({ $hoverable }) =>
|
||||
$hoverable &&
|
||||
css`
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: ${theme.color.accent};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Button = styled.button<{
|
||||
$variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
$block?: boolean;
|
||||
$size?: 'sm' | 'md' | 'lg';
|
||||
}>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${theme.space.sm};
|
||||
border-radius: ${theme.radius.md};
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
|
||||
white-space: nowrap;
|
||||
min-height: 44px;
|
||||
|
||||
${({ $size }) => {
|
||||
switch ($size) {
|
||||
case 'sm':
|
||||
return css`
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
`;
|
||||
case 'lg':
|
||||
return css`
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
min-height: 52px;
|
||||
`;
|
||||
case 'md':
|
||||
default:
|
||||
return css`
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
${({ $block }) => $block && 'width: 100%;'}
|
||||
|
||||
${({ $variant = 'primary' }) => {
|
||||
switch ($variant) {
|
||||
case 'primary':
|
||||
return css`
|
||||
background: ${theme.color.accent};
|
||||
color: white;
|
||||
border: 1px solid ${theme.color.accent};
|
||||
&:hover:not(:disabled) {
|
||||
background: ${theme.color.accentHover};
|
||||
border-color: ${theme.color.accentHover};
|
||||
}
|
||||
`;
|
||||
case 'secondary':
|
||||
return css`
|
||||
background: ${theme.color.surface2};
|
||||
color: ${theme.color.textMain};
|
||||
border: 1px solid ${theme.color.border};
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${theme.color.accent};
|
||||
background: ${theme.color.surfaceHover};
|
||||
}
|
||||
`;
|
||||
case 'ghost':
|
||||
return css`
|
||||
background: transparent;
|
||||
color: ${theme.color.textSub};
|
||||
border: 1px solid transparent;
|
||||
&:hover:not(:disabled) {
|
||||
color: ${theme.color.textMain};
|
||||
border-color: ${theme.color.border};
|
||||
}
|
||||
`;
|
||||
case 'danger':
|
||||
return css`
|
||||
background: ${theme.color.danger};
|
||||
color: white;
|
||||
border: 1px solid ${theme.color.danger};
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Input = styled.input`
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: ${theme.color.surface2};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
color: ${theme.color.textMain};
|
||||
font-size: 16px;
|
||||
min-height: 48px;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.color.textMute};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${theme.color.accent};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Select = styled.select`
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: ${theme.color.surface2};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
color: ${theme.color.textMain};
|
||||
font-size: 16px;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${theme.color.accent};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Textarea = styled.textarea`
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: ${theme.color.surface2};
|
||||
border: 1px solid ${theme.color.border};
|
||||
border-radius: ${theme.radius.md};
|
||||
color: ${theme.color.textMain};
|
||||
font-size: 16px;
|
||||
resize: vertical;
|
||||
min-height: 84px;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.color.textMute};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${theme.color.accent};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Label = styled.label`
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textSub};
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
export const Badge = styled.span<{ $variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
${({ $variant = 'default' }) => {
|
||||
switch ($variant) {
|
||||
case 'success':
|
||||
return css`
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: ${theme.color.success};
|
||||
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||
`;
|
||||
case 'warning':
|
||||
return css`
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: ${theme.color.warning};
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
`;
|
||||
case 'danger':
|
||||
return css`
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: ${theme.color.danger};
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
`;
|
||||
case 'info':
|
||||
return css`
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: ${theme.color.info};
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
`;
|
||||
default:
|
||||
return css`
|
||||
background: ${theme.color.surface2};
|
||||
color: ${theme.color.textSub};
|
||||
border: 1px solid ${theme.color.border};
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
export const SectionTitle = styled.h2`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textSub};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: ${theme.space.md};
|
||||
`;
|
||||
|
||||
export const Stack = styled.div<{ $gap?: string; $horizontal?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: ${({ $horizontal }) => ($horizontal ? 'row' : 'column')};
|
||||
gap: ${({ $gap }) => $gap ?? theme.space.md};
|
||||
`;
|
||||
|
||||
export const ErrorText = styled.p`
|
||||
color: ${theme.color.danger};
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
export const HelpText = styled.p`
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 12px;
|
||||
`;
|
||||
127
frontend/src/lib/api.ts
Normal file
127
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { getToken, clearToken } from './auth';
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? 'https://reloop-api.nabomhalang.co.kr/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token invalid / expired — clear and redirect on next render
|
||||
clearToken();
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export type Persona = 'senior' | 'mid' | 'junior' | 'crammer';
|
||||
export type ReviewIntensity = 'strict' | 'moderate' | 'relaxed';
|
||||
export type StudyResult = 'correct' | 'incorrect' | 'partial';
|
||||
export type ReviewStatus = 'pending' | 'done' | 'skipped' | 'expired';
|
||||
|
||||
export interface MeUser {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
persona: Persona;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
reviewIntensity: ReviewIntensity;
|
||||
onboarded: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Subject {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
subjectId: number;
|
||||
}
|
||||
|
||||
export interface StudyLog {
|
||||
id: number;
|
||||
subjectId: number;
|
||||
tagId: number | null;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
baseCorrectRate: number | null;
|
||||
result: StudyResult;
|
||||
memo: string | null;
|
||||
studiedAt: string;
|
||||
timeSpent: number | null;
|
||||
subject?: { id: number; name: string; color: string };
|
||||
tag?: { id: number; name: string } | null;
|
||||
reviewSchedules?: Array<{
|
||||
id: number;
|
||||
scheduledAt: string;
|
||||
status: ReviewStatus;
|
||||
predictedP: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: number;
|
||||
studyLogId: number;
|
||||
scheduledAt: string;
|
||||
predictedP: number | null;
|
||||
iteration: number;
|
||||
status: ReviewStatus;
|
||||
studyLog: StudyLog & {
|
||||
subject: { id: number; name: string; color: string };
|
||||
tag: { id: number; name: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
user: {
|
||||
nickname: string;
|
||||
persona: Persona;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
reviewIntensity: ReviewIntensity;
|
||||
onboarded: boolean;
|
||||
};
|
||||
queue: { overdue: number; soon: number; total: number };
|
||||
weekly: { correct: number; incorrect: number; partial: number };
|
||||
recentLogs: StudyLog[];
|
||||
topSkills: Array<{
|
||||
id: number;
|
||||
s0: number;
|
||||
sampleCount: number;
|
||||
tag: { id: number; name: string; subject: Subject };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ForgetCurveResponse {
|
||||
tag: { id: number; name: string; subject: Subject };
|
||||
snapshot: { s0: number; lastUpdatedAt: string; sampleCount: number };
|
||||
persona: Persona;
|
||||
intensity: ReviewIntensity;
|
||||
difficulty: number;
|
||||
points: Array<{ t: number; s: number; p: number }>;
|
||||
}
|
||||
22
frontend/src/lib/auth.ts
Normal file
22
frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
const TOKEN_KEY = 'reloop.token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function hasToken(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
56
frontend/src/lib/constants.ts
Normal file
56
frontend/src/lib/constants.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Persona, ReviewIntensity } from './api';
|
||||
|
||||
export const PERSONA_META: Record<
|
||||
Persona,
|
||||
{ label: string; emoji: string; color: string; lambda: number; desc: string }
|
||||
> = {
|
||||
senior: {
|
||||
label: '상위권',
|
||||
emoji: '🧊',
|
||||
color: '#34d399',
|
||||
lambda: 0.1,
|
||||
desc: '잘 안 까먹음. 복습 간격이 넓어도 유지돼.',
|
||||
},
|
||||
mid: {
|
||||
label: '중위권',
|
||||
emoji: '🌊',
|
||||
color: '#60a5fa',
|
||||
lambda: 0.2,
|
||||
desc: '평균적인 망각 속도. 꾸준한 복습으로 관리.',
|
||||
},
|
||||
junior: {
|
||||
label: '하위권',
|
||||
emoji: '🔥',
|
||||
color: '#fbbf24',
|
||||
lambda: 0.4,
|
||||
desc: '금방 까먹어서 자주 복습해야 해.',
|
||||
},
|
||||
crammer: {
|
||||
label: '벼락치기형',
|
||||
emoji: '⚡',
|
||||
color: '#f472b6',
|
||||
lambda: 0.6,
|
||||
desc: '단기 집중형. 망각 속도가 매우 빨라 잦은 복습 필수.',
|
||||
},
|
||||
};
|
||||
|
||||
export const INTENSITY_META: Record<
|
||||
ReviewIntensity,
|
||||
{ label: string; desc: string; threshold: number }
|
||||
> = {
|
||||
strict: {
|
||||
label: '빡세게',
|
||||
desc: '아직 맞출 확률이 꽤 높을 때 미리 복습. 자주 등장.',
|
||||
threshold: 0.7,
|
||||
},
|
||||
moderate: {
|
||||
label: '적당히',
|
||||
desc: '반반일 때 복습. 균형잡힌 기본 설정.',
|
||||
threshold: 0.5,
|
||||
},
|
||||
relaxed: {
|
||||
label: '느슨하게',
|
||||
desc: '많이 까먹은 후 복습. 간격이 넓음.',
|
||||
threshold: 0.35,
|
||||
},
|
||||
};
|
||||
85
frontend/src/styles/GlobalStyle.ts
Normal file
85
frontend/src/styles/GlobalStyle.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import { theme } from './theme';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ${theme.font.sans};
|
||||
background: ${theme.color.bgGradient};
|
||||
background-attachment: fixed;
|
||||
color: ${theme.color.textMain};
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Subtle radial accent glows behind everything */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -50%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.10) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* iOS zoom prevention — inputs must be ≥ 16px */
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul, ol { list-style: none; }
|
||||
|
||||
::selection {
|
||||
background: ${theme.color.accent};
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Scrollbar — subtle */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: ${theme.color.border};
|
||||
border-radius: 4px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: ${theme.color.textMute};
|
||||
}
|
||||
`;
|
||||
|
||||
export default GlobalStyle;
|
||||
30
frontend/src/styles/registry.tsx
Normal file
30
frontend/src/styles/registry.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
|
||||
|
||||
/**
|
||||
* styled-components SSR registry for Next.js 14 App Router.
|
||||
*/
|
||||
export default function StyledComponentsRegistry({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [sheet] = useState(() => new ServerStyleSheet());
|
||||
|
||||
useServerInsertedHTML(() => {
|
||||
const styles = sheet.getStyleElement();
|
||||
sheet.instance.clearTag();
|
||||
return <>{styles}</>;
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>
|
||||
);
|
||||
}
|
||||
86
frontend/src/styles/theme.ts
Normal file
86
frontend/src/styles/theme.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* ReLoop v2 design tokens.
|
||||
*
|
||||
* Philosophy: 학습앱 = 집중 + 깔끔. 어두운 그라데이션 배경 유지하되
|
||||
* 정보가 흐리지 않게 surface contrast 를 분명히. 모바일 first (≥ 360px).
|
||||
*/
|
||||
|
||||
export const theme = {
|
||||
color: {
|
||||
bg: '#0b1020',
|
||||
bgGradient:
|
||||
'linear-gradient(160deg, #0b1020 0%, #141934 45%, #1a1530 100%)',
|
||||
surface: '#141934',
|
||||
surface2: '#1c2340',
|
||||
surfaceHover: '#242c4a',
|
||||
border: '#2a3256',
|
||||
borderSoft: '#1f2642',
|
||||
|
||||
textMain: '#f1f5f9',
|
||||
textSub: '#94a3b8',
|
||||
textMute: '#64748b',
|
||||
|
||||
accent: '#6366f1', // indigo — 주 액센트
|
||||
accentHover: '#818cf8',
|
||||
accent2: '#8b5cf6', // violet — 보조
|
||||
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#38bdf8',
|
||||
},
|
||||
|
||||
persona: {
|
||||
senior: { label: '상위권', color: '#34d399', emoji: '🧊' },
|
||||
mid: { label: '중위권', color: '#60a5fa', emoji: '🌊' },
|
||||
junior: { label: '하위권', color: '#fbbf24', emoji: '🔥' },
|
||||
crammer: { label: '벼락치기형', color: '#f472b6', emoji: '⚡' },
|
||||
},
|
||||
|
||||
space: {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px',
|
||||
xxl: '48px',
|
||||
},
|
||||
|
||||
radius: {
|
||||
sm: '6px',
|
||||
md: '10px',
|
||||
lg: '16px',
|
||||
xl: '20px',
|
||||
pill: '9999px',
|
||||
},
|
||||
|
||||
font: {
|
||||
sans:
|
||||
"'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
||||
mono:
|
||||
"'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
||||
},
|
||||
|
||||
shadow: {
|
||||
sm: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||
md: '0 6px 20px rgba(0, 0, 0, 0.35)',
|
||||
lg: '0 16px 40px rgba(0, 0, 0, 0.45)',
|
||||
glow: '0 0 24px rgba(99, 102, 241, 0.25)',
|
||||
},
|
||||
|
||||
breakpoint: {
|
||||
mobile: '480px',
|
||||
tablet: '768px',
|
||||
desktop: '1024px',
|
||||
wide: '1280px',
|
||||
},
|
||||
};
|
||||
|
||||
export type Theme = typeof theme;
|
||||
|
||||
export const PERSONA_ORDER: Array<keyof typeof theme.persona> = [
|
||||
'senior',
|
||||
'mid',
|
||||
'junior',
|
||||
'crammer',
|
||||
];
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user