frontend: Add download button for original file

This commit is contained in:
Paul Bienkowski 2021-05-01 13:31:03 +02:00
parent 31af59819e
commit 817de8fae5
6 changed files with 104 additions and 19 deletions

View file

@ -5107,6 +5107,11 @@
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
"integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA=="
},
"downloadjs": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz",
"integrity": "sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw="
},
"duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",

View file

@ -11,6 +11,7 @@
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"classnames": "^2.2.6",
"downloadjs": "^1.4.7",
"luxon": "^1.25.0",
"node-sass": "^4.14.1",
"ol": "^6.5.0",

View file

@ -4,6 +4,20 @@ import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
import {setLogin} from 'reducers/login'
import config from 'config.json'
import {create as createPkce} from 'pkce'
import download from 'downloadjs'
function getFileNameFromContentDispostionHeader(contentDisposition: string): string | undefined {
const standardPattern = /filename=(["']?)(.+)\1/i
const wrongPattern = /filename=([^"'][^;"'\n]+)/i
if (standardPattern.test(contentDisposition)) {
return contentDisposition.match(standardPattern)[2]
}
if (wrongPattern.test(contentDisposition)) {
return contentDisposition.match(wrongPattern)[1]
}
}
class RequestError extends Error {
constructor(message, errors) {
@ -122,9 +136,9 @@ class API {
}
async exchangeAuthorizationCode(code) {
const codeVerifier = localStorage.getItem('codeVerifier');
const codeVerifier = localStorage.getItem('codeVerifier')
if (!codeVerifier) {
throw new Error("No code verifier found");
throw new Error('No code verifier found')
}
const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
@ -154,7 +168,7 @@ class API {
const {authorizationEndpoint} = await this.getAuthorizationServerMetadata()
const {codeVerifier, codeChallenge} = createPkce()
localStorage.setItem("codeVerifier", codeVerifier);
localStorage.setItem('codeVerifier', codeVerifier)
const loginUrl = new URL(authorizationEndpoint)
loginUrl.searchParams.append('client_id', config.auth.clientId)
@ -172,10 +186,12 @@ class API {
async fetch(url, options = {}) {
const accessToken = await this.getValidAccessToken()
const {returnResponse = false, ...fetchOptions} = options
const response = await window.fetch(config.apiUrl + '/api' + url, {
...options,
...fetchOptions,
headers: {
...(options.headers || {}),
...(fetchOptions.headers || {}),
Authorization: accessToken,
},
})
@ -189,12 +205,22 @@ class API {
throw new Error('401 Unauthorized')
}
let json
try {
json = await response.json()
} catch (err) {
json = null
if (returnResponse) {
if (response.status === 200) {
return response
} else if (response.status === 204) {
return null
} else {
throw new RequestError('Error code ' + response.status)
}
}
let json
try {
json = await response.json()
} catch (err) {
json = null
}
if (response.status === 200) {
return json
@ -244,6 +270,18 @@ class API {
scope: tokenResponse.scope,
}
}
async downloadFile(url, options = {}) {
const res = await this.fetch(url, {returnResponse: true, ...options})
const blob = await res.blob()
const filename = getFileNameFromContentDispostionHeader(res.headers.get('content-disposition'))
const contentType = res.headers.get('content-type')
// Apparently this workaround is needed for some browsers
const newBlob = new Blob([blob], {type: contentType})
download(newBlob, filename, contentType)
}
}
const api = new API(globalStore)

View file

@ -1,13 +1,42 @@
import React from 'react'
import {Link} from 'react-router-dom'
import {Button} from 'semantic-ui-react'
import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react'
export default function TrackActions({slug}) {
export default function TrackActions({slug, isAuthor, onDownloadOriginal}) {
return (
<Button.Group vertical>
<Link to={`/tracks/${slug}/edit`}>
<Button primary>Edit track</Button>
</Link>
</Button.Group>
<>
{isAuthor ? (
<Dropdown text="Download" button>
<Dropdown.Menu>
<Popup
content={
<>
<p>Only you, the author of this track, can download the original file.</p>
<p>
This is the file as it was uploaded to the server, without modifications, and it can be used with
other tools. Exporting to other formats, and downloading modified files, will be implemented soon.
</p>
</>
}
trigger={<Dropdown.Item text="Original" onClick={onDownloadOriginal} />}
/>
</Dropdown.Menu>
</Dropdown>
) : (
<>
<Button disabled>Download</Button>
<Popup
content={<p>Only the author of this track can download the original file.</p>}
trigger={<Icon name="info circle" />}
/>
</>
)}
{isAuthor && (
<Link to={`/tracks/${slug}/edit`}>
<Button primary>Edit track</Button>
</Link>
)}
</>
)
}

View file

@ -20,6 +20,11 @@ export default function TrackDetails({track, isAuthor}) {
{track.originalFileName != null && (
<List.Item>
{isAuthor && (
<div style={{float: 'right'}}>
</div>
)}
<List.Header>Original Filename</List.Header>
<code>{track.originalFileName}</code>
</List.Item>

View file

@ -1,6 +1,6 @@
import React from 'react'
import {connect} from 'react-redux'
import {Button, Table, Checkbox, Segment, Dimmer, Grid, Loader, Header, Message} from 'semantic-ui-react'
import {Table, Checkbox, Segment, Dimmer, Grid, Loader, Header, Message} from 'semantic-ui-react'
import {useParams, useHistory} from 'react-router-dom'
import {concat, combineLatest, of, from, Subject} from 'rxjs'
import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
@ -106,6 +106,13 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
[slug, reloadComments]
)
const onDownloadOriginal = React.useCallback(
() => {
api.downloadFile(`/tracks/${slug}/download/original.csv`)
},
[slug]
)
const isAuthor = login?.username === data?.track?.author?.username
const {track, trackData, comments} = data || {}
@ -147,7 +154,7 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
<>
<Header as="h1">{track.title || 'Unnamed track'}</Header>
<TrackDetails {...{track, isAuthor}} />
{isAuthor && <TrackActions {...{slug}} />}
<TrackActions {...{isAuthor, onDownloadOriginal, slug}} />
</>
)}
</Segment>