1
Fork 0
This commit is contained in:
prescientmoon 2024-05-12 04:21:28 +02:00
commit 4ea503d68b
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
57 changed files with 12018 additions and 0 deletions

61
typescript/lunargame/client/.gitignore vendored Normal file
View file

@ -0,0 +1,61 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next

View file

@ -0,0 +1,5 @@
coverage
dist
docs
node_modules
*.md

View file

@ -0,0 +1,7 @@
{
"trailingComma": "none",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 4,
"semi": false
}

View file

@ -0,0 +1,6 @@
{
"eslint.enable": true,
"editor.formatOnSave": true,
"prettier.eslintIntegration": true,
"explorer.autoReveal": false
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Matei Adriel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1 @@
# lunarbox-client

View file

@ -0,0 +1,17 @@
module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript'
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }]
],
env: {
test: {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
}
}
}

10066
typescript/lunargame/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
{
"name": "client",
"main": "src/main.ts",
"scripts": {
"dev": "webpack-dev-server",
"build": "cross-env NODE_ENV=production webpack",
"build:dev": "webpack",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@babel/core": "^7.5.0",
"@babel/plugin-proposal-class-properties": "^7.5.0",
"@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "^7.5.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.3.3",
"@types/react-router-dom": "^4.3.4",
"babel-loader": "^8.0.6",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-regenerator-runtime": "^6.5.0",
"css-loader": "^3.0.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.7.0",
"node-sass": "^4.12.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"typescript": "^3.5.2",
"webpack": "^4.35.2",
"webpack-cli": "^3.3.5",
"webpack-dev-server": "^3.7.2",
"webpack-merge": "^4.2.1"
},
"dependencies": {
"@eix/utils": "git+https://github.com/eix-js/utils.git",
"@material-ui/core": "^4.1.3",
"@material-ui/icons": "^4.2.1",
"@material-ui/styles": "^4.2.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-router-dom": "^5.0.1",
"rxjs": "^6.5.2",
"rxjs-hooks": "^0.5.1"
}
}

View file

@ -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())
}
}

View file

@ -0,0 +1,45 @@
import { Singleton } from '@eix/utils'
import { BehaviorSubject } from 'rxjs'
export interface DialogAction {
name: string
variant: 'standard' | 'contained' | 'outlined'
callback: Function
}
export interface Dialog {
title: string
message: string
actions: DialogAction[]
onClose: (event: unknown) => void
}
@Singleton
export class DialogManager {
public active = new BehaviorSubject<Dialog | null>(null)
public queue: Dialog[] = []
public add(dialog: Dialog) {
if (this.active.value !== null) {
this.queue.push(dialog)
} else {
this.activate(dialog)
}
}
private activate(dialog: Dialog) {
this.active.next({
...dialog,
onClose: (event: unknown) => {
dialog.onClose(event)
if (this.queue.length) {
const newDialog = this.queue.shift()
this.activate(newDialog)
} else {
this.active.next(null)
}
}
})
}
}

View file

@ -0,0 +1,54 @@
import React from 'react'
import Dialog from '@material-ui/core/Dialog'
import DialogTitle from '@material-ui/core/DialogTitle'
import { DialogManager } from '../classes/DialogManager'
import { useObservable } from 'rxjs-hooks'
import DialogActions from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import { Button } from '@material-ui/core'
const manager = new DialogManager()
export const BaseDialogRenderer = () => {
const activeDialog = useObservable(() => manager.active)
if (activeDialog !== null) {
return (
<Dialog
onClose={activeDialog.onClose}
open={true}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{activeDialog.title}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{activeDialog.message}
</DialogContentText>
</DialogContent>
<DialogActions>
{activeDialog.actions.map((action, index) => {
return (
<Button
onClick={event => {
action.callback()
activeDialog.onClose(event)
}}
key={index}
>
{action.name}
</Button>
)
})}
</DialogActions>
</Dialog>
)
}
return <></>
}

View file

@ -0,0 +1,49 @@
import { Subscription, BehaviorSubject } from 'rxjs'
export interface FormFieldSnapshot {
passing: boolean
errorMessage?: string
}
export interface FormValidator {
validate: (input: string) => FormFieldSnapshot
}
export class FormField {
private subscription: Subscription
public constructor(
public name: string,
public input: BehaviorSubject<string>,
public output: BehaviorSubject<FormFieldSnapshot>,
private validators: FormValidator[]
) {
this.subscription = this.input.subscribe((text: string) => {
for (const validator of this.validators) {
const result = validator.validate(text)
if (!result.passing) {
return this.output.next(result)
}
}
this.output.next({
passing: true
})
})
}
public dispose() {
if (this.subscription) {
this.subscription.unsubscribe()
}
}
public passes() {
return this.output.value.passing
}
public get value() {
return this.input.value
}
}

