Compare commits

...

5 Commits

Author SHA1 Message Date
RobinB27
7e336dfef0 [Task 3] remove data 2025-11-16 21:18:26 +01:00
RobinB27
6fd4db5520 [Task 3] SSE complete 2025-11-16 21:17:12 +01:00
2d8ac9a33e [Task 3] Fix SSE 2025-11-16 20:07:47 +01:00
0429e3057f [Task 3] SSE 2025-11-16 20:00:33 +01:00
13cc143888 [Task 3] Format 2025-11-16 19:25:55 +01:00
11 changed files with 487 additions and 6712 deletions

View File

@@ -30,6 +30,6 @@
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"vite": "^5.4.9" "vite": "^7.2.2"
} }
} }

View File

@@ -1,83 +1,125 @@
import "./css/App.css"; import './css/App.css';
import '@fortawesome/fontawesome-free/css/all.css'; import '@fortawesome/fontawesome-free/css/all.css';
import { CSV_Data, fileInfo, responseObject } from './types'; import {
import { readCSV, convertCSVtoJSON } from './csv'; CSV_Data, fileInfo, responseObject
} from './types';
import React, {
useEffect,
useRef, useState
} from 'react';
import {
convertCSVtoJSON, readCSV
} from './csv';
import CSVCard from './components/CSVCard';
import DataTable from './components/DataTable';
import FileCard from './components/FileCard';
import InfoCard from './components/InfoCard';
import Layout from './components/Layout';
import "./sse"
import React, { useState, useRef, useEffect } from "react"; function App () {
import Layout from "./components/Layout"; const [
import CSVCard from "./components/CSVCard"; data,
import InfoCard from "./components/InfoCard"; setData
import DataTable from "./components/DataTable"; ] = useState( [] as CSV_Data );
import FileCard from "./components/FileCard"; const [
info,
setInfo
] = useState( {
'filename': 'None',
'filetype': 'None',
'filesize': 'None',
'rowcount': 0
} );
const [
fileList,
setFileList
] = useState( null as responseObject | null );
function App() { // Add evenbt listener for server-sent events on first render
const [data, setData] = useState([] as CSV_Data); const [active, setActive] = useState(false);
const [info, setInfo] = useState({
filename: "None",
filetype: "None",
filesize: "None",
rowcount: 0
});
const [fileList, setFileList] = useState(null as responseObject | null); useEffect( ()=> {
// Effect has to be in top level of the component if (!active) {
useEffect(() => { document.addEventListener("sse:uploaded", async _ev => {
fetch("/status", { method: "GET" }) await fetch( '/status', {
.then((response) => response.json()) 'method': 'GET'
.then((response) => setFileList(response)) } )
.catch((error) => console.log(error)); .then( response => response.json() )
}); .then( response => setFileList( response ) )
.catch( error => console.log( error ) );
});
document.addEventListener("sse:deleted", async _ev => {
await fetch( '/status', {
'method': 'GET'
} )
.then( response => response.json() )
.then( response => setFileList( response ) )
.catch( error => console.log( error ) );
});
setActive(true);
// Initial fetch of file list at first component render
fetch( '/status', {
'method': 'GET'
} )
.then( response => response.json() )
.then( response => setFileList( response ) )
.catch( error => console.log( error ) );
}
})
const formRef = useRef(null); const formRef = useRef( null );
// This is triggered in CSVCard // This is triggered in CSVCard
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => { const handleFileUpload = async ( e: React.ChangeEvent<HTMLInputElement> ): Promise<void> => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) throw new Error("No file received");
const data = await readCSV(e); if ( !file ) throw new Error( 'No file received' );
const newFileInfo: fileInfo = { const data = await readCSV( e );
filename: file.name, const newFileInfo: fileInfo = {
filetype: ".csv", // file.type delivers weird name 'filename': file.name,
filesize: String(file.size) + "B", 'filetype': '.csv', // file.type delivers weird name
rowcount: data.length 'filesize': String( file.size ) + 'B',
} 'rowcount': data.length
setInfo(newFileInfo); };
setData(data);
if (formRef.current) { setInfo( newFileInfo );
// Upload to server setData( data );
const formData = new FormData(formRef.current);
await fetch("/upload", {
method: "POST",
body: formData
});
}
}
const handleFileChange = async (fileName: string) => { if ( formRef.current ) {
const response = await fetch(`/download/${fileName}`); // Upload to server
const blob = await response.blob(); const formData = new FormData( formRef.current );
const text = await blob.text();
if (!response) throw new Error("No file received"); await fetch( '/upload', {
'method': 'POST',
'body': formData
} );
}
};
const data = await convertCSVtoJSON(text); const handleFileChange = async ( fileName: string ) => {
const response = await fetch( `/download/${ fileName }` );
const blob = await response.blob();
const text = await blob.text();
// Updating fileInfo requires more effort since blob doesn't have the metadata if ( !response ) throw new Error( 'No file received' );
setData(data); const data = await convertCSVtoJSON( text );
}
return ( // Updating fileInfo requires more effort since blob doesn't have the metadata
<Layout>
<CSVCard handleChange={handleFileUpload} formRef={formRef}></CSVCard> setData( data );
<FileCard fileList={fileList as responseObject} fileChangeHandle={handleFileChange}></FileCard> };
<InfoCard info={info}></InfoCard>
<DataTable data={data}></DataTable> return (
</Layout> <Layout>
); <CSVCard handleChange={handleFileUpload} formRef={formRef}></CSVCard>
<FileCard fileList={fileList as responseObject} fileChangeHandle={handleFileChange}></FileCard>
<InfoCard info={info}></InfoCard>
<DataTable data={data}></DataTable>
</Layout>
);
} }
export default App; export default App;

