Prvotní nástřel fungující aplikace

This commit is contained in:
Martin Berka
2023-06-01 23:05:51 +02:00
parent bf379e13ed
commit 12583e6efb
59 changed files with 2194 additions and 1011 deletions

51
client/src/Api.ts Normal file
View File

@@ -0,0 +1,51 @@
// type Pizza = {
// name: string;
// // TODO ingredience
// sizes: [
// size: number,
// price: number,
// ];
// }
import { getBaseUrl } from "./Utils";
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
return fetch(getBaseUrl() + url, config).then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as TResponse;
});
}
const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
}
export const getData = async () => {
return await api.get<any>('/api/data');
}
export const getFood = async () => {
return await api.get<any>('/api/food');
}
export const getPizzy = async () => {
return await api.get<any>('/api/pizza');
}
export const createPizzaDay = async () => {
return await api.post<any, any>('/api/createPizzaDay', {});
}
export const deletePizzaDay = async () => {
return await api.post<any, any>('/api/deletePizzaDay', {});
}
export const updateChoice = async (name: string, choice: number | null) => {
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ name, choice }));
}

60
client/src/App.css Normal file
View File

@@ -0,0 +1,60 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.wrapper {
padding: 20px;
}
.title {
margin: 50px 0;
}
.food-tables {
display: flex;
justify-content: space-evenly;
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}

160
client/src/App.tsx Normal file
View File

@@ -0,0 +1,160 @@
import React, { useContext, useEffect, useState } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { getData, getFood, updateChoice } from './Api';
import { useAuth } from './context/auth';
import Login from './Login';
import { Locations, ClientData } from './Types';
import './App.css';
import { Alert, Form, Table } from 'react-bootstrap';
const EVENT_CONNECT = "connect"
function App() {
const auth = useAuth();
const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<any>();
// const [pizzy, setPizzy] = useState();
const socket = useContext(SocketContext);
// Prvotní načtení aktuálního stavu
useEffect(() => {
// getPizzy().then(pizzy => {
// setPizzy(pizzy);
// });
getData().then(data => {
setData(data);
})
getFood().then(food => {
setFood(food);
})
}, []);
// Registrace socket eventů
useEffect(() => {
socket.on(EVENT_CONNECT, () => {
// console.log("Connected!");
setIsConnected(true);
});
socket.on(EVENT_DISCONNECT, () => {
// console.log("Disconnected!");
setIsConnected(false);
});
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// const data: any = JSON.parse(payload);
// console.log("Přijata nová data ze socketu", newData);
setData(newData);
});
return () => {
socket.off(EVENT_CONNECT);
socket.off(EVENT_DISCONNECT);
socket.off(EVENT_MESSAGE);
}
}, [socket]);
const changeChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const index = Object.values(Locations).indexOf(event.target.value as unknown as Locations);
if (auth?.login) {
await updateChoice(auth.login, index > -1 ? index : null);
}
}
const renderFoodTable = (name, food) => {
return <div className='food-table'>
<h3>{name}</h3>
<Table striped bordered hover>
<tbody>
{food.map((f: any, index: number) =>
<tr key={index}>
<td>{f.amount}</td>
<td>{f.name}</td>
<td>{f.price}</td>
</tr>
)}
</tbody>
</Table>
</div>
}
if (!auth || !auth.login) {
return <Login />;
}
if (!data || !isConnected || !food) {
return <div>Načítám data...</div>
}
// const pizzaDayExists = data?.state > 0;
return (
<div className='wrapper'>
<Alert variant={'primary'}>
Tvé zobrazované jméno je {auth.login}. Změnu můžeš provést v local storage prohlížeče.<br />
<small>Pro gamer move: Změň si své jméno na cizí. Můžeš pak libovolně měnit jejich volbu.</small>
</Alert>
<h1 className='title'>Dnes je {data.date}</h1>
<div className='food-tables'>
{renderFoodTable('Sladovnická', food.sladovnicka)}
{renderFoodTable('U Motlíků', food["uMotliku:"])}
{renderFoodTable('TechTower', food.techTower)}
</div>
<div className='content-wrapper'>
<div className='content'>
<p>Jak to dnes vidíš s obědem?</p>
<Form.Select onChange={changeChoice}>
<option></option>
<option value={Locations.SLADOVNICKA}>Sladovnická</option>
<option value={Locations.UMOTLIKU}>U Motlíků</option>
<option value={Locations.TECHTOWER}>TechTower</option>
<option value={Locations.SPSE}>SPŠE</option>
<option value={Locations.VLASTNI}>Mám vlastní</option>
<option value={Locations.OBJEDNAVAM}>Budu objednávat</option>
<option value={Locations.NEOBEDVAM}>Nebudu obědvat</option>
</Form.Select>
<p style={{ fontSize: "12px", marginTop: "5px" }}>
Aktuálně je možné vybrat pouze jednu variantu. Vyber prázdnou položku pro odstranění.
</p>
{Object.keys(data.choices).length > 0 ?
<Table striped bordered hover className='results-table mt-5'>
<tbody>
{Object.keys(data.choices).map((key: string, index: number) =>
<tr key={index}>
<td>{Object.values(Locations)[Number(key)]}</td>
<td>
<ul>
{data.choices[Number(key)].map((p: string, index: number) => <li key={index}>{p}</li>)}
</ul>
</td>
</tr>
)}
</tbody>
</Table>
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
}
</div>
</div>
{/* {!pizzaDayExists &&
<div>
<p>Pro dnešní den není aktuálně založen Pizza day.</p>
<Button onClick={async () => {
await createPizzaDay();
}}>Založit Pizza day</Button>
</div>
}
{pizzaDayExists && <div>
<Button className='danger' onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<OrderList orders={data.orders} />
</div>} */}
{/* <Button onClick={async () => {
const pizzy = await getPizzy();
console.log("Výsledek", pizzy);
}}>Získat pizzy</Button> */}
</div>
);
}
export default App;

