feat(profile): avatar upload + onboarding wizard + mobile dropdown — 7G.6

This commit is contained in:
reloop
2026-04-12 07:48:55 +09:00
parent e332d270c8
commit a5dab35618
15 changed files with 6587 additions and 266 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `avatarUrl` VARCHAR(191) NULL,
ADD COLUMN `focusSubjects` JSON NULL,
ADD COLUMN `targetExamYear` INTEGER NULL;

View File

@@ -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)

View File

@@ -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,

View File

@@ -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({

View File

@@ -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 '';
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

View File

@@ -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`

View File

@@ -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>
);
}

View File

@@ -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`

View File

@@ -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;

View File

@@ -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;