Style friend list with cards

This commit is contained in:
nightbug-xx 2025-06-11 11:29:27 +09:00
parent dbfc2faaa9
commit bfd04fc68c
10 changed files with 240 additions and 65 deletions

View File

@ -18,6 +18,7 @@ import CharacterEditPage from './pages/CharacterEditPage'
import GuidePage from './pages/Guide' import GuidePage from './pages/Guide'
import FriendListPage from './pages/FriendListPage' import FriendListPage from './pages/FriendListPage'
import FriendRequestsPage from './pages/FriendRequestsPage' import FriendRequestsPage from './pages/FriendRequestsPage'
import FriendCharacterDashboard from './pages/FriendCharacterDashboard'
const darkTheme = createTheme({ const darkTheme = createTheme({
palette: { palette: {
@ -49,6 +50,7 @@ function App() {
<Route path="/homeworks/:id/edit" element={<HomeworkEditPage />} /> <Route path="/homeworks/:id/edit" element={<HomeworkEditPage />} />
<Route path="/guide" element={<GuidePage />} /> <Route path="/guide" element={<GuidePage />} />
<Route path="/friends" element={<FriendListPage />} /> <Route path="/friends" element={<FriendListPage />} />
<Route path="/friends/:friend_id/characters" element={<FriendCharacterDashboard />} />
<Route path="/friends/requests" element={<FriendRequestsPage />} /> <Route path="/friends/requests" element={<FriendRequestsPage />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@ -9,7 +9,9 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle DialogTitle,
FormControlLabel,
Checkbox
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
@ -22,6 +24,7 @@ export default function CharacterEditPage() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [server, setServer] = useState('') const [server, setServer] = useState('')
const [combatPower, setCombatPower] = useState('') const [combatPower, setCombatPower] = useState('')
const [isPublic, setIsPublic] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [openConfirm, setOpenConfirm] = useState(false) const [openConfirm, setOpenConfirm] = useState(false)
@ -31,6 +34,7 @@ export default function CharacterEditPage() {
setName(res.data.name) setName(res.data.name)
setServer(res.data.server || '') setServer(res.data.server || '')
setCombatPower(String(res.data.combat_power || '')) setCombatPower(String(res.data.combat_power || ''))
setIsPublic(Boolean(res.data.is_public))
}) })
.catch(() => setError('캐릭터 정보를 불러오는 데 실패했습니다.')) .catch(() => setError('캐릭터 정보를 불러오는 데 실패했습니다.'))
}, [id]) }, [id])
@ -40,7 +44,8 @@ export default function CharacterEditPage() {
await api.put(`/characters/${id}`, { await api.put(`/characters/${id}`, {
name, name,
server, server,
combat_power: Number(combatPower) power: Number(combatPower),
is_public: isPublic,
}) })
navigate('/characters') navigate('/characters')
} catch { } catch {
@ -82,6 +87,10 @@ export default function CharacterEditPage() {
fullWidth fullWidth
type="number" type="number"
/> />
<FormControlLabel
control={<Checkbox checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />}
label="친구에게 노출"
/>
{error && <Typography color="error">{error}</Typography>} {error && <Typography color="error">{error}</Typography>}

View File

@ -0,0 +1,102 @@
import {
Box,
Typography,
Card,
CardContent,
Grid,
Stack,
Checkbox,
Button
} from '@mui/material'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import api from '../lib/api'
interface Character {
id: number
name: string
server: string
}
interface Homework {
id: number
title: string
reset_type: string
clear_count: number
complete_cnt: number
}
export default function FriendCharacterDashboard() {
const { friend_id } = useParams()
const [characters, setCharacters] = useState<Character[]>([])
const [homeworks, setHomeworks] = useState<Record<number, Homework[]>>({})
const navigate = useNavigate()
useEffect(() => {
const fetchData = async () => {
try {
const res = await api.get(`/friends/${friend_id}/characters`)
setCharacters(res.data)
const hwResults = await Promise.all(
res.data.map((char: Character) =>
api
.get(`/friends/${friend_id}/characters/${char.id}/homeworks`)
.then(r => ({ id: char.id, data: r.data }))
)
)
const map: Record<number, Homework[]> = {}
hwResults.forEach(item => {
map[item.id] = item.data
})
setHomeworks(map)
} catch (err) {
console.error('친구 대시보드 데이터를 불러오는 데 실패했습니다.', err)
}
}
fetchData()
}, [friend_id])
return (
<Box sx={{ p: 4 }}>
<Button variant="outlined" onClick={() => navigate(-1)} sx={{ mb: 2 }}>
</Button>
<Typography variant="h5" gutterBottom>
</Typography>
<Grid container spacing={2}>
{characters.map(char => (
<Grid item xs={12} sm={6} md={4} key={char.id} {...({} as any)}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{char.server} : {char.name}
</Typography>
<Stack spacing={1}>
{(homeworks[char.id] || []).map(hw => (
<Box key={hw.id}>
<Typography variant="subtitle2" gutterBottom>
{hw.title} ({hw.clear_count})
</Typography>
<Stack direction="row" spacing={1}>
{Array.from({ length: hw.clear_count }).map((_, idx) => (
<Checkbox
key={idx}
checked={idx < hw.complete_cnt}
disabled
size="small"
/>
))}
</Stack>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
)
}

View File

@ -1,12 +0,0 @@
import { Box, Typography } from '@mui/material'
export default function FriendList() {
return (
<Box sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom>
</Typography>
<Typography> ...</Typography>
</Box>
)
}

View File

@ -1,3 +1,12 @@
import {
Box,
Card,
CardContent,
Container,
Grid,
Typography,
Button,
} from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import api from '../lib/api' import api from '../lib/api'
@ -9,7 +18,6 @@ interface Friend {
} }
export default function FriendListPage() { export default function FriendListPage() {
const [friendIds, setFriendIds] = useState<number[]>([])
const [friends, setFriends] = useState<Friend[]>([]) const [friends, setFriends] = useState<Friend[]>([])
const [showDialog, setShowDialog] = useState(false) const [showDialog, setShowDialog] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@ -17,13 +25,8 @@ export default function FriendListPage() {
useEffect(() => { useEffect(() => {
const fetchFriends = async () => { const fetchFriends = async () => {
try { try {
const ids: number[] = await api.get('/friends/list').then(res => res.data) const res = await api.get('/friends/list')
setFriendIds(ids) setFriends(res.data)
const friendInfos = await Promise.all(
ids.map(id => api.get(`/users/${id}`).then(res => res.data))
)
setFriends(friendInfos)
} catch (e) { } catch (e) {
console.error('친구 목록 불러오기 실패', e) console.error('친구 목록 불러오기 실패', e)
} }
@ -32,26 +35,51 @@ export default function FriendListPage() {
}, []) }, [])
return ( return (
<div> <Container sx={{ mt: 4 }}>
<h2> </h2> <Typography variant="h5" gutterBottom>
<button onClick={() => setShowDialog(true)}>+ </button>
</Typography>
{friends.length === 0 ? ( <Grid container spacing={2}>
<p> .</p>
) : (
<ul>
{friends.map(friend => ( {friends.map(friend => (
<li key={friend.id}> <Grid item xs={12} sm={6} md={4} key={friend.id} {...({} as any)}>
{friend.email} <Card>
<button onClick={() => navigate(`/friends/${friend.id}/characters`)}> <CardContent>
<Typography variant="h6">{friend.email}</Typography>
<Box sx={{ mt: 1 }}>
<Button
variant="outlined"
onClick={() =>
navigate(`/friends/${friend.id}/characters`)
}
>
</button> </Button>
</li> </Box>
</CardContent>
</Card>
</Grid>
))} ))}
</ul> <Grid item xs={12} sm={6} md={4} {...({} as any)}>
)} <Card
onClick={() => setShowDialog(true)}
sx={{
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
}}
>
<CardContent>
<Typography variant="h6" align="center">
+
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{showDialog && <FriendSearchDialog onClose={() => setShowDialog(false)} />} {showDialog && <FriendSearchDialog onClose={() => setShowDialog(false)} />}
</div> </Container>
) )
} }

View File

@ -1,12 +0,0 @@
import { Box, Typography } from '@mui/material'
export default function FriendRequests() {
return (
<Box sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom>
</Typography>
<Typography> ...</Typography>
</Box>
)
}

View File

@ -5,6 +5,8 @@ interface FriendRequest {
id: number id: number
from_user_id: number from_user_id: number
to_user_id: number to_user_id: number
from_user_email?: string
to_user_email?: string
status: 'pending' | 'accepted' | 'rejected' | 'cancelled' status: 'pending' | 'accepted' | 'rejected' | 'cancelled'
created_at: string created_at: string
} }
@ -21,11 +23,17 @@ export default function FriendRequestsPage() {
const res = await api.get(url) const res = await api.get(url)
setRequests(res.data) setRequests(res.data)
const userIds = res.data.map((r: FriendRequest) =>
tab === 'received' ? r.from_user_id : r.to_user_id
)
const emails = await Promise.all( const emails = await Promise.all(
userIds.map(id => api.get(`/users/${id}`).then(res => [id, res.data.email])) res.data.map(async (r: FriendRequest) => {
const targetId = tab === 'received' ? r.from_user_id : r.to_user_id
const emailFromResponse =
tab === 'received' ? r.from_user_email : r.to_user_email
if (emailFromResponse) {
return [targetId, emailFromResponse] as [number, string]
}
const user = await api.get(`/users/${targetId}`)
return [targetId, user.data.email] as [number, string]
})
) )
setEmailMap(Object.fromEntries(emails)) setEmailMap(Object.fromEntries(emails))
} }
@ -35,13 +43,31 @@ export default function FriendRequestsPage() {
const handleRespond = async (id: number, accept: boolean) => { const handleRespond = async (id: number, accept: boolean) => {
await api.post(`/friends/requests/${id}/respond`, null, { params: { accept } }) await api.post(`/friends/requests/${id}/respond`, null, { params: { accept } })
alert(accept ? '친구 요청을 수락했습니다.' : '친구 요청을 거절했습니다.') alert(accept ? '친구 요청을 수락했습니다.' : '친구 요청을 거절했습니다.')
setRequests(requests.filter(r => r.id !== id)) const req = requests.find(r => r.id === id)
const targetId = req ? (tab === 'received' ? req.from_user_id : req.to_user_id) : null
setRequests(prev => prev.filter(r => r.id !== id))
if (targetId !== null) {
setEmailMap(prev => {
const newMap = { ...prev }
delete newMap[targetId]
return newMap
})
}
} }
const handleCancel = async (id: number) => { const handleCancel = async (id: number) => {
await api.post(`/friends/requests/${id}/cancel`) await api.post(`/friends/requests/${id}/cancel`)
alert('요청을 취소했습니다.') alert('요청을 취소했습니다.')
setRequests(requests.filter(r => r.id !== id)) const req = requests.find(r => r.id === id)
const targetId = req ? (tab === 'received' ? req.from_user_id : req.to_user_id) : null
setRequests(prev => prev.filter(r => r.id !== id))
if (targetId !== null) {
setEmailMap(prev => {
const newMap = { ...prev }
delete newMap[targetId]
return newMap
})
}
} }
return ( return (

View File

@ -1,6 +1,7 @@
import { import {
Box, Button, Container, Paper, TextField, Typography, MenuItem, Box, Button, Container, Paper, TextField, Typography, MenuItem,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
FormControlLabel, Checkbox
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
@ -14,6 +15,7 @@ export default function HomeworkEditPage() {
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [resetType, setResetType] = useState('') const [resetType, setResetType] = useState('')
const [clearCount, setClearCount] = useState('') const [clearCount, setClearCount] = useState('')
const [isPublic, setIsPublic] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [openConfirm, setOpenConfirm] = useState(false) const [openConfirm, setOpenConfirm] = useState(false)
@ -25,6 +27,7 @@ export default function HomeworkEditPage() {
setDescription(hw.description || '') setDescription(hw.description || '')
setResetType(hw.reset_type) setResetType(hw.reset_type)
setClearCount(String(hw.clear_count || '')) setClearCount(String(hw.clear_count || ''))
setIsPublic(Boolean(hw.is_public))
}) })
.catch(() => setError('숙제 정보를 불러오는 데 실패했습니다.')) .catch(() => setError('숙제 정보를 불러오는 데 실패했습니다.'))
}, [id]) }, [id])
@ -32,10 +35,11 @@ export default function HomeworkEditPage() {
const handleUpdate = async () => { const handleUpdate = async () => {
try { try {
await api.put(`/homeworks/${id}`, { await api.put(`/homeworks/${id}`, {
name: title, // ✅ title → name title,
description, description,
repeat_type: resetType, // ✅ reset_type → repeat_type reset_type: resetType,
repeat_count: Number(clearCount) // ✅ clear_count → repeat_count clear_count: Number(clearCount),
is_public: isPublic,
}) })
navigate('/homeworks') navigate('/homeworks')
} catch { } catch {
@ -89,6 +93,10 @@ export default function HomeworkEditPage() {
type="number" type="number"
fullWidth fullWidth
/> />
<FormControlLabel
control={<Checkbox checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />}
label="친구에게 노출"
/>
{error && <Typography color="error">{error}</Typography>} {error && <Typography color="error">{error}</Typography>}

View File

@ -1,4 +1,4 @@
import { Box, Button, Container, Paper, TextField, Typography } from '@mui/material' import { Box, Button, Container, Paper, TextField, Typography, FormControlLabel, Checkbox } from '@mui/material'
import { useState } from 'react' import { useState } from 'react'
import api from '../lib/api' import api from '../lib/api'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@ -8,6 +8,7 @@ export default function RegisterCharacter() {
const [server, setServer] = useState('') const [server, setServer] = useState('')
const [job, setJob] = useState('') const [job, setJob] = useState('')
const [power, setPower] = useState('') const [power, setPower] = useState('')
const [isPublic, setIsPublic] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const handleSubmit = async () => { const handleSubmit = async () => {
@ -22,6 +23,7 @@ export default function RegisterCharacter() {
server: server || undefined, server: server || undefined,
job: job || undefined, job: job || undefined,
combat_power: power ? parseInt(power, 10) : undefined, combat_power: power ? parseInt(power, 10) : undefined,
is_public: isPublic,
}) })
alert('캐릭터가 성공적으로 등록되었습니다.') alert('캐릭터가 성공적으로 등록되었습니다.')
navigate('/characters') navigate('/characters')
@ -64,6 +66,10 @@ export default function RegisterCharacter() {
onChange={(e) => setPower(e.target.value)} onChange={(e) => setPower(e.target.value)}
fullWidth fullWidth
/> />
<FormControlLabel
control={<Checkbox checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />}
label="친구에게 노출"
/>
<Button variant="contained" onClick={handleSubmit}> <Button variant="contained" onClick={handleSubmit}>
</Button> </Button>

View File

@ -4,7 +4,9 @@ import {
Button, Button,
Typography, Typography,
MenuItem, MenuItem,
Container Container,
FormControlLabel,
Checkbox
} from '@mui/material' } from '@mui/material'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@ -15,6 +17,8 @@ export default function RegisterHomework() {
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [resetType, setResetType] = useState('') const [resetType, setResetType] = useState('')
const [clearCount, setClearCount] = useState(0) const [clearCount, setClearCount] = useState(0)
const [resetTime, setResetTime] = useState('')
const [isPublic, setIsPublic] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
@ -25,7 +29,9 @@ export default function RegisterHomework() {
title, title,
description, description,
reset_type: resetType, reset_type: resetType,
reset_time: resetTime,
clear_count: clearCount, clear_count: clearCount,
is_public: isPublic,
}) })
navigate('/homeworks') navigate('/homeworks')
} catch (err) { } catch (err) {
@ -65,6 +71,14 @@ export default function RegisterHomework() {
<MenuItem value="weekly"></MenuItem> <MenuItem value="weekly"></MenuItem>
<MenuItem value="monthly"></MenuItem> <MenuItem value="monthly"></MenuItem>
</TextField> </TextField>
<TextField
fullWidth
type="time"
label="초기화 시간"
margin="normal"
value={resetTime}
onChange={(e) => setResetTime(e.target.value)}
/>
<TextField <TextField
fullWidth fullWidth
type="number" type="number"
@ -74,6 +88,10 @@ export default function RegisterHomework() {
value={clearCount} value={clearCount}
onChange={(e) => setClearCount(parseInt(e.target.value) || 0)} onChange={(e) => setClearCount(parseInt(e.target.value) || 0)}
/> />
<FormControlLabel
control={<Checkbox checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />}
label="친구에게 노출"
/>
{error && <Typography color="error">{error}</Typography>} {error && <Typography color="error">{error}</Typography>}
<Button fullWidth variant="contained" sx={{ mt: 2 }} onClick={handleSubmit}> <Button fullWidth variant="contained" sx={{ mt: 2 }} onClick={handleSubmit}>