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

5
.gitignore vendored
View File

@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules node_modules
/.pnp /.pnp
.pnp.js .pnp.js
@ -21,3 +21,6 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
__pycache__
venv

View File

@ -1,7 +1,15 @@
# Pizza Day # Luncher
Zatím to nemá dokumentaci. Zatím to nemá dokumentaci.
Klient je v tomto adresáři, server v adresáři /server, obojí lze spustit pomocí: Server je v adresáři /server, client v adresáři /client obojí lze spustit pomocí:
### `yarn` ### `yarn`
### `yarn start` ### `yarn start`
## TODO
- Popsat Food API, nginx
- Popsat spuštění pro vývoj
- Vyndat URL na Food API do .env
- Neselhat při nedostupnosti nebo chybě z Food API
- Dokončit docker-compose pro kompletní funkčnost
- Implementovat pizza day

3
client/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/node_modules
**/npm-debug.log
build

1
client/.env.production Normal file
View File

@ -0,0 +1 @@
PUBLIC_URL=http://192.168.1.106:3005

1
client/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build

23
client/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM node:alpine AS builder
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .env.production .
RUN yarn install
COPY ./src ./src
COPY ./public ./public
RUN yarn build
FROM node:alpine
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder /build .
RUN yarn global add serve && yarn
CMD ["serve", "-s", "."]

2
client/build.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
docker build -t luncher-client .

View File

