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