Compare commits

..

4 Commits

Author SHA1 Message Date
fd8ec668b4 [Task 3] Fixes, formatting, layout updates 2025-11-17 10:38:31 +01:00
157603d3d7 [Task 3] More fixes 2025-11-17 09:07:47 +01:00
5916cce77f [Task 3] Fix sse crash 2025-11-17 08:14:54 +01:00
RobinB27
5525c4b4ad [Task 3] Loading Spinner 2025-11-17 08:04:46 +01:00
11 changed files with 6667 additions and 115 deletions

View File

@@ -1,5 +1,6 @@
import './css/App.css'; import './css/App.css';
import '@fortawesome/fontawesome-free/css/all.css'; import '@fortawesome/fontawesome-free/css/all.css';
import './sse';
import { import {
CSV_Data, fileInfo, responseObject CSV_Data, fileInfo, responseObject
} from './types'; } from './types';
@@ -15,7 +16,6 @@ import DataTable from './components/DataTable';
import FileCard from './components/FileCard'; import FileCard from './components/FileCard';
import InfoCard from './components/InfoCard'; import InfoCard from './components/InfoCard';
import Layout from './components/Layout'; import Layout from './components/Layout';
import "./sse"
function App () { function App () {
const [ const [
@@ -35,22 +35,29 @@ function App () {
fileList, fileList,
setFileList setFileList
] = useState( null as responseObject | null ); ] = 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 // Add evenbt listener for server-sent events on first render
const [active, setActive] = useState(false); const [
active,
setActive
] = useState( false );
useEffect( () => { useEffect( () => {
if ( !active ) { if ( !active ) {
document.addEventListener("sse:uploaded", async _ev => { document.addEventListener( 'sse:uploaded', () => {
await fetch( '/status', { fetch( '/status', {
'method': 'GET' 'method': 'GET'
} ) } )
.then( response => response.json() ) .then( response => response.json() )
.then( response => setFileList( response ) ) .then( response => setFileList( response ) )
.catch( error => console.log( error ) ); .catch( error => console.log( error ) );
} ); } );
document.addEventListener("sse:deleted", async _ev => { document.addEventListener( 'sse:deleted', () => {
await fetch( '/status', { fetch( '/status', {
'method': 'GET' 'method': 'GET'
} ) } )
.then( response => response.json() ) .then( response => response.json() )
@@ -66,12 +73,13 @@ function App () {
.then( response => setFileList( response ) ) .then( response => setFileList( response ) )
.catch( error => console.log( error ) ); .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> => {
setLoading( true );
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if ( !file ) throw new Error( 'No file received' ); if ( !file ) throw new Error( 'No file received' );
@@ -86,12 +94,13 @@ function App () {
setInfo( newFileInfo ); setInfo( newFileInfo );
setData( data ); setData( data );
setLoading( false );
if ( formRef.current ) { if ( formRef.current ) {
// Upload to server // Upload to server
const formData = new FormData( formRef.current ); const formData = new FormData( formRef.current );
await fetch( '/upload', { await fetch( '/upload?fname=' + file.name, {
'method': 'POST', 'method': 'POST',
'body': formData 'body': formData
} ); } );
@@ -99,6 +108,8 @@ function App () {
}; };
const handleFileChange = async ( fileName: string ) => { const handleFileChange = async ( fileName: string ) => {
setLoading( true );
const response = await fetch( `/download/${ fileName }` ); 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();
@@ -107,8 +118,15 @@ function App () {
const data = await convertCSVtoJSON( text ); const data = await convertCSVtoJSON( text );
// Updating fileInfo requires more effort since blob doesn't have the metadata 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
setLoading( false );
setData( data ); setData( data );
}; };
@@ -117,7 +135,7 @@ function App () {
<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>
<DataTable data={data}></DataTable> <DataTable data={data} loading={loading}></DataTable>
</Layout> </Layout>
); );
} }

View File

@@ -5,8 +5,10 @@ import {
CSV_Data CSV_Data
} from '../types'; } from '../types';
const DataTable = ( props: { const DataTable = ( props: {
'data': CSV_Data 'data': CSV_Data,
'loading': boolean
} ) => { } ) => {
if ( props.data.length == 0 ) return <></>; if ( props.data.length == 0 ) return <></>;
@@ -32,6 +34,7 @@ const DataTable = ( props: {
} }
}; };
if ( sortCol !== 'None' && sortType === 'asc' ) { if ( sortCol !== 'None' && sortType === 'asc' ) {
props.data.sort( ( a, b ) => { props.data.sort( ( a, b ) => {
if ( a[sortCol]! < b[sortCol]! ) return -1; if ( a[sortCol]! < b[sortCol]! ) return -1;
@@ -48,11 +51,13 @@ const DataTable = ( props: {
return 0; return 0;
} ); } );
} else {
props.data.sort();
} }
return ( return (
<article className="table-container"> <article className="table-container">
<header> <header aria-busy={props.loading}>
<h2>Data table</h2> <h2>Data table</h2>
</header> </header>
<div className="table-scroll-wrapper"> <div className="table-scroll-wrapper">
@@ -60,8 +65,9 @@ const DataTable = ( props: {
<thead> <thead>
<tr> <tr>
{ {
header.map( col => <ColHeader header.map( ( col, i ) => <ColHeader
col={col} col={col}
key={i}
sortingHandle={sortingHandler} sortingHandle={sortingHandler}
isSelected={col == sortCol} isSelected={col == sortCol}
sortType={sortType}></ColHeader> ) sortType={sortType}></ColHeader> )
@@ -73,7 +79,8 @@ const DataTable = ( props: {
props.data.map( ( row, i ) => ( props.data.map( ( row, i ) => (
<tr key={i}> <tr key={i}>
{ {
header.map( col => <Row key={i} col={col} content={row[col] as string}></Row> ) header.map( ( col, j ) => <Row key={`${ i }:${ j }`}
col={col} content={row[col] as string}></Row> )
} }
</tr> </tr>
) ) ) )
@@ -116,4 +123,3 @@ const Row = ( props: {
}; };
export default DataTable; export default DataTable;

View File

@@ -28,6 +28,7 @@ const FileCard = ( props: {
<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>
@@ -46,6 +47,7 @@ const FileCard = ( props: {
} }
</tbody> </tbody>
</table> </table>
</div>
</article> </article>
); );
}; };
@@ -80,4 +82,3 @@ const FileRow = ( props: {
}; };
export default FileCard; export default FileCard;

View File

@@ -5,18 +5,21 @@ import {
const InfoCard = ( props: { const InfoCard = ( props: {
'info': fileInfo 'info': fileInfo
} ) => { } ) => {
let noFileMessage = <div></div>;
if ( props.info.filename === 'None' )
noFileMessage = <div id="data-info-placeholder">No file selected</div>;
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>
@@ -28,10 +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;

View File

@@ -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 {

View File

@@ -21,6 +21,7 @@ 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);

View File

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

View File

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

View File

@@ -11,18 +11,39 @@ import {
} from './types'; } from './types';
const app = express(); const app = express();
const sanitizeFilePath = ( path: string ) => {
// eslint-disable-next-line no-useless-escape
return path.replace( /[\/\\].*/, '' ).replace( '..', '' );
};
// 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': ( 'filename': (
_req, file, cb 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 );
fileEvent.emit( 'uploaded', file.fieldname + '-' + uniqueSuffix ); let fname = req.query['fname']
? sanitizeFilePath( String( req.query['fname'] ) )
: file.fieldname;
cb( null, file.fieldname + '-' + uniqueSuffix ); 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
@@ -37,15 +58,29 @@ const fileEvent = new FileEvent();
app.post( app.post(
'/upload', '/upload',
upload.single( 'dataFile' ), upload.single( 'dataFile' ),
( ( _req, res ) => {
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( console.log( 'Uploaded file' );
req, res, next 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 = {
@@ -58,7 +93,7 @@ app.get( '/status', async ( _req, res ) => {
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 );
@@ -140,10 +175,14 @@ const sendSSEData = ( event: string, data: string ) => {
const subs = Object.values( subscribers ); const subs = Object.values( subscribers );
for ( let i = 0; i < subs.length; i++ ) { for ( let i = 0; i < subs.length; i++ ) {
try {
subs[i]!.response.write( `data: ${ JSON.stringify( { subs[i]!.response.write( `data: ${ JSON.stringify( {
'event': event, 'event': event,
'data': data 'data': data
} ) }\n\n` ); } ) }\n\n` );
} catch ( e ) {
console.debug( e );
}
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -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