Translate TrackPage

This commit is contained in:
Paul Bienkowski 2022-07-24 21:51:27 +02:00
parent ab6cc6f6d0
commit 6f7c8d54f2
9 changed files with 292 additions and 145 deletions

View file

@ -0,0 +1,18 @@
import React from "react";
import { Icon } from "semantic-ui-react";
import { useTranslation } from "react-i18next";
export default function Visibility({ public: public_ }: { public: boolean }) {
const { t } = useTranslation();
const icon = public_ ? (
<Icon color="blue" name="eye" fitted />
) : (
<Icon name="eye slash" fitted />
);
const text = public_ ? t("general.public") : t("general.private");
return (
<>
{icon} {text}
</>
);
}

View file

@ -9,3 +9,4 @@ export {default as Page} from './Page'
export {default as Stats} from './Stats' export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'
export {default as Chart} from './Chart' export {default as Chart} from './Chart'
export {default as Visibility} from './Visibility'

View file

@ -1,40 +1,40 @@
import React from 'react' import React from 'react'
import {Link} from 'react-router-dom' import {Link} from 'react-router-dom'
import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react' import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react'
import { useTranslation } from "react-i18next";
export default function TrackActions({slug, isAuthor, onDownload}) { export default function TrackActions({slug, isAuthor, onDownload}) {
const { t } = useTranslation();
return ( return (
<> <>
{isAuthor && (
<Link to={`/tracks/${slug}/edit`}>
<Button primary>Edit track</Button>
</Link>
)}
<Dropdown text="Download" button upward>
<Dropdown.Menu>
<Dropdown.Item text="Original" onClick={() => onDownload('original.csv')} disabled={!isAuthor} />
<Dropdown.Item text="Track (GPX)" onClick={() => onDownload('track.gpx')} />
</Dropdown.Menu>
</Dropdown>
<Popup <Popup
trigger={<Icon name="info circle" />} trigger={<Icon name="info circle" />}
offset={[12, 0]} offset={[12, 0]}
content={ content={
isAuthor ? ( isAuthor ? (
<> <>
<p>Only you, the author of this track, can download the original file.</p> <p>{t('TrackPage.actions.hintAuthorOnly')}</p>
<p> <p>{t('TrackPage.actions.hintOriginal')}</p>
This is the file as it was uploaded to the server, without modifications, and it can be used with other
tools.
</p>
</> </>
) : ( ) : (
<p>Only the author of this track can download the original file.</p> <p>{t('TrackPage.actions.hintAuthorOnlyOthers')}</p>
) )
} }
/> />
<Dropdown text={t('TrackPage.actions.download')} button>
<Dropdown.Menu>
<Dropdown.Item text={t('TrackPage.actions.original')}onClick={() => onDownload('original.csv')} disabled={!isAuthor} />
<Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} />
</Dropdown.Menu>
</Dropdown>
{isAuthor && (
<Link to={`/tracks/${slug}/edit`}>
<Button primary>{t('TrackPage.actions.edit')}</Button>
</Link>
)}
</> </>
) )
} }

View file

