feat(profile): avatar upload + onboarding wizard + mobile dropdown — 7G.6
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
|
||||
5221
backend/pnpm-lock.yaml
generated
Normal file
5221
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `users` ADD COLUMN `avatarUrl` VARCHAR(191) NULL,
|
||||
ADD COLUMN `focusSubjects` JSON NULL,
|
||||
ADD COLUMN `targetExamYear` INTEGER NULL;
|
||||
@@ -56,9 +56,12 @@ model User {
|
||||
email String @unique
|
||||
password String
|
||||
nickname String
|
||||
avatarUrl String?
|
||||
persona Persona @default(mid)
|
||||
currentGrade Int?
|
||||
targetGrade Int?
|
||||
targetExamYear Int?
|
||||
focusSubjects Json?
|
||||
reviewIntensity ReviewIntensity @default(moderate)
|
||||
onboardedAt DateTime?
|
||||
subscriptionTier SubscriptionTier @default(free)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -68,9 +69,12 @@ export class AuthService {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
persona: string;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
targetExamYear: number | null;
|
||||
focusSubjects: Prisma.JsonValue | null;
|
||||
reviewIntensity: string;
|
||||
onboardedAt: Date | null;
|
||||
subscriptionTier: string;
|
||||
@@ -81,9 +85,14 @@ export class AuthService {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nickname: u.nickname,
|
||||
avatarUrl: u.avatarUrl,
|
||||
persona: u.persona,
|
||||
currentGrade: u.currentGrade,
|
||||
targetGrade: u.targetGrade,
|
||||
targetExamYear: u.targetExamYear,
|
||||
focusSubjects: Array.isArray(u.focusSubjects)
|
||||
? u.focusSubjects.filter((subject): subject is string => typeof subject === 'string')
|
||||
: [],
|
||||
reviewIntensity: u.reviewIntensity,
|
||||
onboarded: u.onboardedAt !== null,
|
||||
subscriptionTier: u.subscriptionTier,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import helmet from 'helmet';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule, { cors: false });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: false,
|
||||
});
|
||||
|
||||
// CORS — allow the frontend origin explicitly (covers prod + dev)
|
||||
const allowedOrigins = (
|
||||
@@ -22,6 +26,9 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Delete,
|
||||
Controller,
|
||||
Post,
|
||||
Patch,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Persona, ReviewIntensity } from '@prisma/client';
|
||||
import { extname, join } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
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';
|
||||
|
||||
const ALLOWED_AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
|
||||
const ALLOWED_FOCUS_SUBJECTS = ['국어', '수학', '영어', '한국사', '탐구'] as const;
|
||||
const AVATAR_UPLOAD_DIR = join(__dirname, '..', '..', 'uploads', 'avatars');
|
||||
|
||||
function ensureAvatarUploadDir() {
|
||||
mkdirSync(AVATAR_UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
class OnboardingDto {
|
||||
@IsEnum(Persona)
|
||||
persona: Persona;
|
||||
@@ -27,6 +55,19 @@ class OnboardingDto {
|
||||
@Max(9)
|
||||
targetGrade?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(2020)
|
||||
@Max(2100)
|
||||
targetExamYear?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMaxSize(4)
|
||||
@IsIn(ALLOWED_FOCUS_SUBJECTS, { each: true })
|
||||
focusSubjects?: string[];
|
||||
|
||||
@IsEnum(ReviewIntensity)
|
||||
reviewIntensity: ReviewIntensity;
|
||||
}
|
||||
@@ -52,9 +93,26 @@ class ProfilePatchDto {
|
||||
@Max(9)
|
||||
targetGrade?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(2020)
|
||||
@Max(2100)
|
||||
targetExamYear?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMaxSize(4)
|
||||
@IsIn(ALLOWED_FOCUS_SUBJECTS, { each: true })
|
||||
focusSubjects?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ReviewIntensity)
|
||||
reviewIntensity?: ReviewIntensity;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onboarded?: boolean;
|
||||
}
|
||||
|
||||
@Controller('me')
|
||||
@@ -71,4 +129,60 @@ export class MeController {
|
||||
profile(@CurrentUser() user: AuthUser, @Body() dto: ProfilePatchDto) {
|
||||
return this.me.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('avatar')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
ensureAvatarUploadDir();
|
||||
cb(null, AVATAR_UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
const safeExt = ext || avatarExtensionForMime(file.mimetype);
|
||||
cb(null, `${req.user.id}-${Date.now()}${safeExt}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
cb(
|
||||
null,
|
||||
ALLOWED_AVATAR_MIME_TYPES.includes(
|
||||
file.mimetype as (typeof ALLOWED_AVATAR_MIME_TYPES)[number],
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadAvatar(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@UploadedFile()
|
||||
file?: {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
path: string;
|
||||
},
|
||||
) {
|
||||
return this.me.updateAvatar(user.id, file);
|
||||
}
|
||||
|
||||
@Delete('avatar')
|
||||
removeAvatar(@CurrentUser() user: AuthUser) {
|
||||
return this.me.removeAvatar(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
function avatarExtensionForMime(mimeType: string) {
|
||||
switch (mimeType) {
|
||||
case 'image/jpeg':
|
||||
return '.jpg';
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Persona, ReviewIntensity } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Persona, Prisma, ReviewIntensity } from '@prisma/client';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface OnboardingInput {
|
||||
persona: Persona;
|
||||
currentGrade?: number;
|
||||
targetGrade?: number;
|
||||
targetExamYear?: number;
|
||||
focusSubjects?: string[];
|
||||
reviewIntensity: ReviewIntensity;
|
||||
}
|
||||
|
||||
interface ProfileInput {
|
||||
nickname?: string;
|
||||
persona?: Persona;
|
||||
currentGrade?: number | null;
|
||||
targetGrade?: number | null;
|
||||
targetExamYear?: number | null;
|
||||
focusSubjects?: string[];
|
||||
reviewIntensity?: ReviewIntensity;
|
||||
onboarded?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -20,6 +38,8 @@ export class MeService {
|
||||
persona: input.persona,
|
||||
currentGrade: input.currentGrade ?? null,
|
||||
targetGrade: input.targetGrade ?? null,
|
||||
targetExamYear: input.targetExamYear ?? null,
|
||||
focusSubjects: input.focusSubjects ?? [],
|
||||
reviewIntensity: input.reviewIntensity,
|
||||
onboardedAt: new Date(),
|
||||
},
|
||||
@@ -27,30 +47,88 @@ export class MeService {
|
||||
return this.toView(user);
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: number,
|
||||
input: Partial<{
|
||||
nickname: string;
|
||||
persona: Persona;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
reviewIntensity: ReviewIntensity;
|
||||
}>,
|
||||
) {
|
||||
async updateProfile(userId: number, input: ProfileInput) {
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
|
||||
if (input.nickname !== undefined) data.nickname = input.nickname;
|
||||
if (input.persona !== undefined) data.persona = input.persona;
|
||||
if (input.currentGrade !== undefined) data.currentGrade = input.currentGrade;
|
||||
if (input.targetGrade !== undefined) data.targetGrade = input.targetGrade;
|
||||
if (input.targetExamYear !== undefined) data.targetExamYear = input.targetExamYear;
|
||||
if (input.focusSubjects !== undefined) data.focusSubjects = input.focusSubjects;
|
||||
if (input.reviewIntensity !== undefined) data.reviewIntensity = input.reviewIntensity;
|
||||
if (input.onboarded) data.onboardedAt = new Date();
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: input,
|
||||
data,
|
||||
});
|
||||
return this.toView(user);
|
||||
}
|
||||
|
||||
async updateAvatar(
|
||||
userId: number,
|
||||
file?: {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
path: string;
|
||||
},
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('valid image file is required');
|
||||
}
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype)) {
|
||||
throw new BadRequestException('unsupported avatar file type');
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
throw new BadRequestException('avatar file too large');
|
||||
}
|
||||
|
||||
const currentUser = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { avatarUrl: true },
|
||||
});
|
||||
const avatarUrl = `/uploads/avatars/${file.filename}`;
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { avatarUrl },
|
||||
});
|
||||
|
||||
await this.deleteAvatarFile(currentUser.avatarUrl);
|
||||
|
||||
return { avatarUrl };
|
||||
}
|
||||
|
||||
async removeAvatar(userId: number) {
|
||||
const currentUser = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { avatarUrl: true },
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { avatarUrl: null },
|
||||
});
|
||||
|
||||
await this.deleteAvatarFile(currentUser.avatarUrl);
|
||||
|
||||
return { avatarUrl: null };
|
||||
}
|
||||
|
||||
private toView(u: {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
persona: string;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
targetExamYear: number | null;
|
||||
focusSubjects: Prisma.JsonValue | null;
|
||||
reviewIntensity: string;
|
||||
onboardedAt: Date | null;
|
||||
subscriptionTier: string;
|
||||
@@ -61,9 +139,14 @@ export class MeService {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nickname: u.nickname,
|
||||
avatarUrl: u.avatarUrl,
|
||||
persona: u.persona,
|
||||
currentGrade: u.currentGrade,
|
||||
targetGrade: u.targetGrade,
|
||||
targetExamYear: u.targetExamYear,
|
||||
focusSubjects: Array.isArray(u.focusSubjects)
|
||||
? u.focusSubjects.filter((subject): subject is string => typeof subject === 'string')
|
||||
: [],
|
||||
reviewIntensity: u.reviewIntensity,
|
||||
onboarded: u.onboardedAt !== null,
|
||||
subscriptionTier: u.subscriptionTier,
|
||||
@@ -71,4 +154,18 @@ export class MeService {
|
||||
createdAt: u.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async deleteAvatarFile(avatarUrl: string | null) {
|
||||
if (!avatarUrl?.startsWith('/uploads/')) return;
|
||||
|
||||
const filePath = join(process.cwd(), avatarUrl.slice(1));
|
||||
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
backend/uploads/avatars/.gitkeep
Normal file
1
backend/uploads/avatars/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
api,
|
||||
resolveAssetUrl,
|
||||
type MeUser,
|
||||
type ReviewIntensity,
|
||||
type StudyLog,
|
||||
@@ -37,7 +38,16 @@ type HeatmapCell = {
|
||||
};
|
||||
|
||||
type ProfilePatch = Partial<
|
||||
Pick<MeUser, 'nickname' | 'currentGrade' | 'targetGrade' | 'reviewIntensity'>
|
||||
Pick<
|
||||
MeUser,
|
||||
| 'nickname'
|
||||
| 'currentGrade'
|
||||
| 'targetGrade'
|
||||
| 'targetExamYear'
|
||||
| 'focusSubjects'
|
||||
| 'persona'
|
||||
| 'reviewIntensity'
|
||||
> & { onboarded?: boolean }
|
||||
>;
|
||||
|
||||
const NOTIFICATION_STORAGE_KEY = 'reloop:profile-notifications';
|
||||
@@ -117,10 +127,13 @@ function ProfileBody() {
|
||||
const [notifications, setNotifications] =
|
||||
useState<NotificationPrefs>(DEFAULT_NOTIFICATIONS);
|
||||
const [studyLogs, setStudyLogs] = useState<StudyLog[]>([]);
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
|
||||
const [isAvatarDragOver, setIsAvatarDragOver] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
|
||||
const userRef = useRef<MeUser | null>(null);
|
||||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const currentGradeRef = useRef(5);
|
||||
const targetGradeRef = useRef(3);
|
||||
const gradeSaveTimerRef = useRef<number | null>(null);
|
||||
@@ -136,6 +149,14 @@ function ProfileBody() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarPreviewUrl?.startsWith('blob:')) {
|
||||
window.URL.revokeObjectURL(avatarPreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [avatarPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
currentGradeRef.current = currentGradeDraft;
|
||||
}, [currentGradeDraft]);
|
||||
@@ -182,6 +203,7 @@ function ProfileBody() {
|
||||
const nextUser = meResponse.data;
|
||||
userRef.current = nextUser;
|
||||
setUser(nextUser);
|
||||
setAvatarPreviewUrl(null);
|
||||
setNicknameDraft(nextUser.nickname);
|
||||
setCurrentGradeDraft(nextUser.currentGrade ?? 5);
|
||||
setTargetGradeDraft(nextUser.targetGrade ?? 3);
|
||||
@@ -293,6 +315,94 @@ function ProfileBody() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleAvatarSelection(file: File | null) {
|
||||
if (!file || !userRef.current) return;
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
showToast({
|
||||
message: 'JPG, PNG, WEBP 이미지 파일만 업로드할 수 있습니다',
|
||||
variant: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
showToast({ message: '이미지는 2MB 이하만 업로드할 수 있습니다', variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPreviewUrl = avatarPreviewUrl;
|
||||
const nextPreviewUrl = window.URL.createObjectURL(file);
|
||||
setAvatarPreviewUrl(nextPreviewUrl);
|
||||
setSavingKey('avatar');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await api.post<{ avatarUrl: string }>('/me/avatar', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
const currentUser = userRef.current;
|
||||
if (!currentUser) return;
|
||||
|
||||
const nextUser = {
|
||||
...currentUser,
|
||||
avatarUrl: response.data.avatarUrl,
|
||||
};
|
||||
|
||||
userRef.current = nextUser;
|
||||
setUser(nextUser);
|
||||
showToast({ message: '프로필 사진을 업데이트했습니다', variant: 'success' });
|
||||
} catch {
|
||||
if (nextPreviewUrl.startsWith('blob:')) {
|
||||
window.URL.revokeObjectURL(nextPreviewUrl);
|
||||
}
|
||||
setAvatarPreviewUrl(previousPreviewUrl ?? null);
|
||||
showToast({ message: '프로필 사진 업로드에 실패했습니다', variant: 'danger' });
|
||||
return;
|
||||
} finally {
|
||||
if (previousPreviewUrl?.startsWith('blob:')) {
|
||||
window.URL.revokeObjectURL(previousPreviewUrl);
|
||||
}
|
||||
setSavingKey((current) => (current === 'avatar' ? null : current));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAvatarRemove() {
|
||||
if (!userRef.current?.avatarUrl) return;
|
||||
|
||||
const previousPreviewUrl = avatarPreviewUrl;
|
||||
setAvatarPreviewUrl(null);
|
||||
setSavingKey('avatar');
|
||||
|
||||
try {
|
||||
await api.delete('/me/avatar');
|
||||
const nextUser = { ...userRef.current, avatarUrl: null };
|
||||
userRef.current = nextUser;
|
||||
setUser(nextUser);
|
||||
showToast({ message: '프로필 사진을 제거했습니다', variant: 'success' });
|
||||
} catch {
|
||||
setAvatarPreviewUrl(previousPreviewUrl ?? null);
|
||||
showToast({ message: '프로필 사진 제거에 실패했습니다', variant: 'danger' });
|
||||
} finally {
|
||||
setSavingKey((current) => (current === 'avatar' ? null : current));
|
||||
}
|
||||
}
|
||||
|
||||
function handleAvatarInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
void handleAvatarSelection(file);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function handleAvatarDrop(event: React.DragEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
setIsAvatarDragOver(false);
|
||||
const file = event.dataTransfer.files?.[0] ?? null;
|
||||
void handleAvatarSelection(file);
|
||||
}
|
||||
|
||||
async function handleExportData() {
|
||||
try {
|
||||
const logs = studyLogs.length > 0 ? studyLogs : await fetchAllStudyLogs();
|
||||
@@ -327,6 +437,8 @@ function ProfileBody() {
|
||||
|
||||
const heatmap = useMemo(() => buildHeatmap(studyLogs), [studyLogs]);
|
||||
const streakDays = useMemo(() => countStreakDays(studyLogs), [studyLogs]);
|
||||
const displayAvatarUrl = avatarPreviewUrl ?? resolveAssetUrl(user?.avatarUrl);
|
||||
const displayInitial = (nicknameDraft || user?.nickname || 'U').slice(0, 1).toUpperCase();
|
||||
|
||||
if (loading || !user) {
|
||||
return <LoadingState>로딩 중...</LoadingState>;
|
||||
@@ -348,7 +460,7 @@ function ProfileBody() {
|
||||
|
||||
<MainGrid>
|
||||
<PrimaryColumn>
|
||||
<Card>
|
||||
<Card id="settings">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Icon name="gear" size={18} color={theme.color.textSub} />
|
||||
@@ -365,11 +477,53 @@ function ProfileBody() {
|
||||
</CardHeader>
|
||||
|
||||
<AccountRow>
|
||||
<AvatarRing $pro={user.subscriptionTier === 'pro'}>
|
||||
<AvatarCircle>
|
||||
{(nicknameDraft || user.nickname).slice(0, 1).toUpperCase()}
|
||||
</AvatarCircle>
|
||||
</AvatarRing>
|
||||
<AvatarBlock>
|
||||
<AvatarUploadButton
|
||||
type="button"
|
||||
onClick={() => avatarFileInputRef.current?.click()}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsAvatarDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsAvatarDragOver(false)}
|
||||
onDrop={handleAvatarDrop}
|
||||
$dragOver={isAvatarDragOver}
|
||||
aria-busy={savingKey === 'avatar'}
|
||||
aria-label="프로필 사진 변경"
|
||||
>
|
||||
<AvatarRing $pro={user.subscriptionTier === 'pro'}>
|
||||
<AvatarCircle>
|
||||
{displayAvatarUrl ? (
|
||||
<AvatarImage
|
||||
src={displayAvatarUrl}
|
||||
alt={`${user.nickname} avatar`}
|
||||
/>
|
||||
) : (
|
||||
displayInitial
|
||||
)}
|
||||
</AvatarCircle>
|
||||
</AvatarRing>
|
||||
<AvatarOverlay>
|
||||
<Icon name="camera" size={16} color={theme.color.textBright} />
|
||||
사진 변경
|
||||
</AvatarOverlay>
|
||||
</AvatarUploadButton>
|
||||
|
||||
<HiddenFileInput
|
||||
ref={avatarFileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarInputChange}
|
||||
/>
|
||||
|
||||
{user.avatarUrl || avatarPreviewUrl ? (
|
||||
<AvatarSecondaryButton type="button" onClick={handleAvatarRemove}>
|
||||
사진 제거
|
||||
</AvatarSecondaryButton>
|
||||
) : (
|
||||
<AvatarHint>드래그 앤 드롭 또는 클릭해서 업로드</AvatarHint>
|
||||
)}
|
||||
</AvatarBlock>
|
||||
|
||||
<AccountContent>
|
||||
<NameRow>
|
||||
@@ -957,6 +1111,25 @@ const AccountRow = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const AvatarBlock = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const AvatarUploadButton = styled.button<{ $dragOver: boolean }>`
|
||||
position: relative;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transform: ${({ $dragOver }) => ($dragOver ? 'scale(1.02)' : 'scale(1)')};
|
||||
transition: transform 0.2s ease;
|
||||
`;
|
||||
|
||||
const AvatarRing = styled.div<{ $pro: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -970,9 +1143,21 @@ const AvatarRing = styled.div<{ $pro: boolean }>`
|
||||
? 'linear-gradient(135deg, rgba(79, 70, 229, 1), rgba(124, 58, 237, 1))'
|
||||
: 'rgba(255, 255, 255, 0.08)'};
|
||||
box-shadow: ${({ $pro }) => ($pro ? theme.shadow.glowIndigoStrong : 'none')};
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
|
||||
${AvatarUploadButton}:hover & {
|
||||
box-shadow: ${theme.shadow.glowIndigoStrong};
|
||||
}
|
||||
|
||||
${AvatarUploadButton}[aria-busy='true'] & {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
const AvatarCircle = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -986,6 +1171,62 @@ const AvatarCircle = styled.div`
|
||||
font-family: ${theme.font.display};
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const AvatarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const AvatarOverlay = styled.span`
|
||||
position: absolute;
|
||||
inset: auto 6px 6px 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 16, 32, 0.82);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
|
||||
${AvatarUploadButton}:hover &,
|
||||
${AvatarUploadButton}:focus-visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const HiddenFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const AvatarSecondaryButton = styled.button`
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fca5a5;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #fecaca;
|
||||
}
|
||||
`;
|
||||
|
||||
const AvatarHint = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textMute};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AccountContent = styled.div`
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function AppShell({ children, requireOnboarding = true }: Props)
|
||||
<Layout>
|
||||
{showNav && <SideNav user={user} />}
|
||||
<Main $withNav={showNav}>{children}</Main>
|
||||
{showNav && <BottomNav />}
|
||||
{showNav && <BottomNav user={user} />}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import type { MeUser } from '@/lib/api';
|
||||
import { resolveAssetUrl } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
const TABS = [
|
||||
@@ -16,21 +18,37 @@ const TABS = [
|
||||
{ href: '/profile', label: '프로필', icon: 'user' as const },
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
interface BottomNavProps {
|
||||
user: MeUser;
|
||||
}
|
||||
|
||||
export default function BottomNav({ user }: BottomNavProps) {
|
||||
const pathname = usePathname();
|
||||
const initial = user.nickname.trim().charAt(0).toUpperCase() || 'U';
|
||||
const avatarSrc = resolveAssetUrl(user.avatarUrl);
|
||||
|
||||
return (
|
||||
<Nav>
|
||||
{TABS.map((t) => {
|
||||
const active = pathname.startsWith(t.href);
|
||||
const isProfile = t.href === '/profile';
|
||||
return (
|
||||
<Tab key={t.href} href={t.href} $active={active}>
|
||||
<IconWrap>
|
||||
<Icon
|
||||
name={t.icon}
|
||||
size={18}
|
||||
weight={active ? 'fill' : 'regular'}
|
||||
color="currentColor"
|
||||
/>
|
||||
<IconWrap $profile={isProfile}>
|
||||
{isProfile ? (
|
||||
avatarSrc ? (
|
||||
<AvatarThumb src={avatarSrc} alt={`${user.nickname} avatar`} />
|
||||
) : (
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
)
|
||||
) : (
|
||||
<Icon
|
||||
name={t.icon}
|
||||
size={18}
|
||||
weight={active ? 'fill' : 'regular'}
|
||||
color="currentColor"
|
||||
/>
|
||||
)}
|
||||
</IconWrap>
|
||||
<Label>{t.label}</Label>
|
||||
</Tab>
|
||||
@@ -72,12 +90,36 @@ const Tab = styled(Link)<{ $active: boolean }>`
|
||||
${({ $active }) => ($active ? theme.color.brandIndigo : 'transparent')};
|
||||
`;
|
||||
|
||||
const IconWrap = styled.span`
|
||||
const IconWrap = styled.span<{ $profile?: boolean }>`
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: ${({ $profile }) =>
|
||||
$profile ? 'rgba(255, 255, 255, 0.08)' : 'transparent'};
|
||||
`;
|
||||
|
||||
const AvatarThumb = styled.img`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
`;
|
||||
|
||||
const AvatarFallback = styled.span`
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.surface2};
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import { clearToken } from '@/lib/auth';
|
||||
import type { MeUser, SubscriptionTier } from '@/lib/api';
|
||||
import { resolveAssetUrl, type MeUser, type SubscriptionTier } from '@/lib/api';
|
||||
import { Icon, type IconName } from '@/components/ui/Icon';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
@@ -92,6 +92,12 @@ export default function SideNav({ user, reviewCount }: SideNavProps) {
|
||||
};
|
||||
|
||||
const initial = user.nickname.trim().charAt(0).toUpperCase() || 'U';
|
||||
const avatarSrc = resolveAssetUrl(user.avatarUrl);
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ label: '프로필', href: '/profile', icon: 'user' as const },
|
||||
{ label: '설정', href: '/profile#settings', icon: 'gear' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Aside>
|
||||
@@ -131,19 +137,36 @@ export default function SideNav({ user, reviewCount }: SideNavProps) {
|
||||
|
||||
<UserSection ref={menuRef}>
|
||||
<UserButton type="button" onClick={() => setMenuOpen((prev) => !prev)}>
|
||||
<Avatar>{initial}</Avatar>
|
||||
<Avatar>
|
||||
{avatarSrc ? (
|
||||
<AvatarImage src={avatarSrc} alt={`${user.nickname} avatar`} />
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</Avatar>
|
||||
<UserInfo>
|
||||
<UserName>{user.nickname}</UserName>
|
||||
<PlanLabel>{SUBSCRIPTION_LABELS[user.subscriptionTier]}</PlanLabel>
|
||||
</UserInfo>
|
||||
<CaretWrap $open={menuOpen}>
|
||||
<Icon name="caret-up" size={10} weight="regular" />
|
||||
<Icon name="dots-three-vertical" size={14} weight="fill" />
|
||||
</CaretWrap>
|
||||
</UserButton>
|
||||
|
||||
{menuOpen ? (
|
||||
<Dropdown>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<DropdownLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<Icon name={item.icon} size={14} color={theme.color.textSub} />
|
||||
{item.label}
|
||||
</DropdownLink>
|
||||
))}
|
||||
<LogoutButton type="button" onClick={handleLogout}>
|
||||
<Icon name="sign-out" size={14} color="currentColor" />
|
||||
로그아웃
|
||||
</LogoutButton>
|
||||
</Dropdown>
|
||||
@@ -333,6 +356,13 @@ const Avatar = styled.div`
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const AvatarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
@@ -366,10 +396,10 @@ const CaretWrap = styled.span<{ $open: boolean }>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
transform: ${({ $open }) => ($open ? 'rotate(0deg)' : 'rotate(180deg)')};
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
opacity: ${({ $open }) => ($open ? 1 : 0.72)};
|
||||
|
||||
${UserButton}:hover & {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
@@ -385,11 +415,35 @@ const Dropdown = styled.div`
|
||||
border-radius: ${theme.radius.md};
|
||||
background: rgba(21, 21, 28, 0.96);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
backdrop-filter: blur(18px);
|
||||
`;
|
||||
|
||||
const DropdownLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
`;
|
||||
|
||||
const LogoutButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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_ASSET_BASE_URL = API_BASE_URL.replace(/\/api\/?$/, '');
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -43,9 +44,12 @@ export interface MeUser {
|
||||
id: number;
|
||||
email: string;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
persona: Persona;
|
||||
currentGrade: number | null;
|
||||
targetGrade: number | null;
|
||||
targetExamYear: number | null;
|
||||
focusSubjects: string[];
|
||||
reviewIntensity: ReviewIntensity;
|
||||
onboarded: boolean;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
@@ -53,6 +57,12 @@ export interface MeUser {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function resolveAssetUrl(path: string | null | undefined) {
|
||||
if (!path) return null;
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return `${API_ASSET_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export interface ProblemSetSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user