View file

@ -0,0 +1,40 @@
import { FormField } from './FormField'
import { BaseServer } from '../../../../modules/network/classes/BaseServer'
const server = new BaseServer()
export class FormManager {
constructor(public fields: FormField[]) {}
public collect() {
const data: Record<string, string> = {}
for (const { name, value } of this.fields) {
data[name] = value
}
return data
}
public async submit(url: string) {
if (this.validate()) {
return server.request(url, 'POST', this.collect())
}
}
public validate() {
for (const field of this.fields) {
if (!field.passes()) return false
}
return true
}
public dispose() {
for (const field of this.fields) {
field.dispose()
}
return this
}
}

View file

@ -0,0 +1,38 @@
import React from 'react'
import TextField from '@material-ui/core/TextField'
import { BehaviorSubject } from 'rxjs'
import { FormFieldSnapshot } from '../classes/FormField'
import { useObservable } from 'rxjs-hooks'
export interface TextFieldWithErrorsProps {
name: string
type: string
input: BehaviorSubject<string>
output: BehaviorSubject<FormFieldSnapshot>
className: string
}
const good = '✅'
export const TextFieldWithErrors = (props: TextFieldWithErrorsProps) => {
const outputSnapshot = useObservable(() => props.output, {
passing: true,
errorMessage: good
})
return (
<TextField
fullWidth
className={props.className}
label={props.name}
type={props.type}
error={!outputSnapshot.passing}
helperText={
outputSnapshot.passing ? good : outputSnapshot.errorMessage
}
onChange={event => {
props.input.next(event.target.value)
}}
/>
)
}

View file

@ -0,0 +1,126 @@
import React from 'react'
import Dialog from '@material-ui/core/Dialog'
import DialogActions from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import DialogTitle from '@material-ui/core/DialogTitle'
import { FormValidator, FormField } from '../classes/FormField'
import { Button } from '@material-ui/core'
import { BehaviorSubject } from 'rxjs'
import { FormManager } from '../classes/FormManager'
import { TextFieldWithErrors } from '../components/TextFieldWithError'
import { makeStyles, Theme } from '@material-ui/core/styles'
export interface TextFieldData {
name: string
type: string
validators: FormValidator[]
}
export interface ModalProps {
open: boolean
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) => ({
field: {
marginTop: theme.spacing(2)
}
}))
export const createFormModal = (options: Partial<FormModalOptions> = {}) => {
// This merges all options
const { fields, title, description, onSubmit, url } = {
...defaultFormModalOptions,
...options
}
const formFields = fields.map(
field =>
new FormField(
field.name,
new BehaviorSubject(''),
new BehaviorSubject({
passing: true
}),
field.validators
)
)
const formManager = new FormManager(formFields)
return (props: ModalProps) => {
const handleClose = (event: unknown) => {
props.onClose(event)
}
const classes = useStyles(props)
const textFields = fields.map((field, index) => {
const fieldObject = formFields[index]
return (
<TextFieldWithErrors
className={classes.field}
name={field.name}
type={field.type}
input={fieldObject.input}
output={fieldObject.output}
key={index}
/>
)
})
return (
<Dialog
fullWidth={true}
maxWidth="sm"
open={props.open}
aria-labelledby={title}
onClose={handleClose}
>
<DialogTitle id={title}>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{description}</DialogContentText>
{textFields}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button
variant="contained"
onClick={event => {
if (formManager.validate()) {
formManager.submit(url).then(data => {
handleClose(event)
if (onSubmit) onSubmit(data)
})
}
}}
color="primary"
>
Submit
</Button>
</DialogActions>
</Dialog>
)
}
}

View file

@ -0,0 +1,26 @@
import { FormValidator, FormFieldSnapshot } from '../classes/FormField'
export interface ValidatorCondition {
regex: RegExp
message: string
}
export const createValidator = (
...conditions: ValidatorCondition[]
): { new (): FormValidator } =>
class implements FormValidator {
public validate(text: string): FormFieldSnapshot {
for (const condition of conditions) {
if (!condition.regex.test(text)) {
return {
passing: false,
errorMessage: condition.message
}
}
}
return {
passing: true
}
}
}