@ -1,31 +1,57 @@
import React from 'react' import React from "react";
import {Message, Segment, Form, Button, Loader, Header, Comment} from 'semantic-ui-react' import {
import Markdown from 'react-markdown' Message,
Segment,
Form,
Button,
Loader,
Header,
Comment,
} from "semantic-ui-react";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import {Avatar, FormattedDate} from 'components' import { Avatar, FormattedDate } from "components";
function CommentForm({onSubmit}) { function CommentForm({ onSubmit }) {
const [body, setBody] = React.useState('') const { t } = useTranslation();
const [body, setBody] = React.useState("");
const onSubmitComment = React.useCallback(() => { const onSubmitComment = React.useCallback(() => {
onSubmit({body}) onSubmit({ body });
setBody('') setBody("");
}, [onSubmit, body]) }, [onSubmit, body]);
return ( return (
<Form reply onSubmit={onSubmitComment}> <Form reply onSubmit={onSubmitComment}>
<Form.TextArea rows={4} value={body} onChange={(e) => setBody(e.target.value)} /> <Form.TextArea
<Button content="Post comment" labelPosition="left" icon="edit" primary /> rows={4}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
content={t("TrackPage.comments.post")}
labelPosition="left"
icon="edit"
primary
/>
</Form> </Form>
) );
} }
export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) { export default function TrackComments({
comments,
onSubmit,
onDelete,
login,
hideLoader,
}) {
const { t } = useTranslation();
return ( return (
<> <>
<Comment.Group> <Comment.Group>
<Header as="h2" dividing> <Header as="h2" dividing>
Comments {t("TrackPage.comments.title")}
</Header> </Header>
<Loader active={!hideLoader && comments == null} inline /> <Loader active={!hideLoader && comments == null} inline />
@ -47,11 +73,11 @@ export default function TrackComments({comments, onSubmit, onDelete, login, hide
<Comment.Actions> <Comment.Actions>
<Comment.Action <Comment.Action
onClick={(e) => { onClick={(e) => {
onDelete(comment.id) onDelete(comment.id);
e.preventDefault() e.preventDefault();
}} }}
> >
Delete {t('general.delete')}
</Comment.Action> </Comment.Action>
</Comment.Actions> </Comment.Actions>
)} )}
@ -59,10 +85,12 @@ export default function TrackComments({comments, onSubmit, onDelete, login, hide
</Comment> </Comment>
))} ))}
{comments != null && !comments.length && <Message>Nobody commented... yet</Message>} {comments != null && !comments.length && (
<Message>{t("TrackPage.comments.empty")}</Message>
)}
{login && comments != null && <CommentForm onSubmit={onSubmit} />} {login && comments != null && <CommentForm onSubmit={onSubmit} />}
</Comment.Group> </Comment.Group>
</> </>
) );
} }

View file

@ -1,80 +1,85 @@
import React from 'react' import React from "react";
import {List} from 'semantic-ui-react' import _ from "lodash";
import {Duration} from 'luxon' import { List, Header, Grid } from "semantic-ui-react";
import { Duration } from "luxon";
import { useTranslation } from "react-i18next";
import {FormattedDate} from 'components' import { FormattedDate, Visibility } from "components";
function formatDuration(seconds) { function formatDuration(seconds) {
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'") return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
} }
export default function TrackDetails({track, isAuthor}) { export default function TrackDetails({ track, isAuthor }) {
const { t } = useTranslation();
const items = [
track.public != null &&
isAuthor && [
t("TrackPage.details.visibility"),
<Visibility public={track.public} />,
],
track.uploadedByUserAgent != null && [
t("TrackPage.details.uploadedWith"),
track.uploadedByUserAgent,
],
track.duration != null && [
t("TrackPage.details.duration"),
formatDuration(track.duration),
],
track.createdAt != null && [
t("TrackPage.details.uploadedDate"),
<FormattedDate date={track.createdAt} />,
],
track?.recordedAt != null && [
t("TrackPage.details.recordedDate"),
<FormattedDate date={track?.recordedAt} />,
],
track?.numEvents != null && [
t("TrackPage.details.numEvents"),
track?.numEvents,
],
track?.length != null && [
t("TrackPage.details.length"),
`${(track?.length / 1000).toFixed(2)} km`,
],
track?.processingStatus != null &&
track?.processingStatus != "error" && [
t("TrackPage.details.processingStatus"),
track.processingStatus,
],
track.originalFileName != null && [
t("TrackPage.details.originalFileName"),
<code>{track.originalFileName}</code>,
],
].filter(Boolean);
const COLUMNS = 4;
const chunkSize = Math.ceil(items.length / COLUMNS)
return ( return (
<List horizontal relaxed> <Grid>
{track.public != null && isAuthor && ( <Grid.Row columns={COLUMNS}>
<List.Item> {_.chunk(items, chunkSize).map((chunkItems, idx) => (
<List.Header>Visibility</List.Header> <Grid.Column key={idx}>
{track.public ? 'Public' : 'Private'}
</List.Item>
)}
{track.originalFileName != null && ( <List>
<List.Item> {chunkItems.map(([title, value]) => (
{isAuthor && <div style={{float: 'right'}}></div>} <List.Item key={title}>
<List.Header>{title}</List.Header>
<List.Header>Original Filename</List.Header> <List.Description>{value}</List.Description>
<code>{track.originalFileName}</code> </List.Item>))}
</List.Item>
)}
{track.uploadedByUserAgent != null && (
<List.Item>
<List.Header>Uploaded with</List.Header>
{track.uploadedByUserAgent}
</List.Item>
)}
{track.duration != null && (
<List.Item>
<List.Header>Duration</List.Header>
{formatDuration(track.duration)}
</List.Item>
)}
{track.createdAt != null && (
<List.Item>
<List.Header>Uploaded on</List.Header>
<FormattedDate date={track.createdAt} />
</List.Item>
)}
{track?.recordedAt != null && (
<List.Item>
<List.Header>Recorded on</List.Header>
<FormattedDate date={track?.recordedAt} />
</List.Item>
)}
{track?.numEvents != null && (
<List.Item>
<List.Header>Confirmed events</List.Header>
{track?.numEvents}
</List.Item>
)}
{track?.length != null && (
<List.Item>
<List.Header>Length</List.Header>
{(track?.length / 1000).toFixed(2)} km
</List.Item>
)}
{track?.processingStatus != null && track?.processingStatus != 'error' && (
<List.Item>
<List.Header>Processing</List.Header>
{track.processingStatus}
</List.Item>
)}
</List> </List>
) </Grid.Column>
))}
</Grid.Row>
</Grid>
);
} }

