add auto-scroll for history, broadcast player list on updates
This commit is contained in:
parent
84499982f0
commit
778f94c0aa
|
@ -3,7 +3,7 @@ from collections import deque
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Dict
|
from typing import Dict, Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
@ -101,9 +101,8 @@ async def handler(websocket):
|
||||||
set_actor_agent_for_name(actor.name, actor, player)
|
set_actor_agent_for_name(actor.name, actor, player)
|
||||||
|
|
||||||
# notify all clients that this character is now active
|
# notify all clients that this character is now active
|
||||||
send_and_append(
|
player_event(character_name, id, "join")
|
||||||
{"type": "player", "name": character_name, "id": id}
|
player_list()
|
||||||
)
|
|
||||||
elif message_type == "input" and id in characters:
|
elif message_type == "input" and id in characters:
|
||||||
player = characters[id]
|
player = characters[id]
|
||||||
logger.info("queueing input for player %s: %s", player.name, data)
|
logger.info("queueing input for player %s: %s", player.name, data)
|
||||||
|
@ -121,11 +120,16 @@ async def handler(websocket):
|
||||||
player = characters[id]
|
player = characters[id]
|
||||||
del characters[id]
|
del characters[id]
|
||||||
|
|
||||||
|
logger.info("Disconnecting player for %s", player.name)
|
||||||
|
player_event(player.name, id, "leave")
|
||||||
|
player_list()
|
||||||
|
|
||||||
actor, _ = get_actor_agent_for_name(player.name)
|
actor, _ = get_actor_agent_for_name(player.name)
|
||||||
if actor:
|
if actor and player.fallback_agent:
|
||||||
|
logger.info("Restoring LLM agent for %s", player.name)
|
||||||
set_actor_agent_for_name(player.name, actor, player.fallback_agent)
|
set_actor_agent_for_name(player.name, actor, player.fallback_agent)
|
||||||
|
|
||||||
logger.info("Client disconnected")
|
logger.info("Client disconnected: %s", id)
|
||||||
|
|
||||||
|
|
||||||
socket_thread = None
|
socket_thread = None
|
||||||
|
@ -200,3 +204,21 @@ def server_event(message: str):
|
||||||
"type": "event",
|
"type": "event",
|
||||||
}
|
}
|
||||||
send_and_append(json_broadcast)
|
send_and_append(json_broadcast)
|
||||||
|
|
||||||
|
|
||||||
|
def player_event(character: str, id: str, event: Literal["join", "leave"]):
|
||||||
|
json_broadcast = {
|
||||||
|
"type": "player",
|
||||||
|
"character": character,
|
||||||
|
"id": id,
|
||||||
|
"event": event,
|
||||||
|
}
|
||||||
|
send_and_append(json_broadcast)
|
||||||
|
|
||||||
|
|
||||||
|
def player_list():
|
||||||
|
json_broadcast ={
|
||||||
|
"type": "players",
|
||||||
|
"players": {player.name: player_id for player_id, player in characters.items()},
|
||||||
|
}
|
||||||
|
send_and_append(json_broadcast)
|
||||||
|
|
|
@ -18,6 +18,8 @@ import {
|
||||||
Stack,
|
Stack,
|
||||||
Alert,
|
Alert,
|
||||||
Switch,
|
Switch,
|
||||||
|
FormGroup,
|
||||||
|
FormControlLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Allotment } from 'allotment';
|
import { Allotment } from 'allotment';
|
||||||
|
|
||||||
|
@ -27,6 +29,7 @@ import { PlayerPanel } from './player.js';
|
||||||
|
|
||||||
import 'allotment/dist/style.css';
|
import 'allotment/dist/style.css';
|
||||||
import './main.css';
|
import './main.css';
|
||||||
|
import { HistoryPanel } from './history.js';
|
||||||
|
|
||||||
const useWebSocket = (useWebSocketModule as any).default;
|
const useWebSocket = (useWebSocketModule as any).default;
|
||||||
|
|
||||||
|
@ -38,11 +41,6 @@ const statusStrings = {
|
||||||
[ReadyState.UNINSTANTIATED]: 'Unready',
|
[ReadyState.UNINSTANTIATED]: 'Unready',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function interleave(arr: Array<any>) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
|
||||||
return arr.reduce((acc, val, idx) => acc.concat(val, <Divider component='li' key={`sep-${idx}`} variant='inset' />), []).slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
socketUrl: string;
|
socketUrl: string;
|
||||||
}
|
}
|
||||||
|
@ -77,12 +75,14 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Ite
|
||||||
|
|
||||||
export function App(props: AppProps) {
|
export function App(props: AppProps) {
|
||||||
const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
|
const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
|
||||||
|
const [ autoScroll, setAutoScroll ] = useState<boolean>(true);
|
||||||
const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined);
|
const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined);
|
||||||
const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined);
|
const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined);
|
||||||
const [ clientId, setClientId ] = useState<string>('');
|
const [ clientId, setClientId ] = useState<string>('');
|
||||||
const [ world, setWorld ] = useState<Maybe<World>>(undefined);
|
const [ world, setWorld ] = useState<Maybe<World>>(undefined);
|
||||||
const [ themeMode, setThemeMode ] = useState('light');
|
const [ themeMode, setThemeMode ] = useState('light');
|
||||||
const [ history, setHistory ] = useState<Array<string>>([]);
|
const [ history, setHistory ] = useState<Array<string>>([]);
|
||||||
|
const [ players, setPlayers ] = useState<Record<string, string>>({});
|
||||||
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
|
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
|
||||||
|
|
||||||
function setPlayer(actor: Maybe<Actor>) {
|
function setPlayer(actor: Maybe<Actor>) {
|
||||||
|
@ -112,13 +112,15 @@ export function App(props: AppProps) {
|
||||||
const data = JSON.parse(lastMessage.data);
|
const data = JSON.parse(lastMessage.data);
|
||||||
|
|
||||||
if (data.type === 'id') {
|
if (data.type === 'id') {
|
||||||
|
// unicast the client id to the player
|
||||||
setClientId(data.id);
|
setClientId(data.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'prompt') {
|
if (data.type === 'prompt') {
|
||||||
|
// prompts are broadcast to all players
|
||||||
if (data.id === clientId) {
|
if (data.id === clientId) {
|
||||||
// notify the player and show the prompt
|
// only notify the active player
|
||||||
setActiveTurn(true);
|
setActiveTurn(true);
|
||||||
} else {
|
} else {
|
||||||
const message = `Waiting for ${data.character} to take their turn`;
|
const message = `Waiting for ${data.character} to take their turn`;
|
||||||
|
@ -127,6 +129,11 @@ export function App(props: AppProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === 'players') {
|
||||||
|
setPlayers(data.players);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setHistory((prev) => prev.concat(data));
|
setHistory((prev) => prev.concat(data));
|
||||||
|
|
||||||
// if we get a world event, update the last world state
|
// if we get a world event, update the last world state
|
||||||
|
@ -134,17 +141,15 @@ export function App(props: AppProps) {
|
||||||
setWorld(data.world);
|
setWorld(data.world);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'player' && data.id === clientId) {
|
if (doesExist(world) && data.type === 'player' && data.id === clientId && data.event === 'join') {
|
||||||
// find the actor that matches the player name
|
// find the actor that matches the player name
|
||||||
const { name } = data;
|
const { character: characterName } = data;
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
const actor = world.rooms.flatMap((room) => room.actors).find((a) => a.name === characterName);
|
||||||
const actor = world?.rooms.flatMap((room) => room.actors).find((a) => a.name === name);
|
|
||||||
setCharacter(actor);
|
setCharacter(actor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [lastMessage]);
|
}, [lastMessage]);
|
||||||
|
|
||||||
const items = history.map((item, index) => <EventItem key={`item-${index}`} event={item} />);
|
|
||||||
|
|
||||||
return <ThemeProvider theme={theme}>
|
return <ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
|
@ -156,24 +161,30 @@ export function App(props: AppProps) {
|
||||||
<Typography>
|
<Typography>
|
||||||
Status: {connectionStatus}
|
Status: {connectionStatus}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Switch
|
<FormGroup row>
|
||||||
|
<FormControlLabel control={<Switch
|
||||||
checked={themeMode === 'dark'}
|
checked={themeMode === 'dark'}
|
||||||
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
|
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
|
||||||
inputProps={{ 'aria-label': 'controlled' }}
|
inputProps={{ 'aria-label': 'controlled' }}
|
||||||
sx={{ marginLeft: 'auto' }}
|
sx={{ marginLeft: 'auto' }}
|
||||||
/>
|
/>} label="Dark Mode" />
|
||||||
|
<FormControlLabel control={<Switch
|
||||||
|
checked={autoScroll}
|
||||||
|
onChange={() => setAutoScroll(autoScroll === false)}
|
||||||
|
inputProps={{ 'aria-label': 'controlled' }}
|
||||||
|
sx={{ marginLeft: 'auto' }}
|
||||||
|
/>} label="Auto Scroll" />
|
||||||
|
</FormGroup>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Stack direction="row" spacing={2}>
|
<Stack direction="row" spacing={2}>
|
||||||
<Allotment className='body-allotment'>
|
<Allotment className='body-allotment'>
|
||||||
<Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history">
|
<Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history">
|
||||||
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
|
|
||||||
<PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} />
|
<PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} />
|
||||||
|
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="column" sx={{ minWidth: 600 }}>
|
<Stack direction="column" sx={{ minWidth: 600 }} className="scroll-history">
|
||||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }} className="scroll-history">
|
<HistoryPanel history={history} scroll={autoScroll ? 'instant' : false} />
|
||||||
{interleave(items)}
|
|
||||||
</List>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Allotment>
|
</Allotment>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material';
|
import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material';
|
||||||
import React from 'react';
|
import React, { MutableRefObject } from 'react';
|
||||||
|
|
||||||
import { formatters } from './format.js';
|
import { formatters } from './format.js';
|
||||||
|
|
||||||
export interface EventItemProps {
|
export interface EventItemProps {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
event: any;
|
event: any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
focusRef?: MutableRefObject<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionItem(props: EventItemProps) {
|
export function ActionItem(props: EventItemProps) {
|
||||||
|
@ -13,7 +15,7 @@ export function ActionItem(props: EventItemProps) {
|
||||||
const { actor, room, type } = event;
|
const { actor, room, type } = event;
|
||||||
const content = formatters[type](event);
|
const content = formatters[type](event);
|
||||||
|
|
||||||
return <ListItem alignItems="flex-start">
|
return <ListItem alignItems="flex-start" ref={props.focusRef}>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar alt={actor} src="/static/images/avatar/1.jpg" />
|
<Avatar alt={actor} src="/static/images/avatar/1.jpg" />
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
|
@ -41,7 +43,7 @@ export function WorldItem(props: EventItemProps) {
|
||||||
const { step, world } = event;
|
const { step, world } = event;
|
||||||
const { theme } = world;
|
const { theme } = world;
|
||||||
|
|
||||||
return <ListItem alignItems="flex-start">
|
return <ListItem alignItems="flex-start" ref={props.focusRef}>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" />
|
<Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" />
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
|
@ -65,7 +67,7 @@ export function MessageItem(props: EventItemProps) {
|
||||||
const { event } = props;
|
const { event } = props;
|
||||||
const { message } = event;
|
const { message } = event;
|
||||||
|
|
||||||
return <ListItem alignItems="flex-start">
|
return <ListItem alignItems="flex-start" ref={props.focusRef}>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar alt="System" src="/static/images/avatar/1.jpg" />
|
<Avatar alt="System" src="/static/images/avatar/1.jpg" />
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
|
@ -87,14 +89,25 @@ export function MessageItem(props: EventItemProps) {
|
||||||
|
|
||||||
export function PlayerItem(props: EventItemProps) {
|
export function PlayerItem(props: EventItemProps) {
|
||||||
const { event } = props;
|
const { event } = props;
|
||||||
const { name } = event;
|
const { character, event: innerEvent, id } = event;
|
||||||
|
|
||||||
return <ListItem alignItems="flex-start">
|
let primary = '';
|
||||||
|
let secondary = '';
|
||||||
|
if (innerEvent === 'join') {
|
||||||
|
primary = 'New Player';
|
||||||
|
secondary = `${id} is now playing as ${character}`;
|
||||||
|
}
|
||||||
|
if (innerEvent === 'leave') {
|
||||||
|
primary = 'Player Left';
|
||||||
|
secondary = `${id} has left the game. ${character} is now controlled by an LLM`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ListItem alignItems="flex-start" ref={props.focusRef}>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar alt={name} src="/static/images/avatar/1.jpg" />
|
<Avatar alt={character} src="/static/images/avatar/1.jpg" />
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="New Player"
|
primary={primary}
|
||||||
secondary={
|
secondary={
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ display: 'block' }}
|
sx={{ display: 'block' }}
|
||||||
|
@ -102,7 +115,7 @@ export function PlayerItem(props: EventItemProps) {
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.primary"
|
color="text.primary"
|
||||||
>
|
>
|
||||||
Someone is playing as {name}
|
{secondary}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -116,15 +129,15 @@ export function EventItem(props: EventItemProps) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'action':
|
case 'action':
|
||||||
case 'result':
|
case 'result':
|
||||||
return <ActionItem event={event} />;
|
return <ActionItem event={event} focusRef={props.focusRef} />;
|
||||||
case 'event':
|
case 'event':
|
||||||
return <MessageItem event={event} />;
|
return <MessageItem event={event} focusRef={props.focusRef} />;
|
||||||
case 'player':
|
case 'player':
|
||||||
return <PlayerItem event={event} />;
|
return <PlayerItem event={event} focusRef={props.focusRef} />;
|
||||||
case 'world':
|
case 'world':
|
||||||
return <WorldItem event={event} />;
|
return <WorldItem event={event} focusRef={props.focusRef} />;
|
||||||
default:
|
default:
|
||||||
return <ListItem>
|
return <ListItem ref={props.focusRef}>
|
||||||
<ListItemText primary={`Unknown event type: ${type}`} />
|
<ListItemText primary={`Unknown event type: ${type}`} />
|
||||||
</ListItem>;
|
</ListItem>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Divider, List } from '@mui/material';
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Maybe } from '@apextoaster/js-utils';
|
||||||
|
import { EventItem } from './events';
|
||||||
|
|
||||||
|
export interface HistoryPanelProps {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
history: Array<any>;
|
||||||
|
scroll: 'auto' | 'instant' | 'smooth' | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPanel(props: HistoryPanelProps) {
|
||||||
|
const { history, scroll } = props;
|
||||||
|
const scrollRef = useRef<Maybe<Element>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current && scroll !== false) {
|
||||||
|
scrollRef.current.scrollIntoView({ behavior: scroll as ScrollBehavior, block: 'end' });
|
||||||
|
}
|
||||||
|
}, [scrollRef.current, props.scroll]);
|
||||||
|
|
||||||
|
const items = history.map((item, index) => {
|
||||||
|
if (index === history.length - 1) {
|
||||||
|
return <EventItem key={`item-${index}`} event={item} focusRef={scrollRef} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EventItem key={`item-${index}`} event={item} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||||
|
{interleave(items)}
|
||||||
|
</List>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function interleave(arr: Array<any>) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||||
|
return arr.reduce((acc, val, idx) => acc.concat(val, <Divider component='li' key={`sep-${idx}`} variant='inset' />), []).slice(0, -1);
|
||||||
|
}
|
|
@ -16,24 +16,36 @@ export function PlayerPanel(props: PlayerPanelProps) {
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
if (!actor) {
|
if (!actor) {
|
||||||
return <Card>
|
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6">No player character</Typography>
|
<Typography variant="h6">No player character</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>;
|
</Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Card>
|
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack direction="column" spacing={2}>
|
<Stack direction="column" spacing={2}>
|
||||||
{activeTurn && <Alert severity="warning">It's your turn!</Alert>}
|
{activeTurn && <Alert severity="warning">It's your turn!</Alert>}
|
||||||
<Typography variant="h6">Playing as: {actor.name}</Typography>
|
<Typography variant="h6">Playing as: {actor.name}</Typography>
|
||||||
<Typography variant="body1">{actor.backstory}</Typography>
|
<Typography variant="body1">{actor.backstory}</Typography>
|
||||||
<Stack direction="row" spacing={2}>
|
<Stack direction="row" spacing={2}>
|
||||||
<TextField label="Input" variant="outlined" fullWidth value={input} onChange={(event) => setInput(event.target.value)} />
|
<TextField
|
||||||
<Button variant="contained" onClick={() => {
|
fullWidth
|
||||||
setInput('');
|
label="Input"
|
||||||
|
variant="outlined"
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
sendInput(input);
|
sendInput(input);
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={() => {
|
||||||
|
sendInput(input);
|
||||||
|
setInput('');
|
||||||
}}>Send</Button>
|
}}>Send</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -98,12 +98,16 @@ export function WorldPanel(props: { world: Maybe<World> } & BaseEntityItemProps)
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
if (!doesExist(world)) {
|
if (!doesExist(world)) {
|
||||||
return <Typography variant="h4">
|
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">
|
||||||
No world data available
|
No world data available
|
||||||
</Typography>;
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Card>
|
return <Card style={{ minHeight: 200, overflow: 'auto' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography gutterBottom variant="h5" component="div">{world.name}</Typography>
|
<Typography gutterBottom variant="h5" component="div">{world.name}</Typography>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
|
|
Loading…
Reference in New Issue