View file

@ -0,0 +1,7 @@
import { createValidator } from '../helpers/createValidator'
export const lengthValidator = (min: number, max: number) =>
createValidator({
regex: new RegExp(`^.{${min},${max}}$`),
message: `Must contain between ${min} and ${max} characters.`
})

View file

@ -0,0 +1,38 @@
import { decorable } from '@eix/utils'
import { areEqual } from '../helpers/areEqual'
export interface ObjectArgumentsRef<T> {
instance: T
arguments: unknown[]
}
export const cacheInstances = (argCount = Infinity) => {
const objectMemory: ObjectArgumentsRef<unknown>[] = []
return <T extends { new (...args: any[]): { init: () => void } }>(
toDecorate: T
) => {
return class extends toDecorate {
constructor(...args: any[]) {
super(...args)
const sliceParameters =
argCount === Infinity ? [0] : [0, argCount]
const argumentsToStore = args.slice(...sliceParameters)
const reference = objectMemory.find(instance =>
areEqual(argumentsToStore, instance.arguments)
)
if (reference) return reference.instance as this
else
objectMemory.push({
instance: this,
arguments: argumentsToStore
})
if (super.init) super.init()
}
}
}
}

View file

@ -0,0 +1,10 @@
export const areEqual = <T extends {}>(first: T, last: T) => {
for (const key of Object.keys(first)) {
// for ts to shut up
const typedKey = key as keyof T
if (first[typedKey] !== last[typedKey]) return false
}
return true
}

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="theme-color" content="#091F35" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"
/>
<meta charset="utf-8" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<title>LunarBox</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -0,0 +1,5 @@
import { render } from 'react-dom'
import React from 'react'
import { App } from './modules/core/components/App'
render(<App />, document.querySelector('#app'))

View file

@ -0,0 +1,26 @@
import { createFormModal } from '../../../common/dom/forms/helpers/createFormModal'
import {
emailValidatorList,
passwordValidatorList
} from '../validators/authValidators'
import { Account } from '../../network/types/Account'
import { updateAccount } from '../../helpers/updateAccount'
export const LoginModal = createFormModal({
title: 'Login',
description: `To subscribe to this website, please enter you r email address here. We will send updates occasionally.`,
url: 'auth/login',
fields: [
{
name: 'email',
type: 'email',
validators: emailValidatorList()
},
{
name: 'password',
type: 'password',
validators: passwordValidatorList()
}
],
onSubmit: updateAccount
})

View file

@ -0,0 +1,27 @@
import React, { useState } from 'react'
import Button from '@material-ui/core/Button'
import { ModalProps } from '../../../common/dom/forms/helpers/createFormModal'
export interface ModalButtonProps {
modal: (props: ModalProps) => JSX.Element
children: string
className?: string
contained?: boolean
}
export const ModalButton = (props: ModalButtonProps) => {
const [open, setOpen] = useState(false)
return (
<>
<Button
className={props.className}
variant={props.contained ? 'contained' : 'text'}
onClick={() => setOpen(true)}
>
{props.children}
</Button>
<props.modal open={open} onClose={() => setOpen(false)} />
</>
)
}

View file

@ -0,0 +1,44 @@
import { createFormModal } from '../../../common/dom/forms/helpers/createFormModal'
import {
usernameValidatorList,
emailValidatorList,
passwordValidatorList
} from '../validators/authValidators'
import { updateAccount } from '../../helpers/updateAccount'
import { Account } from '../../network/types/Account'
import { DialogManager } from '../../../common/dom/dialogs/classes/DialogManager'
const dialogManager = new DialogManager()
export const SignupModal = createFormModal({
title: 'Signup',
description: `To create an account you need to provide an username, email and a password.`,
url: 'auth/create',
fields: [
{
name: 'name',
type: 'text',
validators: usernameValidatorList()
},
{
name: 'email',
type: 'email',
validators: emailValidatorList()
},
{
name: 'password',
type: 'password',
validators: passwordValidatorList()
}
],
onSubmit: (data: Account) => {
updateAccount(data)
dialogManager.add({
title: 'Email verification',
message:
'To unlock the full set of features offered by lunrabox, please verify your email',
actions: [],
onClose: () => {}
})
}
})

View file

