Rewrite web front-end using React

This is a big commit, some font-end function are still working,
including manage, error handle, as others.
This commit is contained in:
2021-11-21 17:30:43 +08:00
parent d556bbe0c8
commit e170c8b842
38 changed files with 38580 additions and 19880 deletions

94
web/src/App.css Normal file
View File

@@ -0,0 +1,94 @@
html {
font-size: 1em;
}
body {
margin: auto;
margin-top: 1rem;
}
.base {
display: grid;
grid-row-gap: 1em;
}
.header {
color: white;
background-color: rgb(63, 81, 181);
box-shadow: 0 0 8px #393939;
border-radius: 6px 6px 0 0;
}
.title {
margin-left: 1em;
display: flex;
align-items: center;
}
.title-text {
margin-left: 1em;
margin-right: 1em;
}
.logo {
width: 39px;
height: 39px;
border-radius: 6px;
}
.nav {
display: flex;
justify-content: space-evenly;
}
.nav-link {
color: rgb(229, 232, 245);
padding: 1em;
}
a.unset {
color: unset;
text-decoration: unset;
}
a.active {
color: deeppink;
background-color: lightgray;
border-radius: 0.39em 0.39em 0 0;
}
.audio-player {
height: 39px;
width: 100%;
}
td.clickable {
cursor: pointer;
}
div.clickable {
cursor: pointer;
}
div.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
div.search_toolbar {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
width: 100%;
}
div.feedback {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
button.refresh {
width: 100%;
}
td,
th {
padding-bottom: 0.5em;
padding-top: 0.5em;
}
dialog {
border: solid;
}
.player-options {
display: flex;
}
.ffmpeg-config {
display: flex;
justify-content: space-between;
}

79
web/src/App.js Normal file
View File

@@ -0,0 +1,79 @@
import {
HashRouter as Router,
Routes,
Route,
NavLink,
} from "react-router-dom";
import "./App.css";
import GetRandomFiles from "./component/GetRandomFiles";
import SearchFiles from "./component/SearchFiles";
import SearchFolders from "./component/SearchFolders";
import Manage from "./component/Manage";
import Share from "./component/Share";
import AudioPlayer from "./component/AudioPlayer";
import { useState } from "react";
function App() {
const [playingFile, setPlayingFile] = useState({});
return (
<div className="base">
<Router>
<header className="header">
<h3 className="title">
<img src="favicon.png" alt="logo" className="logo" />
<span className="title-text">MSW Open Music Project</span>
</h3>
<nav className="nav">
<NavLink to="/" className="nav-link">
Feeling luckly
</NavLink>
<NavLink to="/search-files" className="nav-link">
Files
</NavLink>
<NavLink to="/search-folders" className="nav-link">
Folders
</NavLink>
<NavLink to="/manage" className="nav-link">
Manage
</NavLink>
</nav>
</header>
<main>
<Routes>
<Route
index
path="/"
element={<GetRandomFiles setPlayingFile={setPlayingFile} />}
/>
<Route
path="/search-files"
element={<SearchFiles setPlayingFile={setPlayingFile} />}
/>
<Route
path="/search-folders"
element={<SearchFolders setPlayingFile={setPlayingFile} />}
/>
<Route
path="/search-folders/:id"
element={<SearchFolders setPlayingFile={setPlayingFile} />}
/>
<Route path="/manage" element={<Manage />} />
<Route
path="/share/:id"
element={<Share setPlayingFile={setPlayingFile} />}
/>
</Routes>
</main>
<footer>
<AudioPlayer
playingFile={playingFile}
setPlayingFile={setPlayingFile}
/>
</footer>
</Router>
</div>
);
}
export default App;

8
web/src/App.test.js Normal file
View File

@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { CalcReadableFilesizeDetail } from "./Common";
import FfmpegConfig from "./FfmpegConfig";
import FileDialog from "./FileDialog";
function AudioPlayer(props) {
// props.playingFile
// props.setPlayingFile
const [fileDialogShowStatus, setFileDialogShowStatus] = useState(false);
const [loop, setLoop] = useState(true);
const [raw, setRaw] = useState(false);
const [prepare, setPrepare] = useState(false);
const [selectedFfmpegConfig, setSelectedFfmpegConfig] = useState({});
const [playingURL, setPlayingURL] = useState("");
const [isPreparing, setIsPreparing] = useState(false);
const [preparedFilesize, setPreparedFilesize] = useState(null);
useEffect(() => {
// no playing file
if (props.playingFile.id === undefined) {
setPlayingURL("");
return;
}
if (raw) {
console.log("Play raw file");
setPlayingURL("/api/v1/get_file_direct?id=" + props.playingFile.id);
} else {
if (prepare) {
// prepare file
setIsPreparing(true);
fetch("/api/v1/prepare_file_stream_direct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: props.playingFile.id,
config_name: selectedFfmpegConfig.name,
}),
})
.then((response) => response.json())
.then((data) => {
setPreparedFilesize(data.filesize);
setIsPreparing(false);
setPlayingURL(
`/api/v1/get_file_stream_direct?id=${props.playingFile.id}&config=${selectedFfmpegConfig.name}`
);
});
} else {
setPlayingURL(
`/api/v1/get_file_stream?id=${props.playingFile.id}&config=${selectedFfmpegConfig.name}`
);
}
}
}, [props.playingFile.id, raw, prepare, selectedFfmpegConfig]);
let navigate = useNavigate();
return (
<div>
<h5>Player status</h5>
{props.playingFile.id && (
<span>
<FileDialog
showStatus={fileDialogShowStatus}
setShowStatus={setFileDialogShowStatus}
file={props.playingFile}
setPlayingFile={() => {
return;
}}
/>
<button
onClick={() => {
setFileDialogShowStatus(!fileDialogShowStatus);
}}
>
{props.playingFile.filename}
</button>
<button
onClick={() =>
navigate(`search-folders/${props.playingFile.folder_id}`)
}
>
{props.playingFile.foldername}
</button>
<button disabled>
{prepare
? CalcReadableFilesizeDetail(preparedFilesize)
: CalcReadableFilesizeDetail(props.playingFile.filesize)}
</button>
{isPreparing && <button disabled>Preparing...</button>}
{playingURL !== "" && (
<button
onClick={() => {
props.setPlayingFile({});
}}
>
Stop
</button>
)}
</span>
)}
<br />
<input
checked={loop}
onChange={(event) => setLoop(event.target.checked)}
type="checkbox"
/>
<label>Loop</label>
<input
checked={raw}
onChange={(event) => setRaw(event.target.checked)}
type="checkbox"
/>
<label>Raw</label>
{!raw && (
<span>
<input
checked={prepare}
onChange={(event) => setPrepare(event.target.checked)}
type="checkbox"
/>
<label>Prepare</label>
</span>
)}
{playingURL !== "" && (
<audio
controls
autoPlay
loop={loop}
className="audio-player"
src={playingURL}
></audio>
)}
<FfmpegConfig
selectedFfmpegConfig={selectedFfmpegConfig}
setSelectedFfmpegConfig={setSelectedFfmpegConfig}
/>
</div>
);
}
export default AudioPlayer;

