mirror of
https://github.com/janishutz/fundamentals-of-webengineering.git
synced 2025-11-25 13:54:25 +00:00
Compare commits
10 Commits
5fa2b1f618
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 88127c6107 | |||
| fd8ec668b4 | |||
| 157603d3d7 | |||
| 5916cce77f | |||
|
|
5525c4b4ad | ||
|
|
7e336dfef0 | ||
|
|
6fd4db5520 | ||
| 2d8ac9a33e | |||
| 0429e3057f | |||
| 13cc143888 |
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,142 @@
|
|||||||
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 './sse';
|
||||||
import { readCSV, convertCSVtoJSON } from './csv';
|
import {
|
||||||
|
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 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 );
|
||||||
|
// For the loading spinner in DataTable
|
||||||
|
const [
|
||||||
|
loading,
|
||||||
|
setLoading
|
||||||
|
] = useState( false );
|
||||||
|
// Add evenbt listener for server-sent events on first render
|
||||||
|
const [
|
||||||
|
active,
|
||||||
|
setActive
|
||||||
|
] = useState( false );
|
||||||
|
|
||||||
function App() {
|
useEffect( () => {
|
||||||
const [data, setData] = useState([] as CSV_Data);
|
if ( !active ) {
|
||||||
const [info, setInfo] = useState({
|
document.addEventListener( 'sse:uploaded', () => {
|
||||||
filename: "None",
|
fetch( '/status', {
|
||||||
filetype: "None",
|
'method': 'GET'
|
||||||
filesize: "None",
|
} )
|
||||||
rowcount: 0
|
.then( response => response.json() )
|
||||||
});
|
.then( response => setFileList( response ) )
|
||||||
|
.catch( error => console.log( error ) );
|
||||||
|
} );
|
||||||
|
document.addEventListener( 'sse:deleted', () => {
|
||||||
|
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 [fileList, setFileList] = useState(null as responseObject | null);
|
const formRef = useRef( null );
|
||||||
// Effect has to be in top level of the component
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/status", { method: "GET" })
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((response) => setFileList(response))
|
|
||||||
.catch((error) => console.log(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
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> => {
|
||||||
|
setLoading( true );
|
||||||
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 data = await readCSV( e );
|
||||||
const newFileInfo: fileInfo = {
|
const newFileInfo: fileInfo = {
|
||||||
filename: file.name,
|
'filename': file.name,
|
||||||
filetype: ".csv", // file.type delivers weird name
|
'filetype': '.csv', // file.type delivers weird name
|
||||||
filesize: String(file.size) + "B",
|
'filesize': String( file.size ) + 'B',
|
||||||
rowcount: data.length
|
'rowcount': data.length
|
||||||
}
|
};
|
||||||
setInfo(newFileInfo);
|
|
||||||
setData(data);
|
|
||||||
|
|
||||||
if (formRef.current) {
|
setInfo( newFileInfo );
|
||||||
|
setData( data );
|
||||||
|
setLoading( false );
|
||||||
|
|
||||||
|
if ( formRef.current ) {
|
||||||
// Upload to server
|
// Upload to server
|
||||||
const formData = new FormData(formRef.current);
|
const formData = new FormData( formRef.current );
|
||||||
await fetch("/upload", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (fileName: string) => {
|
await fetch( '/upload?fname=' + file.name, {
|
||||||
const response = await fetch(`/download/${fileName}`);
|
'method': 'POST',
|
||||||
|
'body': formData
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async ( fileName: string ) => {
|
||||||
|
setLoading( true );
|
||||||
|
|
||||||
|
const response = await fetch( `/download/${ fileName }` );
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const text = await blob.text();
|
const text = await blob.text();
|
||||||
|
|
||||||
if (!response) throw new Error("No file received");
|
if ( !response ) throw new Error( 'No file received' );
|
||||||
|
|
||||||
const data = await convertCSVtoJSON(text);
|
const data = await convertCSVtoJSON( text );
|
||||||
|
|
||||||
|
setInfo( {
|
||||||
|
'filesize': blob.size + 'B',
|
||||||
|
'filetype': response.headers.get( 'Content-Type' ) ?? 'text/csv',
|
||||||
|
'filename': fileName,
|
||||||
|
'rowcount': data.length
|
||||||
|
} );
|
||||||
|
|
||||||
// Updating fileInfo requires more effort since blob doesn't have the metadata
|
// Updating fileInfo requires more effort since blob doesn't have the metadata
|
||||||
|
setData( data );
|
||||||
setData(data);
|
setLoading( false );
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
<div className={'loading-spinner' + ( loading ? ' active' : '' )}>
|
||||||
|
<div aria-busy="true" >
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<CSVCard handleChange={handleFileUpload} formRef={formRef}></CSVCard>
|
<CSVCard handleChange={handleFileUpload} formRef={formRef}></CSVCard>
|
||||||
<FileCard fileList={fileList as responseObject} fileChangeHandle={handleFileChange}></FileCard>
|
<FileCard fileList={fileList as responseObject} fileChangeHandle={handleFileChange}></FileCard>
|
||||||
<InfoCard info={info}></InfoCard>
|
<InfoCard info={info}></InfoCard>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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>
|
||||||
@@ -14,11 +14,16 @@ const CSVCard = (props: {
|
|||||||
<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
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
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>
|
<small>Please upload a CSV file, where the first row is the header.</small>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default CSVCard;
|
export default CSVCard;
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,57 @@
|
|||||||
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}) => {
|
|
||||||
if (props.data.length == 0) return <></>;
|
|
||||||
|
|
||||||
const header = Object.keys(props.data[0]!);
|
const DataTable = ( props: {
|
||||||
const [sortCol, setSortCol] = useState("None");
|
'data': CSV_Data,
|
||||||
const [sortType, setSortType] = useState("asc");
|
} ) => {
|
||||||
|
if ( props.data.length == 0 ) return <></>;
|
||||||
|
|
||||||
const sortingHandler = (col: String) => {
|
const header = Object.keys( props.data[0]! );
|
||||||
if (sortCol !== col) {
|
const [
|
||||||
setSortCol(col as SetStateAction<string>);
|
sortCol,
|
||||||
setSortType("asc");
|
setSortCol
|
||||||
} else if (sortType === "asc") {
|
] = useState( 'None' );
|
||||||
setSortType("desc");
|
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 {
|
} 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;
|
||||||
});
|
} );
|
||||||
|
} else {
|
||||||
|
props.data.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,48 +64,61 @@ const DataTable = (props: {data: CSV_Data}) => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{
|
{
|
||||||
header.map( (col) => (
|
header.map( ( col, i ) => <ColHeader
|
||||||
<ColHeader col={col} sortingHandle={sortingHandler} isSelected={col == sortCol} sortType={sortType}></ColHeader>
|
col={col}
|
||||||
))
|
key={i}
|
||||||
|
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, j ) => <Row key={`${ i }:${ j }`}
|
||||||
<Row key={i} col={col} content={row[col] as String}></Row>
|
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;
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
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);
|
|
||||||
}
|
list.push( elem );
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = props.fileList != null ? convert(props.fileList) : null;
|
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>
|
||||||
|
<div className="table-scroll-wrapper">
|
||||||
<table id="table-content">
|
<table id="table-content">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -34,25 +39,29 @@ const FileCard = (props: {
|
|||||||
</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!}
|
||||||
|
fileChangeHandle={props.fileChangeHandle}/> ) : <tr></tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</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 +69,16 @@ 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;
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
import { fileInfo } from "../types";
|
import {
|
||||||
|
fileInfo
|
||||||
const InfoCard = (props: {
|
} from '../types';
|
||||||
info: fileInfo
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
let noFileMessage = <div></div>
|
|
||||||
if (props.info.filename === "None")
|
|
||||||
noFileMessage = <div id="data-info-placeholder">No file selected</div>;
|
|
||||||
|
|
||||||
|
const InfoCard = ( props: {
|
||||||
|
'info': fileInfo
|
||||||
|
} ) => {
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
<header>
|
<header>
|
||||||
<h2>Data infos</h2>
|
<h2>Data infos</h2>
|
||||||
|
<InfoRenderer info={props.info}></InfoRenderer>
|
||||||
</header>
|
</header>
|
||||||
<div className="info">
|
</article>
|
||||||
{noFileMessage}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfoRenderer = ( props: {
|
||||||
|
'info': fileInfo
|
||||||
|
} ) => {
|
||||||
|
if ( props.info.filename !== 'None' ) {
|
||||||
|
return <div className="info">
|
||||||
<h4>Filename</h4>
|
<h4>Filename</h4>
|
||||||
<p>{props.info.filename}</p>
|
<p>{props.info.filename}</p>
|
||||||
|
|
||||||
@@ -26,9 +31,13 @@ const InfoCard = (props: {
|
|||||||
|
|
||||||
<h4>Number of rows</h4>
|
<h4>Number of rows</h4>
|
||||||
<p>{props.info.rowcount}</p>
|
<p>{props.info.rowcount}</p>
|
||||||
</div>
|
</div>;
|
||||||
</article>
|
} else {
|
||||||
);
|
return <div className="info">
|
||||||
}
|
<p>No file selected</p>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export default InfoCard;
|
export default InfoCard;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
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: {
|
||||||
|
'children': React.ReactNode
|
||||||
|
} ) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="container-fluid">
|
<nav className="container-fluid">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/*some style for app component*/
|
/*some style for app component*/
|
||||||
:root {
|
:root {
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.5rem;
|
||||||
--border-color: #a0a0a0;
|
--border-color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,11 @@ article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
article.wide {
|
article.wide {
|
||||||
width: 800px
|
width: 800px;
|
||||||
|
|
||||||
|
.table-scroll-wrapper {
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-icons {
|
.action-icons {
|
||||||
@@ -45,8 +49,44 @@ article.wide {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 18px;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
transition: color 0.2s linear;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--pico-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
background-color: rgb(40, 40, 40, 0.3);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
color: white;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.1s linear;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
background-color: rgb(40, 40, 40, 0.6);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body>main {
|
body>main {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ body main {
|
|||||||
& article {
|
& article {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--spacing);
|
padding: var(--spacing);
|
||||||
& > header {
|
|
||||||
|
&>header {
|
||||||
margin: calc(var(--spacing) * -1) calc(var(--spacing) * -1) var(--spacing);
|
margin: calc(var(--spacing) * -1) calc(var(--spacing) * -1) var(--spacing);
|
||||||
padding: var(--spacing);
|
padding: var(--spacing);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ dl {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
dt,
|
dt,
|
||||||
dd {
|
dd {
|
||||||
line-height: 1.42857143;
|
line-height: 1.42857143;
|
||||||
}
|
}
|
||||||
|
|
||||||
dt {
|
dt {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
dd {
|
dd {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import React from "react";
|
import '@fortawesome/fontawesome-free/css/all.css';
|
||||||
import ReactDOM from "react-dom/client";
|
import './index.css';
|
||||||
import "@fortawesome/fontawesome-free/css/all.css";
|
import '@picocss/pico/css/pico.min.css';
|
||||||
import App from "./App";
|
import App from './App';
|
||||||
import "@picocss/pico/css/pico.min.css";
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
|
||||||
import "./index.css";
|
ReactDOM.createRoot( document.getElementById( 'root' )! ).render( <React.StrictMode>
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>, );
|
||||||
);
|
|
||||||
|
|||||||
21
task_3_react/src/client/sse.ts
Normal file
21
task_3_react/src/client/sse.ts
Normal 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
|
||||||
|
} ) );
|
||||||
|
};
|
||||||
@@ -1,81 +1,199 @@
|
|||||||
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
|
const sanitizeFilePath = ( path: string ) => {
|
||||||
const storage = multer.diskStorage({
|
// eslint-disable-next-line no-useless-escape
|
||||||
destination: "./src/server/uploads",
|
return path.replace( /[\/\\].*/, '' ).replace( '..', '' );
|
||||||
filename: (_req, file, cb) => {
|
};
|
||||||
// Suggested in Multer's readme
|
|
||||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1E3);
|
|
||||||
cb(null, file.fieldname + "-" + uniqueSuffix);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Set up file storage
|
||||||
|
const storage = multer.diskStorage( {
|
||||||
|
'destination': './src/server/uploads',
|
||||||
|
'filename': (
|
||||||
|
req, file, cb
|
||||||
|
) => {
|
||||||
|
// Suggested in Multer's readme
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round( Math.random() * 1E3 );
|
||||||
|
|
||||||
|
let fname = req.query['fname']
|
||||||
|
? sanitizeFilePath( String( req.query['fname'] ) )
|
||||||
|
: file.fieldname;
|
||||||
|
|
||||||
|
const index = fname.lastIndexOf( '.' );
|
||||||
|
|
||||||
|
let fext = '';
|
||||||
|
|
||||||
|
if ( index > -1 ) {
|
||||||
|
fname = fname.slice( 0, index );
|
||||||
|
fext = fname.substring( index );
|
||||||
|
}
|
||||||
|
|
||||||
|
fname += '-' + uniqueSuffix + fext;
|
||||||
|
|
||||||
|
fileEvent.emit( 'uploaded', fname );
|
||||||
|
|
||||||
|
cb( null, fname );
|
||||||
|
}
|
||||||
|
} );
|
||||||
// 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) => {
|
( _req, res ) => {
|
||||||
console.log(req, res, next)
|
// NOTE: We do only need the next function in the handler when we want a middleware,
|
||||||
|
// otherwise we can simply omit it
|
||||||
|
console.log( 'Uploaded file' );
|
||||||
|
res.send( 'Ok' );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const zeroExtend = ( num: number ) => {
|
||||||
|
if ( num < 10 ) {
|
||||||
|
return '0' + num;
|
||||||
|
} else {
|
||||||
|
return '' + num;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = ( date: Date ) => {
|
||||||
|
return `${ date.getFullYear() }-${ zeroExtend( date.getMonth() ) }-${ zeroExtend( date.getDay() ) }`
|
||||||
|
+ ' at '
|
||||||
|
+ `${ zeroExtend( date.getHours() ) }:${ zeroExtend( date.getMinutes() ) }:${ zeroExtend( date.getSeconds() ) }`;
|
||||||
|
};
|
||||||
|
|
||||||
// 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());
|
resObject.uploadTimes.push( formatDate( stats.birthtime ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json(resObject);
|
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++ ) {
|
||||||
|
try {
|
||||||
|
subs[i]!.response.write( `data: ${ JSON.stringify( {
|
||||||
|
'event': event,
|
||||||
|
'data': data
|
||||||
|
} ) }\n\n` );
|
||||||
|
} catch ( e ) {
|
||||||
|
console.debug( e );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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' ),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
title,artist,dancingStyle
|
||||||
|
1 Emperor's Dream,Ballroom Dance Orchestra & Marc Reift,Slow Walz
|
||||||
|
2 Kaiserwalzer,Ballroom Dance Orchestra & Marc Reift,Wiener Walzer
|
||||||
|
3 Macarena (Wish),Los del Mar,Flashmob
|
||||||
|
4 Wannabe (Wish),Spice Girls,Discofox
|
||||||
|
5 Dancing Queen (Wish),ABBA,Discofox
|
||||||
|
6 Splish Splash,Bobby Darin,Jive/ Rocknroll
|
||||||
|
"7 See You Later, Alligator",Bill Haley,Jive/ Rocknroll
|
||||||
|
8 Rico Vacilon,Pedro Garcia & His Del Prado Orchestra,Cha Cha Cha
|
||||||
|
9 El Diablo Anda Suelto,Rey Ruiz,Cha ch cha
|
||||||
|
10 Hips Don't Lie (Wish,Shakira,Salsa/ Discofox
|
||||||
|
11 Como Volver a Ser Feliz,Luis Enrique,Salsa
|
||||||
|
12 Moon River,,Slow Walz
|
||||||
|
13 Morning has broken,Cat Stevens,Wiener Walzer
|
||||||
|
14 Jeepers creepers,Benny Goodmann,Discofox/Foxtrott
|
||||||
|
15 ..Baby One More Time (Wish),Britney Spears,Discofox
|
||||||
|
16 Can't Stop the Feeling! (Wish),Justin Timberlake,Discofox
|
||||||
|
17 Tea for Two Cha Cha,Tommy Dorsey and His Orchestra,Cha ch cha
|
||||||
|
18 Muevelo,Rey Ruiz,Cha ch cha
|
||||||
|
19 I feel good,Ray Charles,Jive/Rocknroll
|
||||||
|
21 Jailhouse Rock,Elvis Presley,Jive/Rocknroll
|
||||||
|
22 Are you lonesome,Elvis Presley,Slow Walz
|
||||||
|
23 Louenesee,Span,Walzer
|
||||||
|
24 Something Stupid,Michael Bubl<62>,Rumba/cha cha cha
|
||||||
|
25 Stayin' Alive (wish),Bee Gees,Discofox
|
||||||
|
26 Uptown Funk (Wish),Bruno Mars,Discofox
|
||||||
|
27 suelta la cintura,Ruben leon,Cha ch cha
|
||||||
|
28 Come Dance With Me,Michael Bubl<62>,cha cha cha
|
||||||
|
29 Kiss me,Sixpence None the Richer,Discofox/Foxtrott
|
||||||
|
30 ily (i love you baby) [feat. Emilee],Surf Mesa,Discofox
|
||||||
|
31 Crazy Little Thing Called Love,Queen,Jive/ Rocknroll
|
||||||
|
32 Great Balls of Fire,Jerry Lee Lewis,Jive/ Rocknroll
|
||||||
|
33 La Bamba,Ritchie Valens,Jive/ Rocknroll
|
||||||
|
34 Macho,Charles Fox,Salsa
|
||||||
|
35 The Last Waltz,Engelbert Humperdinck,Walzer
|
||||||
|
36 An der sch<63>nen blauen Donau,New 101 Strings Orchestra,Wiener Walzer
|
||||||
|
37 Dance The Night,Dua Lipa,Discofox
|
||||||
|
38 Iko Iko,Justin Wellington,Discofox
|
||||||
|
39 Beat It (Wish),Michael Jackson,Jive
|
||||||
|
40 Super Freaky Girl (Wish),Nicki Minaj,Jive/ Rocknroll
|
||||||
|
41 California Girls (Wish),Katy Perry,Discofox
|
||||||
|
3 Macarena (Wish),Los del Mar,Flashmob
|
||||||
|
43 Ay Mujer,Rey Ruiz,Cha ch cha
|
||||||
|
44 Lets get loud,Jennifer Lopez,Cha ch cha
|
||||||
|
45 Friday,Riton & Nightcrawlers,Party
|
||||||
|
46 Take On Me,a-ha,Party
|
||||||
|
47 Gimme! Gimme! Gimme! (Wish),ABBA,Party
|
||||||
|
48 I Gotta Feeling (Wish),Black Eyed Peas,Party
|
||||||
|
49 Angels,Robbie Williams,Slow
|
||||||
|
50 Hangover (Wish),Taio Cruz,Party
|
||||||
|
51 The Real Slim Shady (Wish),Eminem,Party
|
||||||
|
52 I don't care,,Party
|
||||||
|
53 YMCA,Village People,Party
|
||||||
|
54 Cotton Eye Joe (Wish),Rednex,Sex & Violins
|
||||||
|
55 Nothing compares to you,Sinhead O' Connor,Slow
|
||||||
|
56 Komet,Udo Lindenberg & Apache 207,Partyende
|
||||||
Reference in New Issue
Block a user