Task 3: React Frontend

This commit is contained in:
RobinB27
2025-11-15 22:41:26 +01:00
parent ebea7897f2
commit 3a61b72642
30 changed files with 820 additions and 0 deletions

25
task_3_react/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
public
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,73 @@
stages:
- lint
- build
- deploy
workflow:
rules:
- if: $CI_COMMIT_BRANCH =~ /^(master|main)$/
- if: $FORCE_DEPLOY
when: always
- when: never
variables:
IMAGE_NAME: $CI_REGISTRY/$CI_PROJECT_PATH
BASE_URI: "a3.webdev-25.ivia.isginf.ch"
FORCE_DEPLOY:
value: "false"
description: "Force deploy the application"
options:
- "true"
- "false"
default:
before_script:
- IMAGE_NAME=$(echo $IMAGE_NAME | tr '[:upper:]' '[:lower:]')
- URI_NAME=$(echo $CI_PROJECT_NAME | tr '[:upper:]' '[:lower:]' | tr _ -)
lint_helm:
stage: lint
image: matthiasgabathuler/my-runner:ubuntu-20.04
script:
- >-
helm lint ${CI_PROJECT_DIR}/helm
--set image.name=${IMAGE_NAME}
--set image.tag=${CI_COMMIT_REF_NAME}
--set build.job_id=${CI_JOB_ID}
--set build.commit=${CI_COMMIT_SHA}
build_webapp:
stage: build
rules:
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- echo "${DOCKER_REGISTRY_AUTH}" > /kaniko/.docker/config.json
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${IMAGE_NAME}-webapp:latest"
deploy_app:
stage: deploy
rules:
image:
name: alpine/helm:3.11.1
entrypoint: [""]
script:
- >-
helm --namespace $K8S_NAMESPACE
--kube-context $K8S_CONTEXT
upgrade a3-$(echo ${CI_PROJECT_NAME} | tr _ -) ${CI_PROJECT_DIR}/helm
--install
--history-max 5
--set image.host=${CI_REGISTRY}
--set image.name=${IMAGE_NAME}
--set image.tag=latest
--set url.hostname.uri=${URI_NAME}
--set url.hostname.base_uri=${BASE_URI}
--set build.job_id=${CI_JOB_ID}
--set build.commit=${CI_COMMIT_SHA}
- >-
echo "webapp URL: http://${URI_NAME}.${BASE_URI}"

8
task_3_react/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node
WORKDIR /app
COPY package.json .
RUN npm i
COPY . .
RUN npm run build
EXPOSE 5173
CMD ["npm", "run", "start"]

29
task_3_react/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Local Development
Only change files inside the `src` directory.
## Client side
All client side files are located in the `src/client` directory.
## Server side
All server side files are located in the `src/server` directory.
# Local Testing
## run container for local testing
```bash
docker build -t my-webapp .
docker run -it --rm -p 5173:5173 my-webapp
```
Open a browser and connect to http://localhost:5173
## run bash in interactive container
```bash
docker build -t my-webapp src/.
docker run -it --rm -p 5173:5173 my-webapp bash
```

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: a3-group-g3-rbacher-jahutz #name of the app, this should be the project name, spaces should be substituted with -
description: My Example App #description for the app
type: application
version: 0.0.1 #for versioning of the helm chart, increase it when new release is built/deployed
appVersion: "0.0.1"

View File

View File

View File

View File

@@ -0,0 +1,56 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-webapp
spec:
selector:
matchLabels:
app: {{ .Release.Name }}-webapp
replicas: 1
template:
metadata:
labels:
app: {{ .Release.Name }}-webapp
annotations:
rollme: {{ randAlphaNum 5 | quote }}
spec:
imagePullSecrets:
- name: regcred
containers:
- name: {{ .Release.Name }}-webapp
image: {{ .Values.image.name }}-webapp:{{ .Values.image.tag }}
imagePullPolicy: Always
ports:
- containerPort: 5173
resources:
limits:
memory: "250M"
requests:
memory: "100M"
commnand: ["npm", "run", "dev"]
volumeMounts:
- name: config-volume
mountPath: /app/vite.config.js
subPath: vite.config.js
volumes:
- name: config-volume
configMap:
name: {{ .Release.Name }}-webapp-config
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-webapp-config
data:
vite.config.js: |
export default {
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: true,
watch: {
usePolling: true,
interval: 1000,
},
},
}

