Compare commits
42 Commits
2166685131
...
68ae7eb6fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 68ae7eb6fd | |||
| 11fcf65d11 | |||
| 056e2b5589 | |||
| bfd04fc68c | |||
| 3707fda83a | |||
| 81b1bb0a75 | |||
| 6524ce6cd8 | |||
| 9d6bef7c64 | |||
| 3e82a68e2c | |||
| 8b2ab534d3 | |||
| ed53cfcc11 | |||
| 6751c9094a | |||
| 6b2318a62a | |||
| f554241cd8 | |||
| 21a284b815 | |||
| dc1c120837 | |||
| 8457f4cbee | |||
| 0c537df8a7 | |||
| 02a9847207 | |||
| 1d40784572 | |||
| 2a18572e59 | |||
| 45a9f5da5a | |||
| bdb8c22ec1 | |||
| 9ed763b5fe | |||
| dbfc2faaa9 | |||
| 39187938f0 | |||
|
|
ae2a17ca5c | ||
|
|
273b76ea77 | ||
| e1fb81bf4e | |||
| 91f5a32dbe | |||
| ee6535188c | |||
| 13ffffd8cd | |||
| 6302c85d0f | |||
| 2667faae28 | |||
| 867be4e3ba | |||
| 70529fab2b | |||
| eb8de2b697 | |||
| d2efeee720 | |||
| 0d52ef187a | |||
| 908196cacd | |||
| bf26a295be | |||
| 7463c99215 |
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Rename this file to .env and set the API base URL for your environment
|
||||||
|
# Example:
|
||||||
|
# VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
VITE_API_BASE_URL=https://api.example.com
|
||||||
|
|
||||||
@ -16,6 +16,9 @@ import Dashboard from './pages/Dashboard'
|
|||||||
import MePage from './pages/MePage'
|
import MePage from './pages/MePage'
|
||||||
import CharacterEditPage from './pages/CharacterEditPage'
|
import CharacterEditPage from './pages/CharacterEditPage'
|
||||||
import GuidePage from './pages/Guide'
|
import GuidePage from './pages/Guide'
|
||||||
|
import FriendListPage from './pages/FriendListPage'
|
||||||
|
import FriendRequestsPage from './pages/FriendRequestsPage'
|
||||||
|
import FriendCharacterDashboard from './pages/FriendCharacterDashboard'
|
||||||
|
|
||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@ -46,6 +49,9 @@ function App() {
|
|||||||
<Route path="/characters/:id/edit" element={<CharacterEditPage />} />
|
<Route path="/characters/:id/edit" element={<CharacterEditPage />} />
|
||||||
<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/:friend_id/characters" element={<FriendCharacterDashboard />} />
|
||||||
|
<Route path="/friends/requests" element={<FriendRequestsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
198
src/components/FriendSearchDialog.tsx
Normal file
198
src/components/FriendSearchDialog.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import api from '../lib/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FriendSearchDialog({ onClose }: Props) {
|
||||||
|
const [mode, setMode] = useState<'email' | 'character'>('email')
|
||||||
|
const [input, setInput] = useState({ email: '', name: '', server: '' })
|
||||||
|
const [result, setResult] = useState<any | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = mode === 'email'
|
||||||
|
? await api.get('/users/public-info', { params: { email: input.email } })
|
||||||
|
: await api.get('/users/by-character', {
|
||||||
|
params: { server: input.server, name: input.name }
|
||||||
|
})
|
||||||
|
setResult(res.data)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.response?.data?.detail || '검색에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequest = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/friends/request', { to_user_email: result.email })
|
||||||
|
alert('친구 요청을 보냈습니다.')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
alert('요청 실패')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="popup-backdrop" onClick={onClose} />
|
||||||
|
<div className="popup">
|
||||||
|
<h3>친구 추가</h3>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="search-mode"
|
||||||
|
checked={mode === 'email'}
|
||||||
|
onChange={() => setMode('email')}
|
||||||
|
/>
|
||||||
|
이메일로 검색
|
||||||
|
</label>
|
||||||
|
<label className="radio-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="search-mode"
|
||||||
|
checked={mode === 'character'}
|
||||||
|
onChange={() => setMode('character')}
|
||||||
|
/>
|
||||||
|
캐릭터로 검색
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{mode === 'email' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="example@naver.com"
|
||||||
|
value={input.email}
|
||||||
|
onChange={e => setInput({ ...input, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="서버명"
|
||||||
|
value={input.server}
|
||||||
|
onChange={e => setInput({ ...input, server: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="캐릭터명"
|
||||||
|
value={input.name}
|
||||||
|
onChange={e => setInput({ ...input, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<button onClick={handleSearch} disabled={loading}>
|
||||||
|
{loading ? '검색 중...' : '검색'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose}>닫기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<p>이메일: {result.email}</p>
|
||||||
|
<p>공개 여부: {result.is_public ? '공개' : '비공개'}</p>
|
||||||
|
|
||||||
|
{result.is_friend ? (
|
||||||
|
<p>✅ 이미 친구입니다</p>
|
||||||
|
) : result.request_sent ? (
|
||||||
|
<p>⏳ 이미 요청 보낸 상태입니다</p>
|
||||||
|
) : result.request_received ? (
|
||||||
|
<p>📩 상대가 요청을 보냈습니다 (요청 수락 화면에서 처리)</p>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleRequest}>친구 요청 보내기</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.popup-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.popup {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #222;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px #000;
|
||||||
|
z-index: 1001;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
.popup input {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.popup button {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option input[type="radio"] {
|
||||||
|
margin-right: 6px;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #aaa;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option input[type="radio"]:checked::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #00aaff;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const [anchorElCharacter, setAnchorElCharacter] = useState<null | HTMLElement>(null)
|
const [anchorElCharacter, setAnchorElCharacter] = useState<null | HTMLElement>(null)
|
||||||
const [anchorElHomework, setAnchorElHomework] = useState<null | HTMLElement>(null)
|
const [anchorElHomework, setAnchorElHomework] = useState<null | HTMLElement>(null)
|
||||||
|
const [anchorElFriend, setAnchorElFriend] = useState<null | HTMLElement>(null)
|
||||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
const handleMenuOpen = (
|
const handleMenuOpen = (
|
||||||
@ -71,6 +72,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<MenuItem component={Link} to="/homeworks" onClick={handleMenuClose(setAnchorElHomework)}>숙제 목록</MenuItem>
|
<MenuItem component={Link} to="/homeworks" onClick={handleMenuClose(setAnchorElHomework)}>숙제 목록</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<Button color="inherit" onClick={handleMenuOpen(setAnchorElFriend)}>친구</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorElFriend}
|
||||||
|
open={Boolean(anchorElFriend)}
|
||||||
|
onClose={handleMenuClose(setAnchorElFriend)}
|
||||||
|
>
|
||||||
|
<MenuItem component={Link} to="/friends" onClick={handleMenuClose(setAnchorElFriend)}>친구목록</MenuItem>
|
||||||
|
<MenuItem component={Link} to="/friends/requests" onClick={handleMenuClose(setAnchorElFriend)}>요청관리</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
<IconButton color="inherit" onClick={handleUserMenuOpen} size="large">
|
<IconButton color="inherit" onClick={handleUserMenuOpen} size="large">
|
||||||
<AccountCircle />
|
<AccountCircle />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
// ✅ 추가: AuthProvider import
|
// ✅ 추가: AuthProvider import
|
||||||
|
|||||||
@ -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,17 +34,19 @@ 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])
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/characters/${id}`, {
|
await api.put(`/characters/${id}`, {
|
||||||
name,
|
name,
|
||||||
server,
|
server,
|
||||||
power: Number(combatPower)
|
power: Number(combatPower),
|
||||||
})
|
is_public: isPublic,
|
||||||
|
})
|
||||||
navigate('/characters')
|
navigate('/characters')
|
||||||
} catch {
|
} catch {
|
||||||
setError('캐릭터 수정에 실패했습니다.')
|
setError('캐릭터 수정에 실패했습니다.')
|
||||||
@ -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>}
|
||||||
|
|
||||||
|
|||||||
102
src/pages/FriendCharacterDashboard.tsx
Normal file
102
src/pages/FriendCharacterDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/pages/FriendListPage.tsx
Normal file
85
src/pages/FriendListPage.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import api from '../lib/api'
|
||||||
|
import FriendSearchDialog from '../components/FriendSearchDialog'
|
||||||
|
|
||||||
|
interface Friend {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FriendListPage() {
|
||||||
|
const [friends, setFriends] = useState<Friend[]>([])
|
||||||
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFriends = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/friends/list')
|
||||||
|
setFriends(res.data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('친구 목록 불러오기 실패', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchFriends()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
친구 목록
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{friends.map(friend => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={friend.id} {...({} as any)}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">{friend.email}</Typography>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/friends/${friend.id}/characters`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
캐릭터 보기
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
<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)} />}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/pages/FriendRequestsPage.tsx
Normal file
106
src/pages/FriendRequestsPage.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import api from '../lib/api'
|
||||||
|
|
||||||
|
interface FriendRequest {
|
||||||
|
id: number
|
||||||
|
from_user_id: number
|
||||||
|
to_user_id: number
|
||||||
|
from_user_email?: string
|
||||||
|
to_user_email?: string
|
||||||
|
status: 'pending' | 'accepted' | 'rejected' | 'cancelled'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FriendRequestsPage() {
|
||||||
|
const [tab, setTab] = useState<'received' | 'sent'>('received')
|
||||||
|
const [requests, setRequests] = useState<FriendRequest[]>([])
|
||||||
|
const [emailMap, setEmailMap] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
const url =
|
||||||
|
tab === 'received' ? '/friends/requests/received' : '/friends/requests/sent'
|
||||||
|
const res = await api.get(url)
|
||||||
|
setRequests(res.data)
|
||||||
|
|
||||||
|
const emails = await Promise.all(
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
fetchRequests()
|
||||||
|
}, [tab])
|
||||||
|
|
||||||
|
const handleRespond = async (id: number, accept: boolean) => {
|
||||||
|
await api.post(`/friends/requests/${id}/respond`, null, { params: { accept } })
|
||||||
|
alert(accept ? '친구 요청을 수락했습니다.' : '친구 요청을 거절했습니다.')
|
||||||
|
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) => {
|
||||||
|
await api.post(`/friends/requests/${id}/cancel`)
|
||||||
|
alert('요청을 취소했습니다.')
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h2>친구 요청 관리</h2>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setTab('received')}>받은 요청</button>
|
||||||
|
<button onClick={() => setTab('sent')}>보낸 요청</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<p>요청이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{requests.map(req => {
|
||||||
|
const targetId = tab === 'received' ? req.from_user_id : req.to_user_id
|
||||||
|
const email = emailMap[targetId] || '로딩중...'
|
||||||
|
return (
|
||||||
|
<li key={req.id}>
|
||||||
|
{email}{' '}
|
||||||
|
{tab === 'received' ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleRespond(req.id, true)}>수락</button>
|
||||||
|
<button onClick={() => handleRespond(req.id, false)}>거부</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => handleCancel(req.id)}>취소</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { Box, Container, Typography, Divider, List, ListItem, ListItemText } fro
|
|||||||
const GuidePage = () => {
|
const GuidePage = () => {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
<Typography variant="h4" gutterBottom>숙제노기 사용법 안내 (v1.0)</Typography>
|
<Typography variant="h4" gutterBottom>숙제노기 사용법 안내 (v2.0)</Typography>
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
숙제노기는 마비노기 모바일의 숙제 관리를 도와주는 웹서비스입니다.
|
숙제노기는 마비노기 모바일의 숙제 관리를 도와주는 웹서비스입니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -42,10 +42,18 @@ const GuidePage = () => {
|
|||||||
<ListItem><ListItemText primary="사용자는 별도 조작 없이 다음 날 다시 체크 가능" /></ListItem>
|
<ListItem><ListItemText primary="사용자는 별도 조작 없이 다음 날 다시 체크 가능" /></ListItem>
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mt: 3 }}>6. 친구 기능</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem><ListItemText primary="상단 메뉴 '친구'에서 친구목록 확인 및 검색 추가" /></ListItem>
|
||||||
|
<ListItem><ListItemText primary="요청관리에서 받은 요청을 수락/거절하고 보낸 요청을 취소" /></ListItem>
|
||||||
|
<ListItem><ListItemText primary="친구의 캐릭터 공개 숙제를 볼 수 있으나 수정은 불가" /></ListItem>
|
||||||
|
<ListItem><ListItemText primary="캐릭터/숙제 등록 시 '친구에게 노출' 체크박스로 공개 여부 설정" /></ListItem>
|
||||||
|
</List>
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
© 숙제노기. 본 가이드는 v1.0 기준으로 작성되었습니다.
|
© 숙제노기. 본 가이드는 v2.0 기준으로 작성되었습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export default function Home() {
|
|||||||
const { isLoggedIn } = useAuth()
|
const { isLoggedIn } = useAuth()
|
||||||
|
|
||||||
const updates = [
|
const updates = [
|
||||||
|
{ date: '2025-06-11', version: 'v2.0', content: '친구 기능 추가: 친구목록/요청 관리 및 캐릭터 공개 보기 지원' },
|
||||||
{ date: '2025-06-06', version: 'v1.1', content: '피드백 및 수정요청 및 기능개선은 nightbug@naver.com 으로 부탁드립니다.' },
|
{ date: '2025-06-06', version: 'v1.1', content: '피드백 및 수정요청 및 기능개선은 nightbug@naver.com 으로 부탁드립니다.' },
|
||||||
{ date: '2025-05-28', version: 'v1.1', content: '캐릭터 및 숙제 목록에서 순서변경기능 추가' },
|
{ date: '2025-05-28', version: 'v1.1', content: '캐릭터 및 숙제 목록에서 순서변경기능 추가' },
|
||||||
{ date: '2025-05-28', version: 'v1.1', content: '홈화면에 업데이트 내역 노출' },
|
{ date: '2025-05-28', version: 'v1.1', content: '홈화면에 업데이트 내역 노출' },
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export default function Login() {
|
|||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { access_token, token_type } = res.data
|
const { access_token } = res.data
|
||||||
console.log('로그인 성공:', access_token)
|
console.log('로그인 성공:', access_token)
|
||||||
|
|
||||||
login(access_token) // ✅ 전역 상태 + localStorage 동시 반영
|
login(access_token) // ✅ 전역 상태 + localStorage 동시 반영
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}>
|
||||||
등록하기
|
등록하기
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user