import React, { useMemo, useEffect, useState, useRef } from 'react' import Plugin, { PlayerData } from '../Plugin' import dayjs from 'dayjs' import Empty from '../components/Empty' import dialog from '../dialog' import { success } from '../toast' import { getSkin } from '../utils' import { CircularLoading } from '../components/Loading' import { usePlugin, useGlobalData } from '../Context' import { Block, Star, StarBorder, AssignmentInd, Equalizer, ExpandLess, ExpandMore, Security, AccessTime, Today, Event, Login, Sick, FaceRetouchingOff, Pets, Fireplace, ErrorOutline, Search, MoreHoriz } from '@mui/icons-material' import { Grid, Toolbar, Card, CardHeader, Divider, Box, Container, TableContainer, Table, TableBody, CardContent, TablePagination, TableHead, TableRow, TableCell, IconButton, Tooltip, ToggleButtonGroup, ToggleButton, List, ListSubheader, ListItem, ListItemText, ListItemIcon, Collapse, ListItemButton, CardActions, Link, Menu, Avatar } from '@mui/material' import { FXAASkinViewer, createOrbitControls, WalkingAnimation, RotatingAnimation } from 'skinview3d' import { useTheme } from '@mui/material/styles' import { useParams, useHistory } from 'react-router-dom' import lang, { minecraft } from '../../languages' interface IPlayerInfo { id: string ban: string | null whitelisted: boolean playTime: number lastOnline: number hasPlayedBefore: boolean firstPlay: number isOP: boolean death: number quit: number playerKill: number entityKill: number tnt: number } export type ActionComponent = React.ComponentType<{ onClose: () => void, player: PlayerData | null }> export const actions: ActionComponent[] = [] const banPlayer = (name: string, plugin: Plugin, refresh: () => void, isBan: boolean) => { const span = <span className='bold'>{name}</span> ;(isBan ? dialog(lang.playerList.confirmBan(span), lang.reason) : dialog(lang.playerList.confirmPardon(span))).then(it => { if (it == null || it === false) return plugin.emit('playerList:' + (isBan ? 'ban' : 'pardon'), name, it) refresh() success() }) } const whitelist = (name: string, plugin: Plugin, refresh: () => void, isAdd: boolean) => { dialog(lang.playerList[isAdd ? 'confirmAddWhitelist' : 'confirmRemoveWhitelist'](<span className='bold'>{name}</span>)).then(it => { if (!it) return plugin.emit(`playerList:${isAdd ? 'add' : 'remove'}Whitelist`, name) refresh() success() }) } // eslint-disable-next-line react/jsx-key const icons = [<AccessTime />, <Today />, <Event />, <Login />, <Sick />, <FaceRetouchingOff />, <Pets />, <Fireplace />] const PlayerInfo: React.FC<{ name?: string }> = React.memo(({ name }) => { const plugin = usePlugin() const globalData = useGlobalData() const [open, setOpen] = useState(false) const [info, setInfo] = useState<IPlayerInfo | undefined>() const refresh = () => plugin.emit('playerList:query', setInfo, name) useEffect(() => { setInfo(undefined) if (name) refresh() }, [name]) return name && info ? <> <Divider /> <List sx={{ width: '100%' }} component='nav' subheader={<ListSubheader component='div' sx={{ backgroundColor: 'inherit' }}>{lang.playerList.details}</ListSubheader>} > <ListItem> <ListItemIcon><AssignmentInd /></ListItemIcon> <ListItemText primary={globalData.onlineMode ? <Link underline='hover' rel='noopener' target='_blank' href={'https://namemc.com/profile/' + info.id}>{info.id}</Link> : info.id } /> </ListItem> {!info.hasPlayedBefore && <ListItem> <ListItemIcon><ErrorOutline color='error' /></ListItemIcon> <ListItemText primary={lang.playerList.hasNotPlayed} /> </ListItem>} {info.ban != null && <ListItem> <ListItemIcon><Block color='error' /></ListItemIcon> <ListItemText primary={lang.playerList.banned + (info.ban ? ': ' + info.ban : '')} /> </ListItem>} {info.whitelisted && <ListItem> <ListItemIcon><Star color='warning' /></ListItemIcon> <ListItemText primary={lang.playerList.whitelisted} /> </ListItem>} {info.isOP && <ListItem> <ListItemIcon><Security color='primary' /></ListItemIcon> <ListItemText primary={lang.playerList.op} /> </ListItem>} {info.hasPlayedBefore && <> <ListItemButton onClick={() => setOpen(!open)}> <ListItemIcon><Equalizer /></ListItemIcon> <ListItemText primary={minecraft['gui.stats']} /> {open ? <ExpandLess /> : <ExpandMore />} </ListItemButton> <Collapse in={open} timeout="auto" unmountOnExit> <List component='div' dense disablePadding> {[ minecraft['stat.minecraft.play_time'] + ': ' + dayjs.duration(info.playTime / 20, 'seconds').humanize(), lang.playerList.firstPlay + ': ' + dayjs(info.firstPlay).fromNow(), lang.playerList.lastPlay + ': ' + dayjs(info.lastOnline).fromNow(), minecraft['stat.minecraft.leave_game'] + ': ' + info.quit, minecraft['stat.minecraft.deaths'] + ': ' + info.death, minecraft['stat.minecraft.player_kills'] + ': ' + info.playerKill, minecraft['stat.minecraft.mob_kills'] + ': ' + info.entityKill, lang.playerList.tnt + ': ' + info.tnt ].map((it, i) => <ListItem key={i} sx={{ pl: 4 }}> <ListItemIcon>{icons[i]}</ListItemIcon> <ListItemText primary={it} /> </ListItem>)} </List> </Collapse> </>} </List> <CardActions disableSpacing sx={{ justifyContent: 'flex-end' }}> <Tooltip title={lang.playerList[info.whitelisted ? 'clickToRemoveWhitelist' : 'clickToAddWhitelist']}> <IconButton onClick={() => whitelist(name, plugin, refresh, !info.whitelisted)}> {info.whitelisted ? <Star color='warning' /> : <StarBorder />} </IconButton> </Tooltip> <Tooltip title={lang.playerList[info.ban == null ? 'clickToBan' : 'clickToPardon']}> <IconButton onClick={() => banPlayer(name, plugin, refresh, info.ban == null)}> <Block color={info.ban == null ? undefined : 'error'} /> </IconButton> </Tooltip> </CardActions> </> : <></> }) const PlayerActions: React.FC = () => { const theme = useTheme() const ref = useRef<HTMLCanvasElement | null>(null) const globalData = useGlobalData() const { name } = useParams<{ name: string }>() useEffect(() => { if (!ref.current || !name) return const viewer = new FXAASkinViewer({ canvas: ref.current!, height: 350, width: ref.current.clientWidth, skin: getSkin(globalData, name) }) viewer.renderer.setClearColor(theme.palette.mode === 'dark' ? 0x2c2c2c : 0xffffff) const resize = () => ref.current && (viewer.width = ref.current.clientWidth) window.addEventListener('resize', resize) const control = createOrbitControls(viewer) control.enableRotate = true control.enablePan = control.enableZoom = false viewer.animations.add(WalkingAnimation) viewer.animations.add(RotatingAnimation) return () => { window.removeEventListener('resize', resize) viewer.dispose() } }, [ref.current, name, theme.palette.mode, globalData]) return <Card> <CardHeader title={name ? lang.playerList.whosDetails(name) : lang.openInv.notSelected} /> <Divider /> <Box sx={{ position: 'relative', '& canvas': { width: '100%!important' } }}> {name ? <canvas ref={ref} height='400' style={{ cursor: 'grab', display: name ? undefined : 'none' }} /> : <CardContent><Empty title={null} /></CardContent>} </Box> <PlayerInfo name={name} /> </Card> } const Players: React.FC = () => { const his = useHistory() const plugin = usePlugin() const [page, setPage] = useState(0) const [loading, setLoading] = useState(true) const [state, setState] = useState<number | null>(null) const [activedPlayer, setActivedPlayer] = useState<PlayerData | null>(null) const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) const [data, setData] = useState<{ count: number, players: PlayerData[] }>(() => ({ count: 0, players: [] })) const globalData = useGlobalData() const { hasWhitelist } = globalData const refresh = () => { setLoading(true) plugin.emit('playerList:fetchPage', (it: any) => { if (it.players == null) it.players = [] setData(it) setLoading(false) }, page, state === 1 || state === 2 ? state : 0, null) } useMemo(refresh, [page, state]) const close = () => { setAnchorEl(null) setActivedPlayer(null) } return <Card> <CardHeader title={lang.playerList.title} action={ <ToggleButtonGroup size='small' color={(state === 1 ? 'warning' : state === 2 ? 'error' : undefined) as any} value={state} exclusive onChange={(_, it) => { if (it === 3) return setState(it) if (state === 3) refresh() }} > <ToggleButton disabled={loading} value={1}><Star /></ToggleButton> <ToggleButton disabled={loading} value={2}><Block /></ToggleButton> <ToggleButton disabled={loading} value={3} onClick={() => state !== 3 && dialog(lang.playerList.nameToSearch, lang.username) .then(filter => { if (filter == null) return his.push('/NekoMaid/playerList/' + filter) setState(3) setLoading(true) plugin.emit('playerList:fetchPage', (it: any) => { if (it.players == null) it.players = [] setPage(0) setData(it) setLoading(false) }, page, 0, filter.toLowerCase()) })}><Search /></ToggleButton> </ToggleButtonGroup> } /> <Divider /> <Box sx={{ position: 'relative' }}> <CircularLoading loading={loading} /> <TableContainer> <Table> <TableHead> <TableRow> <TableCell padding='checkbox' /> <TableCell>{lang.username}</TableCell> <TableCell align='right'>{minecraft['stat.minecraft.play_time']}</TableCell> <TableCell align='right'>{lang.playerList.lastPlay}</TableCell> <TableCell align='right'>{lang.operations}</TableCell> </TableRow> </TableHead> <TableBody> {data.players.map(it => <TableRow key={it.name}> <TableCell sx={{ cursor: 'pointer', padding: theme => theme.spacing(1, 1, 1, 2) }} onClick={() => his.push('/NekoMaid/playerList/' + it.name)}> <Avatar src={getSkin(globalData, it.name, true)} imgProps={{ crossOrigin: 'anonymous', style: { width: 40, height: 40 } }} variant='rounded' /> </TableCell> <TableCell>{it.name}</TableCell> <TableCell align='right'>{dayjs.duration(it.playTime / 20, 'seconds').humanize()}</TableCell> <TableCell align='right'>{dayjs(it.lastOnline).fromNow()}</TableCell> <TableCell align='right'> {(state === 1 || hasWhitelist) && <Tooltip title={lang.playerList[it.whitelisted ? 'clickToRemoveWhitelist' : 'clickToAddWhitelist']}> <IconButton onClick={() => whitelist(it.name, plugin, refresh, !it.whitelisted)}> {it.whitelisted ? <Star color='warning' /> : <StarBorder />} </IconButton> </Tooltip>} <Tooltip title={it.ban == null ? lang.playerList.clickToBan : lang.playerList.banned + ': ' + it.ban}> <IconButton onClick={() => banPlayer(it.name, plugin, refresh, it.ban == null)}> <Block color={it.ban == null ? undefined : 'error'} /> </IconButton> </Tooltip> {actions.length ? <IconButton onClick={e => { setActivedPlayer(anchorEl ? null : it) setAnchorEl(anchorEl ? null : e.currentTarget) }}><MoreHoriz /></IconButton> : null} </TableCell> </TableRow>)} </TableBody> </Table> </TableContainer> <TablePagination rowsPerPageOptions={[]} component='div' count={data.count} rowsPerPage={10} page={page} onPageChange={(_, it) => !loading && setPage(it)} /> </Box> <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} >{actions.map((It, i) => <It key={i} onClose={close} player={activedPlayer} />)}</Menu> </Card> } const PlayerList: React.FC = () => { return <Box sx={{ minHeight: '100%', py: 3 }}> <Toolbar /> <Container maxWidth={false}> <Grid container spacing={3}> <Grid item lg={8} md={12} xl={9} xs={12}><Players /></Grid> <Grid item lg={4} md={6} xl={3} xs={12}><PlayerActions /></Grid> </Grid> </Container> </Box> } export default PlayerList