View File

@@ -0,0 +1,40 @@
export function CalcReadableFilesize(filesize) {
if (filesize < 1024) {
return filesize;
}
if (filesize < 1024 * 1024) {
return Math.round(filesize / 1024) + "K";
}
if (filesize < 1024 * 1024 * 1024) {
return Math.round(filesize / 1024 / 1024) + "M";
}
if (filesize < 1024 * 1024 * 1024 * 1024) {
return Math.round(filesize / 1024 / 1024 / 1024) + "G";
}
}
export function CalcReadableFilesizeDetail(filesize) {
if (filesize < 1024 * 1024) {
return filesize;
}
if (filesize < 1024 * 1024 * 1024) {
return numberWithCommas(Math.round(filesize / 1024)) + "K";
}
if (filesize < 1024 * 1024 * 1024 * 1024) {
return numberWithCommas(Math.round(filesize / 1024 / 1024)) + "M";
}
if (filesize < 1024 * 1024 * 1024 * 1024 * 1024) {
return numberWithCommas(Math.round(filesize / 1024 / 1024 / 1024)) + "G";
}
}
function numberWithCommas(x) {
x = x.toString();
var pattern = /(-?\d+)(\d{3})/;
while (pattern.test(x)) x = x.replace(pattern, "$1,$2");
return x;
}
export function SayHello() {
return "Hello";
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
function FfmpegConfig(props) {
// props.setSelectedFfmpegConfig
// props.selectedFfmpegConfig
const [ffmpegConfigList, setFfmpegConfigList] = useState([]);
useEffect(() => {
fetch("/api/v1/get_ffmpeg_config_list")
.then((response) => response.json())
.then((data) => {
setFfmpegConfigList(data.ffmpeg_config_list);
if (data.ffmpeg_config_list.length > 0) {
props.setSelectedFfmpegConfig(data.ffmpeg_config_list[0]);
}
});
}, []);
return (
<div className="ffmpeg-config">
<select
onChange={(event) => {
props.setSelectedFfmpegConfig(
ffmpegConfigList[event.target.selectedIndex]
);
}}
>
{ffmpegConfigList.map((ffmpegConfig) => (
<option key={ffmpegConfig.name}>{ffmpegConfig.name}</option>
))}
</select>
<span>{props.selectedFfmpegConfig.args}</span>
</div>
);
}
export default FfmpegConfig;

View File

@@ -0,0 +1,44 @@
import { useNavigate } from "react-router";
function FileDialog(props) {
// props.showStatus
// props.setShowStatus
// props.playingFile
// props.setPlayingFile
// props.file
let navigate = useNavigate();
if (!props.showStatus) {
return null;
}
return (
<dialog open>
<p>{props.file.filename}</p>
<FileDialog file={props.file} />
<p>
Download 使用 Axios 异步下载
<br />
Play 调用网页播放器播放
<br />
</p>
<button>Download</button>
<button
onClick={() => {
props.setPlayingFile(props.file);
props.setShowStatus(false);
}}
>
Play
</button>
<button onClick={() => {
navigate(`/share/${props.file.id}`)
props.setShowStatus(false);
}}>Share</button>
<button onClick={() => props.setShowStatus(false)}>Close</button>
</dialog>
);
}
export default FileDialog;

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { CalcReadableFilesize } from "./Common";
import FileDialog from "./FileDialog";
function FileEntry(props) {
const [showStatus, setShowStatus] = useState(false);
let navigate = useNavigate();
return (
<tr>
<td
className="clickable"
onClick={() => {
// double click to play file and close dialog
if (showStatus) {
props.setPlayingFile(props.file);
setShowStatus(false);
return;
}
setShowStatus(true);
}}
>
{props.file.filename}
</td>
<td
className="clickable"
onClick={() => navigate(`/search-folders/${props.file.folder_id}`)}
>
{props.file.foldername}
</td>
<td>
{CalcReadableFilesize(props.file.filesize)}
<FileDialog
setPlayingFile={props.setPlayingFile}
showStatus={showStatus}
setShowStatus={setShowStatus}
file={props.file}
/>
</td>
</tr>
);
}
export default FileEntry;

View File

@@ -0,0 +1,26 @@
import FileEntry from "./FileEntry";
function FilesTable(props) {
return (
<table>
<thead>
<tr>
<th>Filename</th>
<th>Folder Name</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{props.files.map((file) => (
<FileEntry
setPlayingFile={props.setPlayingFile}
key={file.id}
file={file}
/>
))}
</tbody>
</table>
);
}
export default FilesTable;

View File

@@ -0,0 +1,32 @@
import { useNavigate } from "react-router";
function FoldersTable(props) {
let navigate = useNavigate();
return (
<table>
<thead>
<tr>
<th>Folder name</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{props.folders.map((folder) => (
<tr key={folder.id}>
<td
onClick={() => navigate(`/search-folders/${folder.id}`)}
className="clickable"
>
{folder.foldername}
</td>
<td onClick={() => navigate(`/search-folders/${folder.id}`)}>
<button>View</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
export default FoldersTable;

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import FilesTable from "./FilesTable";
function GetRandomFiles(props) {
const [files, setFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
function refresh(setFiles) {
setIsLoading(true);
fetch("/api/v1/get_random_files")
.then((response) => response.json())
.then((data) => {
setIsLoading(false);
setFiles(data.files);
});
}
useEffect(() => {
refresh(setFiles);
}, []);
return (
<div className="page">
<div className="search_toolbar">
<button className="refresh" onClick={() => refresh(setFiles)}>
{isLoading ? "Loading..." : "Refresh"}
</button>
</div>
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
</div>
);
}
export default GetRandomFiles;

View File

@@ -0,0 +1,9 @@
function Manage() {
return (
<div>
<h2>Manage</h2>
</div>
)
}
export default Manage;

View File

@@ -0,0 +1,93 @@
import { useState, useEffect } from "react";
import FilesTable from "./FilesTable";
function SearchFiles(props) {
const [files, setFiles] = useState([]);
const [filename, setFilename] = useState("");
const [offset, setOffset] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const limit = 10;
function searchFiles() {
if (
filename === "" &&
(props.folder === undefined || props.folder.id === undefined)
) {
return;
}
const folder = props.folder ? props.folder : {};
const url = folder.id
? "/api/v1/get_files_in_folder"
: "/api/v1/search_files";
setIsLoading(true);
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: filename,
limit: limit,
offset: offset,
folder_id: folder.id,
}),
})
.then((response) => response.json())
.then((data) => {
setIsLoading(false);
const files = data.files ? data.files : [];
setFiles(files);
});
}
function nextPage() {
setOffset(offset + limit);
}
function lastPage() {
const offsetValue = offset - limit;
if (offsetValue < 0) {
return;
}
setOffset(offsetValue);
}
useEffect(() => searchFiles(), [offset, props.folder]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="page">
<h3>Search Files</h3>
<div className="search_toolbar">
{!props.folder && (
<input
onChange={(event) => setFilename(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
searchFiles();
}
}}
type="text"
placeholder="Enter filename"
/>
)}
<button
disabled={!!props.folder}
onClick={() => {
searchFiles();
}}
>
{isLoading ? "Loading..." : "Search"}
</button>
{props.folder && props.folder.foldername && (
<button onClick={searchFiles}>{props.folder.foldername}</button>
)}
<button onClick={lastPage}>Last page</button>
<button disabled>
{offset} - {offset + files.length}
</button>
<button onClick={nextPage}>Next page</button>
</div>
<FilesTable setPlayingFile={props.setPlayingFile} files={files} />
</div>
);
}
export default SearchFiles;

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import FoldersTable from "./FoldersTable";
import SearchFiles from "./SearchFiles";
function SearchFolders(props) {
const [foldername, setFoldername] = useState("");
const [folders, setFolders] = useState([]);
const [folder, setFolder] = useState({});
const [offset, setOffset] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const limit = 10;
function searchFolder() {
if (foldername === "") {
return;
}
setIsLoading(true);
fetch("/api/v1/search_folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
foldername: foldername,
limit: limit,
offset: offset,
}),
})
.then((response) => response.json())
.then((data) => {
setIsLoading(false);
let folders;
if (data.folders) {
folders = data.folders;
} else {
folders = [];
}
setFolders(folders);
});
}
function nextPage() {
setOffset(offset + limit);
}
function lastPage() {
const offsetValue = offset - limit;
if (offsetValue < 0) {
return;
}
setOffset(offsetValue);
}
function viewFolder(folder) {
setFolder(folder);
}
let params = useParams();
useEffect(() => searchFolder(), [offset]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (params.id !== undefined) {
setFolder({ id: parseInt(params.id) });
}
}, [params.id]);
return (
<div className="page">
<h3>Search Folders</h3>
<div className="search_toolbar">
<input
onChange={(event) => setFoldername(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
searchFolder();
}
}}
type="text"
placeholder="Enter folder name"
/>
<button onClick={searchFolder}>
{isLoading ? "Loading..." : "Search"}
</button>
<button onClick={lastPage}>Last page</button>
<button disabled>
{offset} - {offset + limit}
</button>
<button onClick={nextPage}>Next page</button>
</div>
<FoldersTable viewFolder={viewFolder} folders={folders} />
<SearchFiles setPlayingFile={props.setPlayingFile} folder={folder} />
</div>
);
}
export default SearchFolders;

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import FilesTable from "./FilesTable";
function Share(props) {
let params = useParams();
const [file, setFile] = useState([]);
useEffect(() => {
fetch("/api/v1/get_file_info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: parseInt(params.id),
}),
})
.then((response) => response.json())
.then((data) => {
setFile([data]);
});
}, [params]);
return (
<div className="page">
<h3>Share with others! {params.id}</h3>
<p>
Share link: <a href={window.location.href}>{window.location.href}</a>
</p>
<FilesTable setPlayingFile={props.setPlayingFile} files={file} />
</div>
);
}
export default Share;

13
web/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

18
web/src/index.js Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import 'water.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
web/src/setupTests.js Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';