@ -0,0 +1,40 @@
import React from 'react'
import { useObservable } from 'rxjs-hooks'
import { BaseServer } from '../../network/classes/BaseServer'
import Avatar from '@material-ui/core/Avatar'
import { makeStyles, Theme } from '@material-ui/core/styles'
import { LoginModal } from './LoginModal'
import { ModalButton } from './ModalButton'
import { SignupModal } from './SignupModal'
const { account } = new BaseServer()
const useStyles = makeStyles((theme: Theme) => ({
loginButton: {
marginLeft: theme.spacing(2)
}
}))
export const TopbarAccount = (props: unknown) => {
const accountSnapshot = useObservable(() => account, null)
const classes = useStyles(props)
const signup = (
<>
<ModalButton modal={SignupModal}>Sign up</ModalButton>
<ModalButton
modal={LoginModal}
className={classes.loginButton}
contained
>
Log in
</ModalButton>
</>
)
return accountSnapshot ? (
<Avatar src={accountSnapshot.avatar} alt={accountSnapshot.name} />
) : (
signup
)
}

View file

@ -0,0 +1,6 @@
import { createValidator } from '../../../common/dom/forms/helpers/createValidator'
export const alphaNumericValidator = createValidator({
regex: /^[a-zA-Z0-9_]*$/,
message: 'Must only contain alpha-numeric characters or underscores.'
})

View file

@ -0,0 +1,20 @@
import { fieldLengthValidator } from './fieldLength'
import { emailValidator } from './emailValidator'
import { requiredValidator } from './requiredValidator'
import { alphaNumericValidator } from './alphaNumeric'
export const usernameValidatorList = () => [
new requiredValidator(),
new fieldLengthValidator(),
new alphaNumericValidator()
]
export const emailValidatorList = () => [
new requiredValidator(),
new fieldLengthValidator(),
new emailValidator()
]
export const passwordValidatorList = () => [
new requiredValidator(),
new fieldLengthValidator(),
new alphaNumericValidator()
]

View file

@ -0,0 +1,6 @@
import { createValidator } from '../../../common/dom/forms/helpers/createValidator'
export const emailValidator = createValidator({
regex: /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/,
message: 'Must be a valid email'
})

View file

@ -0,0 +1,3 @@
import { lengthValidator } from '../../../common/dom/forms/validators/lengthValidator'
export const fieldLengthValidator = lengthValidator(3, 30)

View file

@ -0,0 +1,6 @@
import { createValidator } from '../../../common/dom/forms/helpers/createValidator'
export const requiredValidator = createValidator({
regex: /^.{1,}$/,
message: 'Field is required'
})

View file

@ -0,0 +1,24 @@
import '../styles/reset.scss'
import React from 'react'
import CssBaseline from '@material-ui/core/CssBaseline'
import { BrowserRouter as Router } from 'react-router-dom'
import { theme as MuiTheme } from '../data/Theme'
import { ThemeProvider as Theme } from '@material-ui/styles'
import { AppBar } from './AppBar'
import { Body } from './Body'
import { BaseDialogRenderer } from '../../../common/dom/dialogs/components/BaseDialogRenderer'
export const App = () => {
return (
<Theme theme={MuiTheme}>
<CssBaseline />
<Router>
<AppBar />
<Body />
<BaseDialogRenderer />
</Router>
</Theme>
)
}

View file

@ -0,0 +1,76 @@
import React, { useState, KeyboardEvent } from 'react'
import MuiAppBar from '@material-ui/core/AppBar'
import Toolbar from '@material-ui/core/Toolbar'
import Typography from '@material-ui/core/Typography'
import IconButton from '@material-ui/core/IconButton'
import MenuIcon from '@material-ui/icons/Menu'
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'
import { Sidebar } from './Sidebar'
import { makeStyles, Theme } from '@material-ui/core/styles'
import { TopbarAccount } from '../../account/components/TopbarAccount'
const useStyles = makeStyles((theme: Theme) => ({
root: {
flexGrow: 1
},
title: {
flexGrow: 1
},
list: {
width: 250
}
}))
export const AppBar = (props: unknown) => {
const [sidebar, setSidebar] = useState(false)
const classes = useStyles(props)
const closeSidebar = () => setSidebar(false)
const openSidebar = () => setSidebar(true)
const handleKeydown = (open: boolean) => (
event: KeyboardEvent<HTMLDivElement>
) => {
if (event.key === 'Tab' || event.key === 'Shift') {
return
}
setSidebar(open)
}
return (
<>
<div className={classes.root}>
<MuiAppBar position="static">
<Toolbar>
<IconButton
edge="start"
color="inherit"
aria-label="Menu"
onClick={() => setSidebar(true)}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title} />
<TopbarAccount />
</Toolbar>
</MuiAppBar>
</div>
<SwipeableDrawer
open={sidebar}
onClose={closeSidebar}
onOpen={openSidebar}
>
<div
role="presentation"
onClick={closeSidebar}
onKeyDown={handleKeydown(false)}
className={classes.list}
>
<Sidebar />
</div>
</SwipeableDrawer>
</>
)
}