13
client/src/Login.css Normal file
View File

@@ -0,0 +1,13 @@
.login {
height: 100%;
display: flex;
flex-direction: column;
text-align: center;
justify-content: center;
}
.login-inner {
display: flex;
flex-direction: column;
align-items: center;
}

28
client/src/Login.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import './Login.css';
/**
* Formulář pro prvotní zadání přihlašovacího jména.
*/
export default function Login() {
const auth = useAuth();
const [loginName, setLoginName] = useState<string>('');
if (!auth || !auth.login) {
return <div className='login'>
<div className='login-inner'>
<p style={{ fontSize: "12px", marginTop: "10px" }}>Zobrazované jméno by mělo být ideálně vaše jméno a příjmení, nebo přezdívka, pod kterou vás kolegové dokážou snadno identifikovat. Jméno lze kdykoliv upravit/smazat v local storage prohlížeče.<br />PS: Enter nefunguje</p>
Zobrazované jméno: <input style={{ marginTop: "10px" }} onChange={(e) => setLoginName(e.target.value)} type='text' />
<Button onClick={() => {
if (loginName?.length > 0) {
auth?.setLogin(loginName);
}
}} style={{ marginTop: "20px" }}>Uložit</Button>
</div>
</div>
}
// TODO nějaký loader
return <div>TODO</div>;
}

33
client/src/Types.tsx Normal file
View File

@@ -0,0 +1,33 @@
// TODO všechno v tomto souboru jsou duplicity se serverem, ale aktuálně nevím jaký je nejlepší způsob jejich sdílení
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
size: number, // velikost pizzy v cm
price: number, // cena pizzy v Kč, včetně krabice
}
export interface Order {
customer: string, // název člověka
pizzaList: Pizza[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
}
export interface Choices {
[location: string]: string[],
}
export interface ClientData {
date: string, // dnešní datum pro zobrazení
choices: Choices, // seznam voleb
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
VLASTNI = 'Mám vlastní',
OBJEDNAVAM = 'Objednávám',
NEOBEDVAM = 'Neobědvám',
}

38
client/src/Utils.tsx Normal file
View File

@@ -0,0 +1,38 @@
/**
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://localhost:3001';
}
const LOGIN_KEY = "login";
/**
* Uloží login do local storage prohlížeče.
*
* @param login login
*/
export const storeLogin = (login: string) => {
localStorage.setItem(LOGIN_KEY, login);
}
/**
* Vrátí login z local storage, pokud tam je.
*
* @returns login nebo null
*/
export const getLogin = (): string | null => {
return localStorage.getItem(LOGIN_KEY);
}
/**
* Odstraní login z local storage, pokud tam je.
*/
export const deleteLogin = () => {
localStorage.removeItem(LOGIN_KEY);
}

View File

@@ -0,0 +1,21 @@
import { Table } from "react-bootstrap";
import { Order } from "../Types";
export default function OrderList({ orders }: { orders: Order[] }) {
return <Table striped bordered hover>
<thead>
<tr>
<th>Pizza</th>
<th>Jméno</th>
<th>Cena ()</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr>
<td>{order.pizzaList[0].name}, {order.pizzaList[0].size}</td>
<td>{order.customer}</td>
<td>{order.totalPrice}</td>
</tr>)}
</tbody>
</Table>
}

View File

@@ -0,0 +1,58 @@
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
const LOGIN_KEY = 'login';
export type AuthContextProps = {
login?: string,
setLogin: (name: string) => void,
clearLogin: () => void,
}
type ContextProps = {
children: ReactNode
}
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: ContextProps) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
export const useAuth = () => {
return useContext(authContext);
}
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
useEffect(() => {
const login = localStorage.getItem(LOGIN_KEY);
if (login) {
setLogin(login);
}
}, [])
useEffect(() => {
if (loginName) {
localStorage.setItem(LOGIN_KEY, loginName)
} else {
localStorage.removeItem(LOGIN_KEY);
}
}, [loginName]);
function setLogin(login: string) {
setLoginName(login);
}
function clearLogin() {
setLoginName(undefined);
}
return {
login: loginName,
setLogin,
clearLogin
}
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import socketio from "socket.io-client";
import { getBaseUrl } from "../Utils";
// Záměrně omezeno jen na websocket, aby se případně odhalilo chybné nastavení proxy serveru
export const socket = socketio.connect(getBaseUrl(), { transports: ["websocket"] });
export const SocketContext = React.createContext();
// Konstanty websocket eventů, musí odpovídat těm na serveru!
export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message';
// export const EVENT_CONFIG = 'config';
// export const EVENT_TOASTER = 'toaster';
// export const EVENT_VOTING = 'voting';
// export const EVENT_VOTE_CONFIG = 'voteSettings';
// export const EVENT_ADMIN = 'admin';

20
client/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
html,
body,
#root {
width: 100%;
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

19
client/src/index.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<ProvideAuth>
<SocketContext.Provider value={socket}>
<App />
</SocketContext.Provider>
</ProvideAuth>
</React.StrictMode>
);

1
client/src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
client/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />