mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Localize dates and numbers (#574)
This commit is contained in:
@@ -47,6 +47,9 @@
|
||||
"react/prop-types": "off",
|
||||
"react/destructuring-assignment": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/style-prop-object": ["error", {
|
||||
"allow": ["FormattedNumber"]
|
||||
}],
|
||||
"spaced-comment": ["error", "always", {
|
||||
"markers": ["/"]
|
||||
}],
|
||||
|
||||
@@ -24,6 +24,12 @@ import Movies from "./components/Movies/Movies";
|
||||
// Set fontawesome/free-solid-svg as default fontawesome icons
|
||||
library.add(fas);
|
||||
|
||||
const intlFormats = {
|
||||
date: {
|
||||
long: { year: "numeric", month: "long", day: "numeric" },
|
||||
},
|
||||
};
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const config = useConfiguration();
|
||||
const language = config.data?.configuration?.interface?.language ?? "en-US";
|
||||
@@ -33,7 +39,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<IntlProvider locale={language} messages={messages}>
|
||||
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
|
||||
<ToastProvider>
|
||||
<MainNavbar />
|
||||
<div className="main container-fluid">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button, ButtonGroup } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { FormattedNumber, useIntl } from "react-intl";
|
||||
|
||||
interface IPaginationProps {
|
||||
itemsPerPage: number;
|
||||
@@ -62,7 +62,7 @@ export const Pagination: React.FC<IPaginationProps> = ({
|
||||
active={currentPage === page}
|
||||
onClick={() => onChangePage(page)}
|
||||
>
|
||||
{page}
|
||||
<FormattedNumber value={page} />
|
||||
</Button>
|
||||
));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card } from "react-bootstrap";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { FormattedPlural } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
||||
@@ -26,7 +27,16 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
||||
|
||||
function maybeRenderSceneNumber() {
|
||||
if (!props.sceneIndex) {
|
||||
return <span>{props.movie.scene_count} scenes.</span>;
|
||||
return (
|
||||
<span>
|
||||
{props.movie.scene_count}
|
||||
<FormattedPlural
|
||||
value={props.movie.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>Scene number: {props.sceneIndex}</span>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
useFindMovie,
|
||||
@@ -65,6 +66,8 @@ export const Movie: React.FC = () => {
|
||||
getMovieInput() as GQL.MovieDestroyInput
|
||||
);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
function updateMovieEditState(state: Partial<GQL.MovieDataFragment>) {
|
||||
setName(state.name ?? undefined);
|
||||
setAliases(state.aliases ?? undefined);
|
||||
@@ -238,8 +241,10 @@ export const Movie: React.FC = () => {
|
||||
setDuration(value ? Number.parseInt(value, 10) : undefined),
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Date (YYYY-MM-DD)",
|
||||
value: date,
|
||||
title: `Date ${isEditing ? "(YYYY-MM-DD)" : ""}`,
|
||||
value: isEditing
|
||||
? date
|
||||
: intl.formatDate(date, { format: "long" }),
|
||||
isEditing,
|
||||
onChange: setDate,
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Card } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormattedNumber, FormattedPlural } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { NavUtils, TextUtils } from "src/utils";
|
||||
import { CountryFlag } from "src/components/Shared";
|
||||
@@ -39,8 +40,17 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
||||
{age !== 0 ? <div className="text-muted">{ageString}</div> : ""}
|
||||
<CountryFlag country={performer.country} />
|
||||
<div className="text-muted">
|
||||
Stars in {performer.scene_count}{" "}
|
||||
<Link to={NavUtils.makePerformerScenesUrl(performer)}>scenes</Link>.
|
||||
Stars in
|
||||
<FormattedNumber value={performer.scene_count ?? 0} />
|
||||
|
||||
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
|
||||
<FormattedPlural
|
||||
value={performer.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Button, Popover, OverlayTrigger, Table } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -83,6 +84,8 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const Scrapers = useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
@@ -459,6 +462,20 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const formatHeight = () => {
|
||||
if (isEditing) {
|
||||
return height;
|
||||
}
|
||||
if (!height) {
|
||||
return "";
|
||||
}
|
||||
return intl.formatNumber(Number.parseInt(height, 10), {
|
||||
style: "unit",
|
||||
unit: "centimeter",
|
||||
unitDisplay: "narrow",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
@@ -471,7 +488,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
{renderGender()}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Birthdate",
|
||||
value: birthdate,
|
||||
value: isEditing
|
||||
? birthdate
|
||||
: intl.formatDate(birthdate, { format: "long" }),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setBirthdate,
|
||||
})}
|
||||
@@ -489,8 +508,8 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
onChange: setCountry,
|
||||
})}
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Height (cm)",
|
||||
value: height,
|
||||
title: `Height ${isEditing ? "(cm)" : ""}`,
|
||||
value: formatHeight(),
|
||||
isEditing: !!isEditing,
|
||||
onChange: setHeight,
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormattedDate } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { TagLink } from "src/components/Shared";
|
||||
@@ -38,7 +39,9 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
|
||||
{props.scene.title ?? TextUtils.fileNameFromPath(props.scene.path)}
|
||||
</h3>
|
||||
<div className="col-6 scene-details">
|
||||
<h4>{props.scene.date ?? ""}</h4>
|
||||
<h4>
|
||||
<FormattedDate value={props.scene.date ?? ""} format="long" />
|
||||
</h4>
|
||||
{props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ""}
|
||||
{props.scene.file.height && (
|
||||
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { FormattedNumber } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TextUtils } from "src/utils";
|
||||
|
||||
@@ -49,11 +50,23 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
||||
if (props.scene.file.size === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { size, unit } = TextUtils.fileSize(
|
||||
Number.parseInt(props.scene.file.size ?? "0", 10)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<span className="col-4">File Size</span>
|
||||
<span className="col-8 text-truncate">
|
||||
{TextUtils.fileSize(parseInt(props.scene.file.size ?? "0", 10))}
|
||||
<FormattedNumber
|
||||
value={size}
|
||||
// eslint-disable-next-line react/style-prop-object
|
||||
style="unit"
|
||||
unit={unit}
|
||||
unitDisplay="narrow"
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -95,13 +108,15 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
||||
<div className="row">
|
||||
<span className="col-4">Frame Rate</span>
|
||||
<span className="col-8 text-truncate">
|
||||
{props.scene.file.framerate} frames per second
|
||||
<FormattedNumber value={props.scene.file.framerate ?? 0} /> frames per
|
||||
second
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderbitrate() {
|
||||
// TODO: An upcoming react-intl version will support compound units, megabits-per-second
|
||||
if (props.scene.file.bitrate === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -109,7 +124,11 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
||||
<div className="row">
|
||||
<span className="col-4">Bit Rate</span>
|
||||
<span className="col-8 text-truncate">
|
||||
{TextUtils.bitRate(props.scene.file.bitrate ?? 0)}
|
||||
<FormattedNumber
|
||||
value={(props.scene.file.bitrate ?? 0) / 1000000}
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
megabits per second
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Card } from "react-bootstrap";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedPlural } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
studio: GQL.StudioDataFragment;
|
||||
@@ -19,7 +20,15 @@ export const StudioCard: React.FC<IProps> = ({ studio }) => {
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<h5 className="text-truncate">{studio.name}</h5>
|
||||
<span>{studio.scene_count} scenes.</span>
|
||||
<span>
|
||||
{studio.scene_count}
|
||||
<FormattedPlural
|
||||
value={studio.scene_count ?? 0}
|
||||
one="scene"
|
||||
other="scenes"
|
||||
/>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormattedNumber } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
mutateMetadataAutoTag,
|
||||
@@ -115,7 +116,7 @@ export const TagList: React.FC = () => {
|
||||
to={NavUtils.makeTagScenesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
Scenes: {tag.scene_count}
|
||||
Scenes: <FormattedNumber value={tag.scene_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
@@ -123,11 +124,14 @@ export const TagList: React.FC = () => {
|
||||
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
Markers: {tag.scene_marker_count}
|
||||
Markers: <FormattedNumber value={tag.scene_marker_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="tag-list-count">
|
||||
Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
|
||||
Total:{" "}
|
||||
<FormattedNumber
|
||||
value={(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
|
||||
/>
|
||||
</span>
|
||||
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
|
||||
<Icon icon="trash-alt" color="danger" />
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
const Units = ["bytes", "kB", "MB", "GB", "TB", "PB"];
|
||||
// Typescript currently does not implement the intl Unit interface
|
||||
type Unit =
|
||||
| "byte"
|
||||
| "kilobyte"
|
||||
| "megabyte"
|
||||
| "gigabyte"
|
||||
| "terabyte"
|
||||
| "petabyte";
|
||||
const Units: Unit[] = [
|
||||
"byte",
|
||||
"kilobyte",
|
||||
"megabyte",
|
||||
"gigabyte",
|
||||
"terabyte",
|
||||
"petabyte",
|
||||
];
|
||||
|
||||
const truncate = (
|
||||
value?: string,
|
||||
@@ -9,9 +24,9 @@ const truncate = (
|
||||
return value.length > limit ? value.substring(0, limit) + tail : value;
|
||||
};
|
||||
|
||||
const fileSize = (bytes: number = 0, precision: number = 2) => {
|
||||
const fileSize = (bytes: number = 0) => {
|
||||
if (Number.isNaN(parseFloat(String(bytes))) || !Number.isFinite(bytes))
|
||||
return "?";
|
||||
return { size: 0, unit: Units[0] };
|
||||
|
||||
let unit = 0;
|
||||
let count = bytes;
|
||||
@@ -20,7 +35,10 @@ const fileSize = (bytes: number = 0, precision: number = 2) => {
|
||||
unit++;
|
||||
}
|
||||
|
||||
return `${count.toFixed(+precision)} ${Units[unit]}`;
|
||||
return {
|
||||
size: count,
|
||||
unit: Units[unit],
|
||||
};
|
||||
};
|
||||
|
||||
const secondsToTimestamp = (seconds: number) => {
|
||||
|
||||
Reference in New Issue
Block a user