@ -1,6 +1,7 @@
{ {
"name": "luncher", "name": "luncher-client",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
@ -15,6 +16,7 @@
"react-bootstrap": "^2.7.2", "react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"socket.io-client": "^4.6.1",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,14 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
@ -24,9 +22,10 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>Luncher</title>
</head> </head>
<body>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<!-- <!--
@ -39,5 +38,6 @@
To begin the development, run `npm start` or `yarn start`. To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`. To create a production bundle, use `npm run build` or `yarn build`.
--> -->
</body> </body>
</html> </html>

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

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

View File

@ -32,7 +32,29 @@
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); 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';

View File

@ -1,3 +1,10 @@
html,
body,
#root {
width: 100%;
height: 100%;
}
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

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>
);

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -21,6 +21,6 @@
"jsx": "react-jsx" "jsx": "react-jsx"
}, },
"include": [ "include": [
"src" "client/src"
] ]
} }

View File

@ -1691,6 +1691,11 @@
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@sinonjs/commons" "^1.7.0"
"@socket.io/component-emitter@~3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
"@surma/rollup-plugin-off-main-thread@^2.2.3": "@surma/rollup-plugin-off-main-thread@^2.2.3":
version "2.2.3" version "2.2.3"
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
@ -3594,7 +3599,7 @@ debug@2.6.9, debug@^2.6.0:
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
version "4.3.4" version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@ -3915,6 +3920,22 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
engine.io-client@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.4.0.tgz#88cd3082609ca86d7d3c12f0e746d12db4f47c91"
integrity sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
engine.io-parser "~5.0.3"
ws "~8.11.0"
xmlhttprequest-ssl "~2.0.0"
engine.io-parser@~5.0.3:
version "5.0.6"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.6.tgz#7811244af173e157295dec9b2718dfe42a64ef45"
integrity sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==
enhanced-resolve@^5.10.0: enhanced-resolve@^5.10.0:
version "5.12.0" version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
@ -8190,6 +8211,24 @@ slash@^4.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
socket.io-client@^4.6.1:
version "4.6.1"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.6.1.tgz#80d97d5eb0feca448a0fb6d69a7b222d3d547eab"
integrity sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.2"
engine.io-client "~6.4.0"
socket.io-parser "~4.2.1"
socket.io-parser@~4.2.1:
version "4.2.2"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.2.tgz#1dd384019e25b7a3d374877f492ab34f2ad0d206"
integrity sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
sockjs@^0.3.24: sockjs@^0.3.24:
version "0.3.24" version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
@ -9442,6 +9481,11 @@ ws@^8.13.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
ws@~8.11.0:
version "8.11.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
xml-name-validator@^3.0.0: xml-name-validator@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
@ -9452,6 +9496,11 @@ xmlchars@^2.2.0:
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
xmlhttprequest-ssl@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
y18n@^5.0.5: y18n@^5.0.5:
version "5.0.8" version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
version: '3.8'
services:
food_api:
build:
context: ./food_api
# ports:
# - "3002:80"
server:
depends_on:
- food_api
build:
context: ./server
# ports:
# - "3001:3001"
client:
build:
context: ./client
# ports:
# - "3000:3000"
nginx:
depends_on:
- server
- client
restart: always
build:
context: ./nginx
ports:
- 3005:80

10
food_api/Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM python:3.9
WORKDIR /app
COPY ./requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
COPY ./food_service.py /app
COPY ./food_api.py /app
CMD ["uvicorn", "food_api:app", "--host", "0.0.0.0", "--port", "3002"]

43
food_api/README.md Normal file
View File

@ -0,0 +1,43 @@
# TODO
Následující informace jsou neaktuální. Už nemáme Flask, místo WSGI jedeme přes ASGI apod. Místo tohoto dokumentu využijte nadřazený README.md.
# POMPSZČPS
POMPSZČPS, neboli Parser Obědových Menu Plzeňských Stravovacích Zařízení v Části Plzeň-Slovany, je Python aplikace poskytující na jednom místě aktuální obědové menu pro několik stravovacích zařízení v městské části Plzeň 2-Slovany. Aktuálně podporuje následující podniky:
- [Pivnice Sladovnická](https://sladovnicka.unasplzenchutna.cz)
- [Restaurace U Motlíků](https://www.umotliku.cz)
- [Restaurace TechTower](https://www.equifarm.cz/restaurace-techtower)
Pro tyto podniky umožňuje získání aktuálního obědového menu, a to buďto barevným výpisem do konzole (přímým spuštěním `food_service.py`) nebo v podobě [WSGI](https://cs.wikipedia.org/wiki/Web_Server_Gateway_Interface) endpointu (`wsgi.py`), který vrací zmíněná menu jako strukturovaný JSON objekt pro další použití v jiných aplikacích.
## Závislosti
- [Python 3.x](https://www.python.org)
Pro použití jako konzolová aplikace
- [beautifulsoup4](https://pypi.org/project/beautifulsoup4)
Pro použití jako API endpoint
- [beautifulsoup4](https://pypi.org/project/beautifulsoup4)
- [Flask](https://pypi.org/project/Flask)
- [gunicorn](https://pypi.org/project/gunicorn)
## Použití
```bash
python -m venv venv
(Unix): source venv/bin/activate
(Windows): venv\Scripts\activate.bat
pip install -r requirements.txt
```
- Jako konzolová aplikace: `python food_service.py`
- Vypíše přehledně pod sebe menu všech aktuálně integrovaných podniků
- Jako JSON API endpoint
- TODO
## TODO
- Umožnit zadat a zobrazit menu pro jiné dny
- umožnit zadání datumem nebo názvem dne v týdnu
- validace - žádná sobota, neděle
- validace - datum musí být tento týden
- minimálně pro Motlíky to znamená úpravu URL a parseru
- Otestovat rozchození - vytvoření venv, instalace requirements, spuštění jako konzole
- Umožnit konfiguračně určit pro které podniky se bude menu získávat a zobrazovat (vyberu si jen ty, které mě zajímají)
- Umožnit konfiguračně nastavit výrazy pro detekci polévky

20
food_api/food_api.py Normal file
View File

@ -0,0 +1,20 @@
from food_service import getMenuSladovnicka, getMenuTechTower, getMenuUMotliku
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def read_root():
return {
'sladovnicka': getMenuSladovnicka(),
'uMotliku:': getMenuUMotliku(),
'techTower': getMenuTechTower()
}

252
food_api/food_service.py Executable file
View File

@ -0,0 +1,252 @@
#!/usr/bin/env python3
import datetime
from typing import List
from bs4 import BeautifulSoup
import tempfile
import sys
import os
import urllib.request
from datetime import date, timedelta
URL_SLADOVNICKA = "https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka"
URL_MOTLICI = "https://www.umotliku.cz"
URL_TECHTOWER = "https://www.equifarm.cz/restaurace-techtower"
DAY_NAMES = ['pondělí', 'úterý', 'středa',
'čtvrtek', 'pátek', 'sobota', 'neděle']
# Fráze v názvech jídel, které naznačují že se jedná o polévku
SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar']
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
class Food:
name = None
amount = None
price = None
is_soup = False
def __init__(self, name, amount, price, is_soup=False) -> None:
self.name = name
self.amount = amount
self.price = price
self.is_soup = is_soup
def getOrDownloadHtml(prefix: str, url: str):
'''Vrátí HTML pro daný prefix pro aktuální den.
Pokud v tempu neexistuje, provede jeho stažení z předané URL a uložení.'''
filename = prefix + "_" + date.today().strftime("%Y_%m_%d") + ".html"
filepath = os.path.join(tempfile.gettempdir(), filename)
if not os.path.isfile(filepath):
urllib.request.urlretrieve(url, filepath)
file = open(filepath, "r")
contents = file.read()
file.close()
return contents
def isNameOfDay(text: str):
'''Vrátí True, pokud předaný text představuje název dne v týdnu (např. "pondělí")'''
return text.strip().lower() in DAY_NAMES
def getDayNameOfDate(date: datetime.datetime):
'''Vrátí název dne v týdnu - např. pondělí, úterý, ...'''
return DAY_NAMES[date.weekday()]
def getStartOfWeekDate():
'''Vrátí datetime představující pondělí v aktuálním týdnu.'''
today = datetime.datetime.now()
return today - timedelta(days=today.weekday())
def isTextSoupName(text: str):
'''Vrátí True, pokud se předaný text jeví jako název polévky.
Používá se tam, kde nemáme lepší způsob detekce (TechTower).'''
for name in SOUP_NAMES:
if name in text.lower():
return True
return False
def printMenu(name: str, foodList: List[Food]):
'''Vytiskne jídelní lístek na obrazovku.'''
print(f"{bcolors.OKGREEN}{name}{bcolors.ENDC}\n---------------------------------------------------------------------------------")
maxLength = 0
for jidlo in foodList:
if len(jidlo.name) > maxLength:
maxLength = len(jidlo.name)
for jidlo in foodList:
barva = bcolors.HEADER if jidlo.is_soup else bcolors.WARNING
print(f"{barva}{jidlo.amount}\t{jidlo.name.ljust(maxLength)}\t{bcolors.ENDC}{bcolors.OKCYAN}{jidlo.price}{bcolors.ENDC}")
print('\n')
def getMenuSladovnicka() -> List[Food]:
html = getOrDownloadHtml('sladovnicka', URL_SLADOVNICKA)
soup = BeautifulSoup(html, "html.parser")
div = soup.select_one("div.tab-pane.fade.in.active")
datumDen = div.find("h2").text
split = datumDen.split(".")
denMesic = split[0] + "." + split[1] + "."
# nazevDen = split[2]
dnesniDatum = date.today().strftime("%-d.%-m.")
if denMesic != dnesniDatum:
print('Chyba: neočekávané datum na stránce Sladovnické (' +
denMesic + '), očekáváno ' + dnesniDatum, file=sys.stderr)
sys.exit(1)
tables = div.find_all("table", {"class": "simple"})
if len(tables) != 2:
print('Chyba: neočekávaný počet tabulek na stránce Sladovnické (' +
str(len(tables)) + '), očekávány 2', file=sys.stderr)
sys.exit(1)
foodList: List[Food] = []
polevkaValues = tables[0].find_all("td")
amount = polevkaValues[0].text.strip()
name = polevkaValues[1].text.strip()
price = polevkaValues[2].text.strip()
foodList.append(Food(name, amount, price, True))
foodTables = tables[1].find_all("tr")
for food in foodTables:
rows = food.find_all("td")
if (len(rows) != 3):
print("Neočekávaný počet řádek hlavního jídla Sladovnické (" +
str(len(rows)) + ", očekávány 3, přeskakuji...")
continue
amount = rows[0].text.strip()
name = rows[1].text.strip()
price = rows[2].text.strip()
foodList.append(Food(name, amount, price))
return foodList
def getMenuUMotliku() -> List[Food]:
html = getOrDownloadHtml('u_motliku', URL_MOTLICI)
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", {"class": "Xtable-striped"})
rows = table.find_all("tr")
if len(rows) < 4:
print('Chyba: neočekávaný celkový počet řádek tabulky (' +
str(len(rows)) + '), očekáváno 4 a více', file=sys.stderr)
sys.exit(1)
foodList: List[Food] = []
if rows[0].td.text.strip() == 'Polévka':
tds = rows[1].find_all("td")
if len(tds) != 3:
print('Chyba: neočekávaný počet <td> elementů v řádce polévky (' +
str(len(tds)) + '), očekáváno 3', file=sys.stderr)
sys.exit(1)
amount = tds[0].text.strip()
name = tds[1].text.strip()
price = tds[2].text.strip().replace(',-', '')
foodList.append(Food(name, amount, price, True))
rows = rows[2:]
if rows[0].td.text.strip() == 'Hlavní jídlo':
for i in range(1, len(rows)):
tds = rows[i].find_all("td")
if len(tds) != 3:
print("Neočekávaný počet <td> elementů (" + str(len(tds)
) + ") pro hlavní jídlo " + str(i) + ", přeskakuji")
continue
amount = tds[0].text.strip()
name = tds[1].text.strip()
price = tds[2].text.strip().replace(',-', '')
foodList.append(Food(name, amount, price))
return foodList
def getMenuTechTower() -> List[Food]:
html = getOrDownloadHtml('techtower', URL_TECHTOWER)
soup = BeautifulSoup(html, "html.parser")
fonts = soup.find_all("font", {"class": ["wsw-41"]})
font = None
for f in fonts:
if (f.text.strip().startswith("Obědy")):
font = f
if font is None:
print('Chyba: nenalezen <font> pro obědy v HTML Techtower.', file=sys.stderr)
sys.exit(1)
siblings = font.parent.parent.find_next_siblings("p")
# dayNumber = date.today().strftime("%w")
currentDayName = getDayNameOfDate(datetime.datetime.now())
foodList = []
doParse = False
for i in range(0, len(siblings)):
text = siblings[i].text.strip().replace('\t', '').replace('\n', ' ')
if isNameOfDay(text):
if text == currentDayName:
# Našli jsme dnešní den, odtud začínáme parsovat jídla
doParse = True
elif doParse == True:
# Už parsujeme jídla, ale narazili jsme na následující den - končíme
break
elif doParse:
if len(text.strip()) == 0:
# Prázdná řádka - končíme (je za pátečním menu TechTower)
break
price = '? Kč'
if text.endswith(''):
split = text.rsplit(' ', 2)
price = " ".join(split[1:])
text = split[0]
foodList.append(Food(text, '-', price, isTextSoupName(text)))
return foodList
if __name__ == "__main__":
if len(sys.argv) > 1:
input = sys.argv[1].lower()
selectedDate = None
if input[0].isalpha():
matches = []
for day in DAY_NAMES:
if day.startswith(input):
matches.append(day)
if len(matches) == 1:
print("Match - den v týdnu - " + matches[0])
selectedDate = getStartOfWeekDate(
) + timedelta(DAY_NAMES.index(matches[0]))
elif len(matches) == 0:
# TODO zkusit v, z (včera, zítra)
if 'zítra'.startswith(input):
print("Match - zítra")
selectedDate = datetime.datetime.now() + timedelta(days=1)
elif 'včera'.startswith(input):
print("Match - včera")
selectedDate = datetime.datetime.now() + timedelta(days=-1)
elif 'dneska'.startswith(input):
print("Match - dnes")
selectedDate = datetime.datetime.now()
else:
print('Nejasný parametr "' + input +
'" - může znamenat jednu z možností: ' + ', '.join(matches), file=sys.stderr)
sys.exit(1)
else:
# TODO implementovat zadání datem
print('Zadání datem není aktuálně implementováno', file=sys.stderr)
sys.exit(1)
print("Datum: " + selectedDate.strftime('%d.%m.%Y'))
print("Den: " + getDayNameOfDate(selectedDate))
# printMenu('Sladovnická', getMenuSladovnicka())
# printMenu('U Motlíků', getMenuUMotliku())
# printMenu('TechTower', getMenuTechTower())

View File

@ -0,0 +1,3 @@
beautifulsoup4==4.12.2
fastapi==0.95.2
uvicorn==0.22.0

8
food_api/run_dev.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
dir="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
cd $dir
python3 -m venv venv
source venv/bin/activate
pip3 install -r requirements.txt
uvicorn food_api:app --port 3002 --reload

2
nginx/Dockerfile Normal file
View File

@ -0,0 +1,2 @@
FROM nginx
COPY ./default.conf /etc/nginx/conf.d/default.conf

47
nginx/default.conf Normal file
View File

@ -0,0 +1,47 @@
upstream client {
server client:3000;
}
upstream server {
server server:3001;
}
upstream food_api {
server food_api:3002;
}
server {
listen 80;
location / {
proxy_pass http://client;
}
location /static {
proxy_pass http://client;
}
location /sockjs-node {
proxy_pass http://client;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /socket.io {
proxy_pass http://server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /api/food {
rewrite /api/food(.*) /$1 break;
proxy_pass http://food_api;
}
location /api {
# rewrite /api/(.*) /$1 break;
proxy_pass http://server;
}
}

4
run_dev.sh Executable file
View File

@ -0,0 +1,4 @@
./food_api/run_dev.sh &
cd server && yarn install && yarn start &
cd client && yarn install && yarn start &
wait

1
server/.env.production Normal file
View File

@ -0,0 +1 @@
FOOD_API_URL=http://nginx/api/food

1
server/.gitignore vendored
View File

@ -1 +1,2 @@
/node_modules /node_modules
/dist

15
server/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:alpine
ENV LANG cs_CZ.UTF-8
WORKDIR /app
COPY package.json /app
COPY yarn.lock /app
COPY .env.production /app
RUN yarn install --frozen-lockfile
ADD ./dist /app
CMD [ "node", "/app/index.js" ]

3
server/build.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
yarn install --frozen-lockfile && yarn build
docker build -t luncher-server .

10
server/data.json Normal file
View File

@ -0,0 +1,10 @@
{
"2023-05-30": {
"date": "30.05.2023 (úterý)",
"choices": {}
},
"2023-06-01": {
"date": "01.06.2023 (čtvrtek)",
"choices": {}
}
}

View File

@ -3,10 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.ts", "main": "src/index.ts",
"license": "MIT", "license": "MIT",
"private": true,
"scripts": { "scripts": {
"start": "ts-node src/index.ts" "start": "ts-node src/index.ts",
"build": "tsc -p ."
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.17",
"@types/node": "^20.2.5",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.0.2"
@ -17,6 +21,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise": "^4.2.6", "request-promise": "^4.2.6",
"simple-json-db": "^2.0.0",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
} }
} }

View File

@ -6,7 +6,7 @@ import fs from 'fs';
type PizzaSize = { type PizzaSize = {
size: string, size: string,
pizzaPrice: string, pizzaPrice: number,
boxPrice: number, boxPrice: number,
price: number price: number
} }
@ -21,12 +21,15 @@ type Pizza = {
const baseUrl = 'https://www.pizzachefie.cz'; const baseUrl = 'https://www.pizzachefie.cz';
const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`; const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`;
// URL na Food API - získání jídelních lístků restaurací
const foodUrl = process.env.FOOD_API_URL || 'http://localhost:3002';
const buildPizzaUrl = (pizzaUrl: string) => { const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`; return `${baseUrl}/${pizzaUrl}`;
} }
// Ceny krabic dle velikosti // Ceny krabic dle velikosti
const boxPrices = { const boxPrices: { [key: string]: number } = {
"30cm": 13, "30cm": 13,
"35cm": 15, "35cm": 15,
"40cm": 18, "40cm": 18,
@ -55,19 +58,20 @@ const downloadPizzy = async () => {
// Název // Název
const name = $('.produkt > h2', pizzaHtml).first().text() const name = $('.produkt > h2', pizzaHtml).first().text()
// Přísady // Přísady
const ingredients = [] const ingredients: string[] = []
const ingredientsHtml = $('.prisady > li', pizzaHtml); const ingredientsHtml = $('.prisady > li', pizzaHtml);
ingredientsHtml.each((i, elm) => { ingredientsHtml.each((i, elm) => {
ingredients.push($(elm).text()); ingredients.push($(elm).text());
}) })
// Velikosti // Velikosti
const sizes = []; const sizes: PizzaSize[] = [];
const a = $('.varianty > li > a', pizzaHtml); const a = $('.varianty > li > a', pizzaHtml);
a.each((i, elm) => { a.each((i, elm) => {
const size = $('span', elm).text(); // TODO nedoděláno
const priceKc = $(elm).text().split(size).pop().trim(); // const size = $('span', elm).text();
const price = Number.parseInt(priceKc.split(" Kč")[0]); // const priceKc = $(elm).text().split(size).pop().trim();
sizes.push({ size: size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] }); // const price = Number.parseInt(priceKc.split(" Kč")[0]);
// sizes.push({ size: size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
}) })
result.push({ result.push({
name: name, name: name,
@ -101,6 +105,15 @@ export const fetchPizzy = async () => {
console.log(`Zapsán ${dataPath}`); console.log(`Zapsán ${dataPath}`);
return pizzy; return pizzy;
} }
}
// TODO tohle sem absolutně nepatří! dát do vlastní servisky!
export const fetchFood = async () => {
try {
const json = await rp(foodUrl);
return JSON.parse(json);
} catch (error) {
console.error("Chyba při volání Food API", error);
return {};
}
} }

3
server/src/database.ts Normal file
View File

@ -0,0 +1,3 @@
import JSONdb from 'simple-json-db';
export const db = new JSONdb('./data.json');

View File

@ -1,7 +1,9 @@
import express from "express"; import express from "express";
import { Server } from "socket.io"; import { Server } from "socket.io";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import { fetchPizzy } from "./chefie"; import { fetchFood, fetchPizzy } from "./chefie";
import cors from 'cors';
import { getData, updateChoice } from "./service";
const app = express(); const app = express();
const server = require("http").createServer(app); const server = require("http").createServer(app);
@ -13,12 +15,25 @@ const io = new Server(server, {
// Body-parser middleware for parsing JSON // Body-parser middleware for parsing JSON
app.use(bodyParser.json()); app.use(bodyParser.json());
// app.use(express.json());
const cors = require('cors');
app.use(cors({ app.use(cors({
origin: '*' origin: '*'
})); }));
/** Vrátí data pro aktuální den. */
app.get("/api/data", (req, res) => {
res.status(200).json(getData());
});
/** Vrátí obědové menu pro dostupné podniky. */
app.get("/api/food", (req, res) => {
fetchFood().then(food => {
res.status(200).json(food);
})
});
/** Vrátí seznam dostupných pizz. */
app.get("/api/pizza", (req, res) => { app.get("/api/pizza", (req, res) => {
fetchPizzy().then(pizzaList => { fetchPizzy().then(pizzaList => {
console.log("Výsledek", pizzaList); console.log("Výsledek", pizzaList);
@ -26,6 +41,30 @@ app.get("/api/pizza", (req, res) => {
}); });
}); });
// /** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
// app.post("/api/createPizzaDay", (req, res) => {
// const data = createPizzaDay();
// res.status(200).json(data);
// io.emit("message", data);
// });
// /** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
// app.post("/api/deletePizzaDay", (req, res) => {
// deletePizzaDay();
// io.emit("message", getData());
// });
app.post("/api/updateChoice", (req, res) => {
console.log("Změna výběru", req.body);
if (!req.body.hasOwnProperty('name')) {
res.status(400).json({});
}
const data = updateChoice(req.body.name, req.body.choice);
io.emit("message", data);
res.status(200).json(data);
});
// TODO smazat
app.post("/api/zprava", (req, res) => { app.post("/api/zprava", (req, res) => {
const { username, message } = req.body; const { username, message } = req.body;
@ -49,6 +88,7 @@ io.on("connection", (socket) => {
io.emit("message", message); io.emit("message", message);
}); });
// TODO smazat
socket.on("jduKafe", ({ username, timeString }) => { socket.on("jduKafe", ({ username, timeString }) => {
console.log(`Received message: ${username}`); console.log(`Received message: ${username}`);
socket.broadcast.emit("jduKafe", `${timeString}: ${username} -> jdu Kafe`); socket.broadcast.emit("jduKafe", `${timeString}: ${username} -> jdu Kafe`);
@ -60,7 +100,8 @@ io.on("connection", (socket) => {
}); });
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`); console.log(`Server listening on ${HOST}, port ${PORT}`);
}); });

105
server/src/service.ts Normal file
View File

@ -0,0 +1,105 @@
import { ClientData, Locations } from "./types";
import { db } from "./database";
import { getTodayString } from "./utils";
import { getDate } from "./utils";
// /** Jedna konkrétní pizza */
// interface Pizza {
// name: string, // název pizzy
// size: number, // velikost pizzy v cm
// price: number, // cena pizzy v Kč, včetně krabice
// }
// /** Objednávka jednoho člověka */
// 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
// }
// /** Stav pizza dne. */
// enum State {
// NOT_CREATED, // Pizza day nebyl založen
// CREATED, // Pizza day je založen
// LOCKED // Objednávky uzamčeny
// }
// /** Veškerá data pro zobrazení na klientovi */
// interface ClientData {
// date: string, // dnešní datum pro zobrazení
// state: State, // stav pizza dne
// orders?: Order[], // seznam objednávek, pokud není vyplněno, není založen pizza day
// }
/** Vrátí "prázdná" (implicitní) data, pokud ještě nikdo nehlasoval. */
function getEmptyData(): ClientData {
return { date: getTodayString(), choices: {} };
}
/**
* Vrátí veškerá klientská data pro aktuální den.
*/
export function getData(): ClientData {
const data = db.get(getDate()) || getEmptyData();
console.log("Vracím data pro dnešní den", data); // TODO smazat
return data;
}
// /**
// * Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
// */
// export function createPizzaDay(): ClientData {
// const today = getDate();
// if (db.has(today)) {
// throw Error("Pizza day pro dnešní den již existuje");
// }
// const data = { date: getTodayString(), state: State.CREATED, orders: [] };
// db.set(today, data);
// return data;
// }
// /**
// * Smaže pizza day pro aktuální den.
// */
// export function deletePizzaDay() {
// const today = getDate();
// if (!db.has(today)) {
// throw Error("Pizza day pro dnešní den neexistuje");
// }
// db.delete(today);
// }
export function initIfNeeded() {
const today = getDate();
if (!db.has(today)) {
db.set(today, getEmptyData());
}
}
export function removeChoice(login: string, data: ClientData) {
for (let key of Object.keys(data.choices)) {
if (data.choices[key] && data.choices[key].includes(login)) {
const index = data.choices[key].indexOf(login);
data.choices[key].splice(index, 1);
if (data.choices[key].length == 0) {
delete data.choices[key];
}
}
}
return data;
}
export function updateChoice(login: string, choice: Locations | null) {
initIfNeeded();
const today = getDate();
let data: ClientData = db.get(today);
data = removeChoice(login, data);
if (choice !== null) {
if (!data.choices?.[choice]) {
data.choices[choice] = [];
}
data.choices[choice].push(login);
}
db.set(today, data);
return data;
}

18
server/src/types.ts Normal file
View File

@ -0,0 +1,18 @@
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',
}

17
server/src/utils.ts Normal file
View File

@ -0,0 +1,17 @@
export function getDate() {
const date = new Date();
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentYear}-${currentMonth}-${currentDay}`;
}
/** Vrátí human-readable reprezentaci dnešního data pro zobrazení. */
export function getTodayString() {
const date = new Date();
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
let currentDayOfWeek = date.toLocaleDateString("CZ-cs", { weekday: 'long' });
return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`;
}

View File

@ -2,7 +2,11 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2016", "target": "ES2016",
"module": "CommonJS", "module": "CommonJS",
"jsx": "react",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +0,0 @@
type Pizza = {
name: string;
// TODO ingredience
sizes: [
size: number,
price: number,
];
}
const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://localhost:3001';
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
console.log("Calling fetch on", getBaseUrl() + url);
return fetch(getBaseUrl() + url, config).then(response => {
if (!response.ok) {
console.log("Response", response.status)
throw new Error(response.statusText);
}
console.log("response", response);
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 }),
}
export const getPizzy = async () => {
return await api.get<any>('/api/pizza');
}

View File

@ -1,26 +0,0 @@
import React, { useEffect, useState } from 'react';
import './App.css';
import 'bootstrap/dist/css/bootstrap.min.css';
import { Button } from 'react-bootstrap';
import { getPizzy } from './Api';
function App() {
const [pizzy, setPizzy] = useState();
useEffect(() => {
getPizzy().then(pizzy => {
setPizzy(pizzy);
});
}, []);
return (
<div>
<Button onClick={async () => {
const pizzy = await getPizzy();
console.log("Výsledek", pizzy);
}}>Získat pizzy</Button>
</div>
);
}
export default App;

View File

@ -1,19 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';