View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
annotations:
nginx.ingress.kubernetes.io/enable-cors: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- {{ .Values.url.hostname.uri }}.{{ .Values.url.hostname.base_uri }}
secretName: {{ .Release.Name }}-tls
rules:
- host: {{ .Values.url.hostname.uri }}.{{ .Values.url.hostname.base_uri }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-webapp
port:
number: 80

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-webapp
spec:
ports:
- port: 80
targetPort: 5173
selector:
app: {{ .Release.Name }}-webapp

View File

@@ -0,0 +1,10 @@
image:
name: ""
tag: ""
build:
job_id: ""
commit: ""
url:
hostname:
base_uri: "webdev-25.ivia.isginf.ch"
uri: ""

12
task_3_react/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Open data explorer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

35
task_3_react/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "nodemon src/server/main.ts -w src/server",
"build": "vite build",
"start": "NODE_ENV=production ts-node --transpile-only src/server/main.ts",
"format": "prettier . --write",
"tsc": "tsc"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0",
"@picocss/pico": "^2.0.6",
"express": "^4.21.1",
"json-2-csv": "^5.5.6",
"multer": "^1.4.5-lts.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"ts-node": "^10.9.2",
"typescript": "^5.6.3",
"vite-express": "*"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"vite": "^5.4.9"
}
}

View File

@@ -0,0 +1,138 @@
/*some style for app component*/
:root {
--spacing: 0.25rem;
--border-color: #a0a0a0;
}
/* Style for definition list */
dl {
margin-top: 0;
margin-bottom: 20px;
}
dt,
dd {
line-height: 1.42857143;
}
dt {
font-weight: 700;
}
dd {
margin-left: 0;
}
body h1,
body h2 {
margin-bottom: 0;
}
nav {
border-bottom: 1px solid var(--border-color);
}
article {
width: 400px;
}
body>main {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding: var(--spacing) !important;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: start;
gap: var(--spacing);
& article {
margin: 0;
padding: var(--spacing);
&>header {
margin: calc(var(--spacing) * -1) calc(var(--spacing) * -1) var(--spacing);
padding: var(--spacing);
}
}
}
.info {
h4 {
margin-bottom: 2px;
margin-top: 10px;
}
p {
margin: 0;
}
}
.table-container {
width: 100%;
}
.table-scroll-wrapper {
width: 100%;
max-height: 70vh;
overflow: scroll;
}
/* Set a fixed scrollable wrapper */
#table-content {
max-height: 400px;
overflow: auto;
/* Set header to stick to the top of the container. */
& thead tr th {
position: sticky;
top: 0;
}
/* If we use border, we must use table-collapse to avoid a slight movement of the header row */
& table {
border-collapse: collapse;
border-top: 0;
}
/* Because we must set sticky on th, we have to apply background styles here rather than on thead */
& th {
cursor: pointer;
border-left: 1px dotted rgba(200, 209, 224, 0.6);
border-bottom: 1px solid #ddd;
background: #eee;
text-align: left;
box-shadow: 0px 0px 0 2px #e8e8e8;
&.active {
background: #ddd;
}
&.sortable::after {
font-family: FontAwesome;
content: "\f0dc";
position: absolute;
right: 8px;
color: #999;
}
&.active::after {
position: absolute;
right: 8px;
color: #999;
}
&.active.sorting.asc::after {
font-family: FontAwesome;
content: "\f0d8";
}
&.active.sorting.desc::after {
font-family: FontAwesome;
content: "\f0d7";
}
}
}