View file

@ -0,0 +1,21 @@
import React from 'react'
import { SiddebarRoutes } from './SidebarRouteList'
import { makeStyles } from '@material-ui/styles'
import { Route } from 'react-router-dom'
const useStyles = makeStyles({
root: {
height: '90vh',
display: 'block'
}
})
export const Body = (props: unknown) => {
const classes = useStyles(props)
return (
<div className={classes.root}>
<SiddebarRoutes />
</div>
)
}

View file

@ -0,0 +1,61 @@
import React from 'react'
import Typography from '@material-ui/core/Typography'
import Divider from '@material-ui/core/Divider'
import { makeStyles, Theme } from '@material-ui/core/styles'
import { BaseServer } from '../../network/classes/BaseServer'
const useStyles = makeStyles((theme: Theme) => ({
root: {
flexGrow: 1
},
divider: {
marginBottom: theme.spacing(2),
marginTop: theme.spacing(2)
},
a: {
color: '#0000ff'
}
}))
export const Home = (props: unknown) => {
const classes = useStyles(props)
return (
<>
<Typography variant="h4">This is Lunarbox</Typography>
<Divider className={classes.divider} />
<Typography variant="h6" color="textSecondary">
Lunarbox is a game streaming website for games made with the eix
game engine. The project is open source, and right now also
unusable.
</Typography>
<br />
<Typography variant="h6" color="textSecondary">
The project is open source on{' '}
<a
className={classes.a}
href="https://github.com/Mateiadrielrafael/lunarbox-client"
>
github
</a>
.
</Typography>
{/* Todo: remove */}
<button
onClick={async () => {
const server = new BaseServer()
await server.request('account/uid', 'DELETE')
server.account.next(null)
}}
>
logout
</button>
</>
)
}

View file

@ -0,0 +1,26 @@
import React from 'react'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import { routes } from './SidebarRouteData'
import { Link } from 'react-router-dom'
export const Sidebar = () => {
return (
<>
<List>
{routes.map((route, index) => {
return (
<Link className="routeLink" to={route.url} key={index}>
<ListItem button>
<ListItemIcon>{route.icon}</ListItemIcon>
<ListItemText primary={route.name} />
</ListItem>
</Link>
)
})}
</List>
</>
)
}

View file

@ -0,0 +1,21 @@
import React from 'react'
import HomeIcon from '@material-ui/icons/Home'
import GamesIcon from '@material-ui/icons/Games'
import { Route } from '../types/Route'
import { Home } from './Home'
import { Games } from '../../games/components/GamePage'
export const routes: Route[] = [
{
name: 'home',
url: '/',
content: Home,
icon: <HomeIcon />
},
{
name: 'games',
url: '/games',
content: Games,
icon: <GamesIcon />
}
]

View file

@ -0,0 +1,18 @@
import React from 'react'
import { routes } from './SidebarRouteData'
import { Route } from 'react-router-dom'
export const SiddebarRoutes = () => {
return (
<>
{routes.map((route, index) => (
<Route
key={index}
path={route.url}
component={route.content}
exact={route.url === '/'}
/>
))}
</>
)
}

View file

@ -0,0 +1,10 @@
import { createMuiTheme } from '@material-ui/core/styles'
import * as Colors from '@material-ui/core/colors'
export const theme = createMuiTheme({
palette: {
type: 'dark',
primary: Colors.deepPurple,
secondary: Colors.red
}
})

View file

@ -0,0 +1,5 @@
@mixin maxSize {
width: 100%;
height: 100%;
display: block;
}

View file