View file

@ -25,6 +25,7 @@ import {
} from "rxjs/operators"; } from "rxjs/operators";
import { useObservable } from "rxjs-hooks"; import { useObservable } from "rxjs-hooks";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import api from "api"; import api from "api";
import { Page } from "components"; import { Page } from "components";
@ -52,16 +53,17 @@ function TrackMapSettings({
side, side,
setSide, setSide,
}) { }) {
const { t } = useTranslation();
return ( return (
<> <>
<Header as="h4">Map settings</Header> <Header as="h4">{t("TrackPage.mapSettings.title")}</Header>
<List> <List>
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={showTrack} checked={showTrack}
onChange={(e, d) => setShowTrack(d.checked)} onChange={(e, d) => setShowTrack(d.checked)}
/>{" "} />{" "}
Show track {t("TrackPage.mapSettings.showTrack")}
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<span <span
style={{ style={{
@ -73,7 +75,7 @@ function TrackMapSettings({
marginRight: 4, marginRight: 4,
}} }}
/> />
GPS track {t("TrackPage.mapSettings.gpsTrack")}
</div> </div>
<div> <div>
<span <span
@ -86,11 +88,11 @@ function TrackMapSettings({
marginRight: 4, marginRight: 4,
}} }}
/> />
Snapped to road {t("TrackPage.mapSettings.snappedTrack")}
</div> </div>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>Points</List.Header> <List.Header> {t("TrackPage.mapSettings.points")} </List.Header>
<Dropdown <Dropdown
selection selection
value={pointsMode} value={pointsMode}
@ -100,18 +102,18 @@ function TrackMapSettings({
{ {
key: "overtakingEvents", key: "overtakingEvents",
value: "overtakingEvents", value: "overtakingEvents",
text: "Confirmed", text: t("TrackPage.mapSettings.confirmedPoints"),
}, },
{ {
key: "measurements", key: "measurements",
value: "measurements", value: "measurements",
text: "All measurements", text: t("TrackPage.mapSettings.allPoints"),
}, },
]} ]}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>Side (for color)</List.Header> <List.Header>{t("TrackPage.mapSettings.side")}</List.Header>
<Dropdown <Dropdown
selection selection
value={side} value={side}
@ -120,12 +122,12 @@ function TrackMapSettings({
{ {
key: "overtaker", key: "overtaker",
value: "overtaker", value: "overtaker",
text: "Overtaker (Left)", text: t("TrackPage.mapSettings.overtakerSide"),
}, },
{ {
key: "stationary", key: "stationary",
value: "stationary", value: "stationary",
text: "Stationary (Right)", text: t("TrackPage.mapSettings.stationarySide"),
}, },
]} ]}
/> />
@ -138,6 +140,7 @@ function TrackMapSettings({
const TrackPage = connect((state) => ({ login: state.login }))( const TrackPage = connect((state) => ({ login: state.login }))(
function TrackPage({ login }) { function TrackPage({ login }) {
const { slug } = useParams(); const { slug } = useParams();
const { t } = useTranslation();
const [reloadComments, reloadComments$] = useTriggerSubject(); const [reloadComments, reloadComments$] = useTriggerSubject();
const history = useHistory(); const history = useHistory();
@ -234,9 +237,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
await api.downloadFile(`/tracks/${slug}/download/${filename}`); await api.downloadFile(`/tracks/${slug}/download/${filename}`);
} catch (err) { } catch (err) {
if (/Failed to fetch/.test(String(err))) { if (/Failed to fetch/.test(String(err))) {
setDownloadError( setDownloadError(t("TrackPage.downloadError"));
"The track probably has not been imported correctly or recently enough. Please ask your administrator for assistance."
);
} else { } else {
setDownloadError(String(err)); setDownloadError(String(err));
} }
@ -259,7 +260,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements
const [side, setSide] = React.useState("overtaker"); // overtaker|stationary const [side, setSide] = React.useState("overtaker"); // overtaker|stationary
const title = track ? track.title || "Unnamed track" : null; const title = track ? track.title || t("general.unnamedTrack") : null;
return ( return (
<Page <Page
title={title} title={title}
@ -268,16 +269,23 @@ const TrackPage = connect((state) => ({ login: state.login }))(
<Container> <Container>
{track && ( {track && (
<Segment basic> <Segment basic>
<div style={{display: 'flex', alignItems: 'baseline', marginBlockStart: 32, marginBlockEnd: 16}}> <div
<Header as="h1">{title}</Header> style={{
<div style={{marginLeft: 'auto'}}> display: "flex",
alignItems: "baseline",
marginBlockStart: 32,
marginBlockEnd: 16,
}}
>
<Header as="h1">{title}</Header>
<div style={{ marginLeft: "auto" }}>
<TrackActions {...{ isAuthor, onDownload, slug }} /> <TrackActions {...{ isAuthor, onDownload, slug }} />
</div> </div>
</div> </div>
<div style={{marginBlockEnd: 16}}> <div style={{ marginBlockEnd: 16 }}>
<TrackDetails {...{ track, isAuthor }} /> <TrackDetails {...{ track, isAuthor }} />
</div> </div>
</Segment> </Segment>
)} )}
</Container> </Container>
@ -307,8 +315,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
{processing && ( {processing && (
<Message warning> <Message warning>
<Message.Content> <Message.Content>
Track data is still being processed, please reload page in {t("TrackPage.processing")}
a while.
</Message.Content> </Message.Content>
</Message> </Message>
)} )}
@ -316,8 +323,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
{error && ( {error && (
<Message error> <Message error>
<Message.Content> <Message.Content>
The processing of this track failed, please ask your site {t("TrackPage.processingError")}
administrator for help in debugging the issue.
</Message.Content> </Message.Content>
</Message> </Message>
)} )}
@ -328,7 +334,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
{track?.description && ( {track?.description && (
<> <>
<Header as="h2" dividing> <Header as="h2" dividing>
Description {t("TrackPage.description")}
</Header> </Header>
<Markdown>{track.description}</Markdown> <Markdown>{track.description}</Markdown>
</> </>
@ -347,7 +353,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
open={downloadError != null} open={downloadError != null}
cancelButton={false} cancelButton={false}
onConfirm={hideDownloadError} onConfirm={hideDownloadError}
header="Download failed" header={t("TrackPage.downloadFailed")}
content={String(downloadError)} content={String(downloadError)}
/> />
</Page> </Page>

View file

@ -114,15 +114,7 @@ export function TrackListItem({track, privateTracks = false}) {
</Item.Description> </Item.Description>
{privateTracks && ( {privateTracks && (
<Item.Extra> <Item.Extra>
{track.public ? ( <Visibility public={track.public} />
<>
<Icon color="blue" name="eye" fitted /> {t('general.public')}
</>
) : (
<>
<Icon name="eye slash" fitted /> {t('general.private')}
</>
)}
<span style={{marginLeft: '1em'}}> <span style={{marginLeft: '1em'}}>
<Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted /> <Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted />

View file

@ -7,6 +7,8 @@ general:
private: Privat private: Privat
show: Anzeigen show: Anzeigen
edit: Bearbeiten edit: Bearbeiten
save: Speichern
delete: Löschen
App: App:
footer: footer:
@ -221,3 +223,49 @@ SettingsPage:
generate: Neuen API-Schlüssel erstellen generate: Neuen API-Schlüssel erstellen
TrackPage:
downloadFailed: Download fehlgeschlagen
downloadError: Diese Fahrt wurde vermutlich nicht korrekt importiert, oder in letzter Zeit nicht aktualisiert. Bitte frage den Administrator um Hilfe mit diesem Problem.
processing: Diese Fahrt wird gerade importiert, bitte lade die Seite später neu.
processingError: Beim Import dieser Fahrt ist ein Fehler aufgetreten, bitte frage den Administrator um Hilfe mit diesem Problem.
description: Beschreibung
mapSettings:
title: Karteneinstellungen
showTrack: Route anzeigen
gpsTrack: GPS-Route
snappedTrack: Erkannte Straßenroute
points: Punkte
confirmedPoints: Bestätigte Überholungen
allPoints: Alle Messungen
side: Seite für Einfärbung
overtakerSide: Überholung (links)
stationarySide: Ruhender Verkehr (rechts)
details:
visibility: Sichtbarkeit
originalFileName: Original Dateiname
uploadedWith: Hochgeladen mit
duration: Dauer
uploadedDate: Hochgeladen am
recordedDate: Aufgezeichnet am
numEvents: Bestätigte Überholungen
length: Länge
processingStatus: Verarbeitung
actions:
edit: Fahrt bearbeiten
download: Herunterladen
original: Original
gpx: GPX-Track
hintAuthorOnly: Nur du, als Autor:in dieser Fahrt, kannst die Originaldatei herunterladen.
hintOriginal: Dies ist die Originaldatei, wie sie auf den Server hochgeladen wurde, und kann mit anderen Werkzeugen verwendet werden.
hintAuthorOnlyOthers: Nur der:die Autor:in dieser Fahrt kann die Originaldatei herunterladen.
comments:
title: Kommentare
post: Kommentar abschicken
empty: Bisher hat niemand diese Fahrt kommentiert.

View file

@ -12,6 +12,7 @@ general:
show: Show show: Show
edit: Edit edit: Edit
save: Save save: Save
delete: Delete
copied: Copied. copied: Copied.
copyError: Failed to copy. copyError: Failed to copy.
@ -226,3 +227,51 @@ SettingsPage:
generate: Generate new API key generate: Generate new API key
TrackPage:
downloadFailed: Download failed
downloadError: The track probably has not been imported correctly or recently enough. Please ask your administrator for assistance.
processing: Track data is still being processed, please reload page in a while.
processingError: The processing of this track failed, please ask your site administrator for help in debugging the issue.
description: Description
mapSettings:
title: Map settings
showTrack: Show track
gpsTrack: GPS track
snappedTrack: Snapped to road
points: Points
confirmedPoints: Confirmed
allPoints: All measurements
side: Side (for color)
overtakerSide: Overtaker (Left)
stationarySide: Stationary (Right)
details:
visibility: Visibility
originalFileName: Original Filename
uploadedWith: Uploaded with
duration: Duration
uploadedDate: Uploaded on
recordedDate: Recorded on
numEvents: Confirmed events
length: Length
processingStatus: Processing
actions:
edit: Edit track
download: Download
original: Original
gpx: Track (GPX)
hintAuthorOnly: Only you, the author of this track, can download the original file.
hintOriginal: This is the file as it was uploaded to the server, without modifications, and it can be used with other tools.
hintAuthorOnlyOthers: Only the author of this track can download the original file.
comments:
title: Comments
post: Post comment
empty: Nobody commented... yet