View File

@@ -0,0 +1,47 @@
import "./App.css";
import '@fortawesome/fontawesome-free/css/all.css';
import { readCSV } from './csv';
import { CSV_Data, fileInfo } from './types';
import React, { useState } from "react";
import Layout from "./Layout";
import CSVCard from "./components/CSVCard";
import InfoCard from "./components/InfoCard";
import DataTable from "./components/DataTable";
function App() {
const [data, setData] = useState([] as CSV_Data);
const [info, setInfo] = useState({
filename: "None",
filetype: "None",
filesize: "None",
rowcount: 0
});
// This is triggered in CSVCard
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0];
if (!file) throw new Error("No file received");
const data = await readCSV(e);
const newFileInfo: fileInfo = {
filename: file.name,
filetype: file.type,
filesize: String(file.size) + "B",
rowcount: data.length
}
setInfo(newFileInfo);
setData(data);
}
return (
<Layout>
<CSVCard handleChange={handleFileChange}></CSVCard>
<InfoCard info={info}></InfoCard>
<DataTable data={data}></DataTable>
</Layout>
);
}
export default App;

View File

@@ -0,0 +1,29 @@
body h1,
body h2 {
margin-bottom: 0;
}
nav {
border-bottom: 1px solid var(--border-color);
}
body main {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding: var(--spacing) !important;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: start;
gap: var(--spacing);
& article {
margin: 0;
padding: var(--spacing);
& > header {
margin: calc(var(--spacing) * -1) calc(var(--spacing) * -1) var(--spacing);
padding: var(--spacing);
}
}
}

View File

@@ -0,0 +1,21 @@
import React from "react";
import "./Layout.css";
const Layout = (props: { children: React.ReactNode }) => {
return (
<>
<nav className="container-fluid">
<ul>
<li>
<h1>Open data explorer</h1>
</li>
</ul>
</nav>
<main className="container-fluid">
{props.children}
</main>
</>
);
};
export default Layout;

View File