@ -0,0 +1,456 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0-modified | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* make sure to set some focus styles for accessibility */
:focus {
outline: 0;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none;
-moz-appearance: none;
}
input[type='search'] {
-webkit-appearance: none;
-moz-appearance: none;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
textarea {
overflow: auto;
vertical-align: top;
resize: vertical;
}
/**
* Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3.
*/
audio,
canvas,
video {
display: inline-block;
*display: inline;
*zoom: 1;
max-width: 100%;
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address styling not present in IE 7/8/9, Firefox 3, and Safari 4.
* Known issue: no IE 6 support.
*/
[hidden] {
display: none;
}
/**
* 1. Correct text resizing oddly in IE 6/7 when body `font-size` is set using
* `em` units.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-size: 100%; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-ms-text-size-adjust: 100%; /* 2 */
}
/**
* Address `outline` inconsistency between Chrome and other browsers.
*/
a:focus {
outline: thin dotted;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/**
* 1. Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3.
* 2. Improve image quality when scaled in IE 7.
*/
img {
border: 0; /* 1 */
-ms-interpolation-mode: bicubic; /* 2 */
}
/**
* Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11.
*/
figure {
margin: 0;
}
/**
* Correct margin displayed oddly in IE 6/7.
*/
form {
margin: 0;
}
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct color not being inherited in IE 6/7/8/9.
* 2. Correct text not wrapping in Firefox 3.
* 3. Correct alignment displayed oddly in IE 6/7.
*/
legend {
border: 0; /* 1 */
padding: 0;
white-space: normal; /* 2 */
*margin-left: -7px; /* 3 */
}
/**
* 1. Correct font size not being inherited in all browsers.
* 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5,
* and Chrome.
* 3. Improve appearance and consistency in all browsers.
*/
button,
input,
select,
textarea {
font-size: 100%; /* 1 */
margin: 0; /* 2 */
vertical-align: baseline; /* 3 */
*vertical-align: middle; /* 3 */
}
/**
* Address Firefox 3+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
button,
input {
line-height: normal;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+.
* Correct `select` style inheritance in Firefox 4+ and Opera.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
* 4. Remove inner spacing in IE 7 without affecting normal text inputs.
* Known issue: inner spacing remains in IE 6.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
*overflow: visible; /* 4 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* 1. Address box sizing set to content-box in IE 8/9.
* 2. Remove excess padding in IE 8/9.
* 3. Remove excess padding in IE 7.
* Known issue: excess padding remains in IE 6.
*/
input[type='checkbox'],
input[type='radio'] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
*height: 13px; /* 3 */
*width: 13px; /* 3 */
}
/**
* 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome
* (include `-moz` to future-proof).
*/
input[type='search'] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari 5 and Chrome
* on OS X.
*/
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Remove inner padding and border in Firefox 3+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* 1. Remove default vertical scrollbar in IE 6/7/8/9.
* 2. Improve readability and alignment in all browsers.
*/
textarea {
overflow: auto; /* 1 */
vertical-align: top; /* 2 */
}
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
html,
button,
input,
select,
textarea {
color: #222;
}
::-moz-selection {
background: #b3d4fc;
text-shadow: none;
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
img {
vertical-align: middle;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
textarea {
resize: vertical;
}
.chromeframe {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
a {
text-decoration: none;
color: inherit;
}
html,
body {
overflow: hidden;
}

View file

@ -0,0 +1,8 @@
import React, { Component } from 'react'
export interface Route {
name: string
url: string
content: (props: unknown) => JSX.Element
icon: JSX.Element
}

View file

@ -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();
}

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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>
)
}

View 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
}
)

View file

@ -0,0 +1,8 @@
import { BaseServer } from '../network/classes/BaseServer'
import { Account } from '../network/types/Account'
const server = new BaseServer()
export const updateAccount = (data: Account | null) => {
server.account.next(data)
}

View file

@ -0,0 +1,64 @@
import { Account } from '../types/Account'
import { BehaviorSubject } from 'rxjs'
import { Singleton } from '@eix/utils'
import { Response } from '../types/Response'
@Singleton
export class BaseServer {
public account = new BehaviorSubject<Account | null>(null)
public path = 'http://localhost:8000'
constructor() {
this.refreshAccount()
}
public async refreshAccount(url = 'account', method = 'GET', body = {}) {
try {
const account = await this.request<Account>(url, method, body)
this.account.next(account)
return account
} catch (err) {
this.account.next(null)
return null
}
}
public async request<T>(
url: string,
method = 'GET',
body = {},
queryParams: Record<string, string | number> = {}
): Promise<T> {
const noBody = ['GET', 'DELETE']
const useBody = noBody.indexOf(method) === -1
const params = Object.keys(queryParams).map(
key => `${key}=${queryParams[key]}`
)
const finalUrl = `${this.path}/${url}${
params.length ? '?' : ''
}${params.join('&')}`
const response = await fetch(finalUrl, {
...(useBody ? { body: JSON.stringify(body) } : {}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': 'true'
},
method,
credentials: 'include'
})
const parsed: Response<T> = await response.json()
const status = response.status
if (status !== 200) {
console.warn(parsed.message)
throw new Error(parsed.message)
}
return parsed.data
}
}