View File

@@ -1,24 +1,29 @@
import React from "react"; import '../css/Layout.css';
import "../css/Layout.css"; import React from 'react';
const CSVCard = (props: { const CSVCard = ( props: {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void, 'handleChange': ( e: React.ChangeEvent<HTMLInputElement> ) => void,
formRef: React.RefObject<HTMLFormElement> 'formRef': React.RefObject<HTMLFormElement>
}) => { } ) => {
return ( return (
<article> <article>
<header> <header>
<h2>Upload CSV data</h2> <h2>Upload CSV data</h2>
</header> </header>
<form ref={props.formRef} action="/upload" method="post" encType="multipart/form-data" > <form ref={props.formRef} action="/upload" method="post" encType="multipart/form-data" >
<label htmlFor="file-input" className="custom-file-upload"> <label htmlFor="file-input" className="custom-file-upload">
<i className="fa fa-file-csv"></i> Select CSV file to explore <i className="fa fa-file-csv"></i> Select CSV file to explore
</label> </label>
<input id="file-input" type="file" name="dataFile" aria-describedby="fileHelp" accept="text/csv" onChange={props.handleChange}/> <input
<small>Please upload a CSV file, where the first row is the header.</small> id="file-input"
</form> type="file"
</article> name="dataFile"
); 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; export default CSVCard;

View File

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

View File

@@ -1,58 +1,65 @@
import { responseObject } from "../types"; import {
responseObject
} from '../types';
const FileCard = (props: { const FileCard = ( props: {
fileList: responseObject, 'fileList': responseObject,
fileChangeHandle: (fileName: string) => Promise<void> 'fileChangeHandle': ( fileName: string ) => Promise<void>
}) => { } ) => {
const convert = ( res: responseObject ) => {
const list = [];
const convert = (res: responseObject) => { for ( let i = 0; i < res.names.length; i++ ) {
let list = [];
for (let i = 0; i < res.names.length; i++) {
const elem = { const elem = {
filename: res.names[i], 'filename': res.names[i],
uploadTime: res.uploadTimes[i] 'uploadTime': res.uploadTimes[i]
} };
list.push(elem);
}
return list;
}
const list = props.fileList != null ? convert(props.fileList) : null; list.push( elem );
}
return list;
};
const list = props.fileList != null ? convert( props.fileList ) : null;
return ( return (
<article className="wide"> <article className="wide">
<header> <header>
<h2>Select a File</h2> <h2>Select a File</h2>
</header> </header>
<table id="table-content"> <table id="table-content">
<thead> <thead>
<tr> <tr>
<th>Filename</th> <th>Filename</th>
<th>Upload Time</th> <th>Upload Time</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {
list ? list.map( (file, i) => ( list ? list.map( ( file, i ) => <FileRow
<FileRow key={i} filename={file.filename!} uploadTime={file.uploadTime!} fileChangeHandle={props.fileChangeHandle}></FileRow> key={i}
)) : <tr></tr> filename={file.filename!}
} uploadTime={file.uploadTime!}
</tbody> fileChangeHandle={props.fileChangeHandle}/> ) : <tr></tr>
</table> }
</tbody>
</table>
</article> </article>
); );
} };
const FileRow = (props: { const FileRow = ( props: {
filename: string, 'filename': string,
uploadTime: string, 'uploadTime': string,
fileChangeHandle: (fileName: string) => Promise<void> 'fileChangeHandle': ( fileName: string ) => Promise<void>
}) => { } ) => {
const rmFile = async () => {
const remFile = async () => { await fetch( `/delete/${ props.filename }`, {
await fetch(`/delete/${props.filename}`, { method: "DELETE" }); 'method': 'DELETE'
} } );
};
return ( return (
<tr> <tr>
@@ -60,12 +67,17 @@ const FileRow = (props: {
<td>{props.uploadTime}</td> <td>{props.uploadTime}</td>
<td> <td>
<div className="action-icons"> <div className="action-icons">
<i onClick={() => { remFile()} } className="fa-solid fa-trash-can"></i> <i onClick={() => {
<i onClick={() => {props.fileChangeHandle(props.filename)}} className="fa-solid fa-file-arrow-down"></i> rmFile();
} } className="fa-solid fa-trash-can"></i>
<i onClick={() => {
props.fileChangeHandle( props.filename );
}} className="fa-solid fa-file-arrow-down"></i>
</div> </div>
</td> </td>
</tr> </tr>
) );
} };
export default FileCard; export default FileCard;

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
import { CSV_Data } from './types'; import {
import { csv2json } from 'json-2-csv'; CSV_Data
} from './types';
import {
csv2json
} from 'json-2-csv';
export const convertCSVtoJSON = async ( csvText: string ) => { export const convertCSVtoJSON = async ( csvText: string ) => {
// Type cast OK, as the typing of the external library is not perfect -> Actually it is. // Type cast OK, as the typing of the external library is not perfect -> Actually it is.

View File

@@ -0,0 +1,21 @@
interface SSEMessage {
'event': string,
'data': string
}
const eventSource: EventSource = new EventSource( '/sse' );
eventSource.onopen = () => {
document.dispatchEvent( new CustomEvent( 'sse:connect', {
'detail': 'success',
'cancelable': false
} ) );
};
eventSource.onmessage = event => {
const data: SSEMessage = JSON.parse( event.data );
document.dispatchEvent( new CustomEvent( 'sse:' + data.event, {
'cancelable': false,
'detail': data.data
} ) );
};

View File

@@ -1,81 +1,160 @@
import express from "express"; import * as fs from 'node:fs/promises';
import ViteExpress from "vite-express"; import {
import multer from "multer"; EventEmitter
import * as fs from "node:fs/promises"; } from 'node:stream';
import { responseObject } from "./types"; import ViteExpress from 'vite-express';
import path from "path"; import express from 'express';
import multer from 'multer';
import path from 'path';
import {
responseObject
} from './types';
const app = express(); const app = express();
// Set up file storage // Set up file storage
const storage = multer.diskStorage({ const storage = multer.diskStorage( {
destination: "./src/server/uploads", 'destination': './src/server/uploads',
filename: (_req, file, cb) => { 'filename': (
_req, file, cb
) => {
// Suggested in Multer's readme // Suggested in Multer's readme
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1E3); const uniqueSuffix = Date.now() + '-' + Math.round( Math.random() * 1E3 );
cb(null, file.fieldname + "-" + uniqueSuffix);
}
});
fileEvent.emit( 'uploaded', file.fieldname + '-' + uniqueSuffix );
cb( null, file.fieldname + '-' + uniqueSuffix );
}
} );
// CSV file upload endpoint // CSV file upload endpoint
const upload = multer({ storage: storage }); const upload = multer( {
'storage': storage
} );
class FileEvent extends EventEmitter {}
const fileEvent = new FileEvent();
app.post( app.post(
"/upload", '/upload',
upload.single("dataFile"), upload.single( 'dataFile' ),
(req, res, next) => { (
console.log(req, res, next) req, res, next
} ) => {
console.log(
req, res, next
);
}
); );
// Endpoint to send back file names/upload times // Endpoint to send back file names/upload times
app.get("/status", async (_req, res) => { app.get( '/status', async ( _req, res ) => {
const resObject: responseObject = { const resObject: responseObject = {
names: [], 'names': [],
uploadTimes: [] 'uploadTimes': []
}; };
const dir = await fs.opendir( './src/server/uploads/' );
const dir = await fs.opendir("./src/server/uploads/"); for await ( const file of dir ) {
for await (const file of dir) { resObject.names.push( file.name );
resObject.names.push(file.name); const stats = await fs.stat( `./src/server/uploads/${ file.name }` );
const stats = await fs.stat(`./src/server/uploads/${file.name}`);
resObject.uploadTimes.push(stats.birthtime.toString());
}
res.status(200).json(resObject); resObject.uploadTimes.push( stats.birthtime.toString() );
}) }
res.status( 200 ).json( resObject );
} );
// Endpoint to send back whole files // Endpoint to send back whole files
app.get("/download/:fileName", (req, res) => { app.get( '/download/:fileName', ( req, res ) => {
const fileName = req.params.fileName; const fileName = req.params.fileName;
const filePath = path.join(__dirname, "uploads", fileName); // Filepaths must be absolute const filePath = path.join(
res.sendFile(filePath, err => { __dirname, 'uploads', fileName
if (err) { ); // Filepaths must be absolute
console.error("Error sending file:", err);
res.status(500).send("Error downloading file"); res.sendFile( filePath, err => {
} if ( err ) {
}); console.error( 'Error sending file:', err );
}) res.status( 500 ).send( 'Error downloading file' );
}
} );
} );
// Endpoint to remove files from server // Endpoint to remove files from server
app.delete("/delete/:fileName", async (req, res) => { app.delete( '/delete/:fileName', async ( req, res ) => {
const fileName = req.params.fileName; const fileName = req.params.fileName;
const filePath = path.join(__dirname, "uploads", fileName); const filePath = path.join(
try { __dirname, 'uploads', fileName
await fs.unlink(filePath); // deletes the file );
res.status(200).send("File deleted successfully");
} catch (error) {
console.error("Error deleting file:", error);
res.status(500).send("Error deleting file");
}
});
try {
await fs.rm( filePath ); // deletes the file
res.status( 200 ).send( 'File deleted successfully' );
fileEvent.emit( 'deleted', filePath );
} catch ( error ) {
console.error( 'Error deleting file:', error );
res.status( 500 ).send( 'Error deleting file' );
}
} );
// example route which returns a message // example route which returns a message
app.get("/hello", async function (_req, res) { app.get( '/hello', async function ( _req, res ) {
res.status(200).json({ message: "Hello World!" }); res.status( 200 ).json( {
}); 'message': 'Hello World!'
} );
} );
interface SSESubscriber {
'uuid': string;
'response': express.Response;
}
interface SSESubscribers {
[id: string]: SSESubscriber | undefined;
}
const subscribers: SSESubscribers = {};
app.get( '/sse', async ( request: express.Request, response: express.Response ) => {
response.writeHead( 200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
} );
response.status( 200 );
response.flushHeaders();
response.write( `data: ${ JSON.stringify( [] ) }\n\n` );
const uuid = crypto.randomUUID();
subscribers[uuid] = {
'uuid': uuid,
'response': response
};
request.on( 'close', () => {
subscribers[ uuid ] = undefined;
} );
} );
const sendSSEData = ( event: string, data: string ) => {
const subs = Object.values( subscribers );
for ( let i = 0; i < subs.length; i++ ) {
subs[i]!.response.write( `data: ${ JSON.stringify( {
'event': event,
'data': data
} ) }\n\n` );
}
};
fileEvent.on( 'uploaded', file => {
sendSSEData( 'uploaded', file );
} );
fileEvent.on( 'deleted', file => {
sendSSEData( 'deleted', file );
} );
// Do not change below this line // Do not change below this line
ViteExpress.listen(app, 5173, () => ViteExpress.listen(
console.log("Server is listening on http://localhost:5173"), app, 5173, () => console.log( 'Server is listening on http://localhost:5173' ),
); );

File diff suppressed because it is too large Load Diff