@@ -0,0 +1,23 @@
import React from "react";
import "../Layout.css";
const CSVCard = (props: {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) => {
return (
<article>
<header>
<h2>Select CSV data</h2>
</header>
<form>
<label htmlFor="file-input" className="custom-file-upload">
<i className="fa fa-file-csv"></i> Select CSV file to explore
</label>
<input id="file-input" type="file" aria-describedby="fileHelp" accept="text/csv" onChange={props.handleChange}/>
<small>Please upload a CSV file, where the first row is the header.</small>
</form>
</article>
);
}
export default CSVCard;

View File

@@ -0,0 +1,84 @@
import { Key, SetStateAction, useState } from "react";
import { CSV_Data } from "../types";
const DataTable = (props: {data: CSV_Data}) => {
if (props.data.length == 0) return <></>;
const header = Object.keys(props.data[0]!);
const [sortCol, setSortCol] = useState("None");
const [sortType, setSortType] = useState("asc");
const sortingHandler = (col: String) => {
if (sortCol !== col) {
setSortCol(col as SetStateAction<string>);
setSortType("asc");
} else if (sortType === "asc") {
setSortType("desc");
} else {
setSortCol("None");
setSortType("None");
}
}
if (sortCol !== "None" && sortType === "asc") {
props.data.sort( (a, b) => {
if (a[sortCol]! < b[sortCol]!) return -1;
if (a[sortCol]! > b[sortCol]!) return 1;
return 0;
});
} else if (sortCol !== "None" && sortType === "desc") {
props.data.sort( (a, b) => {
if (a[sortCol]! > b[sortCol]!) return -1;
if (a[sortCol]! < b[sortCol]!) return 1;
return 0;
});
}
return (
<table id="table-content">
<thead>
<tr>
{
header.map( (col) => (
<ColHeader col={col} sortingHandle={sortingHandler} isSelected={col == sortCol} sortType={sortType}></ColHeader>
))
}
</tr>
</thead>
<tbody>
{
props.data.map( (row, i) => (
<tr key={i}>
{
header.map( (col) => (
<Row col={col} content={row[col] as String}></Row>
))
}
</tr>
))
}
</tbody>
</table>
)
}
const ColHeader = (props: {col: String, sortingHandle: (s: String) => void, isSelected: boolean, sortType: String}) => {
return (
<th
className={
props.isSelected
? (props.sortType === "asc" ? "active sorting asc" : "active sorting desc")
: "sortable"
}
onClick={() => {props.sortingHandle!(props.col)}}
key={props.col as Key}>
{props.col}
</th>
);
}
const Row = (props: {col: String, content: String}) => {
return <td key={props.col as Key}>{props.content}</td>;
}
export default DataTable;

View File

@@ -0,0 +1,35 @@
import "../Layout.css";
import { fileInfo } from "../types";
const InfoCard = (props: {
info: fileInfo
}) => {
let noFileMessage = <div></div>
if (props.info.filename === "None")
noFileMessage = <div id="data-info-placeholder">No file selected</div>;
return (
<article>
<header>
<h2>Data infos</h2>
</header>
<div className="info">
<h4>Filename</h4>
<p>{props.info.filename}</p>
<h4>File type</h4>
<p>{props.info.filetype}</p>
<h4>File size</h4>
<p>{props.info.filesize}</p>
<h4>Number of rows</h4>
<p>{props.info.rowcount}</p>
</div>
{noFileMessage}
</article>
);
}
export default InfoCard;

View File

@@ -0,0 +1,30 @@
import { CSV_Data } from './types';
import { csv2json } from 'json-2-csv';
const convertCSVtoJSON = async ( csvText: string ) => {
// Type cast OK, as the typing of the external library is not perfect -> Actually it is.
// NOTE: On transpilation to JS, it will be (more or less) disregarded anyway.
// If you claim it isn't good typing, it's the same as expecting it to guess the typing,
// which is literally impossible in TS. But sure...
return ( await csv2json( csvText ) ) as CSV_Data;
};
/**
* Reads a CSV file and returns the data as JSON.
* @param event The change event of the file input.
*/
export const readCSV = async ( event: React.ChangeEvent<HTMLInputElement> ): Promise<CSV_Data> => {
if ( !( event.target instanceof HTMLInputElement ) ) {
throw new Error( 'Not an HTMLInputElement' );
}
const file = event.target.files?.[0];
if ( file == null ) {
throw new Error( 'No file selected' );
}
const result = await file.text();
return await convertCSVtoJSON( result );
};

View File

@@ -0,0 +1,20 @@
:root {
--spacing: 1rem;
--border-color: #a0a0a0;
}
/* Style for definition list */
dl {
margin-top: 0;
margin-bottom: 20px;
}
dt,
dd {
line-height: 1.42857143;
}
dt {
font-weight: 700;
}
dd {
margin-left: 0;
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.css";
import App from "./App";
import "@picocss/pico/css/pico.min.css";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

11
task_3_react/src/client/types.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
export type CSVRecord = Record<string, unknown>;
export type CSV_Data = CSVRecord[];
// InfoCard receives this via props
export type fileInfo = {
filename: string;
filetype: string;
filesize: string;
rowcount: number;
}

1
task_3_react/src/client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,17 @@
import express from "express";
import ViteExpress from "vite-express";
// creates the expres app do not change
const app = express();
// add your routes here
// example route which returns a message
app.get("/hello", async function (_req, res) {
res.status(200).json({ message: "Hello World!" });
});
// Do not change below this line
ViteExpress.listen(app, 5173, () =>
console.log("Server is listening on http://localhost:5173"),
);

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "CommonJS",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["tests/**/*", "public/**/*", "build/**/*"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});