├── .env.template
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── Procfile
├── README.md
├── next.config.js
├── package-lock.json
├── package.json
├── public
├── assets
│ ├── dice.webm
│ ├── logo.svg
│ └── user.png
├── favicon.ico
└── vercel.svg
└── src
├── components
├── AddBox.jsx
├── CharacterBox.jsx
├── EditableRow.jsx
├── Header.jsx
├── Loader.jsx
├── Section.jsx
├── SheetEditableRow.jsx
├── StatusBar.jsx
├── TextFieldIcon.jsx
├── forms
│ ├── CharacterInfoForm.jsx
│ └── index.js
├── index.js
└── modals
│ ├── AttributeModal.jsx
│ ├── ChangePictureModal.jsx
│ ├── ConfirmationModal.jsx
│ ├── CreateCharacterModal.jsx
│ ├── DiceRollModal.jsx
│ ├── GeneratePortraitModal.jsx
│ ├── InfoModal.jsx
│ ├── SkillModal.jsx
│ └── StatusBarModal.jsx
├── contexts
└── ModalContext.jsx
├── database.js
├── hooks
└── useModal.js
├── pages
├── _app.js
├── _document.js
├── api
│ ├── attribute
│ │ ├── [id].js
│ │ └── index.js
│ ├── character
│ │ ├── [id].js
│ │ ├── attribute.js
│ │ ├── index.js
│ │ └── skill.js
│ ├── config
│ │ ├── [name].js
│ │ └── index.js
│ ├── roll
│ │ └── index.js
│ ├── setup
│ │ └── index.js
│ └── skill
│ │ ├── [id].js
│ │ └── index.js
├── dashboard
│ └── index.jsx
├── dice
│ └── [id].jsx
├── index.jsx
├── portrait
│ └── [id].jsx
└── sheet
│ └── [id].jsx
├── prisma
├── migrations
│ ├── 20211111223711_init
│ │ └── migration.sql
│ ├── 20211112234305_added_config_table
│ │ └── migration.sql
│ ├── 20211113001226_config_table_unique_name
│ │ └── migration.sql
│ ├── 20211123004316_added_new_character_attributes
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── server.js
├── theme
└── index.js
├── utils
├── index.js
└── socket.js
└── validations
├── CharacterInfoSchema.js
└── index.js
/.env.template:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 |
3 | DB_PROVIDER_URL=mysql://USER:PASSWORD@HOST:PORT/DATABASE
4 |
5 | PORT=3000
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "@next/next/no-document-import-in-page": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | *.db
37 |
38 | /public/assets/characters/*/
39 |
40 | .env
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 João Dutra
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: npm i && npm start
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Open Source RPG
2 |
3 | Tutorial: https://www.twitch.tv/videos/1215083891
4 |
5 | ## Sobre
6 | O projeto visa criar um sistema via web/navegador de RPG, semelhante e inspirado no usado pela série de RPG [Ordem Paranormal](https://ordemparanormal.com.br/), com integrações em stream, criação de personagens, painel para o mestre, etc. É almejado o público leigo: então não necessariamente você vai precisar saber programar para utilizar o sistema.
7 |
8 | O objetivo principal é criar um app simples, porém funcional, que sirva para qualquer sistema de RPG de mesa (Tormenta, D&D, CoC, etc.). Então, este repositório atual conterá apenas a base simples, com funções básicas para suprir a necessidade da maioria dos sistemas. Se você precisar de algo extremamente específico, dê um fork no repositório e faça sua versão by yourself.
9 |
10 | Quando o projeto estiver minimamente finalizado, um guia será disponibilizado para leigos sobre como fazer o projeto funcionar.
11 |
12 | ### Recursos
13 | - ✔️ Ficha de personagem
14 | - ✔️ Painel do mestre para manipular o sistema
15 | - ✔️ Integração com o OBS (software de streaming) através de Browser Sources
16 | - ✔️ Rolagem de dados (integrada com o OBS)
17 | - Personalização completa da integração com o OBS
18 | - Recursos adicionais opcionais: sanidade, mana, estamina, inventário, etc.
19 | - Recurso adicional geral para o controle do mestre e do software
20 |
21 | ### Tecnologias sendo utilizadas
22 | - Next.JS com SSR (Server-Side Rendering) e API REST
23 | - Prisma como tecnologia ORM
24 | - Banco de dados relacional MySQL
25 | - Socket.io para comunicação em tempo real entre o servidor e o cliente
26 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | reactStrictMode: true,
3 | images: {
4 | domains: ['i.imgur.com', 'media.discordapp.net']
5 | }
6 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "open-source-rpg-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "node src/server.js",
7 | "build": "next build",
8 | "start": "node src/server.js",
9 | "lint": "next lint",
10 | "heroku-postbuild": "npx prisma migrate deploy && next build"
11 | },
12 | "prisma": {
13 | "schema": "src/prisma/schema.prisma"
14 | },
15 | "dependencies": {
16 | "@emotion/react": "^11.4.1",
17 | "@emotion/server": "^11.4.0",
18 | "@emotion/styled": "^11.3.0",
19 | "@material-ui/icons": "^4.11.2",
20 | "@mui/icons-material": "^5.0.3",
21 | "@mui/material": "^5.0.3",
22 | "@mui/styles": "^5.0.1",
23 | "@prisma/client": "^3.2.0",
24 | "axios": "^0.22.0",
25 | "copy-to-clipboard": "^3.3.1",
26 | "dotenv": "^10.0.0",
27 | "express": "^4.17.1",
28 | "formik": "^2.2.9",
29 | "js-queue": "^2.0.2",
30 | "next": "^12.0.1",
31 | "react": "17.0.2",
32 | "react-dom": "17.0.2",
33 | "react-transition-group": "^4.4.2",
34 | "socket.io": "^4.3.1",
35 | "socket.io-client": "^4.3.2",
36 | "yup": "^0.32.11"
37 | },
38 | "devDependencies": {
39 | "eslint": "7.32.0",
40 | "eslint-config-next": "11.1.2",
41 | "prisma": "^3.3.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/public/assets/dice.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jvdutrag/open-source-rpg/f6fbb6ac3bdb63b2624da79ea2553104790a38c5/public/assets/dice.webm
--------------------------------------------------------------------------------
/public/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
21 |
--------------------------------------------------------------------------------
/public/assets/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jvdutrag/open-source-rpg/f6fbb6ac3bdb63b2624da79ea2553104790a38c5/public/assets/user.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jvdutrag/open-source-rpg/f6fbb6ac3bdb63b2624da79ea2553104790a38c5/public/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/AddBox.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles } from '@mui/styles'
3 | import { Add as AddIcon } from '@mui/icons-material'
4 |
5 | const styles = theme => ({
6 | root: {
7 | background: theme.palette.primary[900],
8 | borderRadius: '3px',
9 | padding: '15px',
10 | width: '100%',
11 | display: 'flex',
12 | justifyContent: 'center',
13 | alignItems: 'center',
14 | flexDirection: 'column',
15 | height: '100%',
16 | cursor: 'pointer',
17 | },
18 |
19 | icon: {
20 | fontSize: '65px',
21 | color: theme.palette.primary.main
22 | }
23 | })
24 |
25 | const AddBox = ({
26 | classes,
27 | ...rest
28 | }) => {
29 | return (
30 |
33 | )
34 | }
35 |
36 | export default withStyles(styles)(AddBox);
--------------------------------------------------------------------------------
/src/components/CharacterBox.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import { Button } from '@mui/material';
4 |
5 | import Image from 'next/image';
6 |
7 | import {
8 | Link as LinkIcon,
9 | Delete as DeleteIcon,
10 | Favorite as HeartIcon,
11 | FavoriteBorder as HeartIconNoLife,
12 | VideoCameraFront as CameraIcon
13 | } from '@mui/icons-material';
14 |
15 | import useModal from '../hooks/useModal';
16 |
17 | import GeneratePortraitModal from './modals/GeneratePortraitModal';
18 |
19 | const styles = (theme) => ({
20 | root: {
21 | background: theme.palette.primary[900],
22 | borderRadius: '5px',
23 | padding: '15px',
24 | width: '100%',
25 | display: 'flex',
26 | alignItems: 'center',
27 | minHeight: '121px',
28 | gap: '20px',
29 | },
30 |
31 | characterImage: {
32 | width: '75px',
33 | borderRadius: '50%',
34 | },
35 |
36 | characterName: {
37 | fontSize: '18px',
38 | fontWeight: 'bold',
39 | marginTop: '8px',
40 | },
41 |
42 | hpInfo: {
43 | fontWeight: 'bold',
44 | },
45 |
46 | mainInformations: {
47 | display: 'flex',
48 | justifyContent: 'center',
49 | alignItems: 'start',
50 | flexDirection: 'column',
51 | gap: '10px',
52 | },
53 |
54 | btn: {
55 | width: 40,
56 | height: 40,
57 | minWidth: 40,
58 | borderRadius: '5px'
59 | },
60 | });
61 |
62 | function CharacterBox({ classes, character, deleteCharacter, ...rest }) {
63 | const getCharacterPictureURL = () => {
64 | if(!character) {
65 | return null;
66 | }
67 |
68 | if(character.standard_character_picture_url && character.injured_character_picture_url) {
69 | if(character.current_hit_points > (character.max_hit_points / 2)) {
70 | return character.standard_character_picture_url;
71 | }
72 | else {
73 | return character.injured_character_picture_url;
74 | }
75 | } else {
76 | return `/assets/user.png`
77 | }
78 | }
79 |
80 | const generatePortraitModal = useModal(({ close, custom }) => (
81 |
85 | ));
86 |
87 | return (
88 |
89 |
96 |
97 |
{character.name} (ID: {character.id})
98 |
107 | {character.current_hit_points === 0 ? (
108 |
109 | ) : (
110 |
111 | )}
112 |
113 | {character.current_hit_points}/{character.max_hit_points}
114 |
115 |
116 |
125 |
126 |
134 |
135 |
136 |
143 |
144 |
145 |
152 |
153 |
154 |
155 |
156 | );
157 | }
158 |
159 | export default withStyles(styles)(CharacterBox);
160 |
--------------------------------------------------------------------------------
/src/components/EditableRow.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles } from '@mui/styles'
3 | import {
4 | Delete as DeleteIcon,
5 | Create as EditIcon
6 | } from '@mui/icons-material'
7 |
8 | import { Grid, Button, TextField } from '@mui/material'
9 |
10 | const styles = theme => ({ })
11 |
12 | const EditableRow = ({
13 | classes,
14 | data,
15 |
16 | editRow,
17 | deleteRow
18 | }) => {
19 | return (
20 |
21 |
22 |
23 |
26 |
27 |
28 |
31 |
32 |
33 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default withStyles(styles)(EditableRow);
--------------------------------------------------------------------------------
/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles } from '@mui/styles'
3 |
4 | import Image from 'next/image';
5 |
6 | import { Grid } from '@mui/material'
7 |
8 | const Header = ({
9 | title,
10 | classes
11 | }) => {
12 | return (
13 |
19 |
25 |
28 | {title}
29 |
30 |
31 | )
32 | }
33 |
34 | const styles = theme => ({
35 | title: {
36 | color: '#FFFFFF',
37 | marginTop: 60,
38 | }
39 | });
40 |
41 | export default withStyles(styles)(Header);
42 |
--------------------------------------------------------------------------------
/src/components/Loader.jsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from '@mui/material';
2 |
3 | const Loader = ({ size, ...rest }) => (
4 |
5 | )
6 |
7 | export default Loader;
--------------------------------------------------------------------------------
/src/components/Section.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles } from '@mui/styles'
3 |
4 | const styles = theme => ({
5 | root: {
6 | background: theme.palette.primary[600],
7 | borderRadius: '6px',
8 | padding: '15px',
9 | height: '100%',
10 | overflow: 'auto'
11 | },
12 | title: {
13 | color: theme.palette.primary.main,
14 | textTransform: 'uppercase',
15 | margin: 0,
16 | marginTop: '10px',
17 | marginBottom: '10px',
18 | marginLeft: '5px'
19 | },
20 | subtitle: {
21 | color: theme.palette.secondary.main,
22 | margin: 0,
23 | marginTop: '10px',
24 | marginBottom: '10px',
25 | marginLeft: '5px'
26 | }
27 | })
28 |
29 | const Section = ({
30 | children,
31 | classes,
32 | title,
33 | subtitle,
34 |
35 | renderButton
36 | }) => {
37 | return (
38 |
39 |
40 |
41 |
{title}
42 | {subtitle}
43 |
44 | {
45 | renderButton && (
46 |
47 | {renderButton()}
48 |
49 | )
50 | }
51 |
52 |
53 | {children}
54 |
55 |
56 | )
57 | }
58 |
59 | export default withStyles(styles)(Section);
--------------------------------------------------------------------------------
/src/components/SheetEditableRow.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles } from '@mui/styles'
3 |
4 | import { Grid, TextField } from '@mui/material'
5 |
6 | import useModal from '../hooks/useModal';
7 |
8 | import { InfoModal } from '../components';
9 |
10 | const styles = theme => ({
11 | name: {
12 | display: 'flex',
13 | alignItems: 'center'
14 | },
15 |
16 | textName: {
17 | cursor: 'pointer'
18 | }
19 | });
20 |
21 | const SheetEditableRow = ({
22 | classes,
23 | data,
24 | onValueChange,
25 | onInput
26 | }) => {
27 | const infoModal = useModal(({ close }) => (
28 |
33 | ));
34 |
35 | return (
36 |
37 |
38 |
39 | infoModal.appear()}>
40 | {data.name}
41 |
42 |
43 |
44 | onValueChange(event.target.value)}
54 | onChange={event => onInput(event.target.value)}
55 | />
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | export default withStyles(styles)(SheetEditableRow);
--------------------------------------------------------------------------------
/src/components/StatusBar.jsx:
--------------------------------------------------------------------------------
1 | import { LinearProgress, Box, Typography } from '@mui/material';
2 | import { makeStyles } from '@mui/styles';
3 |
4 | function LinearProgressWithLabel(props) {
5 | const useStyles = makeStyles({
6 | primaryColor: {
7 | backgroundColor: props.primaryColor
8 | },
9 | secondaryColor: {
10 | backgroundColor: props.secondaryColor
11 | }
12 | });
13 |
14 | const classes = useStyles();
15 |
16 | return (
17 |
18 |
19 |
29 |
30 |
31 |
32 | {props.label}
33 |
34 |
35 |
36 | )
37 | }
38 |
39 | const StatusBar = ({
40 | label,
41 | max,
42 | current,
43 | primaryColor,
44 | secondaryColor,
45 | onClick
46 | }) => {
47 | const normalise = (current, max) => ((current - 0) * 100) / (max - 0);
48 |
49 | return (
50 |
51 |
58 |
59 | )
60 | }
61 |
62 | export default StatusBar;
--------------------------------------------------------------------------------
/src/components/TextFieldIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles } from '@mui/styles'
3 | import { InputLabel, OutlinedInput, FilledInput, Input, InputAdornment, IconButton, FormControl } from '@mui/material'
4 |
5 | const styles = theme => ({ })
6 |
7 | const TextFieldIcon = ({
8 | classes,
9 | variant,
10 | label,
11 | type,
12 | Icon,
13 | fullWidth,
14 | onClickIcon,
15 | ...props
16 | }) => {
17 | const InputVariant = (props) => {
18 | if(!variant) {
19 | return null;
20 | }
21 |
22 | switch(variant) {
23 | case 'outlined':
24 | return
25 | case 'filled':
26 | return
27 | default:
28 | return ;
29 | }
30 | }
31 |
32 | return (
33 |
34 | {label}
35 |
39 |
42 |
43 |
44 |
45 | }
46 | label={label}
47 | {...props}
48 | />
49 |
50 | )
51 | }
52 |
53 | export default withStyles(styles)(TextFieldIcon);
--------------------------------------------------------------------------------
/src/components/forms/CharacterInfoForm.jsx:
--------------------------------------------------------------------------------
1 | import { Formik, Form } from 'formik';
2 | import { Grid, TextField, Button } from '@mui/material';
3 |
4 | import { CharacterInfoSchema } from '../../validations';
5 |
6 | import Loader from '../Loader';
7 |
8 | const CharacterInfoForm = ({
9 | initialValues,
10 | onSubmit
11 | }) => (
12 | {
20 | onSubmit(values).then(() => setSubmitting(false));
21 | }}
22 | validationSchema={CharacterInfoSchema}
23 | >
24 | {({
25 | values,
26 | errors,
27 | handleChange,
28 | handleSubmit,
29 | isSubmitting
30 | }) => (
31 |
96 | )}
97 |
98 | )
99 |
100 | export default CharacterInfoForm;
101 |
--------------------------------------------------------------------------------
/src/components/forms/index.js:
--------------------------------------------------------------------------------
1 | import CharacterInfoForm from "./CharacterInfoForm";
2 |
3 | export {
4 | CharacterInfoForm
5 | }
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | import Header from './Header'
2 | import Section from './Section'
3 | import CharacterBox from './CharacterBox'
4 | import AddBox from './AddBox'
5 |
6 | import CreateCharacterModal from './modals/CreateCharacterModal'
7 | import ConfirmationModal from './modals/ConfirmationModal'
8 | import AttributeModal from './modals/AttributeModal'
9 | import SkillModal from './modals/SkillModal'
10 | import StatusBarModal from './modals/StatusBarModal'
11 | import InfoModal from './modals/InfoModal'
12 | import GeneratePortraitModal from './modals/GeneratePortraitModal'
13 | import DiceRollModal from './modals/DiceRollModal'
14 | import ChangePictureModal from './modals/ChangePictureModal'
15 |
16 | import EditableRow from './EditableRow'
17 | import SheetEditableRow from './SheetEditableRow'
18 |
19 | import Loader from './Loader'
20 |
21 | import StatusBar from './StatusBar'
22 |
23 | import TextFieldIcon from './TextFieldIcon'
24 |
25 | export {
26 | Header,
27 | Section,
28 | CharacterBox,
29 | AddBox,
30 |
31 | CreateCharacterModal,
32 | ConfirmationModal,
33 | AttributeModal,
34 | SkillModal,
35 | StatusBarModal,
36 | InfoModal,
37 | GeneratePortraitModal,
38 | DiceRollModal,
39 | ChangePictureModal,
40 |
41 | EditableRow,
42 | Loader,
43 |
44 | StatusBar,
45 |
46 | SheetEditableRow,
47 |
48 | TextFieldIcon
49 | }
--------------------------------------------------------------------------------
/src/components/modals/AttributeModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | TextField, Dialog, DialogActions, DialogContent, Grid,
5 | DialogTitle, Button
6 | } from '@mui/material'
7 |
8 | import { api } from '../../utils';
9 |
10 | const styles = theme => ({
11 |
12 | })
13 |
14 | function AttributeModal({
15 | classes,
16 | handleClose,
17 |
18 | onSubmit,
19 | data,
20 | operation
21 | }) {
22 | const [attribute, setAttribute] = useState({
23 | name: '',
24 | description: ''
25 | });
26 |
27 | useEffect(() => {
28 | if(!data) {
29 | return;
30 | }
31 |
32 | setAttribute({
33 | name: data.name,
34 | description: data.description
35 | });
36 | }, [data]);
37 |
38 | const resetState = () => {
39 | return setAttribute({
40 | name: '',
41 | description: ''
42 | });
43 | }
44 |
45 | const submit = () => {
46 | if(!attribute.name) {
47 | return;
48 | }
49 |
50 | if(operation === 'create') {
51 | api.post('/attribute', attribute)
52 | .then(() => {
53 | // Callback
54 | onSubmit();
55 |
56 | // Close modal
57 | handleClose();
58 |
59 | resetState();
60 | })
61 | .catch(() => {
62 | alert('Erro ao criar o atributo!');
63 | });
64 | }
65 | else if (operation === 'edit') {
66 | api.put(`/attribute/${data.id}`, attribute)
67 | .then(() => {
68 | // Callback
69 | onSubmit();
70 |
71 | // Close modal
72 | handleClose();
73 |
74 | resetState();
75 | })
76 | .catch(err => {
77 | alert('Erro ao editar o atributo!');
78 | });
79 | }
80 | }
81 |
82 | return (
83 |
159 | )
160 | }
161 |
162 | export default withStyles(styles)(AttributeModal);
--------------------------------------------------------------------------------
/src/components/modals/ChangePictureModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | TextField, Dialog, DialogActions, DialogContent,
5 | DialogTitle, Button, Grid, Link
6 | } from '@mui/material'
7 |
8 | import { api } from '../../utils';
9 |
10 | const styles = theme => ({
11 |
12 | })
13 |
14 | function ChangePictureModal({
15 | classes,
16 | handleClose,
17 |
18 | character,
19 | onPictureChange
20 | }) {
21 | const [pictureURLs, setPictureURLs] = useState({
22 | standard_character_picture_url: '',
23 | injured_character_picture_url: ''
24 | });
25 |
26 | useEffect(() => {
27 | setPictureURLs({
28 | standard_character_picture_url: character.standard_character_picture_url,
29 | injured_character_picture_url: character.injured_character_picture_url
30 | });
31 | }, [character]);
32 |
33 | function validateImageURL(url) {
34 |
35 | return ;
36 |
37 |
38 |
39 | if(!pictureURLs.standard_character_picture_url.includes('discord') || !pictureURLs.standard_character_picture_url.includes('imgur')) {
40 | return window.alert('Preencha as duas artes com URLs válidas!');
41 | }
42 |
43 | if(!pictureURLs.injured_character_picture_url.includes('discord') || !pictureURLs.injured_character_picture_url.includes('imgur')) {
44 |
45 | }
46 | }
47 |
48 | const submit = () => {
49 | if(!pictureURLs.injured_character_picture_url || !pictureURLs.standard_character_picture_url) {
50 | return window.alert('Preencha as duas artes!');
51 | }
52 |
53 | const allowedWebsites = ['discord', 'imgur'];
54 |
55 | if(!allowedWebsites.some(website => pictureURLs.injured_character_picture_url.includes(website))) {
56 | return window.alert('Preencha as duas artes com URLs válidas!');
57 | }
58 |
59 | if(!allowedWebsites.some(website => pictureURLs.standard_character_picture_url.includes(website))) {
60 | return window.alert('Preencha as duas artes com URLs válidas!');
61 | }
62 |
63 | if(!pictureURLs.injured_character_picture_url.endsWith('.png') && !pictureURLs.standard_character_picture_url.endsWith('.png')) {
64 | return window.alert('As artes precisam estar em formato PNG.');
65 | }
66 |
67 | api.put(`/character/${character.id}`, {
68 | injured_character_picture_url: pictureURLs.injured_character_picture_url,
69 | standard_character_picture_url: pictureURLs.standard_character_picture_url
70 | })
71 | .then(() => {
72 | // Callback
73 | onPictureChange();
74 |
75 | // Close modal
76 | handleClose();
77 | })
78 | .catch(() => {
79 | return window.alert('Erro ao salvar!');
80 | });
81 | }
82 |
83 | return (
84 |
162 | )
163 | }
164 |
165 | export default withStyles(styles)(ChangePictureModal);
--------------------------------------------------------------------------------
/src/components/modals/ConfirmationModal.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | Dialog, DialogActions, DialogContent, DialogContentText,
5 | DialogTitle, Button
6 | } from '@mui/material';
7 |
8 | const styles = theme => ({
9 |
10 | })
11 |
12 | function ConfirmationModal({
13 | classes,
14 | handleClose,
15 | title,
16 | text,
17 | data,
18 | onConfirmation
19 | }) {
20 | return (
21 |
49 | )
50 | }
51 |
52 | export default withStyles(styles)(ConfirmationModal);
--------------------------------------------------------------------------------
/src/components/modals/CreateCharacterModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | TextField, Dialog, DialogActions, DialogContent, DialogContentText,
5 | DialogTitle, Button
6 | } from '@mui/material'
7 |
8 | import { api } from '../../utils';
9 |
10 | const styles = theme => ({
11 |
12 | })
13 |
14 | function CreateCharacterModal({
15 | classes,
16 | handleClose,
17 | onCharacterCreated
18 | }) {
19 | const [character, setCharacter] = useState({
20 | name: ''
21 | });
22 |
23 | const resetState = () => {
24 | return setCharacter({
25 | name: ''
26 | });
27 | }
28 |
29 | const createCharacter = () => {
30 | if(!character.name) {
31 | return;
32 | }
33 |
34 | api.post('/character', character)
35 | .then(() => {
36 | // Callback
37 | onCharacterCreated();
38 |
39 | // Close modal
40 | handleClose();
41 |
42 | resetState();
43 | })
44 | .catch(() => {
45 | alert('Erro ao criar o personagem!');
46 | });
47 | }
48 |
49 | return (
50 |
95 | )
96 | }
97 |
98 | export default withStyles(styles)(CreateCharacterModal);
--------------------------------------------------------------------------------
/src/components/modals/DiceRollModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | TextField, Dialog, DialogActions, DialogContent, DialogContentText,
5 | DialogTitle, Button, Grid, Select, MenuItem, FormControl, InputLabel
6 | } from '@mui/material'
7 |
8 | import { api } from '../../utils'
9 |
10 | const styles = theme => ({
11 |
12 | })
13 |
14 | function DiceRollModal({
15 | classes,
16 | handleClose,
17 |
18 | characterId,
19 | onDiceRoll
20 | }) {
21 | const [timesToRoll, setTimesToRoll] = useState(1);
22 | const [facesNumber, setFacesNumber] = useState(6);
23 |
24 | const [buttonDisabled, setButtonDisabled] = useState(false);
25 |
26 | const [result, setResult] = useState(null);
27 |
28 | const rollDice = () => {
29 | setButtonDisabled(true);
30 |
31 | if(!timesToRoll || !facesNumber) {
32 | return window.alert('É necessário escolher todos os campos!');
33 | }
34 |
35 | if(timesToRoll < 1) {
36 | return window.alert('O número de dados precisa ser maior que 1.');
37 | }
38 |
39 | api.post('roll', {
40 | character_id: characterId,
41 | max_number: facesNumber,
42 | times: timesToRoll
43 | })
44 | .then(res => {
45 | setResult(res.data);
46 |
47 | setButtonDisabled(false);
48 |
49 | onDiceRoll(res.data);
50 | })
51 | .catch(err => {
52 | console.log(err);
53 | });
54 | }
55 |
56 | return (
57 |
174 | )
175 | }
176 |
177 | export default withStyles(styles)(DiceRollModal);
--------------------------------------------------------------------------------
/src/components/modals/GeneratePortraitModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | Dialog, DialogActions, DialogContent, Grid,
5 | DialogTitle, Button, FormGroup, FormControlLabel, Checkbox
6 | } from '@mui/material'
7 |
8 | import {
9 | ContentCopy as CopyIcon
10 | } from '@mui/icons-material';
11 |
12 | import copyToClipboard from 'copy-to-clipboard';
13 |
14 | import TextFieldIcon from '../TextFieldIcon';
15 |
16 | const styles = theme => ({
17 |
18 | })
19 |
20 | function GeneratePortraitModal({
21 | classes,
22 | handleClose,
23 |
24 | characterId
25 | }) {
26 | const [showOptions, setShowOptions] = useState({
27 | name: true,
28 | stats: true,
29 | picture: true
30 | });
31 |
32 | const getPortraitURL = () => {
33 | const baseURL = window.location.href.replace('/dashboard', '');
34 |
35 | let url = `${baseURL}/portrait/${characterId}?show=`;
36 |
37 | if(showOptions.name) {
38 | url += 'name,';
39 | }
40 |
41 | if(showOptions.stats) {
42 | url += 'stats,';
43 | }
44 |
45 | if(showOptions.picture) {
46 | url += 'picture';
47 | }
48 |
49 | return url;
50 | }
51 |
52 | const getDiceURL = () => {
53 | const baseURL = window.location.href.replace('/dashboard', '');
54 |
55 | let url = `${baseURL}/dice/${characterId}`;
56 |
57 | return url;
58 | }
59 |
60 | const copyURLToClipboard = url => {
61 | return copyToClipboard(url);
62 | }
63 |
64 | return (
65 |
126 | )
127 | }
128 |
129 | export default withStyles(styles)(GeneratePortraitModal);
--------------------------------------------------------------------------------
/src/components/modals/InfoModal.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | Dialog, DialogActions, DialogContent, DialogContentText,
5 | DialogTitle, Button
6 | } from '@mui/material';
7 |
8 | const styles = theme => ({
9 |
10 | })
11 |
12 | function InfoModal({
13 | classes,
14 | handleClose,
15 | title,
16 | text
17 | }) {
18 | return (
19 |
39 | )
40 | }
41 |
42 | export default withStyles(styles)(InfoModal);
--------------------------------------------------------------------------------
/src/components/modals/SkillModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | TextField, Dialog, DialogActions, DialogContent, Grid,
5 | DialogTitle, Button
6 | } from '@mui/material'
7 |
8 | import { api } from '../../utils';
9 |
10 | const styles = theme => ({
11 |
12 | })
13 |
14 | function SkillModal({
15 | classes,
16 | handleClose,
17 |
18 | onSubmit,
19 | data,
20 | operation
21 | }) {
22 | const [skill, setSkill] = useState({
23 | name: '',
24 | description: ''
25 | });
26 |
27 | useEffect(() => {
28 | if(!data) {
29 | return;
30 | }
31 |
32 | setSkill({
33 | name: data.name,
34 | description: data.description
35 | });
36 | }, [data]);
37 |
38 | const resetState = () => {
39 | return setSkill({
40 | name: '',
41 | description: ''
42 | });
43 | }
44 |
45 | const submit = () => {
46 | if(!skill.name) {
47 | return;
48 | }
49 |
50 | if(operation === 'create') {
51 | api.post('/skill', skill)
52 | .then(() => {
53 | // Callback
54 | onSubmit();
55 |
56 | // Close modal
57 | handleClose();
58 |
59 | resetState();
60 | })
61 | .catch(() => {
62 | alert('Erro ao criar a perícia!');
63 | });
64 | }
65 | else if (operation === 'edit') {
66 | api.put(`/skill/${data.id}`, skill)
67 | .then(() => {
68 | // Callback
69 | onSubmit();
70 |
71 | // Close modal
72 | handleClose();
73 |
74 | resetState();
75 | })
76 | .catch(() => {
77 | alert('Erro ao editar a perícia!');
78 | });
79 | }
80 | }
81 |
82 | return (
83 |
159 | )
160 | }
161 |
162 | export default withStyles(styles)(SkillModal);
--------------------------------------------------------------------------------
/src/components/modals/StatusBarModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { withStyles } from '@mui/styles';
3 | import {
4 | TextField, Dialog, DialogActions, DialogContent, Grid,
5 | DialogTitle, Button
6 | } from '@mui/material'
7 |
8 | const styles = theme => ({
9 |
10 | })
11 |
12 | function StatusBarModal({
13 | classes,
14 | handleClose,
15 |
16 | onSubmit,
17 | data,
18 | type
19 | }) {
20 | const [newData, setNewData] = useState({
21 | current: 0,
22 | max: 0
23 | });
24 |
25 | useEffect(() => {
26 | if(!newData) {
27 | return;
28 | }
29 |
30 | setNewData({
31 | current: data.current,
32 | max: data.max
33 | });
34 | }, [data]);
35 |
36 | const resetState = () => {
37 | return setNewData({
38 | current: 0,
39 | max: 0
40 | });
41 | }
42 |
43 | const submit = () => {
44 | if(!newData.current || !newData.max) {
45 | return;
46 | }
47 |
48 | onSubmit(newData).then(() => resetState());
49 | }
50 |
51 | const getTitle = () => {
52 | switch (type) {
53 | case 'hp': return 'Alterar pontos de vida';
54 | default: return 'Alterar pontos';
55 | }
56 | }
57 |
58 | return (
59 |
132 | )
133 | }
134 |
135 | export default withStyles(styles)(StatusBarModal);
--------------------------------------------------------------------------------
/src/contexts/ModalContext.jsx:
--------------------------------------------------------------------------------
1 | import { createContext, useState } from 'react';
2 | import { Backdrop } from '@mui/material';
3 |
4 | export const ModalContext = createContext();
5 |
6 | function Modal({ component }) {
7 | return (
8 | theme.zIndex.drawer + 1 }}
10 | open={true}
11 | onClick={() => {}}
12 | >
13 | {component}
14 |
15 | )
16 | }
17 |
18 | export function ModalProvider({ children }) {
19 | const [modalComponent, setModalComponent] = useState(null);
20 |
21 | function modalFunction(component) {
22 | function close() {
23 | setModalComponent(null);
24 | }
25 |
26 | function appear(custom = null) {
27 | setModalComponent(component({ close, custom }));
28 | }
29 |
30 | return {
31 | appear,
32 | close
33 | }
34 | }
35 |
36 | return (
37 |
38 | {children}
39 |
40 | { modalComponent && }
41 |
42 | )
43 | }
--------------------------------------------------------------------------------
/src/database.js:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | export const prisma = global.prisma || new PrismaClient();
4 |
5 | if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
6 |
--------------------------------------------------------------------------------
/src/hooks/useModal.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { ModalContext } from '../contexts/ModalContext';
3 |
4 | export default function useModal(component) {
5 | return useContext(ModalContext)(component);
6 | }
--------------------------------------------------------------------------------
/src/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ThemeProvider } from '@mui/material/styles';
3 | import { CssBaseline } from '@mui/material';
4 |
5 | import theme from '../theme'
6 | import { ModalProvider } from '../contexts/ModalContext';
7 |
8 | function MyApp({ Component, pageProps }) {
9 |
10 | React.useEffect(() => {
11 | const jssStyles = document.querySelector('#jss-server-side');
12 | if (jssStyles) {
13 | jssStyles.parentElement.removeChild(jssStyles);
14 | }
15 | }, []);
16 |
17 | return (
18 | <>
19 |
20 |
21 |
22 |
23 |
24 |
25 | >
26 | )
27 | }
28 |
29 | export default MyApp
30 |
--------------------------------------------------------------------------------
/src/pages/_document.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import { ServerStyleSheets } from '@mui/styles';
4 |
5 | import theme from '../theme';
6 |
7 | export default class MyDocument extends Document {
8 | render() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 | }
26 |
27 | MyDocument.getInitialProps = async (ctx) => {
28 | const sheets = new ServerStyleSheets();
29 | const originalRenderPage = ctx.renderPage;
30 |
31 | ctx.renderPage = () => originalRenderPage({
32 | enhanceApp: (App) => (props) => sheets.collect(),
33 | });
34 |
35 | const initialProps = await Document.getInitialProps(ctx);
36 |
37 | return {
38 | ...initialProps,
39 | styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()]
40 | }
41 | }
--------------------------------------------------------------------------------
/src/pages/api/attribute/[id].js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'DELETE') {
5 | const id = Number(req.query.id);
6 |
7 | const deleteFromCharacterAttributes = prisma.characterAttributes.deleteMany({
8 | where: {
9 | attribute_id: id
10 | }
11 | });
12 |
13 | const deleteAttribute = prisma.attribute.delete({
14 | where: {
15 | id
16 | }
17 | });
18 |
19 | await prisma.$transaction([deleteFromCharacterAttributes, deleteAttribute]);
20 |
21 | return res.status(200).json({ success: true });
22 | }
23 | else if(req.method === 'PUT') {
24 | const { body } = req;
25 |
26 | if(!body.name) {
27 | return res.status(400).json({ error: 'Name not set' });
28 | }
29 |
30 | const id = Number(req.query.id);
31 |
32 | const attribute = await prisma.attribute.update({
33 | where: {
34 | id
35 | },
36 | data: body
37 | });
38 |
39 | return res.status(200).json(attribute);
40 | }
41 | else {
42 | return res.status(404);
43 | }
44 | }
--------------------------------------------------------------------------------
/src/pages/api/attribute/index.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'POST') {
5 | const { body } = req;
6 |
7 | if(!body.name) {
8 | return res.status(400).json({ error: 'Name not set' });
9 | }
10 |
11 | const attribute = await prisma.attribute.create({
12 | data: body
13 | });
14 |
15 | // Assign Created Attribute to All Characters
16 | const characters = await prisma.character.findMany();
17 |
18 | characters.forEach(async character => {
19 | await prisma.characterAttributes.create({
20 | data: {
21 | character_id: character.id,
22 | attribute_id: attribute.id
23 | }
24 | });
25 | });
26 |
27 | return res.status(200).json(attribute);
28 | }
29 | else if(req.method === 'GET') {
30 | const attributes = await prisma.attribute.findMany({
31 | orderBy: [
32 | {
33 | name: 'asc',
34 | }
35 | ]
36 | });
37 |
38 | return res.status(200).json(attributes);
39 | }
40 | else {
41 | return res.status(404);
42 | }
43 | }
--------------------------------------------------------------------------------
/src/pages/api/character/[id].js:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | const prisma = new PrismaClient();
4 |
5 | export default async function handler(req, res) {
6 | if(req.method === 'DELETE') {
7 | const id = Number(req.query.id);
8 |
9 | const deleteRolls = prisma.roll.deleteMany({
10 | where: {
11 | character_id: id
12 | }
13 | })
14 |
15 | const deleteAttributes = prisma.characterAttributes.deleteMany({
16 | where: {
17 | character_id: id
18 | }
19 | });
20 |
21 | const deleteSkills = prisma.characterSkills.deleteMany({
22 | where: {
23 | character_id: id
24 | }
25 | });
26 |
27 | const deleteCharacter = prisma.character.delete({
28 | where: {
29 | id
30 | }
31 | });
32 |
33 | await prisma.$transaction([deleteRolls, deleteAttributes, deleteSkills, deleteCharacter]);
34 |
35 | return res.status(200).json({ success: true });
36 | }
37 | else if(req.method === 'GET') {
38 | const id = Number(req.query.id);
39 |
40 | const character = await prisma.character.findUnique({
41 | where: {
42 | id
43 | },
44 | include: {
45 | attributes: {
46 | include: {
47 | attribute: true
48 | }
49 | },
50 | skills: {
51 | include: {
52 | skill: true
53 | }
54 | }
55 | }
56 | });
57 |
58 | return res.status(200).json(character);
59 | }
60 | else if(req.method === 'PUT') {
61 | const { body } = req;
62 |
63 | const id = Number(req.query.id);
64 |
65 | const character = await prisma.character.update({
66 | where: {
67 | id
68 | },
69 | data: body
70 | });
71 |
72 | return res.status(200).json(character);
73 | }
74 | else {
75 | return res.status(404);
76 | }
77 | }
--------------------------------------------------------------------------------
/src/pages/api/character/attribute.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'PUT') {
5 | const { character_id, attribute_id, value } = req.body;
6 |
7 | if(!character_id || !attribute_id || (value === undefined || value === null )) {
8 | return res.status(400).json({ error: 'Missing Required Data' });
9 | }
10 |
11 | const result = await prisma.characterAttributes.update({
12 | data: {
13 | value: value.toString()
14 | },
15 | where: {
16 | character_id_attribute_id: {
17 | attribute_id,
18 | character_id
19 | }
20 | }
21 | });
22 |
23 | return res.json(result);
24 | }
25 | else {
26 | return res.status(404);
27 | }
28 | }
--------------------------------------------------------------------------------
/src/pages/api/character/index.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | function parseRelationArray(array, entityName) {
4 | return array.map(item => ({
5 | [entityName]: {
6 | connect: {
7 | id: item.id
8 | }
9 | }
10 | }));
11 | }
12 |
13 | export default async function handler(req, res) {
14 | if(req.method === 'POST') {
15 | const { body } = req;
16 |
17 | if(!body.name) {
18 | return res.status(400).json({ error: 'Name not set' });
19 | }
20 |
21 | const attributes = await prisma.attribute.findMany();
22 | const skills = await prisma.skill.findMany();
23 |
24 | const character = await prisma.character.create({
25 | data: {
26 | ...body,
27 |
28 | // Create Character With Many to Many Relations Set
29 | attributes: {
30 | create: parseRelationArray(attributes, 'attribute')
31 | },
32 | skills: {
33 | create: parseRelationArray(skills, 'skill')
34 | }
35 | },
36 | include: {
37 | attributes: true,
38 | skills: true
39 | }
40 | });
41 |
42 | return res.status(200).json(character);
43 | }
44 | else if(req.method === 'GET') {
45 | const characters = await prisma.character.findMany({
46 | include: {
47 | attributes: {
48 | include: {
49 | attribute: true
50 | }
51 | },
52 | skills: {
53 | include: {
54 | skill: true
55 | }
56 | }
57 | }
58 | });
59 |
60 | return res.status(200).json(characters);
61 | }
62 | else {
63 | return res.status(404);
64 | }
65 | }
--------------------------------------------------------------------------------
/src/pages/api/character/skill.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'PUT') {
5 | const { character_id, skill_id, value } = req.body;
6 |
7 | if(!character_id || !skill_id || (value === undefined || value === null )) {
8 | return res.status(400).json({ error: 'Missing Required Data' });
9 | }
10 |
11 | const result = await prisma.characterSkills.update({
12 | data: {
13 | value: value.toString()
14 | },
15 | where: {
16 | character_id_skill_id: {
17 | skill_id,
18 | character_id
19 | }
20 | }
21 | });
22 |
23 | return res.json(result);
24 | }
25 | else {
26 | return res.status(404);
27 | }
28 | }
--------------------------------------------------------------------------------
/src/pages/api/config/[name].js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'PUT') {
5 | const { body } = req;
6 |
7 | const name = req.query.name;
8 |
9 | const config = await prisma.config.update({
10 | where: {
11 | name
12 | },
13 | data: body
14 | });
15 |
16 | return res.status(200).json(config);
17 | }
18 | else {
19 | return res.status(404);
20 | }
21 | }
--------------------------------------------------------------------------------
/src/pages/api/config/index.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'POST') {
5 | const { body } = req;
6 |
7 | if(!body.name || !body.value) {
8 | return res.status(400).json({ error: 'Values Not Set' });
9 | }
10 |
11 | const config = await prisma.config.create({
12 | data: {
13 | name: body.name,
14 | value: body.value
15 | }
16 | });
17 |
18 | return res.status(200).json(config);
19 | }
20 | else {
21 | return res.status(404);
22 | }
23 | }
--------------------------------------------------------------------------------
/src/pages/api/roll/index.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | import { generateRandomNumber } from '../../../utils';
4 |
5 | export default async function handler(req, res) {
6 | if(req.method === 'POST') {
7 | const { body } = req;
8 |
9 | if(!body.character_id || !body.max_number) {
10 | return res.status(400).json({ error: 'Data not Set' });
11 | }
12 |
13 | const character = await prisma.character.findUnique({
14 | where: {
15 | id: Number(body.character_id)
16 | }
17 | });
18 |
19 | if(!character) {
20 | return res.status(400).json({ error: 'Character not found' });
21 | }
22 |
23 | // If times not set, times is one (will roll only once)
24 | body.times = body.times ? Number(body.times) : 1;
25 |
26 | const rolls = [];
27 |
28 | for(let i = 0; i < body.times; i++) {
29 | const number = generateRandomNumber(body.max_number);
30 |
31 | const rollObject = {
32 | max_number: body.max_number,
33 | rolled_number: number,
34 | character_id: Number(body.character_id)
35 | }
36 |
37 | rolls.push(rollObject);
38 | }
39 |
40 | await prisma.roll.createMany({
41 | data: rolls
42 | });
43 |
44 | return res.status(200).json(rolls);
45 | }
46 | else {
47 | return res.status(404);
48 | }
49 | }
--------------------------------------------------------------------------------
/src/pages/api/setup/index.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'POST') {
5 | const configs = [
6 | {
7 | name: 'DICE_ON_SCREEN_TIMEOUT_IN_MS',
8 | value: '5000'
9 | },
10 | {
11 | name: 'TIME_BETWEEN_DICES_IN_MS',
12 | value: '2000'
13 | }
14 | ]
15 |
16 | await prisma.config.createMany({
17 | data: configs
18 | });
19 |
20 | return res.status(200).json({ success: true });
21 | }
22 | else {
23 | return res.status(404);
24 | }
25 | }
--------------------------------------------------------------------------------
/src/pages/api/skill/[id].js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'DELETE') {
5 | const id = Number(req.query.id);
6 |
7 | const deleteFromCharacterSkills = prisma.characterSkills.deleteMany({
8 | where: {
9 | skill_id: id
10 | }
11 | });
12 |
13 | const deleteSkill = prisma.skill.delete({
14 | where: {
15 | id
16 | }
17 | });
18 |
19 | await prisma.$transaction([deleteFromCharacterSkills, deleteSkill]);
20 |
21 | return res.status(200).json({ success: true });
22 | }
23 | else if(req.method === 'PUT') {
24 | const { body } = req;
25 |
26 | if(!body.name) {
27 | return res.status(400).json({ error: 'Name not set' });
28 | }
29 |
30 | const id = Number(req.query.id);
31 |
32 | const skill = await prisma.skill.update({
33 | where: {
34 | id
35 | },
36 | data: body
37 | });
38 |
39 | return res.status(200).json(skill);
40 | }
41 | else {
42 | return res.status(404);
43 | }
44 | }
--------------------------------------------------------------------------------
/src/pages/api/skill/index.js:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../database';
2 |
3 | export default async function handler(req, res) {
4 | if(req.method === 'POST') {
5 | const { body } = req;
6 |
7 | if(!body.name) {
8 | return res.status(400).json({ error: 'Name not set' });
9 | }
10 |
11 | const skill = await prisma.skill.create({
12 | data: body
13 | });
14 |
15 | // Assign Created Skill to All Characters
16 | const characters = await prisma.character.findMany();
17 |
18 | characters.forEach(async character => {
19 | await prisma.characterSkills.create({
20 | data: {
21 | character_id: character.id,
22 | skill_id: skill.id
23 | }
24 | });
25 | });
26 |
27 | return res.status(200).json(skill);
28 | }
29 | else if(req.method === 'GET') {
30 | const skills = await prisma.skill.findMany({
31 | orderBy: [
32 | {
33 | name: 'asc',
34 | }
35 | ]
36 | });
37 |
38 | return res.status(200).json(skills);
39 | }
40 | else {
41 | return res.status(404);
42 | }
43 | }
--------------------------------------------------------------------------------
/src/pages/dashboard/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import Head from 'next/head';
3 | import { useRouter } from 'next/router';
4 | import { withStyles } from '@mui/styles';
5 | import { Grid, Container, Button, TextField } from '@mui/material';
6 | import {
7 | Add as AddIcon
8 | } from '@mui/icons-material';
9 |
10 | import { Header, Section, CharacterBox, AddBox,
11 | CreateCharacterModal, ConfirmationModal, EditableRow,
12 | AttributeModal, SkillModal
13 | } from '../../components';
14 |
15 | import { api } from '../../utils';
16 | import useModal from '../../hooks/useModal';
17 |
18 | import { prisma } from '../../database';
19 |
20 | export const getServerSideProps = async () => {
21 | function parseConfigs(array) {
22 | return array.map(config => {
23 | if(config.name === 'DICE_ON_SCREEN_TIMEOUT_IN_MS' || 'TIME_BETWEEN_DICES_IN_MS') {
24 | return {
25 | ...config,
26 | value: parseInt(config.value) / 1000
27 | }
28 | }
29 |
30 | return config;
31 | });
32 | }
33 |
34 | const characters = await prisma.character.findMany({
35 | orderBy: [
36 | {
37 | name: 'asc',
38 | },
39 | ],
40 | });
41 |
42 | const attributes = await prisma.attribute.findMany({
43 | orderBy: [
44 | {
45 | name: 'asc',
46 | },
47 | ],
48 | });
49 |
50 | const skills = await prisma.skill.findMany({
51 | orderBy: [
52 | {
53 | name: 'asc',
54 | },
55 | ],
56 | });
57 |
58 | const configs = await prisma.config.findMany();
59 |
60 | const serializedCharacters = JSON.parse(JSON.stringify(characters));
61 | const serializedAttributes = JSON.parse(JSON.stringify(attributes));
62 | const serializedSkills = JSON.parse(JSON.stringify(skills));
63 | const serializedConfigs = JSON.parse(JSON.stringify(parseConfigs(configs)));
64 |
65 | return {
66 | props: {
67 | characters: serializedCharacters,
68 | attributes: serializedAttributes,
69 | skills: serializedSkills,
70 | configs: serializedConfigs
71 | },
72 | };
73 | }
74 |
75 | function Dashboard({
76 | classes,
77 |
78 | characters,
79 | attributes,
80 | skills,
81 | configs
82 | }) {
83 | const router = useRouter();
84 |
85 | const [updatedConfigs, setUpdatedConfigs] = useState({
86 | DICE_ON_SCREEN_TIMEOUT_IN_MS: null,
87 | TIME_BETWEEN_DICES_IN_MS: null
88 | });
89 |
90 | useEffect(() => {
91 | configs.forEach(config => {
92 | setUpdatedConfigs(prevState => ({
93 | ...prevState,
94 | [config.name]: config.value
95 | }));
96 | });
97 | }, [configs]);
98 |
99 | const refreshData = () => {
100 | return router.replace(router.asPath);
101 | }
102 |
103 | const updateConfigs = () => {
104 | api.put('/config/DICE_ON_SCREEN_TIMEOUT_IN_MS', {
105 | value: `${parseInt(updatedConfigs.DICE_ON_SCREEN_TIMEOUT_IN_MS) * 1000}`
106 | });
107 |
108 | api.put('/config/TIME_BETWEEN_DICES_IN_MS', {
109 | value: `${parseInt(updatedConfigs.TIME_BETWEEN_DICES_IN_MS) * 1000}`
110 | });
111 | }
112 |
113 | const runInitialSetup = () => {
114 | api.post('/setup')
115 | .then(res => {
116 | if(res.data.success) {
117 | return window.location.reload();
118 | }
119 | });
120 | }
121 |
122 | const confirmationModal = useModal(({ close, custom }) => (
123 | {
129 | const { id, type } = data;
130 |
131 | api
132 | .delete(`/${type}/${id}`)
133 | .then(() => {
134 | refreshData();
135 | })
136 | .catch(() => {
137 | alert(`Erro ao apagar: ${type}`);
138 | });
139 | }}
140 | />
141 | ));
142 |
143 | const createCharacterModal = useModal(({ close }) => (
144 | {
147 | refreshData();
148 | }}
149 | />
150 | ));
151 |
152 | const attributeModal = useModal(({ close, custom }) => (
153 | {
157 | refreshData();
158 | }}
159 | operation={custom.operation}
160 | />
161 | ));
162 |
163 | const skillModal = useModal(({ close, custom }) => (
164 | {
168 | refreshData();
169 | }}
170 | operation={custom.operation}
171 | />
172 | ));
173 |
174 | return (
175 | <>
176 |
177 |
178 | Dashboard do Mestre | RPG
179 |
180 |
181 |
182 |
183 |
184 | {
185 | configs.length > 0 ? (
186 | <>
187 |
188 |
191 |
192 | {characters.map((character, index) => (
193 |
194 |
197 | confirmationModal.appear({
198 | title: 'Apagar personagem',
199 | text: 'Deseja apagar este personagem?',
200 | data: { id: character.id, type: 'character' },
201 | })
202 | }
203 | />
204 |
205 | ))}
206 |
207 | createCharacterModal.appear()} />
208 |
209 |
210 |
211 |
212 |
213 |
214 | (
217 |
227 | )}
228 | >
229 |
236 | {attributes.map((attribute, index) => (
237 |
238 | {
241 | attributeModal.appear({ operation: 'edit', data });
242 | }}
243 | deleteRow={(data) => {
244 | confirmationModal.appear({
245 | title: 'Apagar atributo',
246 | text: 'Deseja apagar este atributo?',
247 | data: { id: data.id, type: 'attribute' },
248 | });
249 | }}
250 | />
251 |
252 | ))}
253 |
254 |
255 |
256 |
257 |
258 | (
261 |
271 | )}
272 | >
273 |
280 | {skills.map((skill, index) => (
281 |
282 | {
285 | skillModal.appear({ operation: 'edit', data })
286 | }}
287 | deleteRow={(data) => {
288 | confirmationModal.appear({
289 | title: 'Apagar perícia',
290 | text: 'Deseja apagar esta perícia?',
291 | data: { id: data.id, type: 'skill' },
292 | });
293 | }}
294 | />
295 |
296 | ))}
297 |
298 |
299 |
300 |
301 |
302 |
305 |
311 |
312 |
313 | Integração com OBS
314 |
315 |
316 |
317 | {
324 | const value = e.target.value;
325 |
326 | setUpdatedConfigs(prevState => ({
327 | ...prevState,
328 | DICE_ON_SCREEN_TIMEOUT_IN_MS: value
329 | }));
330 | }}
331 | />
332 |
333 |
334 |
335 | {
342 | const value = e.target.value;
343 |
344 | setUpdatedConfigs(prevState => ({
345 | ...prevState,
346 | TIME_BETWEEN_DICES_IN_MS: value
347 | }));
348 | }}
349 | />
350 |
351 |
352 |
353 |
356 |
357 |
358 |
359 |
360 |
361 | >
362 | ) : (
363 |
364 |
367 |
368 | )
369 | }
370 |
371 |
372 | >
373 | );
374 | }
375 |
376 | const styles = (theme) => ({
377 | scrollableBox: {
378 | overflow: 'auto',
379 | maxHeight: '300px',
380 | paddingRight: '10px',
381 | },
382 | });
383 |
384 | export default withStyles(styles)(Dashboard);
385 |
--------------------------------------------------------------------------------
/src/pages/dice/[id].jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useMemo } from 'react';
2 | import Head from 'next/head';
3 | import Queue from 'js-queue';
4 |
5 | import { withStyles } from '@mui/styles';
6 |
7 | import socket from '../../utils/socket';
8 |
9 | import { prisma } from '../../database';
10 |
11 | export const getServerSideProps = async ({ params }) => {
12 | const characterId = isNaN(params.id) ? null : Number(params.id);
13 |
14 | if(!characterId) {
15 | return {
16 | props: {
17 | character: null
18 | }
19 | }
20 | }
21 |
22 | const character = await prisma.character.findUnique({
23 | where: {
24 | id: characterId
25 | }
26 | });
27 |
28 | if(!character) {
29 | return {
30 | props: {
31 | character: null
32 | }
33 | }
34 | }
35 |
36 | const configs = await prisma.config.findMany();
37 |
38 | const serialized = JSON.parse(JSON.stringify(character));
39 |
40 | return {
41 | props: {
42 | character: serialized,
43 | config: {
44 | diceOnScreenTimeoutInMS: parseInt(configs.find(config => config.name === 'DICE_ON_SCREEN_TIMEOUT_IN_MS').value),
45 | timeBetweenDicesInMS: parseInt(configs.find(config => config.name === 'TIME_BETWEEN_DICES_IN_MS').value),
46 | }
47 | }
48 | }
49 | }
50 |
51 | function Dice({
52 | classes,
53 | character,
54 | config
55 | }) {
56 | const queue = useMemo(() => new Queue(), []);
57 |
58 | const [currentDice, setCurrentDice] = useState(null);
59 |
60 | useEffect(() => {
61 | document.body.style.backgroundColor = 'transparent';
62 | }, []);
63 |
64 | useEffect(() => {
65 | function showDiceOnScreen(roll) {
66 | setCurrentDice(roll);
67 |
68 | setTimeout(() => {
69 | // Remove Dice
70 | setCurrentDice(null);
71 | }, config.diceOnScreenTimeoutInMS);
72 |
73 | setTimeout(() => {
74 | this.next();
75 | }, config.diceOnScreenTimeoutInMS + config.timeBetweenDicesInMS);
76 | }
77 |
78 | socket.emit('room:join', `dice_character_${character.id}`);
79 |
80 | socket.on('dice_roll', data => {
81 | data.rolls.forEach(roll => {
82 | queue.add(showDiceOnScreen.bind(queue, roll));
83 | });
84 | });
85 | }, [character, queue, config]);
86 |
87 | if(!character) {
88 | return (
89 | Personagem não existe!
90 | )
91 | }
92 |
93 | return (
94 |
95 |
96 | Dados de {character.name} | RPG
97 |
98 |
99 | {
100 | currentDice && (
101 |
102 |
103 |
106 |
107 |
108 | {currentDice.rolled_number}
109 |
110 |
111 | )
112 | }
113 |
114 |
115 | )
116 | }
117 |
118 | const styles = (theme) => ({
119 | container: {
120 | display: 'flex',
121 | flexDirection: 'row',
122 | alignItems: 'center',
123 | fontFamily: 'Fruktur',
124 | userSelect: 'none'
125 | },
126 | diceContainer: {
127 | position: 'relative'
128 | },
129 | diceResult: {
130 | position: 'absolute',
131 | top: '180px',
132 | display: 'flex',
133 | justifyContent: 'center',
134 | alignItems: 'center',
135 | width: '100%'
136 | },
137 | diceNumber: {
138 | zIndex: 2,
139 | fontSize: '150px',
140 | textShadow: '0 0 10px #FFFFFF'
141 | }
142 | });
143 |
144 | export default withStyles(styles)(Dice);
145 |
--------------------------------------------------------------------------------
/src/pages/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 |
3 | import Head from 'next/head'
4 | import Router from 'next/router'
5 |
6 | export default function Home() {
7 | useEffect(() => {
8 | Router.push('/dashboard');
9 | }, []);
10 |
11 | return (
12 |
13 |
14 |
Home
15 |
16 |
17 | Redirecionando...
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/portrait/[id].jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Head from 'next/head';
3 | import Image from 'next/image';
4 | import { useRouter } from 'next/router';
5 |
6 | import { withStyles } from '@mui/styles';
7 |
8 | import socket from '../../utils/socket';
9 |
10 | import { prisma } from '../../database';
11 |
12 | export const getServerSideProps = async ({ params }) => {
13 | const characterId = isNaN(params.id) ? null : Number(params.id);
14 |
15 | if(!characterId) {
16 | return {
17 | props: {
18 | character: null
19 | }
20 | }
21 | }
22 |
23 | const character = await prisma.character.findUnique({
24 | where: {
25 | id: characterId
26 | }
27 | });
28 |
29 | if(!character) {
30 | return {
31 | props: {
32 | character: null
33 | }
34 | }
35 | }
36 |
37 | const serialized = JSON.parse(JSON.stringify(character));
38 |
39 | return {
40 | props: {
41 | character: serialized
42 | }
43 | }
44 | }
45 |
46 | function Portrait({
47 | classes,
48 | character
49 | }) {
50 | const router = useRouter();
51 |
52 | const showOptions = router.query.show || '';
53 |
54 | const [isDead, setIsDead] = useState(false);
55 |
56 | const [showOnly, setShowOnly] = useState({
57 | picture: false,
58 | name: false,
59 | stats: false
60 | });
61 |
62 | const [hitPoints, setHitPoints] = useState({
63 | current: 0,
64 | max: 0
65 | });
66 |
67 | const updateHitPoints = data => {
68 | if(data.current === 0) {
69 | setIsDead(true);
70 | } else {
71 | setIsDead(false);
72 | }
73 |
74 | setHitPoints({
75 | current: data.current,
76 | max: data.max
77 | });
78 | }
79 |
80 | const getCharacterPicture = () => {
81 | if(character.standard_character_picture_url && character.injured_character_picture_url) {
82 | if(hitPoints.current > (hitPoints.max / 2)) {
83 | return character.standard_character_picture_url;
84 | }
85 | else {
86 | return character.injured_character_picture_url;
87 | }
88 | } else {
89 | return `/assets/user.png`
90 | }
91 | }
92 |
93 | useEffect(() => {
94 | document.body.style.backgroundColor = 'transparent';
95 |
96 | const splitShowOptions = showOptions.split(',');
97 |
98 | splitShowOptions.forEach(option => {
99 | setShowOnly(prevState => ({
100 | ...prevState,
101 | [option]: true
102 | }));
103 | });
104 |
105 | updateHitPoints({
106 | current: character.current_hit_points,
107 | max: character.max_hit_points
108 | });
109 | }, [character, showOptions]);
110 |
111 | useEffect(() => {
112 | socket.emit('room:join', `portrait_character_${character.id}`);
113 |
114 | socket.on('update_hit_points', data => {
115 | updateHitPoints(data);
116 | });
117 | }, [character]);
118 |
119 | if(!character) {
120 | return (
121 | Personagem não existe!
122 | );
123 | }
124 |
125 | return (
126 |
127 |
128 | Portrait de {character.name} | RPG
129 |
130 |
131 |
132 |
140 |
141 |
142 |
143 | {character.name}
144 |
145 |
146 |
147 | {hitPoints.current}/{hitPoints.max}
148 |
149 |
150 |
151 |
152 |
153 | )
154 | }
155 |
156 | const styles = (theme) => ({
157 | container: {
158 | display: 'flex',
159 | flexDirection: 'row',
160 | alignItems: 'center',
161 | fontFamily: 'Fruktur'
162 | },
163 |
164 | name: {
165 | textTransform: 'uppercase',
166 | fontSize: '72px',
167 | color: '#fff',
168 | textShadow: '0 0 10px #FFFFFF'
169 | },
170 |
171 | hitPoints: {
172 | textTransform: 'uppercase',
173 | fontSize: '62px',
174 | color: '#ffe2e2',
175 | textShadow: '0 0 10px #ff0000'
176 | },
177 |
178 | deadPicture: {
179 | filter: 'brightness(0%)'
180 | }
181 | });
182 |
183 | export default withStyles(styles)(Portrait);
184 |
--------------------------------------------------------------------------------
/src/pages/sheet/[id].jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Head from 'next/head';
3 | import Image from 'next/image';
4 | import { useRouter } from 'next/router';
5 |
6 | import { Grid, Container, Button } from '@mui/material';
7 | import { withStyles } from '@mui/styles';
8 |
9 | import { api } from '../../utils';
10 |
11 | import socket from '../../utils/socket';
12 |
13 | import {
14 | Header, Section, StatusBar, SheetEditableRow,
15 |
16 | DiceRollModal, StatusBarModal, ChangePictureModal
17 | } from '../../components';
18 |
19 | import {
20 | CharacterInfoForm
21 | } from '../../components/forms';
22 |
23 | import useModal from '../../hooks/useModal';
24 |
25 | import { prisma } from '../../database';
26 |
27 | export const getServerSideProps = async ({ params }) => {
28 | const characterId = isNaN(params.id) ? null : Number(params.id);
29 |
30 | if(!characterId) {
31 | return {
32 | props: {
33 | character: null
34 | }
35 | }
36 | }
37 |
38 | const character = await prisma.character.findUnique({
39 | where: {
40 | id: characterId
41 | },
42 | include: {
43 | attributes: {
44 | include: {
45 | attribute: true
46 | }
47 | },
48 | skills: {
49 | include: {
50 | skill: true
51 | }
52 | }
53 | }
54 | });
55 |
56 | if(!character) {
57 | return {
58 | props: {
59 | character: null
60 | }
61 | }
62 | }
63 |
64 | const serialized = JSON.parse(JSON.stringify(character));
65 |
66 | return {
67 | props: {
68 | rawCharacter: serialized
69 | }
70 | }
71 | }
72 |
73 | function Sheet({
74 | classes,
75 | rawCharacter
76 | }) {
77 | const router = useRouter();
78 |
79 | const refreshData = () => {
80 | return router.replace(router.asPath);
81 | }
82 |
83 | const [character, setCharacter] = useState(rawCharacter);
84 |
85 | const onCharacterInfoSubmit = async values => {
86 | return new Promise((resolve, reject) => {
87 | api.put(`/character/${character.id}`, values)
88 | .then(() => {
89 | resolve();
90 | })
91 | .catch(() => {
92 | reject();
93 | });
94 | });
95 | }
96 |
97 | const onHitPointsModalSubmit = async newData => {
98 | return new Promise((resolve, reject) => {
99 | const data = {
100 | current_hit_points: Number(newData.current),
101 | max_hit_points: Number(newData.max)
102 | }
103 |
104 | api
105 | .put(`/character/${character.id}`, data)
106 | .then(() => {
107 | updateCharacterState(data);
108 |
109 | resolve();
110 |
111 | socket.emit('update_hit_points', { character_id: character.id, current: data.current_hit_points, max: data.max_hit_points });
112 | })
113 | .catch(err => {
114 | alert(`Erro ao atualizar a vida!`, err);
115 |
116 | reject();
117 | });
118 | });
119 | }
120 |
121 | useEffect(() => {
122 | setCharacter(rawCharacter);
123 | }, [rawCharacter]);
124 |
125 | const updateCharacterState = data => {
126 | return setCharacter(prevState => ({
127 | ...prevState,
128 | ...data
129 | }));
130 | }
131 |
132 | const hitPointsModal = useModal(({ close }) => (
133 | {
136 | onHitPointsModalSubmit(newData).then(() => close());
137 | }}
138 | handleClose={close}
139 | data={{
140 | current: character.current_hit_points,
141 | max: character.max_hit_points
142 | }}
143 | />
144 | ));
145 |
146 | const diceRollModal = useModal(({ close }) => (
147 | {
149 | const parsedData = {
150 | character_id: character.id,
151 | rolls: rollData.map(each => ({
152 | rolled_number: each.rolled_number,
153 | max_number: each.max_number
154 | }))
155 | }
156 |
157 | socket.emit('dice_roll', parsedData);
158 | }}
159 | handleClose={close}
160 | characterId={character.id}
161 | />
162 | ));
163 |
164 | const changePictureModal = useModal(({ close }) => (
165 | refreshData()}
167 | handleClose={close}
168 | character={character}
169 | />
170 | ));
171 |
172 | const updateCharacterAttributeValue = (attribute, value) => {
173 | const index = character.attributes.findIndex(a => a.attribute_id === attribute.attribute_id);
174 |
175 | const newArray = character.attributes;
176 |
177 | newArray[index] = {
178 | ...attribute,
179 | value
180 | }
181 |
182 | setCharacter(prevState => ({
183 | ...prevState,
184 | attributes: newArray
185 | }));
186 | }
187 |
188 | const updateCharacterSkillValue = (skill, value) => {
189 | const index = character.skills.findIndex(s => s.skill_id === skill.skill_id);
190 |
191 | const newArray = character.skills;
192 |
193 | newArray[index] = {
194 | ...skill,
195 | value
196 | }
197 |
198 | setCharacter(prevState => ({
199 | ...prevState,
200 | skills: newArray
201 | }));
202 | }
203 |
204 | const getCharacterPictureURL = () => {
205 | if(!character) {
206 | return null;
207 | }
208 |
209 | if(character.standard_character_picture_url && character.injured_character_picture_url) {
210 | if(character.current_hit_points > (character.max_hit_points / 2)) {
211 | return character.standard_character_picture_url;
212 | }
213 | else {
214 | return character.injured_character_picture_url;
215 | }
216 | } else {
217 | return `/assets/user.png`
218 | }
219 | }
220 |
221 | if(!rawCharacter) {
222 | return (
223 | Personagem não existe!
224 | );
225 | }
226 |
227 | return (
228 |
229 |
230 | Ficha de {character.name} | RPG
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
241 |
242 |
243 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 | changePictureModal.appear()}
262 | />
263 |
264 |
265 |
271 |
272 |
273 |
274 |
275 | Vida
276 |
277 |
278 | {
285 | hitPointsModal.appear();
286 | }}
287 | />
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
298 |
299 | {
300 | character.attributes.map((each, index) => (
301 |
302 | {
309 | api.put('/character/attribute', {
310 | character_id: character.id,
311 | attribute_id: each.attribute.id,
312 | value: newValue
313 | })
314 | .catch(err => {
315 | alert(`Erro ao atualizar o valor! Erro: ${err.toString()}`);
316 | })
317 | }}
318 | onInput={newValue => {
319 | updateCharacterAttributeValue(each, newValue);
320 | }}
321 | />
322 |
323 | ))
324 | }
325 |
326 |
327 |
328 |
329 |
332 |
333 | {
334 | character.skills.map((each, index) => (
335 |
336 | {
343 | api.put('/character/skill', {
344 | character_id: character.id,
345 | skill_id: each.skill.id,
346 | value: newValue
347 | })
348 | .catch(err => {
349 | alert(`Erro ao atualizar o valor! Erro: ${err.toString()}`);
350 | })
351 | }}
352 | onInput={newValue => {
353 | updateCharacterSkillValue(each, newValue);
354 | }}
355 | />
356 |
357 | ))
358 | }
359 |
360 |
361 |
362 |
363 |
364 |
365 | )
366 | }
367 |
368 | const styles = (theme) => ({
369 | characterImage: {
370 | width: '200px',
371 | borderRadius: '50%',
372 | cursor: 'pointer'
373 | },
374 |
375 | alignCenter: {
376 | display: 'flex',
377 | justifyContent: 'center',
378 | alignItems: 'center'
379 | },
380 |
381 | bar: {
382 | marginBottom: '15px'
383 | },
384 |
385 | barTitle: {
386 | marginBottom: '10px',
387 | color: theme.palette.secondary.main,
388 | textTransform: 'uppercase',
389 | fontSize: '18px',
390 | fontWeight: 'bold'
391 | }
392 | });
393 |
394 | export default withStyles(styles)(Sheet);
395 |
--------------------------------------------------------------------------------
/src/prisma/migrations/20211111223711_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `character` (
3 | `id` INTEGER NOT NULL AUTO_INCREMENT,
4 | `name` VARCHAR(191) NOT NULL,
5 | `age` INTEGER NULL,
6 | `gender` VARCHAR(191) NULL,
7 | `player_name` VARCHAR(191) NULL,
8 | `current_hit_points` INTEGER NOT NULL DEFAULT 0,
9 | `max_hit_points` INTEGER NOT NULL DEFAULT 0,
10 | `current_picture` INTEGER NOT NULL DEFAULT 1,
11 | `is_dead` BOOLEAN NOT NULL DEFAULT false,
12 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
13 |
14 | PRIMARY KEY (`id`)
15 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
16 |
17 | -- CreateTable
18 | CREATE TABLE `character_attributes` (
19 | `character_id` INTEGER NOT NULL,
20 | `attribute_id` INTEGER NOT NULL,
21 | `value` VARCHAR(191) NULL,
22 |
23 | PRIMARY KEY (`character_id`, `attribute_id`)
24 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
25 |
26 | -- CreateTable
27 | CREATE TABLE `attribute` (
28 | `id` INTEGER NOT NULL AUTO_INCREMENT,
29 | `name` VARCHAR(191) NOT NULL,
30 | `description` VARCHAR(191) NULL,
31 |
32 | PRIMARY KEY (`id`)
33 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
34 |
35 | -- CreateTable
36 | CREATE TABLE `character_skills` (
37 | `character_id` INTEGER NOT NULL,
38 | `skill_id` INTEGER NOT NULL,
39 | `value` VARCHAR(191) NULL,
40 |
41 | PRIMARY KEY (`character_id`, `skill_id`)
42 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
43 |
44 | -- CreateTable
45 | CREATE TABLE `skills` (
46 | `id` INTEGER NOT NULL AUTO_INCREMENT,
47 | `name` VARCHAR(191) NOT NULL,
48 | `description` VARCHAR(191) NULL,
49 |
50 | PRIMARY KEY (`id`)
51 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
52 |
53 | -- CreateTable
54 | CREATE TABLE `roll` (
55 | `id` INTEGER NOT NULL AUTO_INCREMENT,
56 | `max_number` INTEGER NOT NULL,
57 | `rolled_number` INTEGER NOT NULL,
58 | `character_id` INTEGER NOT NULL,
59 | `rolled_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
60 |
61 | PRIMARY KEY (`id`)
62 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
63 |
64 | -- AddForeignKey
65 | ALTER TABLE `character_attributes` ADD CONSTRAINT `character_attributes_character_id_fkey` FOREIGN KEY (`character_id`) REFERENCES `character`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
66 |
67 | -- AddForeignKey
68 | ALTER TABLE `character_attributes` ADD CONSTRAINT `character_attributes_attribute_id_fkey` FOREIGN KEY (`attribute_id`) REFERENCES `attribute`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
69 |
70 | -- AddForeignKey
71 | ALTER TABLE `character_skills` ADD CONSTRAINT `character_skills_character_id_fkey` FOREIGN KEY (`character_id`) REFERENCES `character`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
72 |
73 | -- AddForeignKey
74 | ALTER TABLE `character_skills` ADD CONSTRAINT `character_skills_skill_id_fkey` FOREIGN KEY (`skill_id`) REFERENCES `skills`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
75 |
76 | -- AddForeignKey
77 | ALTER TABLE `roll` ADD CONSTRAINT `roll_character_id_fkey` FOREIGN KEY (`character_id`) REFERENCES `character`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
78 |
--------------------------------------------------------------------------------
/src/prisma/migrations/20211112234305_added_config_table/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `config` (
3 | `id` INTEGER NOT NULL AUTO_INCREMENT,
4 | `name` VARCHAR(191) NOT NULL,
5 | `value` VARCHAR(191) NULL,
6 |
7 | PRIMARY KEY (`id`)
8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
9 |
--------------------------------------------------------------------------------
/src/prisma/migrations/20211113001226_config_table_unique_name/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[name]` on the table `config` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX `config_name_key` ON `config`(`name`);
9 |
--------------------------------------------------------------------------------
/src/prisma/migrations/20211123004316_added_new_character_attributes/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE `character` ADD COLUMN `injured_character_picture_url` VARCHAR(191) NULL,
3 | ADD COLUMN `standard_character_picture_url` VARCHAR(191) NULL;
4 |
--------------------------------------------------------------------------------
/src/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "mysql"
--------------------------------------------------------------------------------
/src/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "mysql"
3 | url = env("DB_PROVIDER_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | model Character {
11 | id Int @id @default(autoincrement())
12 | name String
13 | age Int?
14 | gender String?
15 | player_name String?
16 | current_hit_points Int @default(0)
17 | max_hit_points Int @default(0)
18 | current_picture Int @default(1)
19 | is_dead Boolean @default(false)
20 | standard_character_picture_url String?
21 | injured_character_picture_url String?
22 |
23 | created_at DateTime @default(now())
24 |
25 | attributes CharacterAttributes[]
26 | skills CharacterSkills[]
27 | rolls Roll[]
28 |
29 | @@map("character")
30 | }
31 |
32 | model CharacterAttributes {
33 | character Character @relation(fields: [character_id], references: [id])
34 | character_id Int
35 | attribute Attribute @relation(fields: [attribute_id], references: [id])
36 | attribute_id Int
37 |
38 | value String?
39 |
40 | @@id([character_id, attribute_id])
41 |
42 | @@map("character_attributes")
43 | }
44 |
45 | model Attribute {
46 | id Int @id @default(autoincrement())
47 | name String
48 | description String?
49 |
50 | characters CharacterAttributes[]
51 |
52 | @@map("attribute")
53 | }
54 |
55 | model CharacterSkills {
56 | character Character @relation(fields: [character_id], references: [id])
57 | character_id Int
58 | skill Skill @relation(fields: [skill_id], references: [id])
59 | skill_id Int
60 |
61 | value String?
62 |
63 | @@id([character_id, skill_id])
64 | @@map("character_skills")
65 | }
66 |
67 | model Skill {
68 | id Int @id @default(autoincrement())
69 | name String
70 | description String?
71 |
72 | characters CharacterSkills[]
73 |
74 | @@map("skills")
75 | }
76 |
77 | model Roll {
78 | id Int @id @default(autoincrement())
79 |
80 | max_number Int
81 | rolled_number Int
82 |
83 | character Character @relation(fields: [character_id], references: [id])
84 | character_id Int
85 |
86 | rolled_at DateTime @default(now())
87 |
88 | @@map("roll")
89 | }
90 |
91 | model Config {
92 | id Int @id @default(autoincrement())
93 | name String @unique
94 | value String?
95 |
96 | @@map("config")
97 | }
98 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 |
3 | const app = require('express')();
4 | const server = require('http').Server(app);
5 | const io = require('socket.io')(server);
6 | const next = require('next');
7 |
8 | const dev = process.env.NODE_ENV !== 'production';
9 |
10 | const nextApp = next({ dev });
11 | const nextHandler = nextApp.getRequestHandler();
12 |
13 | io.on('connect', socket => {
14 | socket.on('room:join', roomName => {
15 | return socket.join(roomName);
16 | });
17 |
18 | socket.on('update_hit_points', data => {
19 | return io.to(`portrait_character_${data.character_id}`).emit('update_hit_points', data);
20 | });
21 |
22 | socket.on('dice_roll', data => {
23 | return io.to(`dice_character_${data.character_id}`).emit('dice_roll', data);
24 | });
25 | });
26 |
27 | nextApp.prepare().then(() => {
28 | app.all('*', (req, res) => {
29 | return nextHandler(req, res);
30 | });
31 |
32 | server.listen(process.env.PORT || 3000, err => {
33 | if(err) {
34 | throw err;
35 | }
36 |
37 | console.log('[Server] Successfully started on port', process.env.PORT || 3000);
38 | });
39 | })
--------------------------------------------------------------------------------
/src/theme/index.js:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mui/material/styles'
2 |
3 | const theme = createTheme({
4 | palette: {
5 | background: {
6 | default: '#2b2b2b'
7 | },
8 | mode: 'dark',
9 | primary: {
10 | main: '#639EC2',
11 | 600: '#201E1E',
12 | 900: '#181717',
13 | },
14 | secondary: {
15 | main: '#8c8c8c',
16 | }
17 | }
18 | });
19 |
20 | export default theme
21 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const api = axios.create({
4 | baseURL: '/api'
5 | });
6 |
7 | const generateRandomNumber = max => {
8 | return Math.floor(Math.random() * (max - 1 + 1)) + 1;
9 | }
10 |
11 | export {
12 | api,
13 | generateRandomNumber
14 | }
--------------------------------------------------------------------------------
/src/utils/socket.js:
--------------------------------------------------------------------------------
1 | import io from 'socket.io-client';
2 |
3 | const socket = io();
4 |
5 | export default socket;
--------------------------------------------------------------------------------
/src/validations/CharacterInfoSchema.js:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 |
3 | const schema = Yup.object().shape({
4 | name: Yup.string().required('O nome do personagem é obrigatório'),
5 | player_name: Yup.string().nullable(),
6 | age: Yup.number().nullable(),
7 | gender: Yup.string().nullable()
8 | });
9 |
10 | export default schema;
--------------------------------------------------------------------------------
/src/validations/index.js:
--------------------------------------------------------------------------------
1 | import CharacterInfoSchema from './CharacterInfoSchema';
2 |
3 | export {
4 | CharacterInfoSchema
5 | }
--------------------------------------------------------------------------------