Compare commits

..

2 Commits

Author SHA1 Message Date
RobinB27
5fa2b1f618 Task 3: File Selector 2025-11-16 18:22:03 +01:00
RobinB27
f65cf176f9 Task 3: Backend 2025-11-16 14:45:18 +01:00
13 changed files with 6665 additions and 43 deletions

View File

@@ -1,13 +1,14 @@
import "./App.css";
import "./css/App.css";
import '@fortawesome/fontawesome-free/css/all.css';
import { readCSV } from './csv';
import { CSV_Data, fileInfo } from './types';
import { CSV_Data, fileInfo, responseObject } from './types';
import { readCSV, convertCSVtoJSON } from './csv';
import React, { useState } from "react";
import Layout from "./Layout";
import React, { useState, useRef, useEffect } from "react";
import Layout from "./components/Layout";
import CSVCard from "./components/CSVCard";
import InfoCard from "./components/InfoCard";
import DataTable from "./components/DataTable";
import FileCard from "./components/FileCard";
function App() {
const [data, setData] = useState([] as CSV_Data);
@@ -18,8 +19,19 @@ function App() {
rowcount: 0
});
const [fileList, setFileList] = useState(null as responseObject | 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
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0];
if (!file) throw new Error("No file received");
@@ -27,17 +39,41 @@ function App() {
const newFileInfo: fileInfo = {
filename: file.name,
filetype: file.type,
filetype: ".csv", // file.type delivers weird name
filesize: String(file.size) + "B",
rowcount: data.length
}
setInfo(newFileInfo);
setData(data);
if (formRef.current) {
// Upload to server
const formData = new FormData(formRef.current);
await fetch("/upload", {
method: "POST",
body: formData
});
}
}
const handleFileChange = async (fileName: string) => {
const response = await fetch(`/download/${fileName}`);
const blob = await response.blob();
const text = await blob.text();
if (!response) throw new Error("No file received");
const data = await convertCSVtoJSON(text);
// Updating fileInfo requires more effort since blob doesn't have the metadata
setData(data);
}
return (
<Layout>
<CSVCard handleChange={handleFileChange}></CSVCard>
<CSVCard handleChange={handleFileUpload} formRef={formRef}></CSVCard>
<FileCard fileList={fileList as responseObject} fileChangeHandle={handleFileChange}></FileCard>
<InfoCard info={info}></InfoCard>
<DataTable data={data}></DataTable>
</Layout>

View File

@@ -1,19 +1,20 @@
import React from "react";
import "../Layout.css";
import "../css/Layout.css";
const CSVCard = (props: {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
formRef: React.RefObject<HTMLFormElement>
}) => {
return (
<article>
<header>
<h2>Select CSV data</h2>
<h2>Upload CSV data</h2>
</header>
<form>
<form ref={props.formRef} action="/upload" method="post" encType="multipart/form-data" >
<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}/>
<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>
</form>
</article>

View File

@@ -35,30 +35,37 @@ const DataTable = (props: {data: CSV_Data}) => {
}
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>
))
}
<article className="table-container">
<header>
<h2>Data table</h2>
</header>
<div className="table-scroll-wrapper">
<table id="table-content">
<thead>
<tr>
{
header.map( (col) => (
<ColHeader col={col} sortingHandle={sortingHandler} isSelected={col == sortCol} sortType={sortType}></ColHeader>
))
}
</tr>
))
}
</tbody>
</table>
</thead>
<tbody>
{
props.data.map( (row, i) => (
<tr key={i}>
{
header.map( (col) => (
<Row key={i} col={col} content={row[col] as String}></Row>
))
}
</tr>
))
}
</tbody>
</table>
</div>
</article>
)
}
@@ -77,7 +84,7 @@ const ColHeader = (props: {col: String, sortingHandle: (s: String) => void, isSe
);
}
const Row = (props: {col: String, content: String}) => {
const Row = (props: {col: String, content: String, key: Key}) => {
return <td key={props.col as Key}>{props.content}</td>;
}

View File

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

View File

@@ -1,4 +1,3 @@
import "../Layout.css";
import { fileInfo } from "../types";
const InfoCard = (props: {
@@ -15,6 +14,7 @@ const InfoCard = (props: {
<h2>Data infos</h2>
</header>
<div className="info">
{noFileMessage}
<h4>Filename</h4>
<p>{props.info.filename}</p>
@@ -27,7 +27,6 @@ const InfoCard = (props: {
<h4>Number of rows</h4>
<p>{props.info.rowcount}</p>
</div>
{noFileMessage}
</article>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import "./Layout.css";
import "../css/Layout.css";
const Layout = (props: { children: React.ReactNode }) => {
return (

View File

@@ -36,6 +36,19 @@ article {
width: 400px;
}
article.wide {
width: 800px
}
.action-icons {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 18px;
cursor: pointer;
}
body>main {
max-height: calc(100vh - 100px);
overflow-y: auto;

View File

@@ -1,7 +1,7 @@
import { CSV_Data } from './types';
import { csv2json } from 'json-2-csv';
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.
// 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,

View File

@@ -9,3 +9,9 @@ export type fileInfo = {
filesize: string;
rowcount: number;
}
// FileCard receives this via props
export type responseObject = {
names: string[],
uploadTimes: string[]
}

View File

@@ -1,10 +1,74 @@
import express from "express";
import ViteExpress from "vite-express";
import multer from "multer";
import * as fs from "node:fs/promises";
import { responseObject } from "./types";
import path from "path";
// creates the expres app do not change
const app = express();
// add your routes here
// 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);
cb(null, file.fieldname + "-" + uniqueSuffix);
}
});
// CSV file upload endpoint
const upload = multer({ storage: storage });
app.post(
"/upload",
upload.single("dataFile"),
(req, res, next) => {
console.log(req, res, next)
}
);
// Endpoint to send back file names/upload times
app.get("/status", async (_req, res) => {
const resObject: responseObject = {
names: [],
uploadTimes: []
};
const dir = await fs.opendir("./src/server/uploads/");
for await (const file of dir) {
resObject.names.push(file.name);
const stats = await fs.stat(`./src/server/uploads/${file.name}`);
resObject.uploadTimes.push(stats.birthtime.toString());
}
res.status(200).json(resObject);
})
// Endpoint to send back whole files
app.get("/download/:fileName", (req, res) => {
const fileName = req.params.fileName;
const filePath = path.join(__dirname, "uploads", fileName); // Filepaths must be absolute
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
app.delete("/delete/:fileName", async (req, res) => {
const fileName = req.params.fileName;
const filePath = path.join(__dirname, "uploads", fileName);
try {
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");
}
});
// example route which returns a message
app.get("/hello", async function (_req, res) {

4
task_3_react/src/server/types.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export type responseObject = {
names: string[],
uploadTimes: string[]
}

File diff suppressed because it is too large Load Diff