View file

@ -0,0 +1,30 @@
import { cacheInstances } from '../../../common/lang/objects/decorators/cacheInstances'
import { BehaviorSubject } from 'rxjs'
import { AccountPublicData } from '../types/AccountPublicData'
import { BaseServer } from './BaseServer'
@cacheInstances()
export class PublicAccount {
public static server = new BaseServer()
public account = new BehaviorSubject<AccountPublicData | null>(null)
public constructor(public name: string) {}
public init() {
this.refresh()
}
public async refresh() {
try {
const account = await PublicAccount.server.request<
AccountPublicData
>(`user/name/${this.name}`)
this.account.next(account)
return account
} catch {
this.account.next(null)
return null
}
}
}

View file

@ -0,0 +1,2 @@
export const defaultAvatar =
'https://cdn-images-1.medium.com/max/1200/1*MccriYX-ciBniUzRKAUsAw.png'

View file

@ -0,0 +1,6 @@
import { AccountPublicData } from './AccountPublicData'
export interface Account extends AccountPublicData {
uid: string
verified: boolean
}

View file

@ -0,0 +1,6 @@
export interface AccountPublicData {
email: string
name: string
description: string
avatar: string
}

View file

@ -0,0 +1,5 @@
export interface GameChunkElementData {
id: string
thumbail: string
avatar: string
}

View file

@ -0,0 +1,4 @@
export interface Response<T> {
data: T
message: string
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"moduleResolution": "node",
"esModuleInterop": true,
"jsx": "preserve",
"noImplicitAny": true,
"experimentalDecorators": true,
"target": "esnext"
},
"exclude": ["node_modules"],
"include": ["src"]
}

View file

@ -0,0 +1,114 @@
const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const webpackMerge = require('webpack-merge')
const isProduction = process.env.NODE_ENV === 'production'
const projectRoot = resolve(__dirname)
const sourceFolder = resolve(projectRoot, 'src')
const buildFolder = resolve(projectRoot, 'dist')
const htmlTemplateFile = resolve(sourceFolder, 'index.html')
const babelRule = {
test: /\.(js|tsx?)$/,
use: 'babel-loader'
}
const sassRule = {
test: /\.scss$/,
use: [
isProduction
? MiniCssExtractPlugin.loader
: {
loader: 'style-loader',
options: {
singleton: true
}
},
{ loader: 'css-loader' },
{
loader: 'sass-loader',
options: {
includePaths: [sourceFolder]
}
}
]
}
const baseConfig = {
mode: 'none',
entry: ['babel-regenerator-runtime', resolve(sourceFolder, 'main')],
output: {
filename: 'js/[name].js',
path: buildFolder,
publicPath: '/'
},
module: {
rules: [babelRule, sassRule]
},
resolve: {
extensions: ['.js', '.ts', '.tsx', '.scss']
},
plugins: []
}
const devConfig = {
mode: 'development',
plugins: [
new HtmlWebpackPlugin({
template: htmlTemplateFile,
chunksSortMode: 'dependency'
})
],
devtool: 'inline-source-map',
devServer: {
historyApiFallback: true
}
}
const prodConfig = {
mode: 'production',
optimization: {
minimize: true,
nodeEnv: 'production'
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].min.css'
}),
new OptimizeCssAssetsWebpackPlugin(),
new HtmlWebpackPlugin({
template: htmlTemplateFile,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true
},
inject: true
}),
new HtmlWebpackInlineSourcePlugin()
],
devtool: 'source-map'
}
function getFinalConfig() {
if (process.env.NODE_ENV === 'production') {
console.info('Running production config')
return webpackMerge(baseConfig, prodConfig)
}
console.info('Running development config')
return webpackMerge(baseConfig, devConfig)
}
module.exports = getFinalConfig()