typescript(lunargame/client): games page
Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
parent
db4f749cd3
commit
ba844b45c1
50
typescript/lunargame/client/package-lock.json
generated
50
typescript/lunargame/client/package-lock.json
generated
|
@ -7546,6 +7546,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react": {
|
||||||
|
"version": "16.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz",
|
||||||
|
"integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==",
|
||||||
|
"requires": {
|
||||||
|
"loose-envify": "^1.1.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"scheduler": "^0.13.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"version": "16.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz",
|
||||||
|
"integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==",
|
||||||
|
"requires": {
|
||||||
|
"loose-envify": "^1.1.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"scheduler": "^0.13.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-event-listener": {
|
"react-event-listener": {
|
||||||
"version": "0.6.6",
|
"version": "0.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.6.tgz",
|
||||||
|
@ -7988,6 +8010,22 @@
|
||||||
"aproba": "^1.1.1"
|
"aproba": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rxjs": {
|
||||||
|
"version": "6.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz",
|
||||||
|
"integrity": "sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^1.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rxjs-hooks": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs-hooks/-/rxjs-hooks-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-UVF2PH6PdzGr1FPgRljzNtOxu8Yt3J5S2cM2KCv6ZRs3E/XaRI7/qiQySZlp9YIMpFVOuYeJDHgePkSLatY22g==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^1.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
@ -8154,6 +8192,15 @@
|
||||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
|
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"version": "0.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz",
|
||||||
|
"integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==",
|
||||||
|
"requires": {
|
||||||
|
"loose-envify": "^1.1.0",
|
||||||
|
"object-assign": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"schema-utils": {
|
"schema-utils": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
|
||||||
|
@ -9234,8 +9281,7 @@
|
||||||
"tslib": {
|
"tslib": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
|
||||||
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
|
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"tty-browserify": {
|
"tty-browserify": {
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|
|
@ -38,6 +38,10 @@
|
||||||
"@material-ui/core": "^4.1.3",
|
"@material-ui/core": "^4.1.3",
|
||||||
"@material-ui/icons": "^4.2.1",
|
"@material-ui/icons": "^4.2.1",
|
||||||
"@material-ui/styles": "^4.2.0",
|
"@material-ui/styles": "^4.2.0",
|
||||||
"react-router-dom": "^5.0.1"
|
"react": "^16.8.6",
|
||||||
|
"react-dom": "^16.8.6",
|
||||||
|
"react-router-dom": "^5.0.1",
|
||||||
|
"rxjs": "^6.5.2",
|
||||||
|
"rxjs-hooks": "^0.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { cacheInstances } from '../../lang/objects/decorators/cacheInstances'
|
||||||
|
import { BehaviorSubject } from 'rxjs'
|
||||||
|
import { BaseServer } from '../../../modules/network/classes/BaseServer'
|
||||||
|
|
||||||
|
export interface InfiniteListConfig {
|
||||||
|
urls: {
|
||||||
|
chunk: string
|
||||||
|
count: string
|
||||||
|
}
|
||||||
|
pageSize: number
|
||||||
|
initialLoads?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
@cacheInstances(1)
|
||||||
|
export class InfiniteList<T> {
|
||||||
|
private static server = new BaseServer()
|
||||||
|
|
||||||
|
private count = 0
|
||||||
|
private page = 0
|
||||||
|
|
||||||
|
public elements = new Set<T>()
|
||||||
|
public refresh = new BehaviorSubject(0)
|
||||||
|
public ready = new BehaviorSubject(false)
|
||||||
|
|
||||||
|
constructor(public name: string, private config: InfiniteListConfig) {}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.count = await InfiniteList.server.request(this.config.urls.count)
|
||||||
|
|
||||||
|
this.ready.next(true)
|
||||||
|
this.update()
|
||||||
|
|
||||||
|
for (let index = 0; index < this.config.initialLoads; index++) {
|
||||||
|
this.loadChunk()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunk() {
|
||||||
|
if (this.elements.size >= this.count) return
|
||||||
|
|
||||||
|
const chunk = await InfiniteList.server.request<T[]>(
|
||||||
|
this.config.urls.chunk,
|
||||||
|
'GET',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
page: this.page++,
|
||||||
|
pageSize: this.config.pageSize
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const element of chunk) {
|
||||||
|
this.elements.add(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.refresh.next(this.refresh.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
get data() {
|
||||||
|
return Array.from(this.elements.values())
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,19 +22,35 @@ export interface ModalProps {
|
||||||
onClose: Function
|
onClose: Function
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FormModalOptions {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
url: string
|
||||||
|
fields: TextFieldData[]
|
||||||
|
onSubmit: (data: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultFormModalOptions: FormModalOptions = {
|
||||||
|
title: 'Mymodal',
|
||||||
|
description: 'This is a modal',
|
||||||
|
url: '',
|
||||||
|
fields: [],
|
||||||
|
onSubmit: () => {}
|
||||||
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => ({
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
field: {
|
field: {
|
||||||
marginTop: theme.spacing(2)
|
marginTop: theme.spacing(2)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const createFormModal = (
|
export const createFormModal = (options: Partial<FormModalOptions> = {}) => {
|
||||||
title: string,
|
// This merges all options
|
||||||
description: string,
|
const { fields, title, description, onSubmit, url } = {
|
||||||
url: string,
|
...defaultFormModalOptions,
|
||||||
fields: TextFieldData[],
|
...options
|
||||||
onSubmit?: (data: unknown) => void
|
}
|
||||||
) => {
|
|
||||||
const formFields = fields.map(
|
const formFields = fields.map(
|
||||||
field =>
|
field =>
|
||||||
new FormField(
|
new FormField(
|
||||||
|
@ -54,7 +70,7 @@ export const createFormModal = (
|
||||||
props.onClose(event)
|
props.onClose(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
const classes = useStyles()
|
const classes = useStyles(props)
|
||||||
|
|
||||||
const textFields = fields.map((field, index) => {
|
const textFields = fields.map((field, index) => {
|
||||||
const fieldObject = formFields[index]
|
const fieldObject = formFields[index]
|
||||||
|
|
|
@ -6,11 +6,11 @@ import {
|
||||||
import { Account } from '../../network/types/Account'
|
import { Account } from '../../network/types/Account'
|
||||||
import { updateAccount } from '../../helpers/updateAccount'
|
import { updateAccount } from '../../helpers/updateAccount'
|
||||||
|
|
||||||
export const LoginModal = createFormModal(
|
export const LoginModal = createFormModal({
|
||||||
'Login',
|
title: 'Login',
|
||||||
`To subscribe to this website, please enter you r email address here. We will send updates occasionally.`,
|
description: `To subscribe to this website, please enter you r email address here. We will send updates occasionally.`,
|
||||||
'auth/login',
|
url: 'auth/login',
|
||||||
[
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'email',
|
name: 'email',
|
||||||
type: 'email',
|
type: 'email',
|
||||||
|
@ -22,5 +22,5 @@ export const LoginModal = createFormModal(
|
||||||
validators: passwordValidatorList()
|
validators: passwordValidatorList()
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
updateAccount
|
onSubmit: updateAccount
|
||||||
)
|
})
|
||||||
|
|
|
@ -10,11 +10,11 @@ import { DialogManager } from '../../../common/dom/dialogs/classes/DialogManager
|
||||||
|
|
||||||
const dialogManager = new DialogManager()
|
const dialogManager = new DialogManager()
|
||||||
|
|
||||||
export const SignupModal = createFormModal(
|
export const SignupModal = createFormModal({
|
||||||
'Signup',
|
title: 'Signup',
|
||||||
`To create an account you need to provide an username, email and a password.`,
|
description: `To create an account you need to provide an username, email and a password.`,
|
||||||
'auth/create',
|
url: 'auth/create',
|
||||||
[
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
@ -31,7 +31,7 @@ export const SignupModal = createFormModal(
|
||||||
validators: passwordValidatorList()
|
validators: passwordValidatorList()
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
(data: Account) => {
|
onSubmit: (data: Account) => {
|
||||||
updateAccount(data)
|
updateAccount(data)
|
||||||
dialogManager.add({
|
dialogManager.add({
|
||||||
title: 'Email verification',
|
title: 'Email verification',
|
||||||
|
@ -41,4 +41,4 @@ export const SignupModal = createFormModal(
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
|
@ -5,7 +5,8 @@ import { Route } from 'react-router-dom'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
padding: '5%'
|
height: '90vh',
|
||||||
|
display: 'block'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import HomeIcon from '@material-ui/icons/Home'
|
import HomeIcon from '@material-ui/icons/Home'
|
||||||
|
import GamesIcon from '@material-ui/icons/Games'
|
||||||
import { Route } from '../types/Route'
|
import { Route } from '../types/Route'
|
||||||
import { Home } from './Home'
|
import { Home } from './Home'
|
||||||
|
import { Games } from '../../games/components/GamePage'
|
||||||
|
|
||||||
export const routes: Route[] = [
|
export const routes: Route[] = [
|
||||||
{
|
{
|
||||||
|
@ -11,13 +13,9 @@ export const routes: Route[] = [
|
||||||
icon: <HomeIcon />
|
icon: <HomeIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'about',
|
name: 'games',
|
||||||
url: '/about',
|
url: '/games',
|
||||||
content: () => (
|
content: Games,
|
||||||
<>
|
icon: <GamesIcon />
|
||||||
<h1>This is the about component</h1>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
icon: <HomeIcon />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
@mixin maxSize {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
|
@ -449,3 +449,8 @@ a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { Component } from 'react'
|
import React, { Component } from 'react'
|
||||||
|
|
||||||
export type acceptedContent = React.ComponentElement<unknown, Component>
|
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
name: string
|
name: string
|
||||||
url: string
|
url: string
|
||||||
content: () => acceptedContent
|
content: (props: unknown) => JSX.Element
|
||||||
icon: acceptedContent
|
icon: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
@import '../../core/styles/mixins/maxSize.scss';
|
||||||
|
|
||||||
|
.gameListItemContainer {
|
||||||
|
padding: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameListItem {
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameListItem,
|
||||||
|
.gameListItemContainer {
|
||||||
|
@include maxSize();
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React, { CSSProperties } from 'react'
|
||||||
|
import './GameListItem.scss'
|
||||||
|
import { GameChunkElementData } from '../../network/types/GameChunkElementData'
|
||||||
|
|
||||||
|
export const GameListItem = (props: { game: GameChunkElementData }) => {
|
||||||
|
const styles: CSSProperties = {
|
||||||
|
backgroundImage: `url(${props.game.thumbail})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gameListItemContainer">
|
||||||
|
<div className="gameListItem" style={styles} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
.gameListContainer {
|
||||||
|
overflow-y: scroll;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 5vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameListContainer > .gameListGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameListGrid::before {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
padding-bottom: 100%;
|
||||||
|
grid-row: 1 / 1;
|
||||||
|
grid-column: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameListGrid > *:first-child {
|
||||||
|
grid-row: 1 / 1;
|
||||||
|
grid-column: 1 / 1;
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React, { UIEvent } from 'react'
|
||||||
|
import './GamePage.scss'
|
||||||
|
import { useObservable } from 'rxjs-hooks'
|
||||||
|
import { GameInfiniteList } from '../constants'
|
||||||
|
import { GameListItem } from './GameListItem'
|
||||||
|
|
||||||
|
export const Games = () => {
|
||||||
|
useObservable(() => GameInfiniteList.refresh)
|
||||||
|
|
||||||
|
const ready = useObservable(() => GameInfiniteList.ready)
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return <h1>Loading...</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||||
|
const element = e.target as HTMLDivElement
|
||||||
|
|
||||||
|
if (
|
||||||
|
element.scrollHeight - element.scrollTop <
|
||||||
|
element.clientHeight + 50
|
||||||
|
) {
|
||||||
|
GameInfiniteList.loadChunk()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onScroll={handleScroll} className="gameListContainer">
|
||||||
|
<div className="gameListGrid">
|
||||||
|
{GameInfiniteList.data.map(game => (
|
||||||
|
<GameListItem key={game.id} game={game} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
14
typescript/lunargame/client/src/modules/games/constants.ts
Normal file
14
typescript/lunargame/client/src/modules/games/constants.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { InfiniteList } from '../../common/dom/classes/InfiniteList'
|
||||||
|
import { GameChunkElementData } from '../network/types/GameChunkElementData'
|
||||||
|
|
||||||
|
export const GameInfiniteList = new InfiniteList<GameChunkElementData>(
|
||||||
|
'gameList',
|
||||||
|
{
|
||||||
|
pageSize: 5,
|
||||||
|
urls: {
|
||||||
|
chunk: 'game/chunk',
|
||||||
|
count: 'game/count'
|
||||||
|
},
|
||||||
|
initialLoads: 6
|
||||||
|
}
|
||||||
|
)
|
|
@ -26,14 +26,22 @@ export class BaseServer {
|
||||||
public async request<T>(
|
public async request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
method = 'GET',
|
method = 'GET',
|
||||||
body = {}
|
body = {},
|
||||||
|
queryParams: Record<string, string | number> = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const noBody = ['GET', 'DELETE']
|
const noBody = ['GET', 'DELETE']
|
||||||
|
const useBody = noBody.indexOf(method) === -1
|
||||||
|
|
||||||
const response = await fetch(`${this.path}/${url}`, {
|
const params = Object.keys(queryParams).map(
|
||||||
...(noBody.indexOf(method) === -1
|
key => `${key}=${queryParams[key]}`
|
||||||
? { body: JSON.stringify(body) }
|
)
|
||||||
: {}),
|
|
||||||
|
const finalUrl = `${this.path}/${url}${
|
||||||
|
params.length ? '?' : ''
|
||||||
|
}${params.join('&')}`
|
||||||
|
|
||||||
|
const response = await fetch(finalUrl, {
|
||||||
|
...(useBody ? { body: JSON.stringify(body) } : {}),
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
@ -42,6 +50,7 @@ export class BaseServer {
|
||||||
method,
|
method,
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsed: Response<T> = await response.json()
|
const parsed: Response<T> = await response.json()
|
||||||
const status = response.status
|
const status = response.status
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface GameChunkElementData {
|
||||||
|
id: string
|
||||||
|
thumbail: string
|
||||||
|
avatar: string
|
||||||
|
}
|
Loading…
Reference in a new issue