Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function Dashboard() {
const hasChildren = mode.children && mode.children.length > 0

return (
<Grid item xs={12} md={6} key={mode.id}>
<Grid key={mode.id} size={{xs: 12, md: 6}}>
<Card
onMouseEnter={() => handleCardHover(mode.id)}
onMouseLeave={handleCardLeave}
Expand Down Expand Up @@ -467,7 +467,7 @@ function ReportsPage() {

{/* 통계 요약 카드 */}
<Grid container spacing={2} sx={{ mb: 4 }}>
<Grid item xs={6} md={3}>
<Grid size={{xs: 6, md: 3}}>
<Card sx={{ p: 2.5, borderRadius: '16px', height: '100%' }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
{isKorean ? '총 학습일' : 'Study Days'}
Expand All @@ -480,7 +480,7 @@ function ReportsPage() {
</Typography>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid size={{xs: 6, md: 3}}>
<Card sx={{ p: 2.5, borderRadius: '16px', height: '100%' }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
{isKorean ? '학습한 단어' : 'Words Learned'}
Expand All @@ -493,7 +493,7 @@ function ReportsPage() {
</Typography>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid size={{xs: 6, md: 3}}>
<Card sx={{ p: 2.5, borderRadius: '16px', height: '100%' }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
{isKorean ? '테스트 완료' : 'Tests Taken'}
Expand All @@ -506,7 +506,7 @@ function ReportsPage() {
</Typography>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid size={{xs: 6, md: 3}}>
<Card sx={{ p: 2.5, borderRadius: '16px', height: '100%' }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
{isKorean ? '평균 점수' : 'Average Score'}
Expand All @@ -527,7 +527,7 @@ function ReportsPage() {
{isKorean ? '연속 학습 기록' : 'Study Streak'}
</Typography>
<Grid container spacing={3}>
<Grid item xs={6}>
<Grid size={{xs: 6}}>
<Box
sx={{
p: 2,
Expand All @@ -544,7 +544,7 @@ function ReportsPage() {
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Grid size={{xs: 6}}>
<Box
sx={{
p: 2,
Expand Down Expand Up @@ -626,7 +626,7 @@ function SettingsPage() {
<CardContent sx={{ p: 3 }}>
<Grid container spacing={2}>
{languageOptions.map((option) => (
<Grid item xs={6} key={option.value}>
<Grid key={option.value} size={{xs: 6}}>
<Box
onClick={() => setLanguage(option.value)}
sx={{
Expand Down Expand Up @@ -678,7 +678,7 @@ function SettingsPage() {
</Box>
<CardContent sx={{ p: 3 }}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Grid size={{xs: 6}}>
<Box
onClick={() => setTtsVoice('FEMALE')}
sx={{
Expand Down Expand Up @@ -720,7 +720,7 @@ function SettingsPage() {
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Grid size={{xs: 6}}>
<Box
onClick={() => setTtsVoice('MALE')}
sx={{
Expand Down
14 changes: 12 additions & 2 deletions src/api/chatApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,31 @@ const chatApi = axios.create({
},
})

// Request interceptor
// Request interceptor - JWT 토큰 자동 추가
chatApi.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)

// Response interceptor
// Response interceptor - 401 에러 시 로그인 페이지로 이동
chatApi.interceptors.response.use(
(response) => response.data,
(error) => {
console.error('Chat API Error:', error.response?.data || error.message)

if (error.response?.status === 401) {
localStorage.removeItem('accessToken')
window.location.href = '/login'
}

return Promise.reject(error)
}
)
Expand Down
14 changes: 12 additions & 2 deletions src/api/vocabApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,31 @@ const vocabApi = axios.create({
},
})

// Request interceptor
// Request interceptor - JWT 토큰 자동 추가
vocabApi.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)

// Response interceptor
// Response interceptor - 401 에러 시 로그인 페이지로 이동
vocabApi.interceptors.response.use(
(response) => response.data,
(error) => {
console.error('Vocab API Error:', error.response?.data || error.message)

if (error.response?.status === 401) {
localStorage.removeItem('accessToken')
window.location.href = '/login'
}

return Promise.reject(error)
}
)
Expand Down
11 changes: 10 additions & 1 deletion src/contexts/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,26 @@ export function AuthProvider({ children }) {
checkAuthUser()
}, [])

// 현재 인증된 사용자 확인
// 현재 인증된 사용자 확인 및 토큰 저장
const checkAuthUser = async () => {
try {
const currentUser = await getCurrentUser()

// Cognito 세션에서 토큰 가져와서 localStorage에 저장
const session = await fetchAuthSession()
const idToken = session.tokens?.idToken?.toString()
if (idToken) {
localStorage.setItem('accessToken', idToken)
}

setUser({
...currentUser,
email: currentUser.signInDetails?.loginId || currentUser.username,
})
setIsAuthenticated(true)
} catch (error) {
// 로그인되지 않은 상태
localStorage.removeItem('accessToken')
setUser(null)
setIsAuthenticated(false)
} finally {
Expand Down Expand Up @@ -131,6 +139,7 @@ export function AuthProvider({ children }) {
const logout = useCallback(async () => {
try {
await signOut()
localStorage.removeItem('accessToken')
setUser(null)
setIsAuthenticated(false)
return { success: true }
Expand Down
117 changes: 90 additions & 27 deletions src/domains/badge/components/BadgeGrid.jsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,72 @@
import {useRef, useState} from 'react'
import {Box, Skeleton, Typography} from '@mui/material'
import BadgeCard from './BadgeCard'
import {useSettings} from '../../../contexts/SettingsContext'

export default function BadgeGrid({badges = [], loading = false, size = 'medium'}) {
const {isKorean} = useSettings()
const scrollRef = useRef(null)
const [isDragging, setIsDragging] = useState(false)
const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)

// 드래그 스크롤 핸들러
const handleMouseDown = (e) => {
setIsDragging(true)
setStartX(e.pageX - scrollRef.current.offsetLeft)
setScrollLeft(scrollRef.current.scrollLeft)
}

const handleMouseLeave = () => {
setIsDragging(false)
}

const handleMouseUp = () => {
setIsDragging(false)
}

const handleMouseMove = (e) => {
if (!isDragging) return
e.preventDefault()
const x = e.pageX - scrollRef.current.offsetLeft
const walk = (x - startX) * 1.5
scrollRef.current.scrollLeft = scrollLeft - walk
}

// 터치 이벤트 핸들러
const handleTouchStart = (e) => {
setIsDragging(true)
setStartX(e.touches[0].pageX - scrollRef.current.offsetLeft)
setScrollLeft(scrollRef.current.scrollLeft)
}

const handleTouchMove = (e) => {
if (!isDragging) return
const x = e.touches[0].pageX - scrollRef.current.offsetLeft
const walk = (x - startX) * 1.5
scrollRef.current.scrollLeft = scrollLeft - walk
}

const handleTouchEnd = () => {
setIsDragging(false)
}

if (loading) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(3, 1fr)',
sm: 'repeat(4, 1fr)',
md: 'repeat(5, 1fr)',
lg: 'repeat(6, 1fr)',
},
display: 'flex',
gap: 2,
justifyItems: 'center',
overflowX: 'auto',
pb: 1,
'&::-webkit-scrollbar': {height: 6},
'&::-webkit-scrollbar-thumb': {backgroundColor: '#d1d5db', borderRadius: 3},
}}
>
{Array.from({length: 12}).map((_, index) => (
<Box key={index} sx={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
<Skeleton variant="circular" width={80} height={80}/>
<Skeleton variant="text" width={60} sx={{mt: 1}}/>
{Array.from({length: 6}).map((_, index) => (
<Box key={index} sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0}}>
<Skeleton variant="circular" width={72} height={72}/>
<Skeleton variant="text" width={50} sx={{mt: 1}}/>
</Box>
))}
</Box>
Expand All @@ -32,12 +75,7 @@ export default function BadgeGrid({badges = [], loading = false, size = 'medium'

if (badges.length === 0) {
return (
<Box
sx={{
py: 6,
textAlign: 'center',
}}
>
<Box sx={{py: 4, textAlign: 'center'}}>
<Typography variant="body1" color="text.secondary">
{isKorean ? '배지가 없습니다' : 'No badges available'}
</Typography>
Expand All @@ -54,20 +92,45 @@ export default function BadgeGrid({badges = [], loading = false, size = 'medium'

return (
<Box
ref={scrollRef}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(3, 1fr)',
sm: 'repeat(4, 1fr)',
md: 'repeat(5, 1fr)',
lg: 'repeat(6, 1fr)',
display: 'flex',
gap: {xs: 1.5, sm: 2},
overflowX: 'auto',
pb: 1,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
'&::-webkit-scrollbar': {
height: 6,
},
'&::-webkit-scrollbar-track': {
backgroundColor: '#f3f4f6',
borderRadius: 3,
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: '#d1d5db',
borderRadius: 3,
'&:hover': {
backgroundColor: '#9ca3af',
},
},
gap: {xs: 1.5, sm: 2, md: 3},
justifyItems: 'center',
scrollBehavior: isDragging ? 'auto' : 'smooth',
// 스크롤 힌트 그라데이션
maskImage: 'linear-gradient(to right, black 90%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to right, black 90%, transparent 100%)',
}}
>
{sortedBadges.map((badge) => (
<BadgeCard key={badge.badgeType} badge={badge} size={size}/>
<Box key={badge.badgeType} sx={{flexShrink: 0}}>
<BadgeCard badge={badge} size={size}/>
</Box>
))}
</Box>
)
Expand Down
9 changes: 6 additions & 3 deletions src/domains/badge/services/badgeService.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import badgeApi from '../../../api/badgeApi'

// Mock 데이터 사용 여부
const USE_MOCK = true
// Mock 데이터 사용 여부 (환경변수로 제어: VITE_USE_MOCK=true)
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'

// Placeholder 이미지 (실제 S3 이미지가 없을 경우 대비)
const PLACEHOLDER_BADGE = 'https://via.placeholder.com/100x100/FFD700/000000?text=Badge'
Expand Down Expand Up @@ -188,7 +188,10 @@ const withMock = (apiCall, mockData) => {
setTimeout(() => resolve(mockData), 500)
})
}
return apiCall().catch(() => mockData)
// 실제 API 호출 시 응답의 data 필드 추출 (백엔드 응답: { isSuccess, message, data })
return apiCall()
.then(response => response.data || response)
.catch(() => mockData)
}

/**
Expand Down
Loading