Incorporate i18n into UI elements (#1471)

* Update zh-tw string table (till 975343d2)
* Prepare localization table
* Implement i18n for Performers & Tags
* Add "add" action strings
* Use Lodash merge for deep merging language JSONs

The original implementation does not properly merge language files, causing unexpected localization string fallback behavior.

* Localize pagination strings
* Use Field name value as null id fallback

...otherwise FormattedMessage is gonna throw when the ID is null

* Use localized "Path" string for all instances
* Localize the "Interface" tab under settings
* Localize scene & performer cards
* Rename locale folder for better compatibility with i18n-ally
* Localize majority of the categories and features
This commit is contained in:
Still Hsu
2021-06-14 14:48:59 +09:00
committed by GitHub
parent 46bbede9a0
commit 3ae187e6f0
105 changed files with 3441 additions and 1084 deletions

View File

@@ -6,5 +6,12 @@
"javascript.preferences.importModuleSpecifier": "relative", "javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative", "typescript.preferences.importModuleSpecifier": "relative",
"editor.wordWrapColumn": 120, "editor.wordWrapColumn": 120,
"editor.rulers": [120] "editor.rulers": [
120
],
"i18n-ally.localesPaths": [
"src/locales"
],
"i18n-ally.keystyle": "nested",
"i18n-ally.sourceLanguage": "en-GB"
} }

View File

@@ -1,13 +1,14 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Route, Switch, useRouteMatch } from "react-router-dom"; import { Route, Switch, useRouteMatch } from "react-router-dom";
import { IntlProvider } from "react-intl"; import { IntlProvider } from "react-intl";
import { merge } from "lodash";
import { ToastProvider } from "src/hooks/Toast"; import { ToastProvider } from "src/hooks/Toast";
import LightboxProvider from "src/hooks/Lightbox/context"; import LightboxProvider from "src/hooks/Lightbox/context";
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons"; import { fas } from "@fortawesome/free-solid-svg-icons";
import { initPolyfills } from "src/polyfills"; import { initPolyfills } from "src/polyfills";
import locales from "src/locale"; import locales from "src/locales";
import { useConfiguration, useSystemStatus } from "src/core/StashService"; import { useConfiguration, useSystemStatus } from "src/core/StashService";
import { flattenMessages } from "src/utils"; import { flattenMessages } from "src/utils";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -58,12 +59,12 @@ export const App: React.FC = () => {
const messageLanguage = languageMessageString(language); const messageLanguage = languageMessageString(language);
// use en-GB as default messages if any messages aren't found in the chosen language // use en-GB as default messages if any messages aren't found in the chosen language
const mergedMessages = { const mergedMessages = merge(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
...(locales as any)[defaultMessageLanguage], (locales as any)[defaultMessageLanguage],
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
...(locales as any)[messageLanguage], (locales as any)[messageLanguage]
}; );
const messages = flattenMessages(mergedMessages); const messages = flattenMessages(mergedMessages);
const setupMatch = useRouteMatch(["/setup", "/migrate"]); const setupMatch = useRouteMatch(["/setup", "/migrate"]);

View File

@@ -9,6 +9,7 @@
* Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364)) * Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364))
### 🎨 Improvements ### 🎨 Improvements
* Added internationalisation for all UI pages and added zh-TW language option. ([#1471](https://github.com/stashapp/stash/pull/1471))
* Add option to disable audio for generated previews. ([#1454](https://github.com/stashapp/stash/pull/1454)) * Add option to disable audio for generated previews. ([#1454](https://github.com/stashapp/stash/pull/1454))
* Prompt when leaving scene edit page with unsaved changes. ([#1429](https://github.com/stashapp/stash/pull/1429)) * Prompt when leaving scene edit page with unsaved changes. ([#1429](https://github.com/stashapp/stash/pull/1429))
* Make multi-set mode buttons more obvious in multi-edit dialog. ([#1435](https://github.com/stashapp/stash/pull/1435)) * Make multi-set mode buttons more obvious in multi-edit dialog. ([#1435](https://github.com/stashapp/stash/pull/1435))

View File

@@ -4,7 +4,7 @@ import { useGalleryDestroy } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared"; import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormattedMessage } from "react-intl"; import { useIntl } from "react-intl";
interface IDeleteGalleryDialogProps { interface IDeleteGalleryDialogProps {
selected: Pick<GQL.Gallery, "id">[]; selected: Pick<GQL.Gallery, "id">[];
@@ -14,20 +14,22 @@ interface IDeleteGalleryDialogProps {
export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = ( export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
props: IDeleteGalleryDialogProps props: IDeleteGalleryDialogProps
) => { ) => {
const plural = props.selected.length > 1; const intl = useIntl();
const singularEntity = intl.formatMessage({ id: "gallery" });
const pluralEntity = intl.formatMessage({ id: "galleries" });
const singleMessageId = "deleteGalleryText"; const header = intl.formatMessage(
const pluralMessageId = "deleteGallerysText"; { id: "dialogs.delete_entity_title" },
{ count: props.selected.length, singularEntity, pluralEntity }
const singleMessage = );
"Are you sure you want to delete this gallery? Galleries for zip files will be re-added during the next scan unless the zip file is also deleted."; const toastMessage = intl.formatMessage(
const pluralMessage = { id: "toast.delete_entity" },
"Are you sure you want to delete these galleries? Galleries for zip files will be re-added during the next scan unless the zip files are also deleted."; { count: props.selected.length, singularEntity, pluralEntity }
);
const header = plural ? "Delete Galleries" : "Delete Gallery"; const message = intl.formatMessage(
const toastMessage = plural ? "Deleted galleries" : "Deleted gallery"; { id: "dialogs.delete_entity_desc" },
const messageId = plural ? pluralMessageId : singleMessageId; { count: props.selected.length, singularEntity, pluralEntity }
const message = plural ? pluralMessage : singleMessage; );
const [deleteFile, setDeleteFile] = useState<boolean>(false); const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true); const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
@@ -63,17 +65,19 @@ export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
show show
icon="trash-alt" icon="trash-alt"
header={header} header={header}
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }} accept={{
variant: "danger",
onClick: onDelete,
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isDeleting} isRunning={isDeleting}
> >
<p> <p>{message}</p>
<FormattedMessage id={messageId} defaultMessage={message} />
</p>
<Form> <Form>
<Form.Check <Form.Check
id="delete-file" id="delete-file"
@@ -84,7 +88,9 @@ export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
<Form.Check <Form.Check
id="delete-generated" id="delete-generated"
checked={deleteGenerated} checked={deleteGenerated}
label="Delete generated supporting files" label={intl.formatMessage({
id: "actions.delete_generated_supporting_files",
})}
onChange={() => setDeleteGenerated(!deleteGenerated)} onChange={() => setDeleteGenerated(!deleteGenerated)}
/> />
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap"; import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { useBulkGalleryUpdate } from "src/core/StashService"; import { useBulkGalleryUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -17,6 +18,7 @@ interface IListOperationProps {
export const EditGalleriesDialog: React.FC<IListOperationProps> = ( export const EditGalleriesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps props: IListOperationProps
) => { ) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
@@ -138,7 +140,14 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
input: getGalleryInput(), input: getGalleryInput(),
}, },
}); });
Toast.success({ content: "Updated galleries" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "galleries" }).toLocaleLowerCase(),
}
),
});
props.onClose(true); props.onClose(true);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -347,11 +356,21 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
<Modal <Modal
show show
icon="pencil-alt" icon="pencil-alt"
header="Edit Galleries" header={intl.formatMessage(
accept={{ onClick: onSave, text: "Apply" }} { id: "dialogs.edit_entity_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "gallery" }),
pluralEntity: intl.formatMessage({ id: "galleries" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isUpdating} isRunning={isUpdating}
@@ -359,7 +378,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
<Form> <Form>
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -372,7 +391,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Studio", title: intl.formatMessage({ id: "studio" }),
})} })}
<Col xs={9}> <Col xs={9}>
<StudioSelect <StudioSelect
@@ -386,19 +405,23 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
</Form.Group> </Form.Group>
<Form.Group controlId="performers"> <Form.Group controlId="performers">
<Form.Label>Performers</Form.Label> <Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="tags"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="organized"> <Form.Group controlId="organized">
<Form.Check <Form.Check
type="checkbox" type="checkbox"
label="Organized" label={intl.formatMessage({ id: "organized" })}
checked={organized} checked={organized}
ref={checkboxRef} ref={checkboxRef}
onChange={() => cycleOrganized()} onChange={() => cycleOrganized()}

View File

@@ -1,6 +1,7 @@
import { Tab, Nav, Dropdown } from "react-bootstrap"; import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom"; import { useParams, useHistory, Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { import {
mutateMetadataScan, mutateMetadataScan,
useFindGallery, useFindGallery,
@@ -28,6 +29,7 @@ export const Gallery: React.FC = () => {
const { tab = "images", id = "new" } = useParams<IGalleryParams>(); const { tab = "images", id = "new" } = useParams<IGalleryParams>();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const isNew = id === "new"; const isNew = id === "new";
const { data, error, loading } = useFindGallery(id); const { data, error, loading } = useFindGallery(id);
@@ -73,7 +75,15 @@ export const Gallery: React.FC = () => {
paths: [gallery.path], paths: [gallery.path],
}); });
Toast.success({ content: "Rescanning image" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.rescanning_entity" },
{
count: 1,
singularEntity: intl.formatMessage({ id: "gallery" }),
}
),
});
} }
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
@@ -103,7 +113,7 @@ export const Gallery: React.FC = () => {
variant="secondary" variant="secondary"
id="operation-menu" id="operation-menu"
className="minimal" className="minimal"
title="Operations" title={intl.formatMessage({ id: "operations" })}
> >
<Icon icon="ellipsis-v" /> <Icon icon="ellipsis-v" />
</Dropdown.Toggle> </Dropdown.Toggle>
@@ -114,7 +124,7 @@ export const Gallery: React.FC = () => {
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onRescan()} onClick={() => onRescan()}
> >
Rescan <FormattedMessage id="actions.rescan" />
</Dropdown.Item> </Dropdown.Item>
) : undefined} ) : undefined}
<Dropdown.Item <Dropdown.Item
@@ -122,7 +132,10 @@ export const Gallery: React.FC = () => {
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => setIsDeleteAlertOpen(true)} onClick={() => setIsDeleteAlertOpen(true)}
> >
Delete Gallery <FormattedMessage
id="actions.delete_entity"
values={{ entityType: intl.formatMessage({ id: "gallery" }) }}
/>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
@@ -142,22 +155,28 @@ export const Gallery: React.FC = () => {
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="gallery-details-panel">Details</Nav.Link> <Nav.Link eventKey="gallery-details-panel">
<FormattedMessage id="details" />
</Nav.Link>
</Nav.Item> </Nav.Item>
{gallery.scenes.length > 0 && ( {gallery.scenes.length > 0 && (
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="gallery-scenes-panel">Scenes</Nav.Link> <Nav.Link eventKey="gallery-scenes-panel">
<FormattedMessage id="scenes" />
</Nav.Link>
</Nav.Item> </Nav.Item>
)} )}
{gallery.path ? ( {gallery.path ? (
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="gallery-file-info-panel"> <Nav.Link eventKey="gallery-file-info-panel">
File Info <FormattedMessage id="file_info" />
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
) : undefined} ) : undefined}
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="gallery-edit-panel">Edit</Nav.Link> <Nav.Link eventKey="gallery-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item className="ml-auto"> <Nav.Item className="ml-auto">
<OrganizedButton <OrganizedButton
@@ -212,10 +231,14 @@ export const Gallery: React.FC = () => {
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="images">Images</Nav.Link> <Nav.Link eventKey="images">
<FormattedMessage id="images" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="add">Add</Nav.Link> <Nav.Link eventKey="add">
<FormattedMessage id="actions.add" />
</Nav.Link>
</Nav.Item> </Nav.Item>
</Nav> </Nav>
</div> </div>
@@ -255,7 +278,12 @@ export const Gallery: React.FC = () => {
return ( return (
<div className="row new-view"> <div className="row new-view">
<div className="col-6"> <div className="col-6">
<h2>Create Gallery</h2> <h2>
<FormattedMessage
id="actions.create_entity"
values={{ entityType: intl.formatMessage({ id: "gallery" }) }}
/>
</h2>
<GalleryEditPanel <GalleryEditPanel
isNew isNew
gallery={undefined} gallery={undefined}

View File

@@ -7,6 +7,7 @@ import { showWhenSelected } from "src/hooks/ListHook";
import { mutateAddGalleryImages } from "src/core/StashService"; import { mutateAddGalleryImages } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { useIntl } from "react-intl";
interface IGalleryAddProps { interface IGalleryAddProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: Partial<GQL.GalleryDataFragment>;
@@ -14,6 +15,7 @@ interface IGalleryAddProps {
export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => { export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
function filterHook(filter: ListFilterModel) { function filterHook(filter: ListFilterModel) {
const galleryValue = { const galleryValue = {
@@ -60,8 +62,16 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
gallery_id: gallery.id!, gallery_id: gallery.id!,
image_ids: Array.from(selectedIds.values()), image_ids: Array.from(selectedIds.values()),
}); });
const imageCount = selectedIds.size;
Toast.success({ Toast.success({
content: "Added images", content: intl.formatMessage(
{ id: "toast.added_entity" },
{
count: imageCount,
singularEntity: intl.formatMessage({ id: "image" }),
pluralEntity: intl.formatMessage({ id: "images" }),
}
),
}); });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -70,7 +80,10 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
const otherOperations = [ const otherOperations = [
{ {
text: "Add to Gallery", text: intl.formatMessage(
{ id: "actions.add_to_entity" },
{ entityType: intl.formatMessage({ id: "gallery" }) }
),
onClick: addImages, onClick: addImages,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
postRefetch: true, postRefetch: true,

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { import {
Button, Button,
@@ -49,6 +50,7 @@ interface IExistingProps {
export const GalleryEditPanel: React.FC< export const GalleryEditPanel: React.FC<
IProps & (INewProps | IExistingProps) IProps & (INewProps | IExistingProps)
> = ({ gallery, isNew, isVisible, onDelete }) => { > = ({ gallery, isNew, isVisible, onDelete }) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
const [title, setTitle] = useState<string>(gallery?.title ?? ""); const [title, setTitle] = useState<string>(gallery?.title ?? "");
@@ -173,7 +175,16 @@ export const GalleryEditPanel: React.FC<
}, },
}); });
if (result.data?.galleryUpdate) { if (result.data?.galleryUpdate) {
Toast.success({ content: "Updated gallery" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "gallery" })
.toLocaleLowerCase(),
}
),
});
} }
} }
} catch (e) { } catch (e) {
@@ -249,7 +260,7 @@ export const GalleryEditPanel: React.FC<
<DropdownButton <DropdownButton
className="d-inline-block" className="d-inline-block"
id="gallery-scrape" id="gallery-scrape"
title="Scrape with..." title={intl.formatMessage({ id: "actions.scrape_with" })}
> >
{queryableScrapers.map((s) => ( {queryableScrapers.map((s) => (
<Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}> <Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}>
@@ -260,7 +271,9 @@ export const GalleryEditPanel: React.FC<
<span className="fa-icon"> <span className="fa-icon">
<Icon icon="sync-alt" /> <Icon icon="sync-alt" />
</span> </span>
<span>Reload scrapers</span> <span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Dropdown.Item> </Dropdown.Item>
</DropdownButton> </DropdownButton>
); );
@@ -359,14 +372,14 @@ export const GalleryEditPanel: React.FC<
<div className="form-container row px-3 pt-3"> <div className="form-container row px-3 pt-3">
<div className="col edit-buttons mb-3 pl-0"> <div className="col edit-buttons mb-3 pl-0">
<Button className="edit-button" variant="primary" onClick={onSave}> <Button className="edit-button" variant="primary" onClick={onSave}>
Save <FormattedMessage id="actions.save" />
</Button> </Button>
<Button <Button
className="edit-button" className="edit-button"
variant="danger" variant="danger"
onClick={() => onDelete()} onClick={() => onDelete()}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
</div> </div>
<Col xs={6} className="text-right"> <Col xs={6} className="text-right">
@@ -376,21 +389,23 @@ export const GalleryEditPanel: React.FC<
<div className="form-container row px-3"> <div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12"> <div className="col-12 col-lg-6 col-xl-12">
{FormUtils.renderInputGroup({ {FormUtils.renderInputGroup({
title: "Title", title: intl.formatMessage({ id: "title" }),
value: title, value: title,
onChange: setTitle, onChange: setTitle,
isEditing: true, isEditing: true,
})} })}
<Form.Group controlId="url" as={Row}> <Form.Group controlId="url" as={Row}>
<Col xs={3} className="pr-0 url-label"> <Col xs={3} className="pr-0 url-label">
<Form.Label className="col-form-label">URL</Form.Label> <Form.Label className="col-form-label">
{intl.formatMessage({ id: "url" })}
</Form.Label>
<div className="float-right scrape-button-container"> <div className="float-right scrape-button-container">
{maybeRenderScrapeButton()} {maybeRenderScrapeButton()}
</div> </div>
</Col> </Col>
<Col xs={9}> <Col xs={9}>
{EditableTextUtils.renderInputGroup({ {EditableTextUtils.renderInputGroup({
title: "URL", title: intl.formatMessage({ id: "url" }),
value: url, value: url,
onChange: setUrl, onChange: setUrl,
isEditing: true, isEditing: true,
@@ -398,7 +413,7 @@ export const GalleryEditPanel: React.FC<
</Col> </Col>
</Form.Group> </Form.Group>
{FormUtils.renderInputGroup({ {FormUtils.renderInputGroup({
title: "Date", title: intl.formatMessage({ id: "date" }),
value: date, value: date,
isEditing: true, isEditing: true,
onChange: setDate, onChange: setDate,
@@ -406,7 +421,7 @@ export const GalleryEditPanel: React.FC<
})} })}
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -418,7 +433,7 @@ export const GalleryEditPanel: React.FC<
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Studio", title: intl.formatMessage({ id: "studio" }),
})} })}
<Col xs={9}> <Col xs={9}>
<StudioSelect <StudioSelect
@@ -432,7 +447,7 @@ export const GalleryEditPanel: React.FC<
<Form.Group controlId="performers" as={Row}> <Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Performers", title: intl.formatMessage({ id: "performers" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -452,7 +467,7 @@ export const GalleryEditPanel: React.FC<
<Form.Group controlId="tags" as={Row}> <Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Tags", title: intl.formatMessage({ id: "tags" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -470,7 +485,7 @@ export const GalleryEditPanel: React.FC<
<Form.Group controlId="scenes" as={Row}> <Form.Group controlId="scenes" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Scenes", title: intl.formatMessage({ id: "scenes" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -487,7 +502,9 @@ export const GalleryEditPanel: React.FC<
</div> </div>
<div className="col-12 col-lg-6 col-xl-12"> <div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="details"> <Form.Group controlId="details">
<Form.Label>Details</Form.Label> <Form.Label>
<FormattedMessage id="details" />
</Form.Label>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="gallery-description text-input" className="gallery-description text-input"

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
import { FormattedMessage } from "react-intl";
interface IGalleryFileInfoPanelProps { interface IGalleryFileInfoPanelProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
@@ -12,7 +13,9 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
function renderChecksum() { function renderChecksum() {
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Checksum</span> <span className="col-4">
<FormattedMessage id="media_info.checksum" />
</span>
<TruncatedText className="col-8" text={props.gallery.checksum} /> <TruncatedText className="col-8" text={props.gallery.checksum} />
</div> </div>
); );
@@ -23,7 +26,9 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Path</span> <span className="col-4">
<FormattedMessage id="path" />
</span>
<a href={filePath} className="col-8"> <a href={filePath} className="col-8">
<TruncatedText text={filePath} /> <TruncatedText text={filePath} />
</a> </a>

View File

@@ -7,6 +7,7 @@ import { mutateRemoveGalleryImages } from "src/core/StashService";
import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { useIntl } from "react-intl";
interface IGalleryDetailsProps { interface IGalleryDetailsProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
@@ -15,6 +16,7 @@ interface IGalleryDetailsProps {
export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
gallery, gallery,
}) => { }) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
function filterHook(filter: ListFilterModel) { function filterHook(filter: ListFilterModel) {
@@ -63,7 +65,10 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
image_ids: Array.from(selectedIds.values()), image_ids: Array.from(selectedIds.values()),
}); });
Toast.success({ Toast.success({
content: "Added images", content: intl.formatMessage(
{ id: "toast.added_entity" },
{ entity: intl.formatMessage({ id: "images" }) }
),
}); });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { StudioSelect, PerformerSelect } from "src/components/Shared"; import { StudioSelect, PerformerSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TagSelect } from "src/components/Shared/Select"; import { TagSelect } from "src/components/Shared/Select";
@@ -41,6 +42,7 @@ function renderScrapedStudio(
} }
function renderScrapedStudioRow( function renderScrapedStudioRow(
title: string,
result: ScrapeResult<string>, result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void, onChange: (value: ScrapeResult<string>) => void,
newStudio?: GQL.ScrapedSceneStudio, newStudio?: GQL.ScrapedSceneStudio,
@@ -48,7 +50,7 @@ function renderScrapedStudioRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Studio" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedStudio(result)} renderOriginalField={() => renderScrapedStudio(result)}
renderNewField={() => renderNewField={() =>
@@ -87,6 +89,7 @@ function renderScrapedPerformers(
} }
function renderScrapedPerformersRow( function renderScrapedPerformersRow(
title: string,
result: ScrapeResult<string[]>, result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void, onChange: (value: ScrapeResult<string[]>) => void,
newPerformers: GQL.ScrapedScenePerformer[], newPerformers: GQL.ScrapedScenePerformer[],
@@ -94,7 +97,7 @@ function renderScrapedPerformersRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Performers" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedPerformers(result)} renderOriginalField={() => renderScrapedPerformers(result)}
renderNewField={() => renderNewField={() =>
@@ -133,6 +136,7 @@ function renderScrapedTags(
} }
function renderScrapedTagsRow( function renderScrapedTagsRow(
title: string,
result: ScrapeResult<string[]>, result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void, onChange: (value: ScrapeResult<string[]>) => void,
newTags: GQL.ScrapedSceneTag[], newTags: GQL.ScrapedSceneTag[],
@@ -140,7 +144,7 @@ function renderScrapedTagsRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Tags" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedTags(result)} renderOriginalField={() => renderScrapedTags(result)}
renderNewField={() => renderNewField={() =>
@@ -169,6 +173,7 @@ interface IHasStoredID {
export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ( export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
props: IGalleryScrapeDialogProps props: IGalleryScrapeDialogProps
) => { ) => {
const intl = useIntl();
const [title, setTitle] = useState<ScrapeResult<string>>( const [title, setTitle] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.gallery.title, props.scraped.title) new ScrapeResult<string>(props.gallery.title, props.scraped.title)
); );
@@ -288,7 +293,13 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
Toast.success({ Toast.success({
content: ( content: (
<span> <span>
Created studio: <b>{toCreate.name}</b> <FormattedMessage
id="actions.created_entity"
values={{
entity_type: intl.formatMessage({ id: "studio" }),
entity_name: <b>{toCreate.name}</b>,
}}
/>
</span> </span>
), ),
}); });
@@ -323,7 +334,13 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
Toast.success({ Toast.success({
content: ( content: (
<span> <span>
Created performer: <b>{toCreate.name}</b> <FormattedMessage
id="actions.created_entity"
values={{
entity_type: intl.formatMessage({ id: "performer" }),
entity_name: <b>{toCreate.name}</b>,
}}
/>
</span> </span>
), ),
}); });
@@ -359,7 +376,13 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
Toast.success({ Toast.success({
content: ( content: (
<span> <span>
Created tag: <b>{toCreate.name}</b> <FormattedMessage
id="actions.created_entity"
values={{
entity_type: intl.formatMessage({ id: "tag" }),
entity_name: <b>{toCreate.name}</b>,
}}
/>
</span> </span>
), ),
}); });
@@ -401,41 +424,44 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
return ( return (
<> <>
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Title" title={intl.formatMessage({ id: "title" })}
result={title} result={title}
onChange={(value) => setTitle(value)} onChange={(value) => setTitle(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="URL" title={intl.formatMessage({ id: "url" })}
result={url} result={url}
onChange={(value) => setURL(value)} onChange={(value) => setURL(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Date" title={intl.formatMessage({ id: "date" })}
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
result={date} result={date}
onChange={(value) => setDate(value)} onChange={(value) => setDate(value)}
/> />
{renderScrapedStudioRow( {renderScrapedStudioRow(
intl.formatMessage({ id: "studios" }),
studio, studio,
(value) => setStudio(value), (value) => setStudio(value),
newStudio, newStudio,
createNewStudio createNewStudio
)} )}
{renderScrapedPerformersRow( {renderScrapedPerformersRow(
intl.formatMessage({ id: "performers" }),
performers, performers,
(value) => setPerformers(value), (value) => setPerformers(value),
newPerformers, newPerformers,
createNewPerformer createNewPerformer
)} )}
{renderScrapedTagsRow( {renderScrapedTagsRow(
intl.formatMessage({ id: "tags" }),
tags, tags,
(value) => setTags(value), (value) => setTags(value),
newTags, newTags,
createNewTag createNewTag
)} )}
<ScrapedTextAreaRow <ScrapedTextAreaRow
title="Details" title={intl.formatMessage({ id: "details" })}
result={details} result={details}
onChange={(value) => setDetails(value)} onChange={(value) => setDetails(value)}
/> />

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom"; import { Link, useHistory } from "react-router-dom";
@@ -28,22 +29,23 @@ export const GalleryList: React.FC<IGalleryList> = ({
filterHook, filterHook,
persistState, persistState,
}) => { }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const otherOperations = [ const otherOperations = [
{ {
text: "View Random", text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom, onClick: viewRandom,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]; ];
@@ -183,8 +185,10 @@ export const GalleryList: React.FC<IGalleryList> = ({
<Table className="col col-sm-6 mx-auto"> <Table className="col col-sm-6 mx-auto">
<thead> <thead>
<tr> <tr>
<th>Preview</th> <th>{intl.formatMessage({ id: "actions.preview" })}</th>
<th className="d-none d-sm-none">Title</th> <th className="d-none d-sm-none">
{intl.formatMessage({ id: "title" })}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -4,7 +4,7 @@ import { useImagesDestroy } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared"; import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormattedMessage } from "react-intl"; import { useIntl } from "react-intl";
interface IDeleteImageDialogProps { interface IDeleteImageDialogProps {
selected: GQL.SlimImageDataFragment[]; selected: GQL.SlimImageDataFragment[];
@@ -14,20 +14,22 @@ interface IDeleteImageDialogProps {
export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = ( export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
props: IDeleteImageDialogProps props: IDeleteImageDialogProps
) => { ) => {
const plural = props.selected.length > 1; const intl = useIntl();
const singularEntity = intl.formatMessage({ id: "image" });
const pluralEntity = intl.formatMessage({ id: "images" });
const singleMessageId = "deleteImageText"; const header = intl.formatMessage(
const pluralMessageId = "deleteImagesText"; { id: "dialogs.delete_entity_title" },
{ count: props.selected.length, singularEntity, pluralEntity }
const singleMessage = );
"Are you sure you want to delete this image? Unless the file is also deleted, this image will be re-added when scan is performed."; const toastMessage = intl.formatMessage(
const pluralMessage = { id: "toast.delete_entity" },
"Are you sure you want to delete these images? Unless the files are also deleted, these images will be re-added when scan is performed."; { count: props.selected.length, singularEntity, pluralEntity }
);
const header = plural ? "Delete Images" : "Delete Image"; const message = intl.formatMessage(
const toastMessage = plural ? "Deleted images" : "Deleted image"; { id: "dialogs.delete_entity_desc" },
const messageId = plural ? pluralMessageId : singleMessageId; { count: props.selected.length, singularEntity, pluralEntity }
const message = plural ? pluralMessage : singleMessage; );
const [deleteFile, setDeleteFile] = useState<boolean>(false); const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true); const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
@@ -63,28 +65,32 @@ export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
show show
icon="trash-alt" icon="trash-alt"
header={header} header={header}
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }} accept={{
variant: "danger",
onClick: onDelete,
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isDeleting} isRunning={isDeleting}
> >
<p> <p>{message}</p>
<FormattedMessage id={messageId} defaultMessage={message} />
</p>
<Form> <Form>
<Form.Check <Form.Check
id="delete-image" id="delete-image"
checked={deleteFile} checked={deleteFile}
label="Delete file" label={intl.formatMessage({ id: "actions.delete_file" })}
onChange={() => setDeleteFile(!deleteFile)} onChange={() => setDeleteFile(!deleteFile)}
/> />
<Form.Check <Form.Check
id="delete-image-generated" id="delete-image-generated"
checked={deleteGenerated} checked={deleteGenerated}
label="Delete generated supporting files" label={intl.formatMessage({
id: "actions.delete_generated_supporting_files",
})}
onChange={() => setDeleteGenerated(!deleteGenerated)} onChange={() => setDeleteGenerated(!deleteGenerated)}
/> />
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap"; import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { useBulkImageUpdate } from "src/core/StashService"; import { useBulkImageUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -17,6 +18,7 @@ interface IListOperationProps {
export const EditImagesDialog: React.FC<IListOperationProps> = ( export const EditImagesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps props: IListOperationProps
) => { ) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
@@ -138,7 +140,12 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
input: getImageInput(), input: getImageInput(),
}, },
}); });
Toast.success({ content: "Updated images" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "images" }).toLocaleLowerCase() }
),
});
props.onClose(true); props.onClose(true);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -344,11 +351,21 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
<Modal <Modal
show show
icon="pencil-alt" icon="pencil-alt"
header="Edit Images" header={intl.formatMessage(
accept={{ onClick: onSave, text: "Apply" }} { id: "dialogs.edit_entity_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "image" }),
pluralEntity: intl.formatMessage({ id: "images" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isUpdating} isRunning={isUpdating}
@@ -356,7 +373,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
<Form> <Form>
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -369,7 +386,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Studio", title: intl.formatMessage({ id: "studio" }),
})} })}
<Col xs={9}> <Col xs={9}>
<StudioSelect <StudioSelect
@@ -383,19 +400,23 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
</Form.Group> </Form.Group>
<Form.Group controlId="performers"> <Form.Group controlId="performers">
<Form.Label>Performers</Form.Label> <Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="tags"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="organized"> <Form.Group controlId="organized">
<Form.Check <Form.Check
type="checkbox" type="checkbox"
label="Organized" label={intl.formatMessage({ id: "organized" })}
checked={organized} checked={organized}
ref={checkboxRef} ref={checkboxRef}
onChange={() => cycleOrganized()} onChange={() => cycleOrganized()}

View File

@@ -1,5 +1,6 @@
import { Tab, Nav, Dropdown } from "react-bootstrap"; import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useHistory, Link } from "react-router-dom"; import { useParams, useHistory, Link } from "react-router-dom";
import { import {
useFindImage, useFindImage,
@@ -28,6 +29,7 @@ export const Image: React.FC = () => {
const { id = "new" } = useParams<IImageParams>(); const { id = "new" } = useParams<IImageParams>();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const { data, error, loading } = useFindImage(id); const { data, error, loading } = useFindImage(id);
const image = data?.findImage; const image = data?.findImage;
@@ -53,7 +55,15 @@ export const Image: React.FC = () => {
paths: [image.path], paths: [image.path],
}); });
Toast.success({ content: "Rescanning image" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.rescanning_entity" },
{
count: 1,
singularEntity: intl.formatMessage({ id: "image" }),
}
),
});
} }
const onOrganizedClick = async () => { const onOrganizedClick = async () => {
@@ -139,14 +149,17 @@ export const Image: React.FC = () => {
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onRescan()} onClick={() => onRescan()}
> >
Rescan <FormattedMessage id="actions.rescan" />
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
key="delete-image" key="delete-image"
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => setIsDeleteAlertOpen(true)} onClick={() => setIsDeleteAlertOpen(true)}
> >
Delete Image <FormattedMessage
id="actions.delete_entity"
values={{ entityType: intl.formatMessage({ id: "image" }) }}
/>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
@@ -166,13 +179,19 @@ export const Image: React.FC = () => {
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="image-details-panel">Details</Nav.Link> <Nav.Link eventKey="image-details-panel">
<FormattedMessage id="details" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="image-file-info-panel">File Info</Nav.Link> <Nav.Link eventKey="image-file-info-panel">
<FormattedMessage id="file_info" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="image-edit-panel">Edit</Nav.Link> <Nav.Link eventKey="image-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item className="ml-auto"> <Nav.Item className="ml-auto">
<OCounterButton <OCounterButton

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, Col, Row } from "react-bootstrap"; import { Button, Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useImageUpdate } from "src/core/StashService"; import { useImageUpdate } from "src/core/StashService";
@@ -24,6 +25,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
isVisible, isVisible,
onDelete, onDelete,
}) => { }) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [title, setTitle] = useState<string>(image?.title ?? ""); const [title, setTitle] = useState<string>(image?.title ?? "");
const [rating, setRating] = useState<number>(image.rating ?? NaN); const [rating, setRating] = useState<number>(image.rating ?? NaN);
@@ -102,7 +104,12 @@ export const ImageEditPanel: React.FC<IProps> = ({
}, },
}); });
if (result.data?.imageUpdate) { if (result.data?.imageUpdate) {
Toast.success({ content: "Updated image" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() }
),
});
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -117,28 +124,28 @@ export const ImageEditPanel: React.FC<IProps> = ({
<div className="form-container row px-3 pt-3"> <div className="form-container row px-3 pt-3">
<div className="col edit-buttons mb-3 pl-0"> <div className="col edit-buttons mb-3 pl-0">
<Button className="edit-button" variant="primary" onClick={onSave}> <Button className="edit-button" variant="primary" onClick={onSave}>
Save <FormattedMessage id="actions.save" />
</Button> </Button>
<Button <Button
className="edit-button" className="edit-button"
variant="danger" variant="danger"
onClick={() => onDelete()} onClick={() => onDelete()}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
</div> </div>
</div> </div>
<div className="form-container row px-3"> <div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12"> <div className="col-12 col-lg-6 col-xl-12">
{FormUtils.renderInputGroup({ {FormUtils.renderInputGroup({
title: "Title", title: intl.formatMessage({ id: "title" }),
value: title, value: title,
onChange: setTitle, onChange: setTitle,
isEditing: true, isEditing: true,
})} })}
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -150,7 +157,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Studio", title: intl.formatMessage({ id: "studio" }),
})} })}
<Col xs={9}> <Col xs={9}>
<StudioSelect <StudioSelect
@@ -164,7 +171,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
<Form.Group controlId="performers" as={Row}> <Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Performers", title: intl.formatMessage({ id: "performers" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -184,7 +191,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
<Form.Group controlId="tags" as={Row}> <Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Tags", title: intl.formatMessage({ id: "tags" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { FormattedNumber } from "react-intl"; import { FormattedMessage, 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";
import { TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
@@ -14,7 +14,9 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
function renderChecksum() { function renderChecksum() {
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Checksum</span> <span className="col-4">
<FormattedMessage id="media_info.checksum" />
</span>
<TruncatedText className="col-8" text={props.image.checksum} /> <TruncatedText className="col-8" text={props.image.checksum} />
</div> </div>
); );
@@ -26,7 +28,9 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
} = props; } = props;
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Path</span> <span className="col-4">
<FormattedMessage id="path" />
</span>
<a href={`file://${path}`} className="col-8"> <a href={`file://${path}`} className="col-8">
<TruncatedText text={`file://${props.image.path}`} /> <TruncatedText text={`file://${props.image.path}`} />
</a>{" "} </a>{" "}

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -118,22 +119,23 @@ export const ImageList: React.FC<IImageList> = ({
persistanceKey, persistanceKey,
extraOperations, extraOperations,
}) => { }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const otherOperations = (extraOperations ?? []).concat([ const otherOperations = (extraOperations ?? []).concat([
{ {
text: "View Random", text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom, onClick: viewRandom,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]); ]);

View File

@@ -13,7 +13,7 @@ import {
import { NoneCriterion } from "src/models/list-filter/criteria/none"; import { NoneCriterion } from "src/models/list-filter/criteria/none";
import { makeCriteria } from "src/models/list-filter/criteria/factory"; import { makeCriteria } from "src/models/list-filter/criteria/factory";
import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { defineMessages, useIntl } from "react-intl"; import { defineMessages, FormattedMessage, useIntl } from "react-intl";
import { import {
criterionIsHierarchicalLabelValue, criterionIsHierarchicalLabelValue,
CriterionType, CriterionType,
@@ -339,7 +339,9 @@ export const AddFilter: React.FC<IAddFilterProps> = (
return ( return (
<Form.Group controlId="filter"> <Form.Group controlId="filter">
<Form.Label>Filter</Form.Label> <Form.Label>
<FormattedMessage id="search_filter.name" />
</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={onChangedCriteriaType} onChange={onChangedCriteriaType}
@@ -356,12 +358,18 @@ export const AddFilter: React.FC<IAddFilterProps> = (
); );
} }
const title = !props.editingCriterion ? "Add Filter" : "Update Filter"; const title = !props.editingCriterion
? intl.formatMessage({ id: "search_filter.add_filter" })
: intl.formatMessage({ id: "search_filter.update_filter" });
return ( return (
<> <>
<OverlayTrigger <OverlayTrigger
placement="top" placement="top"
overlay={<Tooltip id="filter-tooltip">Filter</Tooltip>} overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.name" />
</Tooltip>
}
> >
<Button variant="secondary" onClick={() => onToggle()} active={isOpen}> <Button variant="secondary" onClick={() => onToggle()} active={isOpen}>
<Icon icon="filter" /> <Icon icon="filter" />

View File

@@ -20,7 +20,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { useFocus } from "src/utils"; import { useFocus } from "src/utils";
import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { import {
Criterion, Criterion,
CriterionValue, CriterionValue,
@@ -280,16 +280,22 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
} }
function getLabel(option: DisplayMode) { function getLabel(option: DisplayMode) {
let displayModeId = "unknown";
switch (option) { switch (option) {
case DisplayMode.Grid: case DisplayMode.Grid:
return "Grid"; displayModeId = "grid";
break;
case DisplayMode.List: case DisplayMode.List:
return "List"; displayModeId = "list";
break;
case DisplayMode.Wall: case DisplayMode.Wall:
return "Wall"; displayModeId = "wall";
break;
case DisplayMode.Tagger: case DisplayMode.Tagger:
return "Tagger"; displayModeId = "tagger";
break;
} }
return intl.formatMessage({ id: `display_mode.${displayModeId}` });
} }
return props.filterOptions.displayModeOptions.map((option) => ( return props.filterOptions.displayModeOptions.map((option) => (
@@ -361,7 +367,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onSelectAll()} onClick={() => onSelectAll()}
> >
Select All <FormattedMessage id="actions.select_all" />
</Dropdown.Item> </Dropdown.Item>
); );
} }
@@ -375,7 +381,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onSelectNone()} onClick={() => onSelectNone()}
> >
Select None <FormattedMessage id="actions.select_none" />
</Dropdown.Item> </Dropdown.Item>
); );
} }
@@ -450,7 +456,13 @@ export const ListFilter: React.FC<IListFilterProps> = (
return ( return (
<ButtonGroup className="ml-2"> <ButtonGroup className="ml-2">
{props.onEdit && ( {props.onEdit && (
<OverlayTrigger overlay={<Tooltip id="edit">Edit</Tooltip>}> <OverlayTrigger
overlay={
<Tooltip id="edit">
{intl.formatMessage({ id: "actions.edit" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onEdit}> <Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" /> <Icon icon="pencil-alt" />
</Button> </Button>
@@ -458,7 +470,13 @@ export const ListFilter: React.FC<IListFilterProps> = (
)} )}
{props.onDelete && ( {props.onDelete && (
<OverlayTrigger overlay={<Tooltip id="delete">Delete</Tooltip>}> <OverlayTrigger
overlay={
<Tooltip id="delete">
{intl.formatMessage({ id: "actions.delete" })}
</Tooltip>
}
>
<Button variant="danger" onClick={onDelete}> <Button variant="danger" onClick={onDelete}>
<Icon icon="trash" /> <Icon icon="trash" />
</Button> </Button>
@@ -481,7 +499,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
<InputGroup className="mr-2 flex-grow-1"> <InputGroup className="mr-2 flex-grow-1">
<FormControl <FormControl
ref={queryRef} ref={queryRef}
placeholder="Search..." placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
defaultValue={props.filter.searchTerm} defaultValue={props.filter.searchTerm}
onInput={onChangeQuery} onInput={onChangeQuery}
className="bg-secondary text-white border-secondary w-50" className="bg-secondary text-white border-secondary w-50"
@@ -510,8 +528,8 @@ export const ListFilter: React.FC<IListFilterProps> = (
overlay={ overlay={
<Tooltip id="sort-direction-tooltip"> <Tooltip id="sort-direction-tooltip">
{props.filter.sortDirection === SortDirectionEnum.Asc {props.filter.sortDirection === SortDirectionEnum.Asc
? "Ascending" ? intl.formatMessage({ id: "ascending" })
: "Descending"} : intl.formatMessage({ id: "descending" })}
</Tooltip> </Tooltip>
} }
> >
@@ -528,7 +546,9 @@ export const ListFilter: React.FC<IListFilterProps> = (
{props.filter.sortBy === "random" && ( {props.filter.sortBy === "random" && (
<OverlayTrigger <OverlayTrigger
overlay={ overlay={
<Tooltip id="sort-reshuffle-tooltip">Reshuffle</Tooltip> <Tooltip id="sort-reshuffle-tooltip">
{intl.formatMessage({ id: "actions.reshuffle" })}
</Tooltip>
} }
> >
<Button variant="secondary" onClick={onReshuffleRandomSort}> <Button variant="secondary" onClick={onReshuffleRandomSort}>

View File

@@ -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 { FormattedNumber, useIntl } from "react-intl"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
interface IPaginationProps { interface IPaginationProps {
itemsPerPage: number; itemsPerPage: number;
@@ -75,7 +75,9 @@ export const Pagination: React.FC<IPaginationProps> = ({
disabled={currentPage === 1} disabled={currentPage === 1}
onClick={() => onChangePage(1)} onClick={() => onChangePage(1)}
> >
<span className="d-none d-sm-inline">First</span> <span className="d-none d-sm-inline">
<FormattedMessage id="pagination.first" />
</span>
<span className="d-inline d-sm-none">&#x300a;</span> <span className="d-inline d-sm-none">&#x300a;</span>
</Button> </Button>
<Button <Button
@@ -84,7 +86,7 @@ export const Pagination: React.FC<IPaginationProps> = ({
disabled={currentPage === 1} disabled={currentPage === 1}
onClick={() => onChangePage(currentPage - 1)} onClick={() => onChangePage(currentPage - 1)}
> >
Previous <FormattedMessage id="pagination.previous" />
</Button> </Button>
{pageButtons} {pageButtons}
<Button <Button
@@ -93,14 +95,16 @@ export const Pagination: React.FC<IPaginationProps> = ({
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
onClick={() => onChangePage(currentPage + 1)} onClick={() => onChangePage(currentPage + 1)}
> >
Next <FormattedMessage id="pagination.next" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
onClick={() => onChangePage(totalPages)} onClick={() => onChangePage(totalPages)}
> >
<span className="d-none d-sm-inline">Last</span> <span className="d-none d-sm-inline">
<FormattedMessage id="pagination.last" />
</span>
<span className="d-inline d-sm-none">&#x300b;</span> <span className="d-inline d-sm-none">&#x300b;</span>
</Button> </Button>
</ButtonGroup> </ButtonGroup>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -24,6 +25,7 @@ interface IMovieParams {
} }
export const Movie: React.FC = () => { export const Movie: React.FC = () => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const { id = "new" } = useParams<IMovieParams>(); const { id = "new" } = useParams<IMovieParams>();
@@ -141,10 +143,23 @@ export const Movie: React.FC = () => {
<Modal <Modal
show={isDeleteAlertOpen} show={isDeleteAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: "Delete", variant: "danger", onClick: onDelete }} accept={{
text: intl.formatMessage({ id: "actions.delete" }),
variant: "danger",
onClick: onDelete,
}}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }} cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
> >
<p>Are you sure you want to delete {movie?.name ?? "movie"}?</p> <p>
<FormattedMessage
id="dialogs.delete_confirm"
values={{
entityName:
movie?.name ??
intl.formatMessage({ id: "movie" }).toLocaleLowerCase(),
}}
/>
</p>
</Modal> </Modal>
); );
} }

View File

@@ -17,7 +17,9 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
if (movie.aliases) { if (movie.aliases) {
return ( return (
<div> <div>
<span className="alias-head">Also known as </span> <span className="alias-head">
{intl.formatMessage({ id: "also_known_as" })}{" "}
</span>
<span className="alias">{movie.aliases}</span> <span className="alias">{movie.aliases}</span>
</div> </div>
); );
@@ -31,7 +33,9 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
return ( return (
<dl className="row"> <dl className="row">
<dt className="col-3 col-xl-2">Rating</dt> <dt className="col-3 col-xl-2">
{intl.formatMessage({ id: "rating" })}
</dt>
<dd className="col-9 col-xl-10"> <dd className="col-9 col-xl-10">
<RatingStars value={movie.rating} disabled /> <RatingStars value={movie.rating} disabled />
</dd> </dd>
@@ -50,31 +54,31 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
<div> <div>
<TextField <TextField
name="Duration" id="duration"
value={ value={
movie.duration ? DurationUtils.secondsToString(movie.duration) : "" movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
} }
/> />
<TextField <TextField
name="Date" id="date"
value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""} value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""}
/> />
<URLField <URLField
name="Studio" id="studio"
value={movie.studio?.name} value={movie.studio?.name}
url={`/studios/${movie.studio?.id}`} url={`/studios/${movie.studio?.id}`}
/> />
<TextField name="Director" value={movie.director} /> <TextField id="director" value={movie.director} />
{renderRatingField()} {renderRatingField()}
<URLField <URLField
name="URL" id="url"
value={movie.url} value={movie.url}
url={TextUtils.sanitiseURL(movie.url ?? "")} url={TextUtils.sanitiseURL(movie.url ?? "")}
/> />
<TextField name="Synopsis" value={movie.synopsis} /> <TextField id="synopsis" value={movie.synopsis} />
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -49,6 +50,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
setBackImage, setBackImage,
onImageEncoding, onImageEncoding,
}) => { }) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const isNew = movie === undefined; const isNew = movie === undefined;
@@ -332,7 +334,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
variant="secondary" variant="secondary"
onClick={() => setIsImageAlertOpen(false)} onClick={() => setIsImageAlertOpen(false)}
> >
Cancel <FormattedMessage id="actions.cancel" />
</Button> </Button>
<Button <Button
@@ -378,22 +380,29 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
// TODO: CSS class // TODO: CSS class
return ( return (
<div> <div>
{isNew && <h2>Add Movie</h2>} {isNew && (
<h2>
{intl.formatMessage(
{ id: "actions.add_entity" },
{ entityType: intl.formatMessage({ id: "movie" }) }
)}
</h2>
)}
<Prompt <Prompt
when={formik.dirty} when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?" message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
/> />
<Form noValidate onSubmit={formik.handleSubmit} id="movie-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="movie-edit">
<Form.Group controlId="name" as={Row}> <Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
Name {intl.formatMessage({ id: "name" })}
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
className="text-input" className="text-input"
placeholder="Name" placeholder={intl.formatMessage({ id: "name" })}
{...formik.getFieldProps("name")} {...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name} isInvalid={!!formik.errors.name}
/> />
@@ -403,11 +412,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Col> </Col>
</Form.Group> </Form.Group>
{renderTextField("aliases", "Aliases")} {renderTextField("aliases", intl.formatMessage({ id: "aliases" }))}
<Form.Group controlId="duration" as={Row}> <Form.Group controlId="duration" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Duration {intl.formatMessage({ id: "duration" })}
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<DurationInput <DurationInput
@@ -419,11 +428,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Col> </Col>
</Form.Group> </Form.Group>
{renderTextField("date", "Date (YYYY-MM-DD)")} {renderTextField("date", intl.formatMessage({ id: "date" }))}
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Studio {intl.formatMessage({ id: "studio" })}
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<StudioSelect <StudioSelect
@@ -438,11 +447,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Col> </Col>
</Form.Group> </Form.Group>
{renderTextField("director", "Director")} {renderTextField("director", intl.formatMessage({ id: "director" }))}
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Rating {intl.formatMessage({ id: "rating" })}
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<RatingStars <RatingStars
@@ -456,13 +465,13 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Form.Group controlId="url" as={Row}> <Form.Group controlId="url" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
URL {intl.formatMessage({ id: "url" })}
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<InputGroup> <InputGroup>
<Form.Control <Form.Control
className="text-input" className="text-input"
placeholder="URL" placeholder={intl.formatMessage({ id: "url" })}
{...formik.getFieldProps("url")} {...formik.getFieldProps("url")}
/> />
<InputGroup.Append>{maybeRenderScrapeButton()}</InputGroup.Append> <InputGroup.Append>{maybeRenderScrapeButton()}</InputGroup.Append>
@@ -472,13 +481,13 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Form.Group controlId="synopsis" as={Row}> <Form.Group controlId="synopsis" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Synopsis {intl.formatMessage({ id: "synopsis" })}
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="text-input" className="text-input"
placeholder="Synopsis" placeholder={intl.formatMessage({ id: "synopsis" })}
{...formik.getFieldProps("synopsis")} {...formik.getFieldProps("synopsis")}
/> />
</Col> </Col>

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
@@ -18,22 +19,23 @@ import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { MovieCard } from "./MovieCard"; import { MovieCard } from "./MovieCard";
export const MovieList: React.FC = () => { export const MovieList: React.FC = () => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const otherOperations = [ const otherOperations = [
{ {
text: "View Random", text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom, onClick: viewRandom,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]; ];
@@ -58,8 +60,8 @@ export const MovieList: React.FC = () => {
<DeleteEntityDialog <DeleteEntityDialog
selected={selectedMovies} selected={selectedMovies}
onClose={onClose} onClose={onClose}
singularEntity="movie" singularEntity={intl.formatMessage({ id: "movie" })}
pluralEntity="movies" pluralEntity={intl.formatMessage({ id: "movies" })}
destroyMutation={useMoviesDestroy} destroyMutation={useMoviesDestroy}
/> />
); );

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap"; import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { useBulkPerformerUpdate } from "src/core/StashService"; import { useBulkPerformerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -17,6 +18,7 @@ interface IListOperationProps {
export const EditPerformersDialog: React.FC<IListOperationProps> = ( export const EditPerformersDialog: React.FC<IListOperationProps> = (
props: IListOperationProps props: IListOperationProps
) => { ) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating, setRating] = useState<number>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>( const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
@@ -92,7 +94,16 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
setIsUpdating(true); setIsUpdating(true);
try { try {
await updatePerformers(); await updatePerformers();
Toast.success({ content: "Updated performers" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "performers" })
.toLocaleLowerCase(),
}
),
});
props.onClose(true); props.onClose(true);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -232,17 +243,20 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
show show
icon="pencil-alt" icon="pencil-alt"
header="Edit Performers" header="Edit Performers"
accept={{ onClick: onSave, text: "Apply" }} accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isUpdating} isRunning={isUpdating}
> >
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -254,7 +268,9 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
</Form.Group> </Form.Group>
<Form> <Form>
<Form.Group controlId="tags"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>

View File

@@ -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 { FormattedMessage, useIntl } 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 { import {
@@ -39,11 +40,22 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
onSelectedChanged, onSelectedChanged,
extraCriteria, extraCriteria,
}) => { }) => {
const intl = useIntl();
const age = TextUtils.age( const age = TextUtils.age(
performer.birthdate, performer.birthdate,
ageFromDate ?? performer.death_date ageFromDate ?? performer.death_date
); );
const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`; const ageL10nId = ageFromDate
? "media_info.performer_card.age_context"
: "media_info.performer_card.age";
const ageL10String = intl.formatMessage({
id: "years_old",
defaultMessage: "years old",
});
const ageString = intl.formatMessage(
{ id: ageL10nId },
{ age, years_old: ageL10String }
);
function maybeRenderFavoriteIcon() { function maybeRenderFavoriteIcon() {
if (performer.favorite === false) { if (performer.favorite === false) {
@@ -143,7 +155,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
performer.rating ? `rating-${performer.rating}` : "" performer.rating ? `rating-${performer.rating}` : ""
}`} }`}
> >
RATING: {performer.rating} <FormattedMessage id="rating" />: {performer.rating}
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Button, Tabs, Tab } from "react-bootstrap"; import { Button, Tabs, Tab } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import cx from "classnames"; import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -32,6 +33,7 @@ interface IPerformerParams {
export const Performer: React.FC = () => { export const Performer: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
const intl = useIntl();
const { tab = "details", id = "new" } = useParams<IPerformerParams>(); const { tab = "details", id = "new" } = useParams<IPerformerParams>();
const isNew = id === "new"; const isNew = id === "new";
@@ -126,19 +128,19 @@ export const Performer: React.FC = () => {
id="performer-details" id="performer-details"
unmountOnExit unmountOnExit
> >
<Tab eventKey="details" title="Details"> <Tab eventKey="details" title={intl.formatMessage({ id: "details" })}>
<PerformerDetailsPanel performer={performer} /> <PerformerDetailsPanel performer={performer} />
</Tab> </Tab>
<Tab eventKey="scenes" title="Scenes"> <Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}>
<PerformerScenesPanel performer={performer} /> <PerformerScenesPanel performer={performer} />
</Tab> </Tab>
<Tab eventKey="galleries" title="Galleries"> <Tab eventKey="galleries" title={intl.formatMessage({ id: "galleries" })}>
<PerformerGalleriesPanel performer={performer} /> <PerformerGalleriesPanel performer={performer} />
</Tab> </Tab>
<Tab eventKey="images" title="Images"> <Tab eventKey="images" title={intl.formatMessage({ id: "images" })}>
<PerformerImagesPanel performer={performer} /> <PerformerImagesPanel performer={performer} />
</Tab> </Tab>
<Tab eventKey="edit" title="Edit"> <Tab eventKey="edit" title={intl.formatMessage({ id: "actions.edit" })}>
<PerformerEditPanel <PerformerEditPanel
performer={performer} performer={performer}
isVisible={activeTabKey === "edit"} isVisible={activeTabKey === "edit"}
@@ -148,7 +150,10 @@ export const Performer: React.FC = () => {
onImageEncoding={onImageEncoding} onImageEncoding={onImageEncoding}
/> />
</Tab> </Tab>
<Tab eventKey="operations" title="Operations"> <Tab
eventKey="operations"
title={intl.formatMessage({ id: "operations" })}
>
<PerformerOperationsPanel performer={performer} /> <PerformerOperationsPanel performer={performer} />
</Tab> </Tab>
</Tabs> </Tabs>
@@ -163,7 +168,10 @@ export const Performer: React.FC = () => {
<span className="age"> <span className="age">
{TextUtils.age(performer.birthdate, performer.death_date)} {TextUtils.age(performer.birthdate, performer.death_date)}
</span> </span>
<span className="age-tail"> years old</span> <span className="age-tail">
{" "}
<FormattedMessage id="years_old" />
</span>
</div> </div>
); );
} }
@@ -173,7 +181,9 @@ export const Performer: React.FC = () => {
if (performer?.aliases) { if (performer?.aliases) {
return ( return (
<div> <div>
<span className="alias-head">Also known as </span> <span className="alias-head">
<FormattedMessage id="also_known_as" />{" "}
</span>
<span className="alias">{performer.aliases}</span> <span className="alias">{performer.aliases}</span>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { TagLink } from "src/components/Shared"; import { TagLink } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { genderToString } from "src/core/StashService"; import { genderToString } from "src/core/StashService";
@@ -24,7 +24,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<dl className="row"> <dl className="row">
<dt className="col-3 col-xl-2">Tags</dt> <dt className="col-3 col-xl-2">
<FormattedMessage id="tags" />
</dt>
<dd className="col-9 col-xl-10"> <dd className="col-9 col-xl-10">
<ul className="pl-0"> <ul className="pl-0">
{(performer.tags ?? []).map((tag) => ( {(performer.tags ?? []).map((tag) => (
@@ -43,7 +45,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-3 col-xl-2">Rating:</dt> <dt className="col-3 col-xl-2">
<FormattedMessage id="rating" />:
</dt>
<dd className="col-9 col-xl-10"> <dd className="col-9 col-xl-10">
<RatingStars value={performer.rating} /> <RatingStars value={performer.rating} />
</dd> </dd>
@@ -111,36 +115,36 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<> <>
<TextField <TextField
name="Gender" id="gender"
value={genderToString(performer.gender ?? undefined)} value={genderToString(performer.gender ?? undefined)}
/> />
<TextField <TextField
name="Birthdate" id="birthdate"
value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)} value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
/> />
<TextField <TextField
name="Death Date" id="death_date"
value={TextUtils.formatDate(intl, performer.death_date ?? undefined)} value={TextUtils.formatDate(intl, performer.death_date ?? undefined)}
/> />
<TextField name="Ethnicity" value={performer.ethnicity} /> <TextField id="ethnicity" value={performer.ethnicity} />
<TextField name="Hair Color" value={performer.hair_color} /> <TextField id="hair_color" value={performer.hair_color} />
<TextField name="Eye Color" value={performer.eye_color} /> <TextField id="eye_color" value={performer.eye_color} />
<TextField name="Country" value={performer.country} /> <TextField id="country" value={performer.country} />
<TextField name="Height" value={formatHeight(performer.height)} /> <TextField id="height" value={formatHeight(performer.height)} />
<TextField name="Weight" value={formatWeight(performer.weight)} /> <TextField id="weight" value={formatWeight(performer.weight)} />
<TextField name="Measurements" value={performer.measurements} /> <TextField id="measurements" value={performer.measurements} />
<TextField name="Fake Tits" value={performer.fake_tits} /> <TextField id="fake_tits" value={performer.fake_tits} />
<TextField name="Career Length" value={performer.career_length} /> <TextField id="career_length" value={performer.career_length} />
<TextField name="Tattoos" value={performer.tattoos} /> <TextField id="tattoos" value={performer.tattoos} />
<TextField name="Piercings" value={performer.piercings} /> <TextField id="piercings" value={performer.piercings} />
<TextField name="Details" value={performer.details} /> <TextField id="details" value={performer.details} />
<URLField <URLField
name="URL" id="url"
value={performer.url} value={performer.url}
url={TextUtils.sanitiseURL(performer.url ?? "")} url={TextUtils.sanitiseURL(performer.url ?? "")}
/> />
<URLField <URLField
name="Twitter" id="twitter"
value={performer.twitter} value={performer.twitter}
url={TextUtils.sanitiseURL( url={TextUtils.sanitiseURL(
performer.twitter ?? "", performer.twitter ?? "",
@@ -148,7 +152,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
)} )}
/> />
<URLField <URLField
name="Instagram" id="instagram"
value={performer.instagram} value={performer.instagram}
url={TextUtils.sanitiseURL( url={TextUtils.sanitiseURL(
performer.instagram ?? "", performer.instagram ?? "",

View File

@@ -9,6 +9,7 @@ import {
Row, Row,
Badge, Badge,
} from "react-bootstrap"; } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
@@ -64,6 +65,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onImageChange, onImageChange,
onImageEncoding, onImageEncoding,
}) => { }) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
@@ -610,7 +612,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<span className="fa-icon"> <span className="fa-icon">
<Icon icon="sync-alt" /> <Icon icon="sync-alt" />
</span> </span>
<span>Reload scrapers</span> <span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Button> </Button>
</div> </div>
</> </>
@@ -626,7 +630,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
rootClose rootClose
> >
<Button variant="secondary" className="mr-2"> <Button variant="secondary" className="mr-2">
Scrape with... <FormattedMessage id="actions.scrape_with" />
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
); );
@@ -694,7 +698,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
disabled={!formik.dirty} disabled={!formik.dirty}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
Save <FormattedMessage id="actions.save" />
</Button> </Button>
{!isNew ? ( {!isNew ? (
<Button <Button
@@ -702,7 +706,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
variant="danger" variant="danger"
onClick={() => setIsDeleteAlertOpen(true)} onClick={() => setIsDeleteAlertOpen(true)}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
) : ( ) : (
"" ""
@@ -718,7 +722,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
variant="danger" variant="danger"
onClick={() => formik.setFieldValue("image", null)} onClick={() => formik.setFieldValue("image", null)}
> >
Clear image <FormattedMessage id="actions.clear_image" />
</Button> </Button>
</Col> </Col>
</Row> </Row>
@@ -747,10 +751,19 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Modal <Modal
show={isDeleteAlertOpen} show={isDeleteAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: "Delete", variant: "danger", onClick: onDelete }} accept={{
text: intl.formatMessage({ id: "actions.delete" }),
variant: "danger",
onClick: onDelete,
}}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }} cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
> >
<p>Are you sure you want to delete {performer.name}?</p> <p>
<FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: performer.name }}
/>
</p>
</Modal> </Modal>
); );
} }
@@ -759,7 +772,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return ( return (
<Form.Group controlId="tags" as={Row}> <Form.Group controlId="tags" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Tags <FormattedMessage id="tags" defaultMessage="Tags" />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<TagSelect <TagSelect
@@ -838,7 +851,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return ( return (
<Form.Group controlId={field} as={Row}> <Form.Group controlId={field} as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
{title} <FormattedMessage id={field} defaultMessage={title} />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -866,7 +879,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="performer-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="performer-edit">
<Form.Group controlId="name" as={Row}> <Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
Name <FormattedMessage id="name" />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -883,7 +896,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form.Group controlId="aliases" as={Row}> <Form.Group controlId="aliases" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Alias <FormattedMessage id="aliases" />
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -897,7 +910,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form.Group as={Row}> <Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
Gender <FormattedMessage id="gender" />
</Form.Label> </Form.Label>
<Col xs="auto"> <Col xs="auto">
<Form.Control <Form.Control
@@ -927,7 +940,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form.Group controlId="tattoos" as={Row}> <Form.Group controlId="tattoos" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Tattoos <FormattedMessage id="tattoos" />
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -941,7 +954,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form.Group controlId="piercings" as={Row}> <Form.Group controlId="piercings" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Piercings <FormattedMessage id="piercings" />
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -955,9 +968,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderTextField("career_length", "Career Length")} {renderTextField("career_length", "Career Length")}
<Form.Group controlId="name" as={Row}> <Form.Group controlId="url" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
URL <FormattedMessage id="url" />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<InputGroup> <InputGroup>
@@ -975,7 +988,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderTextField("instagram", "Instagram")} {renderTextField("instagram", "Instagram")}
<Form.Group controlId="details" as={Row}> <Form.Group controlId="details" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
Details <FormattedMessage id="details" />
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -990,7 +1003,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
Rating <FormattedMessage id="rating" />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<RatingStars <RatingStars

View File

@@ -1,5 +1,6 @@
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import React from "react"; import React from "react";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { mutateMetadataAutoTag } from "src/core/StashService"; import { mutateMetadataAutoTag } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -25,5 +26,9 @@ export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({
} }
} }
return <Button onClick={onAutoTag}>Auto Tag</Button>; return (
<Button onClick={onAutoTag}>
<FormattedMessage id="actions.auto_tag" />
</Button>
);
}; };

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } 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 {
ScrapeDialog, ScrapeDialog,
@@ -49,12 +50,13 @@ function renderScrapedGender(
} }
function renderScrapedGenderRow( function renderScrapedGenderRow(
title: string,
result: ScrapeResult<string>, result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void onChange: (value: ScrapeResult<string>) => void
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Gender" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedGender(result)} renderOriginalField={() => renderScrapedGender(result)}
renderNewField={() => renderNewField={() =>
@@ -91,6 +93,7 @@ function renderScrapedTags(
} }
function renderScrapedTagsRow( function renderScrapedTagsRow(
title: string,
result: ScrapeResult<string[]>, result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void, onChange: (value: ScrapeResult<string[]>) => void,
newTags: GQL.ScrapedSceneTag[], newTags: GQL.ScrapedSceneTag[],
@@ -98,7 +101,7 @@ function renderScrapedTagsRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Tags" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedTags(result)} renderOriginalField={() => renderScrapedTags(result)}
renderNewField={() => renderNewField={() =>
@@ -123,6 +126,8 @@ interface IPerformerScrapeDialogProps {
export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = ( export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
props: IPerformerScrapeDialogProps props: IPerformerScrapeDialogProps
) => { ) => {
const intl = useIntl();
function translateScrapedGender(scrapedGender?: string | null) { function translateScrapedGender(scrapedGender?: string | null) {
if (!scrapedGender) { if (!scrapedGender) {
return; return;
@@ -385,109 +390,114 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
return ( return (
<> <>
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Name" title={intl.formatMessage({ id: "name" })}
result={name} result={name}
onChange={(value) => setName(value)} onChange={(value) => setName(value)}
/> />
<ScrapedTextAreaRow <ScrapedTextAreaRow
title="Aliases" title={intl.formatMessage({ id: "aliases" })}
result={aliases} result={aliases}
onChange={(value) => setAliases(value)} onChange={(value) => setAliases(value)}
/> />
{renderScrapedGenderRow(gender, (value) => setGender(value))} {renderScrapedGenderRow(
intl.formatMessage({ id: "gender" }),
gender,
(value) => setGender(value)
)}
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Birthdate" title={intl.formatMessage({ id: "birthdate" })}
result={birthdate} result={birthdate}
onChange={(value) => setBirthdate(value)} onChange={(value) => setBirthdate(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Death Date" title={intl.formatMessage({ id: "death_date" })}
result={deathDate} result={deathDate}
onChange={(value) => setDeathDate(value)} onChange={(value) => setDeathDate(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Ethnicity" title={intl.formatMessage({ id: "ethnicity" })}
result={ethnicity} result={ethnicity}
onChange={(value) => setEthnicity(value)} onChange={(value) => setEthnicity(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Country" title={intl.formatMessage({ id: "country" })}
result={country} result={country}
onChange={(value) => setCountry(value)} onChange={(value) => setCountry(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Hair Color" title={intl.formatMessage({ id: "hair_color" })}
result={hairColor} result={hairColor}
onChange={(value) => setHairColor(value)} onChange={(value) => setHairColor(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Eye Color" title={intl.formatMessage({ id: "eye_color" })}
result={eyeColor} result={eyeColor}
onChange={(value) => setEyeColor(value)} onChange={(value) => setEyeColor(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Weight" title={intl.formatMessage({ id: "weight" })}
result={weight} result={weight}
onChange={(value) => setWeight(value)} onChange={(value) => setWeight(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Height" title={intl.formatMessage({ id: "height" })}
result={height} result={height}
onChange={(value) => setHeight(value)} onChange={(value) => setHeight(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Measurements" title={intl.formatMessage({ id: "measurements" })}
result={measurements} result={measurements}
onChange={(value) => setMeasurements(value)} onChange={(value) => setMeasurements(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Fake Tits" title={intl.formatMessage({ id: "fake_tits" })}
result={fakeTits} result={fakeTits}
onChange={(value) => setFakeTits(value)} onChange={(value) => setFakeTits(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Career Length" title={intl.formatMessage({ id: "career_length" })}
result={careerLength} result={careerLength}
onChange={(value) => setCareerLength(value)} onChange={(value) => setCareerLength(value)}
/> />
<ScrapedTextAreaRow <ScrapedTextAreaRow
title="Tattoos" title={intl.formatMessage({ id: "tattoos" })}
result={tattoos} result={tattoos}
onChange={(value) => setTattoos(value)} onChange={(value) => setTattoos(value)}
/> />
<ScrapedTextAreaRow <ScrapedTextAreaRow
title="Piercings" title={intl.formatMessage({ id: "piercings" })}
result={piercings} result={piercings}
onChange={(value) => setPiercings(value)} onChange={(value) => setPiercings(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="URL" title={intl.formatMessage({ id: "url" })}
result={url} result={url}
onChange={(value) => setURL(value)} onChange={(value) => setURL(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Twitter" title={intl.formatMessage({ id: "twitter" })}
result={twitter} result={twitter}
onChange={(value) => setTwitter(value)} onChange={(value) => setTwitter(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Instagram" title={intl.formatMessage({ id: "instagram" })}
result={instagram} result={instagram}
onChange={(value) => setInstagram(value)} onChange={(value) => setInstagram(value)}
/> />
<ScrapedTextAreaRow <ScrapedTextAreaRow
title="Details" title={intl.formatMessage({ id: "details" })}
result={details} result={details}
onChange={(value) => setDetails(value)} onChange={(value) => setDetails(value)}
/> />
{renderScrapedTagsRow( {renderScrapedTagsRow(
intl.formatMessage({ id: "tags" }),
tags, tags,
(value) => setTags(value), (value) => setTags(value),
newTags, newTags,
createNewTag createNewTag
)} )}
<ScrapedImageRow <ScrapedImageRow
title="Performer Image" title={intl.formatMessage({ id: "performer_image" })}
className="performer-image" className="performer-image"
result={image} result={image}
onChange={(value) => setImage(value)} onChange={(value) => setImage(value)}
@@ -498,7 +508,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
return ( return (
<ScrapeDialog <ScrapeDialog
title="Performer Scrape Results" title={intl.formatMessage({ id: "dialogs.scrape_entity_title" })}
renderScrapeRows={renderScrapeRows} renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
props.onClose(apply ? makeNewScrapedItem() : undefined); props.onClose(apply ? makeNewScrapedItem() : undefined);

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Modal, LoadingIndicator } from "src/components/Shared"; import { Modal, LoadingIndicator } from "src/components/Shared";
@@ -24,6 +25,7 @@ const PerformerScrapeModal: React.FC<IProps> = ({
onHide, onHide,
onSelectPerformer, onSelectPerformer,
}) => { }) => {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState<string>(name ?? ""); const [query, setQuery] = useState<string>(name ?? "");
const { data, loading } = useScrapePerformerList(scraper.id, query); const { data, loading } = useScrapePerformerList(scraper.id, query);
@@ -41,7 +43,11 @@ const PerformerScrapeModal: React.FC<IProps> = ({
show show
onHide={onHide} onHide={onHide}
header={`Scrape performer from ${scraper.name}`} header={`Scrape performer from ${scraper.name}`}
accept={{ text: "Cancel", onClick: onHide, variant: "secondary" }} accept={{
text: intl.formatMessage({ id: "actions.cancel" }),
onClick: onHide,
variant: "secondary",
}}
> >
<div className={CLASSNAME}> <div className={CLASSNAME}>
<Form.Control <Form.Control

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Modal, LoadingIndicator } from "src/components/Shared"; import { Modal, LoadingIndicator } from "src/components/Shared";
@@ -24,6 +25,7 @@ const PerformerStashBoxModal: React.FC<IProps> = ({
onHide, onHide,
onSelectPerformer, onSelectPerformer,
}) => { }) => {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState<string>(name ?? ""); const [query, setQuery] = useState<string>(name ?? "");
const { data, loading } = GQL.useQueryStashBoxPerformerQuery({ const { data, loading } = GQL.useQueryStashBoxPerformerQuery({
@@ -49,7 +51,11 @@ const PerformerStashBoxModal: React.FC<IProps> = ({
show show
onHide={onHide} onHide={onHide}
header={`Scrape performer from ${instance.name ?? "Stash-Box"}`} header={`Scrape performer from ${instance.name ?? "Stash-Box"}`}
accept={{ text: "Cancel", onClick: onHide, variant: "secondary" }} accept={{
text: intl.formatMessage({ id: "actions.cancel" }),
onClick: onHide,
variant: "secondary",
}}
> >
<div className={CLASSNAME}> <div className={CLASSNAME}>
<Form.Control <Form.Control

View File

@@ -1,5 +1,6 @@
import _ from "lodash"; import _ from "lodash";
import React, { useState } from "react"; import React, { useState } from "react";
import { useIntl } from "react-intl";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { import {
@@ -31,6 +32,7 @@ export const PerformerList: React.FC<IPerformerList> = ({
persistState, persistState,
extraCriteria, extraCriteria,
}) => { }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
@@ -41,12 +43,12 @@ export const PerformerList: React.FC<IPerformerList> = ({
onClick: getRandom, onClick: getRandom,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]; ];
@@ -112,8 +114,8 @@ export const PerformerList: React.FC<IPerformerList> = ({
<DeleteEntityDialog <DeleteEntityDialog
selected={selectedPerformers} selected={selectedPerformers}
onClose={onClose} onClose={onClose}
singularEntity="performer" singularEntity={intl.formatMessage({ id: "performer" })}
pluralEntity="performers" pluralEntity={intl.formatMessage({ id: "performers" })}
destroyMutation={usePerformersDestroy} destroyMutation={usePerformersDestroy}
/> />
); );

View File

@@ -11,7 +11,7 @@ import {
Tooltip, Tooltip,
} from "react-bootstrap"; } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom"; import { Link, useHistory } from "react-router-dom";
import { FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import querystring from "query-string"; import querystring from "query-string";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -32,6 +32,7 @@ import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
const CLASSNAME = "duplicate-checker"; const CLASSNAME = "duplicate-checker";
export const SceneDuplicateChecker: React.FC = () => { export const SceneDuplicateChecker: React.FC = () => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const { page, size, distance } = querystring.parse(history.location.search); const { page, size, distance } = querystring.parse(history.location.search);
const currentPage = Number.parseInt( const currentPage = Number.parseInt(
@@ -321,10 +322,14 @@ export const SceneDuplicateChecker: React.FC = () => {
/> />
)} )}
{maybeRenderEdit()} {maybeRenderEdit()}
<h4>Duplicate Scenes</h4> <h4>
<FormattedMessage id="dupe_check.title" />
</h4>
<Form.Group> <Form.Group>
<Row noGutters> <Row noGutters>
<Form.Label>Search Accuracy</Form.Label> <Form.Label>
<FormattedMessage id="dupe_check.search_accuracy_label" />
</Form.Label>
<Col xs={2}> <Col xs={2}>
<Form.Control <Form.Control
as="select" as="select"
@@ -340,31 +345,53 @@ export const SceneDuplicateChecker: React.FC = () => {
defaultValue={distance ?? 0} defaultValue={distance ?? 0}
className="input-control ml-4" className="input-control ml-4"
> >
<option value={0}>Exact</option> <option value={0}>
<option value={4}>High</option> {intl.formatMessage({ id: "dupe_check.options.exact" })}
<option value={8}>Medium</option> </option>
<option value={10}>Low</option> <option value={4}>
{intl.formatMessage({ id: "dupe_check.options.high" })}
</option>
<option value={8}>
{intl.formatMessage({ id: "dupe_check.options.medium" })}
</option>
<option value={10}>
{intl.formatMessage({ id: "dupe_check.options.low" })}
</option>
</Form.Control> </Form.Control>
</Col> </Col>
</Row> </Row>
<Form.Text> <Form.Text>
Levels below &ldquo;Exact&rdquo; can take longer to calculate. False <FormattedMessage id="dupe_check.description" />
positives might also be returned on lower accuracy levels.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
{maybeRenderMissingPhashWarning()} {maybeRenderMissingPhashWarning()}
<div className="d-flex mb-2"> <div className="d-flex mb-2">
<h6 className="mr-auto align-self-center"> <h6 className="mr-auto align-self-center">
{scenes.length} sets of duplicates found. <FormattedMessage
id="dupe_check.found_sets"
values={{ setCount: scenes.length }}
/>
</h6> </h6>
{checkCount > 0 && ( {checkCount > 0 && (
<ButtonGroup> <ButtonGroup>
<OverlayTrigger overlay={<Tooltip id="edit">Edit</Tooltip>}> <OverlayTrigger
overlay={
<Tooltip id="edit">
{intl.formatMessage({ id: "actions.edit" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onEdit}> <Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" /> <Icon icon="pencil-alt" />
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
<OverlayTrigger overlay={<Tooltip id="delete">Delete</Tooltip>}> <OverlayTrigger
overlay={
<Tooltip id="delete">
{intl.formatMessage({ id: "actions.delete" })}
</Tooltip>
}
>
<Button variant="danger" onClick={handleDeleteChecked}> <Button variant="danger" onClick={handleDeleteChecked}>
<Icon icon="trash" /> <Icon icon="trash" />
</Button> </Button>
@@ -416,14 +443,14 @@ export const SceneDuplicateChecker: React.FC = () => {
<tr> <tr>
<th> </th> <th> </th>
<th> </th> <th> </th>
<th>Details</th> <th>{intl.formatMessage({ id: "details" })}</th>
<th> </th> <th> </th>
<th>Duration</th> <th>{intl.formatMessage({ id: "duration" })}</th>
<th>Filesize</th> <th>{intl.formatMessage({ id: "filesize" })}</th>
<th>Resolution</th> <th>{intl.formatMessage({ id: "resolution" })}</th>
<th>Bitrate</th> <th>{intl.formatMessage({ id: "bitrate" })}</th>
<th>Codec</th> <th>{intl.formatMessage({ id: "media_info.video_codec" })}</th>
<th>Delete</th> <th>{intl.formatMessage({ id: "actions.delete" })}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -494,7 +521,7 @@ export const SceneDuplicateChecker: React.FC = () => {
variant="danger" variant="danger"
onClick={() => handleDeleteScene(scene)} onClick={() => handleDeleteScene(scene)}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
</td> </td>
</tr> </tr>

View File

@@ -6,6 +6,7 @@ import {
Form, Form,
InputGroup, InputGroup,
} from "react-bootstrap"; } from "react-bootstrap";
import { useIntl } from "react-intl";
import { ParserField } from "./ParserField"; import { ParserField } from "./ParserField";
import { ShowFields } from "./ShowFields"; import { ShowFields } from "./ShowFields";
@@ -83,6 +84,7 @@ interface IParserInputProps {
export const ParserInput: React.FC<IParserInputProps> = ( export const ParserInput: React.FC<IParserInputProps> = (
props: IParserInputProps props: IParserInputProps
) => { ) => {
const intl = useIntl();
const [pattern, setPattern] = useState<string>(props.input.pattern); const [pattern, setPattern] = useState<string>(props.input.pattern);
const [ignoreWords, setIgnoreWords] = useState<string>( const [ignoreWords, setIgnoreWords] = useState<string>(
props.input.ignoreWords.join(" ") props.input.ignoreWords.join(" ")
@@ -127,7 +129,9 @@ export const ParserInput: React.FC<IParserInputProps> = (
<Form.Group> <Form.Group>
<Form.Group className="row"> <Form.Group className="row">
<Form.Label htmlFor="filename-pattern" className="col-2"> <Form.Label htmlFor="filename-pattern" className="col-2">
Filename Pattern {intl.formatMessage({
id: "config.tools.scene_filename_parser.filename_pattern",
})}
</Form.Label> </Form.Label>
<InputGroup className="col-8"> <InputGroup className="col-8">
<Form.Control <Form.Control
@@ -139,7 +143,12 @@ export const ParserInput: React.FC<IParserInputProps> = (
value={pattern} value={pattern}
/> />
<InputGroup.Append> <InputGroup.Append>
<DropdownButton id="parser-field-select" title="Add Field"> <DropdownButton
id="parser-field-select"
title={intl.formatMessage({
id: "config.tools.scene_filename_parser.add_field",
})}
>
{validFields.map((item) => ( {validFields.map((item) => (
<Dropdown.Item <Dropdown.Item
key={item.field} key={item.field}
@@ -153,12 +162,18 @@ export const ParserInput: React.FC<IParserInputProps> = (
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
<Form.Text className="text-muted row col-10 offset-2"> <Form.Text className="text-muted row col-10 offset-2">
Use &apos;\&apos; to escape literal {} characters {intl.formatMessage({
id: "config.tools.scene_filename_parser.escape_chars",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="row" controlId="ignored-words"> <Form.Group className="row" controlId="ignored-words">
<Form.Label className="col-2">Ignored words</Form.Label> <Form.Label className="col-2">
{intl.formatMessage({
id: "config.tools.scene_filename_parser.ignored_words",
})}
</Form.Label>
<InputGroup className="col-8"> <InputGroup className="col-8">
<Form.Control <Form.Control
className="text-input" className="text-input"
@@ -169,14 +184,18 @@ export const ParserInput: React.FC<IParserInputProps> = (
/> />
</InputGroup> </InputGroup>
<Form.Text className="text-muted col-10 offset-2"> <Form.Text className="text-muted col-10 offset-2">
Matches with {"{i}"} {intl.formatMessage({
id: "config.tools.scene_filename_parser.matches_with",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<h5>Title</h5> <h5>{intl.formatMessage({ id: "title" })}</h5>
<Form.Group className="row"> <Form.Group className="row">
<Form.Label htmlFor="whitespace-characters" className="col-2"> <Form.Label htmlFor="whitespace-characters" className="col-2">
Whitespace characters: {intl.formatMessage({
id: "config.tools.scene_filename_parser.whitespace_chars",
})}
</Form.Label> </Form.Label>
<InputGroup className="col-8"> <InputGroup className="col-8">
<Form.Control <Form.Control
@@ -188,7 +207,9 @@ export const ParserInput: React.FC<IParserInputProps> = (
/> />
</InputGroup> </InputGroup>
<Form.Text className="text-muted col-10 offset-2"> <Form.Text className="text-muted col-10 offset-2">
These characters will be replaced with whitespace in the title {intl.formatMessage({
id: "config.tools.scene_filename_parser.whitespace_chars_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
@@ -199,7 +220,11 @@ export const ParserInput: React.FC<IParserInputProps> = (
checked={capitalizeTitle} checked={capitalizeTitle}
onChange={() => setCapitalizeTitle(!capitalizeTitle)} onChange={() => setCapitalizeTitle(!capitalizeTitle)}
/> />
<Form.Label htmlFor="capitalize-title">Capitalize title</Form.Label> <Form.Label htmlFor="capitalize-title">
{intl.formatMessage({
id: "config.tools.scene_filename_parser.capitalize_title",
})}
</Form.Label>
</Form.Group> </Form.Group>
{/* TODO - mapping stuff will go here */} {/* TODO - mapping stuff will go here */}
@@ -208,7 +233,9 @@ export const ParserInput: React.FC<IParserInputProps> = (
<DropdownButton <DropdownButton
variant="secondary" variant="secondary"
id="recipe-select" id="recipe-select"
title="Select Parser Recipe" title={intl.formatMessage({
id: "config.tools.scene_filename_parser.select_parser_recipe",
})}
drop="up" drop="up"
> >
{builtInRecipes.map((item) => ( {builtInRecipes.map((item) => (
@@ -232,7 +259,7 @@ export const ParserInput: React.FC<IParserInputProps> = (
<Form.Group className="row"> <Form.Group className="row">
<Button variant="secondary" className="ml-3 col-1" onClick={onFind}> <Button variant="secondary" className="ml-3 col-1" onClick={onFind}>
Find {intl.formatMessage({ id: "actions.find" })}
</Button> </Button>
<Form.Control <Form.Control
as="select" as="select"

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useState, useCallback, useRef } from "react"; import React, { useEffect, useState, useCallback, useRef } from "react";
import { Button, Card, Form, Table } from "react-bootstrap"; import { Button, Card, Form, Table } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { import {
queryParseSceneFilenames, queryParseSceneFilenames,
@@ -35,6 +36,7 @@ const initialShowFieldsState = new Map<string, boolean>([
]); ]);
export const SceneFilenameParser: React.FC = () => { export const SceneFilenameParser: React.FC = () => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [parserResult, setParserResult] = useState<SceneParserResult[]>([]); const [parserResult, setParserResult] = useState<SceneParserResult[]>([]);
const [parserInput, setParserInput] = useState<IParserInput>( const [parserInput, setParserInput] = useState<IParserInput>(
@@ -184,7 +186,12 @@ export const SceneFilenameParser: React.FC = () => {
try { try {
await updateScenes(); await updateScenes();
Toast.success({ content: "Updated scenes" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "scenes" }).toLocaleLowerCase() }
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -333,17 +340,41 @@ export const SceneFilenameParser: React.FC = () => {
<Table> <Table>
<thead> <thead>
<tr className="scene-parser-row"> <tr className="scene-parser-row">
<th className="parser-field-filename">Filename</th> <th className="parser-field-filename">
{renderHeader("Title", allTitleSet, onSelectAllTitleSet)} {intl.formatMessage({
{renderHeader("Date", allDateSet, onSelectAllDateSet)} id: "config.tools.scene_filename_parser.filename",
{renderHeader("Rating", allRatingSet, onSelectAllRatingSet)} })}
</th>
{renderHeader( {renderHeader(
"Performers", intl.formatMessage({ id: "title" }),
allTitleSet,
onSelectAllTitleSet
)}
{renderHeader(
intl.formatMessage({ id: "date" }),
allDateSet,
onSelectAllDateSet
)}
{renderHeader(
intl.formatMessage({ id: "rating" }),
allRatingSet,
onSelectAllRatingSet
)}
{renderHeader(
intl.formatMessage({ id: "performers" }),
allPerformerSet, allPerformerSet,
onSelectAllPerformerSet onSelectAllPerformerSet
)} )}
{renderHeader("Tags", allTagSet, onSelectAllTagSet)} {renderHeader(
{renderHeader("Studio", allStudioSet, onSelectAllStudioSet)} intl.formatMessage({ id: "tags" }),
allTagSet,
onSelectAllTagSet
)}
{renderHeader(
intl.formatMessage({ id: "studio" }),
allStudioSet,
onSelectAllStudioSet
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -365,7 +396,7 @@ export const SceneFilenameParser: React.FC = () => {
onChangePage={(page) => onPageChanged(page)} onChangePage={(page) => onPageChanged(page)}
/> />
<Button variant="primary" onClick={onApply}> <Button variant="primary" onClick={onApply}>
Apply <FormattedMessage id="actions.apply" />
</Button> </Button>
</> </>
); );
@@ -373,7 +404,9 @@ export const SceneFilenameParser: React.FC = () => {
return ( return (
<Card id="parser-container" className="col col-sm-9 mx-auto"> <Card id="parser-container" className="col col-sm-9 mx-auto">
<h4>Scene Filename Parser</h4> <h4>
{intl.formatMessage({ id: "config.tools.scene_filename_parser.title" })}
</h4>
<ParserInput <ParserInput
input={parserInput} input={parserInput}
onFind={(input) => onFindClicked(input)} onFind={(input) => onFindClicked(input)}

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Collapse } from "react-bootstrap"; import { Button, Collapse } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
interface IShowFieldsProps { interface IShowFieldsProps {
@@ -8,6 +9,7 @@ interface IShowFieldsProps {
} }
export const ShowFields = (props: IShowFieldsProps) => { export const ShowFields = (props: IShowFieldsProps) => {
const intl = useIntl();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
function handleClick(label: string) { function handleClick(label: string) {
@@ -33,7 +35,11 @@ export const ShowFields = (props: IShowFieldsProps) => {
<div> <div>
<Button onClick={() => setOpen(!open)} className="minimal"> <Button onClick={() => setOpen(!open)} className="minimal">
<Icon icon={open ? "chevron-down" : "chevron-right"} /> <Icon icon={open ? "chevron-down" : "chevron-right"} />
<span>Display fields</span> <span>
{intl.formatMessage({
id: "config.tools.scene_filename_parser.display_fields",
})}
</span>
</Button> </Button>
<Collapse in={open}> <Collapse in={open}>
<div>{fieldRows}</div> <div>{fieldRows}</div>

View File

@@ -4,7 +4,7 @@ import { useScenesDestroy } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared"; import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormattedMessage } from "react-intl"; import { useIntl } from "react-intl";
interface IDeleteSceneDialogProps { interface IDeleteSceneDialogProps {
selected: GQL.SlimSceneDataFragment[]; selected: GQL.SlimSceneDataFragment[];
@@ -14,20 +14,22 @@ interface IDeleteSceneDialogProps {
export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = ( export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (
props: IDeleteSceneDialogProps props: IDeleteSceneDialogProps
) => { ) => {
const plural = props.selected.length > 1; const intl = useIntl();
const singularEntity = intl.formatMessage({ id: "scene" });
const pluralEntity = intl.formatMessage({ id: "scenes" });
const singleMessageId = "deleteSceneText"; const header = intl.formatMessage(
const pluralMessageId = "deleteScenesText"; { id: "dialogs.delete_entity_title" },
{ count: props.selected.length, singularEntity, pluralEntity }
const singleMessage = );
"Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed."; const toastMessage = intl.formatMessage(
const pluralMessage = { id: "toast.delete_entity" },
"Are you sure you want to delete these scenes? Unless the files are also deleted, these scenes will be re-added when scan is performed."; { count: props.selected.length, singularEntity, pluralEntity }
);
const header = plural ? "Delete Scenes" : "Delete Scene"; const message = intl.formatMessage(
const toastMessage = plural ? "Deleted scenes" : "Deleted scene"; { id: "dialogs.delete_entity_desc" },
const messageId = plural ? pluralMessageId : singleMessageId; { count: props.selected.length, singularEntity, pluralEntity }
const message = plural ? pluralMessage : singleMessage; );
const [deleteFile, setDeleteFile] = useState<boolean>(false); const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true); const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
@@ -63,28 +65,32 @@ export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (
show show
icon="trash-alt" icon="trash-alt"
header={header} header={header}
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }} accept={{
variant: "danger",
onClick: onDelete,
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isDeleting} isRunning={isDeleting}
> >
<p> <p>{message}</p>
<FormattedMessage id={messageId} defaultMessage={message} />
</p>
<Form> <Form>
<Form.Check <Form.Check
id="delete-file" id="delete-file"
checked={deleteFile} checked={deleteFile}
label="Delete file" label={intl.formatMessage({ id: "actions.delete_file" })}
onChange={() => setDeleteFile(!deleteFile)} onChange={() => setDeleteFile(!deleteFile)}
/> />
<Form.Check <Form.Check
id="delete-generated" id="delete-generated"
checked={deleteGenerated} checked={deleteGenerated}
label="Delete generated supporting files" label={intl.formatMessage({
id: "actions.delete_generated_supporting_files",
})}
onChange={() => setDeleteGenerated(!deleteGenerated)} onChange={() => setDeleteGenerated(!deleteGenerated)}
/> />
</Form> </Form>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap"; import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { useBulkSceneUpdate } from "src/core/StashService"; import { useBulkSceneUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -17,6 +18,7 @@ interface IListOperationProps {
export const EditScenesDialog: React.FC<IListOperationProps> = ( export const EditScenesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps props: IListOperationProps
) => { ) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
@@ -134,7 +136,12 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
setIsUpdating(true); setIsUpdating(true);
try { try {
await updateScenes(); await updateScenes();
Toast.success({ content: "Updated scenes" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "scenes" }).toLocaleLowerCase() }
),
});
props.onClose(true); props.onClose(true);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -340,11 +347,21 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
<Modal <Modal
show show
icon="pencil-alt" icon="pencil-alt"
header="Edit Scenes" header={intl.formatMessage(
accept={{ onClick: onSave, text: "Apply" }} { id: "dialogs.edit_entity_title" },
{
count: props?.selected?.length ?? 1,
singularEntity: intl.formatMessage({ id: "scene" }),
pluralEntity: intl.formatMessage({ id: "scenes" }),
}
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(false), onClick: () => props.onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isUpdating} isRunning={isUpdating}
@@ -352,7 +369,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
<Form> <Form>
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -365,7 +382,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Studio", title: intl.formatMessage({ id: "studio" }),
})} })}
<Col xs={9}> <Col xs={9}>
<StudioSelect <StudioSelect
@@ -379,19 +396,23 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
</Form.Group> </Form.Group>
<Form.Group controlId="performers"> <Form.Group controlId="performers">
<Form.Label>Performers</Form.Label> <Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="tags"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>
<FormattedMessage id="performers" />
</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="organized"> <Form.Group controlId="organized">
<Form.Check <Form.Check
type="checkbox" type="checkbox"
label="Organized" label={intl.formatMessage({ id: "organized" })}
checked={organized} checked={organized}
ref={checkboxRef} ref={checkboxRef}
onChange={() => cycleOrganized()} onChange={() => cycleOrganized()}

View File

@@ -6,6 +6,7 @@ import {
DropdownButton, DropdownButton,
Spinner, Spinner,
} from "react-bootstrap"; } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon, SweatDrops } from "src/components/Shared"; import { Icon, SweatDrops } from "src/components/Shared";
export interface IOCounterButtonProps { export interface IOCounterButtonProps {
@@ -21,6 +22,7 @@ export interface IOCounterButtonProps {
export const OCounterButton: React.FC<IOCounterButtonProps> = ( export const OCounterButton: React.FC<IOCounterButtonProps> = (
props: IOCounterButtonProps props: IOCounterButtonProps
) => { ) => {
const intl = useIntl();
if (props.loading) return <Spinner animation="border" role="status" />; if (props.loading) return <Spinner animation="border" role="status" />;
const renderButton = () => ( const renderButton = () => (
@@ -28,7 +30,7 @@ export const OCounterButton: React.FC<IOCounterButtonProps> = (
className="minimal pr-1" className="minimal pr-1"
onClick={props.onIncrement} onClick={props.onIncrement}
variant="secondary" variant="secondary"
title="O-Counter" title={intl.formatMessage({ id: "o_counter" })}
> >
<SweatDrops /> <SweatDrops />
<span className="ml-2">{props.value}</span> <span className="ml-2">{props.value}</span>

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Button, Badge, Card } from "react-bootstrap"; import { Button, Badge, Card } from "react-bootstrap";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
@@ -46,7 +47,7 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
className="ml-auto" className="ml-auto"
onClick={() => onEdit(marker)} onClick={() => onEdit(marker)}
> >
Edit <FormattedMessage id="actions.edit" />
</Button> </Button>
</div> </div>
<div>{TextUtils.secondsToTimestamp(marker.seconds)}</div> <div>{TextUtils.secondsToTimestamp(marker.seconds)}</div>

View File

@@ -1,6 +1,7 @@
import { Tab, Nav, Dropdown, Button, ButtonGroup } from "react-bootstrap"; import { Tab, Nav, Dropdown, Button, ButtonGroup } from "react-bootstrap";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useLocation, useHistory, Link } from "react-router-dom"; import { useParams, useLocation, useHistory, Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@@ -45,6 +46,7 @@ export const Scene: React.FC = () => {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
const [generateScreenshot] = useSceneGenerateScreenshot(); const [generateScreenshot] = useSceneGenerateScreenshot();
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp()); const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
@@ -197,7 +199,17 @@ export const Scene: React.FC = () => {
paths: [scene.path], paths: [scene.path],
}); });
Toast.success({ content: "Rescanning scene" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.rescanning_entity" },
{
count: 1,
singularEntity: intl
.formatMessage({ id: "scene" })
.toLocaleLowerCase(),
}
),
});
} }
async function onGenerateScreenshot(at?: number) { async function onGenerateScreenshot(at?: number) {
@@ -211,7 +223,9 @@ export const Scene: React.FC = () => {
at, at,
}, },
}); });
Toast.success({ content: "Generating screenshot" }); Toast.success({
content: intl.formatMessage({ id: "toast.generating_screenshot" }),
});
} }
async function onQueueLessScenes() { async function onQueueLessScenes() {
@@ -343,14 +357,14 @@ export const Scene: React.FC = () => {
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onRescan()} onClick={() => onRescan()}
> >
Rescan <FormattedMessage id="actions.rescan" />
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
key="generate" key="generate"
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => setIsGenerateDialogOpen(true)} onClick={() => setIsGenerateDialogOpen(true)}
> >
Generate... <FormattedMessage id="actions.generate" />
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
key="generate-screenshot" key="generate-screenshot"
@@ -359,21 +373,24 @@ export const Scene: React.FC = () => {
onGenerateScreenshot(JWUtils.getPlayer().getPosition()) onGenerateScreenshot(JWUtils.getPlayer().getPosition())
} }
> >
Generate thumbnail from current <FormattedMessage id="actions.generate_thumb_from_current" />
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
key="generate-default" key="generate-default"
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => onGenerateScreenshot()} onClick={() => onGenerateScreenshot()}
> >
Generate default thumbnail <FormattedMessage id="actions.generate_thumb_default" />
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
key="delete-scene" key="delete-scene"
className="bg-secondary text-white" className="bg-secondary text-white"
onClick={() => setIsDeleteAlertOpen(true)} onClick={() => setIsDeleteAlertOpen(true)}
> >
Delete Scene <FormattedMessage
id="actions.delete_entity"
values={{ entityType: intl.formatMessage({ id: "scene" }) }}
/>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
@@ -393,43 +410,60 @@ export const Scene: React.FC = () => {
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-details-panel">Details</Nav.Link> <Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="scenes" />
</Nav.Link>
</Nav.Item> </Nav.Item>
{(queueScenes ?? []).length > 0 ? ( {(queueScenes ?? []).length > 0 ? (
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-queue-panel">Queue</Nav.Link> <Nav.Link eventKey="scene-queue-panel">
<FormattedMessage id="queue" />
</Nav.Link>
</Nav.Item> </Nav.Item>
) : ( ) : (
"" ""
)} )}
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-markers-panel">Markers</Nav.Link> <Nav.Link eventKey="scene-markers-panel">
<FormattedMessage id="markers" />
</Nav.Link>
</Nav.Item> </Nav.Item>
{scene.movies.length > 0 ? ( {scene.movies.length > 0 ? (
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-movie-panel">Movies</Nav.Link> <Nav.Link eventKey="scene-movie-panel">
<FormattedMessage
id="countables.movies"
values={{ count: scene.movies.length }}
/>
</Nav.Link>
</Nav.Item> </Nav.Item>
) : ( ) : (
"" ""
)} )}
{scene.galleries.length === 1 ? ( {scene.galleries.length >= 1 ? (
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-gallery-panel">Gallery</Nav.Link> <Nav.Link eventKey="scene-galleries-panel">
</Nav.Item> <FormattedMessage
) : undefined} id="countables.gallery"
{scene.galleries.length > 1 ? ( values={{ count: scene.galleries.length }}
<Nav.Item> />
<Nav.Link eventKey="scene-galleries-panel">Galleries</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
) : undefined} ) : undefined}
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-video-filter-panel">Filters</Nav.Link> <Nav.Link eventKey="scene-video-filter-panel">
<FormattedMessage id="effect_filters.name" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-file-info-panel">File Info</Nav.Link> <Nav.Link eventKey="scene-file-info-panel">
<FormattedMessage id="file_info" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-edit-panel">Edit</Nav.Link> <Nav.Link eventKey="scene-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<ButtonGroup className="ml-auto"> <ButtonGroup className="ml-auto">
<Nav.Item className="ml-auto"> <Nav.Item className="ml-auto">

View File

@@ -1,6 +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 { FormattedDate, FormattedMessage } 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, TruncatedText } from "src/components/Shared"; import { TagLink, TruncatedText } from "src/components/Shared";
@@ -17,7 +17,9 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
if (!props.scene.details || props.scene.details === "") return; if (!props.scene.details || props.scene.details === "") return;
return ( return (
<> <>
<h6>Details</h6> <h6>
<FormattedMessage id="details" />
</h6>
<p className="pre">{props.scene.details}</p> <p className="pre">{props.scene.details}</p>
</> </>
); );
@@ -30,7 +32,12 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
)); ));
return ( return (
<> <>
<h6>Tags</h6> <h6>
<FormattedMessage
id="countables.tags"
values={{ count: props.scene.tags.length }}
/>
</h6>
{tags} {tags}
</> </>
); );
@@ -49,7 +56,12 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
return ( return (
<> <>
<h6>Performers</h6> <h6>
<FormattedMessage
id="countables.performers"
values={{ count: props.scene.performers.length }}
/>
</h6>
<div className="row justify-content-center scene-performers"> <div className="row justify-content-center scene-performers">
{cards} {cards}
</div> </div>
@@ -85,14 +97,15 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
) : undefined} ) : undefined}
{props.scene.rating ? ( {props.scene.rating ? (
<h6> <h6>
Rating: <RatingStars value={props.scene.rating} /> <FormattedMessage id="rating" />:{" "}
<RatingStars value={props.scene.rating} />
</h6> </h6>
) : ( ) : (
"" ""
)} )}
{props.scene.file.width && props.scene.file.height && ( {props.scene.file.width && props.scene.file.height && (
<h6> <h6>
Resolution:{" "} <FormattedMessage id="resolution" />:{" "}
{TextUtils.resolution( {TextUtils.resolution(
props.scene.file.width, props.scene.file.width,
props.scene.file.height props.scene.file.height

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { import {
Button, Button,
Dropdown, Dropdown,
@@ -49,6 +50,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
isVisible, isVisible,
onDelete, onDelete,
}) => { }) => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
scene.galleries.map((g) => ({ scene.galleries.map((g) => ({
@@ -229,7 +231,12 @@ export const SceneEditPanel: React.FC<IProps> = ({
}, },
}); });
if (result.data?.sceneUpdate) { if (result.data?.sceneUpdate) {
Toast.success({ content: "Updated scene" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() }
),
});
// clear the cover image so that it doesn't appear dirty // clear the cover image so that it doesn't appear dirty
formik.resetForm({ values: formik.values }); formik.resetForm({ values: formik.values });
} }
@@ -360,7 +367,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
<DropdownButton <DropdownButton
className="d-inline-block" className="d-inline-block"
id="scene-scrape" id="scene-scrape"
title="Scrape with..." title={intl.formatMessage({ id: "actions.scrape_with" })}
> >
{stashBoxes.map((s, index) => ( {stashBoxes.map((s, index) => (
<Dropdown.Item <Dropdown.Item
@@ -379,7 +386,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
<span className="fa-icon"> <span className="fa-icon">
<Icon icon="sync-alt" /> <Icon icon="sync-alt" />
</span> </span>
<span>Reload scrapers</span> <span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Dropdown.Item> </Dropdown.Item>
</DropdownButton> </DropdownButton>
); );
@@ -549,7 +558,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
<div id="scene-edit-details"> <div id="scene-edit-details">
<Prompt <Prompt
when={formik.dirty} when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?" message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
/> />
{maybeRenderScrapeDialog()} {maybeRenderScrapeDialog()}
@@ -562,14 +571,14 @@ export const SceneEditPanel: React.FC<IProps> = ({
disabled={!formik.dirty} disabled={!formik.dirty}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
Save <FormattedMessage id="actions.save" />
</Button> </Button>
<Button <Button
className="edit-button" className="edit-button"
variant="danger" variant="danger"
onClick={() => onDelete()} onClick={() => onDelete()}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
</div> </div>
<Col xs={6} className="text-right"> <Col xs={6} className="text-right">
@@ -579,10 +588,12 @@ export const SceneEditPanel: React.FC<IProps> = ({
</div> </div>
<div className="form-container row px-3"> <div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12"> <div className="col-12 col-lg-6 col-xl-12">
{renderTextField("title", "Title")} {renderTextField("title", intl.formatMessage({ id: "title" }))}
<Form.Group controlId="url" as={Row}> <Form.Group controlId="url" as={Row}>
<Col xs={3} className="pr-0 url-label"> <Col xs={3} className="pr-0 url-label">
<Form.Label className="col-form-label">URL</Form.Label> <Form.Label className="col-form-label">
<FormattedMessage id="url" />
</Form.Label>
<div className="float-right scrape-button-container"> <div className="float-right scrape-button-container">
{maybeRenderScrapeButton()} {maybeRenderScrapeButton()}
</div> </div>
@@ -590,16 +601,20 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Col xs={9}> <Col xs={9}>
<Form.Control <Form.Control
className="text-input" className="text-input"
placeholder="URL" placeholder={intl.formatMessage({ id: "url" })}
{...formik.getFieldProps("url")} {...formik.getFieldProps("url")}
isInvalid={!!formik.getFieldMeta("url").error} isInvalid={!!formik.getFieldMeta("url").error}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
{renderTextField("date", "Date", "YYYY-MM-DD")} {renderTextField(
"date",
intl.formatMessage({ id: "date" }),
"YYYY-MM-DD"
)}
<Form.Group controlId="rating" as={Row}> <Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Rating", title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingStars
@@ -612,7 +627,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="galleries" as={Row}> <Form.Group controlId="galleries" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Galleries", title: intl.formatMessage({ id: "galleries" }),
})} })}
<Col xs={9}> <Col xs={9}>
<GallerySelect <GallerySelect
@@ -624,7 +639,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Studio", title: intl.formatMessage({ id: "studios" }),
})} })}
<Col xs={9}> <Col xs={9}>
<StudioSelect <StudioSelect
@@ -641,7 +656,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form.Group controlId="performers" as={Row}> <Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Performers", title: intl.formatMessage({ id: "performers" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -664,7 +679,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form.Group controlId="moviesScenes" as={Row}> <Form.Group controlId="moviesScenes" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Movies/Scenes", title: `${intl.formatMessage({
id: "movies",
})}/${intl.formatMessage({ id: "scenes" })}`,
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -685,7 +702,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form.Group controlId="tags" as={Row}> <Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: "Tags", title: intl.formatMessage({ id: "tags" }),
labelProps: { labelProps: {
column: true, column: true,
sm: 3, sm: 3,
@@ -726,7 +743,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Button <Button
variant="danger" variant="danger"
className="mr-2 py-0" className="mr-2 py-0"
title="Delete StashID" title={intl.formatMessage(
{ id: "actions.delete_entity" },
{ entityType: intl.formatMessage({ id: "stash_id" }) }
)}
onClick={() => removeStashID(stashID)} onClick={() => removeStashID(stashID)}
> >
<Icon icon="trash-alt" /> <Icon icon="trash-alt" />
@@ -740,7 +760,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
</div> </div>
<div className="col-12 col-lg-6 col-xl-12"> <div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="details"> <Form.Group controlId="details">
<Form.Label>Details</Form.Label> <Form.Label>
<FormattedMessage id="details" />
</Form.Label>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="scene-description text-input" className="scene-description text-input"
@@ -752,14 +774,16 @@ export const SceneEditPanel: React.FC<IProps> = ({
</Form.Group> </Form.Group>
<div> <div>
<Form.Group controlId="cover"> <Form.Group controlId="cover">
<Form.Label>Cover Image</Form.Label> <Form.Label>
<FormattedMessage id="cover_image" />
</Form.Label>
{imageEncoding ? ( {imageEncoding ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
<img <img
className="scene-cover" className="scene-cover"
src={coverImagePreview} src={coverImagePreview}
alt="Scene cover" alt={intl.formatMessage({ id: "cover_image" })}
/> />
)} )}
<ImageInput <ImageInput

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { FormattedNumber } from "react-intl"; import { FormattedMessage, 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";
import { TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
@@ -15,7 +15,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
if (props.scene.oshash) { if (props.scene.oshash) {
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Hash</span> <span className="col-4">
<FormattedMessage id="media_info.hash" />
</span>
<TruncatedText className="col-8" text={props.scene.oshash} /> <TruncatedText className="col-8" text={props.scene.oshash} />
</div> </div>
); );
@@ -26,7 +28,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
if (props.scene.checksum) { if (props.scene.checksum) {
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Checksum</span> <span className="col-4">
<FormattedMessage id="media_info.checksum" />
</span>
<TruncatedText className="col-8" text={props.scene.checksum} /> <TruncatedText className="col-8" text={props.scene.checksum} />
</div> </div>
); );
@@ -39,7 +43,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} = props; } = props;
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Path</span> <span className="col-4">
<FormattedMessage id="path" />
</span>
<a href={`file://${path}`} className="col-8"> <a href={`file://${path}`} className="col-8">
<TruncatedText text={`file://${props.scene.path}`} /> <TruncatedText text={`file://${props.scene.path}`} />
</a>{" "} </a>{" "}
@@ -50,7 +56,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
function renderStream() { function renderStream() {
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Stream</span> <span className="col-4">
<FormattedMessage id="media_info.stream" />
</span>
<a href={props.scene.paths.stream ?? ""} className="col-8"> <a href={props.scene.paths.stream ?? ""} className="col-8">
<TruncatedText text={props.scene.paths.stream} /> <TruncatedText text={props.scene.paths.stream} />
</a>{" "} </a>{" "}
@@ -69,7 +77,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
return ( return (
<div className="row"> <div className="row">
<span className="col-4">File Size</span> <span className="col-4">
<FormattedMessage id="filesize" />
</span>
<span className="col-8 text-truncate"> <span className="col-8 text-truncate">
<FormattedNumber <FormattedNumber
value={size} value={size}
@@ -90,7 +100,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Duration</span> <span className="col-4">
<FormattedMessage id="duration" />
</span>
<TruncatedText <TruncatedText
className="col-8" className="col-8"
text={TextUtils.secondsToTimestamp(props.scene.file.duration ?? 0)} text={TextUtils.secondsToTimestamp(props.scene.file.duration ?? 0)}
@@ -105,7 +117,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Dimensions</span> <span className="col-4">
<FormattedMessage id="dimensions" />
</span>
<TruncatedText <TruncatedText
className="col-8" className="col-8"
text={`${props.scene.file.width} x ${props.scene.file.height}`} text={`${props.scene.file.width} x ${props.scene.file.height}`}
@@ -120,7 +134,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Frame Rate</span> <span className="col-4">
<FormattedMessage id="framerate" />
</span>
<span className="col-8 text-truncate"> <span className="col-8 text-truncate">
<FormattedNumber value={props.scene.file.framerate ?? 0} /> frames per <FormattedNumber value={props.scene.file.framerate ?? 0} /> frames per
second second
@@ -136,7 +152,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Bit Rate</span> <span className="col-4">
<FormattedMessage id="bitrate" />
</span>
<span className="col-8 text-truncate"> <span className="col-8 text-truncate">
<FormattedNumber <FormattedNumber
value={(props.scene.file.bitrate ?? 0) / 1000000} value={(props.scene.file.bitrate ?? 0) / 1000000}
@@ -154,7 +172,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Video Codec</span> <span className="col-4">
<FormattedMessage id="media_info.video_codec" />
</span>
<TruncatedText className="col-8" text={props.scene.file.video_codec} /> <TruncatedText className="col-8" text={props.scene.file.video_codec} />
</div> </div>
); );
@@ -166,7 +186,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Audio Codec</span> <span className="col-4">
<FormattedMessage id="media_info.audio_codec" />
</span>
<TruncatedText className="col-8" text={props.scene.file.audio_codec} /> <TruncatedText className="col-8" text={props.scene.file.audio_codec} />
</div> </div>
); );
@@ -178,7 +200,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <div className="row">
<span className="col-4">Downloaded From</span> <span className="col-4">
<FormattedMessage id="media_info.downloaded_from" />
</span>
<a href={TextUtils.sanitiseURL(props.scene.url)} className="col-8"> <a href={TextUtils.sanitiseURL(props.scene.url)} className="col-8">
<TruncatedText text={props.scene.url} /> <TruncatedText text={props.scene.url} />
</a> </a>
@@ -224,7 +248,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
return ( return (
<div className="row"> <div className="row">
<abbr className="col-4" title="Perceptual hash"> <abbr className="col-4" title="Perceptual hash">
PHash <FormattedMessage id="media_info.phash" />
</abbr> </abbr>
<TruncatedText className="col-8" text={props.scene.phash} /> <TruncatedText className="col-8" text={props.scene.phash} />
</div> </div>

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { Field, FieldProps, Form as FormikForm, Formik } from "formik"; import { Field, FieldProps, Form as FormikForm, Formik } from "formik";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@@ -169,7 +170,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
onClick={onClose} onClick={onClose}
className="ml-2" className="ml-2"
> >
Cancel <FormattedMessage id="actions.cancel" />
</Button> </Button>
{editingMarker && ( {editingMarker && (
<Button <Button
@@ -177,7 +178,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
className="ml-auto" className="ml-auto"
onClick={() => onDelete()} onClick={() => onDelete()}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { WallPanel } from "src/components/Wall/WallPanel"; import { WallPanel } from "src/components/Wall/WallPanel";
@@ -57,7 +58,9 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
return ( return (
<div className="scene-markers-panel"> <div className="scene-markers-panel">
<Button onClick={() => onOpenEditor()}>Create Marker</Button> <Button onClick={() => onOpenEditor()}>
<FormattedMessage id="actions.create_marker" />
</Button>
<div className="container"> <div className="container">
<PrimaryTags <PrimaryTags
sceneMarkers={props.scene.scene_markers ?? []} sceneMarkers={props.scene.scene_markers ?? []}

View File

@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useAllMoviesForFilter } from "src/core/StashService"; import { useAllMoviesForFilter } from "src/core/StashService";
import { Form, Row, Col } from "react-bootstrap"; import { Form, Row, Col } from "react-bootstrap";
@@ -13,6 +14,7 @@ export interface IProps {
export const SceneMovieTable: React.FunctionComponent<IProps> = ( export const SceneMovieTable: React.FunctionComponent<IProps> = (
props: IProps props: IProps
) => { ) => {
const intl = useIntl();
const { data } = useAllMoviesForFilter(); const { data } = useAllMoviesForFilter();
const items = !!data && !!data.allMovies ? data.allMovies : []; const items = !!data && !!data.allMovies ? data.allMovies : [];
@@ -72,10 +74,10 @@ export const SceneMovieTable: React.FunctionComponent<IProps> = (
<div className="movie-table"> <div className="movie-table">
<Row> <Row>
<Form.Label column xs={9}> <Form.Label column xs={9}>
Movie {intl.formatMessage({ id: "movie" })}
</Form.Label> </Form.Label>
<Form.Label column xs={3}> <Form.Label column xs={3}>
Scene # {intl.formatMessage({ id: "movie_scene_number" })}
</Form.Label> </Form.Label>
</Row> </Row>
{renderTableData()} {renderTableData()}

View File

@@ -20,6 +20,7 @@ import {
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { DurationUtils } from "src/utils"; import { DurationUtils } from "src/utils";
import { useIntl } from "react-intl";
function renderScrapedStudio( function renderScrapedStudio(
result: ScrapeResult<string>, result: ScrapeResult<string>,
@@ -44,6 +45,7 @@ function renderScrapedStudio(
} }
function renderScrapedStudioRow( function renderScrapedStudioRow(
title: string,
result: ScrapeResult<string>, result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void, onChange: (value: ScrapeResult<string>) => void,
newStudio?: GQL.ScrapedSceneStudio, newStudio?: GQL.ScrapedSceneStudio,
@@ -51,7 +53,7 @@ function renderScrapedStudioRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Studio" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedStudio(result)} renderOriginalField={() => renderScrapedStudio(result)}
renderNewField={() => renderNewField={() =>
@@ -90,6 +92,7 @@ function renderScrapedPerformers(
} }
function renderScrapedPerformersRow( function renderScrapedPerformersRow(
title: string,
result: ScrapeResult<string[]>, result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void, onChange: (value: ScrapeResult<string[]>) => void,
newPerformers: GQL.ScrapedScenePerformer[], newPerformers: GQL.ScrapedScenePerformer[],
@@ -97,7 +100,7 @@ function renderScrapedPerformersRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Performers" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedPerformers(result)} renderOriginalField={() => renderScrapedPerformers(result)}
renderNewField={() => renderNewField={() =>
@@ -136,6 +139,7 @@ function renderScrapedMovies(
} }
function renderScrapedMoviesRow( function renderScrapedMoviesRow(
title: string,
result: ScrapeResult<string[]>, result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void, onChange: (value: ScrapeResult<string[]>) => void,
newMovies: GQL.ScrapedSceneMovie[], newMovies: GQL.ScrapedSceneMovie[],
@@ -143,7 +147,7 @@ function renderScrapedMoviesRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Movies" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedMovies(result)} renderOriginalField={() => renderScrapedMovies(result)}
renderNewField={() => renderNewField={() =>
@@ -182,6 +186,7 @@ function renderScrapedTags(
} }
function renderScrapedTagsRow( function renderScrapedTagsRow(
title: string,
result: ScrapeResult<string[]>, result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void, onChange: (value: ScrapeResult<string[]>) => void,
newTags: GQL.ScrapedSceneTag[], newTags: GQL.ScrapedSceneTag[],
@@ -189,7 +194,7 @@ function renderScrapedTagsRow(
) { ) {
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title="Tags" title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedTags(result)} renderOriginalField={() => renderScrapedTags(result)}
renderNewField={() => renderNewField={() =>
@@ -321,6 +326,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
const [createMovie] = useMovieCreate(); const [createMovie] = useMovieCreate();
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
// don't show the dialog if nothing was scraped // don't show the dialog if nothing was scraped
@@ -520,52 +526,56 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
return ( return (
<> <>
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Title" title={intl.formatMessage({ id: "title" })}
result={title} result={title}
onChange={(value) => setTitle(value)} onChange={(value) => setTitle(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="URL" title={intl.formatMessage({ id: "url" })}
result={url} result={url}
onChange={(value) => setURL(value)} onChange={(value) => setURL(value)}
/> />
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Date" title={intl.formatMessage({ id: "date" })}
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
result={date} result={date}
onChange={(value) => setDate(value)} onChange={(value) => setDate(value)}
/> />
{renderScrapedStudioRow( {renderScrapedStudioRow(
intl.formatMessage({ id: "studios" }),
studio, studio,
(value) => setStudio(value), (value) => setStudio(value),
newStudio, newStudio,
createNewStudio createNewStudio
)} )}
{renderScrapedPerformersRow( {renderScrapedPerformersRow(
intl.formatMessage({ id: "performers" }),
performers, performers,
(value) => setPerformers(value), (value) => setPerformers(value),
newPerformers, newPerformers,
createNewPerformer createNewPerformer
)} )}
{renderScrapedMoviesRow( {renderScrapedMoviesRow(
intl.formatMessage({ id: "movies" }),
movies, movies,
(value) => setMovies(value), (value) => setMovies(value),
newMovies, newMovies,
createNewMovie createNewMovie
)} )}
{renderScrapedTagsRow( {renderScrapedTagsRow(
intl.formatMessage({ id: "tags" }),
tags, tags,
(value) => setTags(value), (value) => setTags(value),
newTags, newTags,
createNewTag createNewTag
)} )}
<ScrapedTextAreaRow <ScrapedTextAreaRow
title="Details" title={intl.formatMessage({ id: "details" })}
result={details} result={details}
onChange={(value) => setDetails(value)} onChange={(value) => setDetails(value)}
/> />
<ScrapedImageRow <ScrapedImageRow
title="Cover Image" title={intl.formatMessage({ id: "cover_image" })}
className="scene-cover" className="scene-cover"
result={image} result={image}
onChange={(value) => setImage(value)} onChange={(value) => setImage(value)}
@@ -576,7 +586,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
return ( return (
<ScrapeDialog <ScrapeDialog
title="Scene Scrape Results" title={intl.formatMessage({ id: "dialogs.scrape_entity_title" })}
renderScrapeRows={renderScrapeRows} renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
props.onClose(apply ? makeNewScrapedItem() : undefined); props.onClose(apply ? makeNewScrapedItem() : undefined);

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
import { JWUtils } from "src/utils"; import { JWUtils } from "src/utils";
@@ -85,6 +86,8 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
divider: 100, divider: 100,
}; };
const intl = useIntl();
const [contrastValue, setContrastValue] = useState(contrastRange.default); const [contrastValue, setContrastValue] = useState(contrastRange.default);
const [brightnessValue, setBrightnessValue] = useState( const [brightnessValue, setBrightnessValue] = useState(
brightnessRange.default brightnessRange.default
@@ -342,7 +345,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderBlur() { function renderBlur() {
return renderSlider({ return renderSlider({
title: "Blur", title: intl.formatMessage({ id: "effect_filters.blur" }),
range: blurRange, range: blurRange,
value: blurValue, value: blurValue,
setValue: setBlurValue, setValue: setBlurValue,
@@ -352,7 +355,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderContrast() { function renderContrast() {
return renderSlider({ return renderSlider({
title: "Contrast", title: intl.formatMessage({ id: "effect_filters.contrast" }),
className: "contrast-slider", className: "contrast-slider",
range: contrastRange, range: contrastRange,
value: contrastValue, value: contrastValue,
@@ -363,7 +366,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderBrightness() { function renderBrightness() {
return renderSlider({ return renderSlider({
title: "Brightness", title: intl.formatMessage({ id: "effect_filters.brightness" }),
className: "brightness-slider", className: "brightness-slider",
range: brightnessRange, range: brightnessRange,
value: brightnessValue, value: brightnessValue,
@@ -374,7 +377,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderGammaSlider() { function renderGammaSlider() {
return renderSlider({ return renderSlider({
title: "Gamma", title: intl.formatMessage({ id: "effect_filters.gamma" }),
className: "gamma-slider", className: "gamma-slider",
range: gammaRange, range: gammaRange,
value: gammaValue, value: gammaValue,
@@ -385,7 +388,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderSaturate() { function renderSaturate() {
return renderSlider({ return renderSlider({
title: "Saturation", title: intl.formatMessage({ id: "effect_filters.saturation" }),
className: "saturation-slider", className: "saturation-slider",
range: saturateRange, range: saturateRange,
value: saturateValue, value: saturateValue,
@@ -396,7 +399,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderHueRotateSlider() { function renderHueRotateSlider() {
return renderSlider({ return renderSlider({
title: "Hue", title: intl.formatMessage({ id: "effect_filters.hue" }),
className: "hue-rotate-slider", className: "hue-rotate-slider",
range: hueRotateRange, range: hueRotateRange,
value: hueRotateValue, value: hueRotateValue,
@@ -407,7 +410,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderWhiteBalance() { function renderWhiteBalance() {
return renderSlider({ return renderSlider({
title: "Warmth", title: intl.formatMessage({ id: "effect_filters.warmth" }),
className: "white-balance-slider", className: "white-balance-slider",
range: whiteBalanceRange, range: whiteBalanceRange,
value: whiteBalanceValue, value: whiteBalanceValue,
@@ -421,7 +424,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderRedSlider() { function renderRedSlider() {
return renderSlider({ return renderSlider({
title: "Red", title: intl.formatMessage({ id: "effect_filters.red" }),
className: "red-slider", className: "red-slider",
range: colourRange, range: colourRange,
value: redValue, value: redValue,
@@ -434,7 +437,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderGreenSlider() { function renderGreenSlider() {
return renderSlider({ return renderSlider({
title: "Green", title: intl.formatMessage({ id: "effect_filters.green" }),
className: "green-slider", className: "green-slider",
range: colourRange, range: colourRange,
value: greenValue, value: greenValue,
@@ -447,7 +450,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderBlueSlider() { function renderBlueSlider() {
return renderSlider({ return renderSlider({
title: "Blue", title: intl.formatMessage({ id: "effect_filters.blue" }),
className: "blue-slider", className: "blue-slider",
range: colourRange, range: colourRange,
value: blueValue, value: blueValue,
@@ -460,7 +463,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderRotate() { function renderRotate() {
return renderSlider({ return renderSlider({
title: "Rotate", title: intl.formatMessage({ id: "effect_filters.rotate" }),
range: rotateRange, range: rotateRange,
value: rotateValue, value: rotateValue,
setValue: setRotateValue, setValue: setRotateValue,
@@ -472,7 +475,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderScale() { function renderScale() {
return renderSlider({ return renderSlider({
title: "Scale", title: intl.formatMessage({ id: "effect_filters.scale" }),
range: scaleRange, range: scaleRange,
value: scaleValue, value: scaleValue,
setValue: setScaleValue, setValue: setScaleValue,
@@ -482,7 +485,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
function renderAspectRatio() { function renderAspectRatio() {
return renderSlider({ return renderSlider({
title: "Aspect", title: intl.formatMessage({ id: "effect_filters.aspect" }),
range: aspectRatioRange, range: aspectRatioRange,
value: aspectRatioValue, value: aspectRatioValue,
setValue: setAspectRatioValue, setValue: setAspectRatioValue,
@@ -557,7 +560,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
type="button" type="button"
onClick={() => onRotateAndScale(0)} onClick={() => onRotateAndScale(0)}
> >
Rotate Left & Scale <FormattedMessage id="effect_filters.rotate_left_and_scale" />
</Button> </Button>
</span> </span>
<span className="col-6"> <span className="col-6">
@@ -567,7 +570,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
type="button" type="button"
onClick={() => onRotateAndScale(1)} onClick={() => onRotateAndScale(1)}
> >
Rotate Right & Scale <FormattedMessage id="effect_filters.rotate_right_and_scale" />
</Button> </Button>
</span> </span>
</div> </div>
@@ -603,7 +606,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
type="button" type="button"
onClick={() => onResetFilters()} onClick={() => onResetFilters()}
> >
Reset Filters <FormattedMessage id="effect_filters.reset_filters" />
</Button> </Button>
</span> </span>
<span className="col-6"> <span className="col-6">
@@ -613,7 +616,7 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
type="button" type="button"
onClick={() => onResetTransforms()} onClick={() => onResetTransforms()}
> >
Reset Transforms <FormattedMessage id="effect_filters.reset_transforms" />
</Button> </Button>
</span> </span>
</div> </div>
@@ -632,7 +635,9 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
<div className="container scene-video-filter"> <div className="container scene-video-filter">
<div className="row form-group"> <div className="row form-group">
<span className="col-12"> <span className="col-12">
<h5>Filters</h5> <h5>
<FormattedMessage id="effect_filters.name" />
</h5>
</span> </span>
</div> </div>
{renderBrightness()} {renderBrightness()}
@@ -647,7 +652,9 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
{renderBlur()} {renderBlur()}
<div className="row form-group"> <div className="row form-group">
<span className="col-12"> <span className="col-12">
<h5>Transforms</h5> <h5>
<FormattedMessage id="effect_filters.name_transforms" />
</h5>
</span> </span>
</div> </div>
{renderRotate()} {renderRotate()}
@@ -655,7 +662,9 @@ export const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (
{renderAspectRatio()} {renderAspectRatio()}
<div className="row form-group"> <div className="row form-group">
<span className="col-12"> <span className="col-12">
<h5>Actions</h5> <h5>
<FormattedMessage id="actions_name" />
</h5>
</span> </span>
</div> </div>
{renderRotateAndScale()} {renderRotateAndScale()}

View File

@@ -7,6 +7,7 @@ import {
import { Modal, Icon } from "src/components/Shared"; import { Modal, Icon } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
interface ISceneGenerateDialogProps { interface ISceneGenerateDialogProps {
selectedIds: string[]; selectedIds: string[];
@@ -42,6 +43,7 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false); const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
useEffect(() => { useEffect(() => {
@@ -97,11 +99,14 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
<Modal <Modal
show show
icon="cogs" icon="cogs"
header="Generate" header={intl.formatMessage({ id: "actions.generate" })}
accept={{ onClick: onGenerate, text: "Generate" }} accept={{
onClick: onGenerate,
text: intl.formatMessage({ id: "actions.generate" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(), onClick: () => props.onClose(),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
> >
@@ -110,7 +115,9 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
<Form.Check <Form.Check
id="preview-task" id="preview-task"
checked={previews} checked={previews}
label="Previews (video previews which play when hovering over a scene)" label={intl.formatMessage({
id: "dialogs.scene_gen.video_previews",
})}
onChange={() => setPreviews(!previews)} onChange={() => setPreviews(!previews)}
/> />
<div className="d-flex flex-row"> <div className="d-flex flex-row">
@@ -119,7 +126,9 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
id="image-preview-task" id="image-preview-task"
checked={imagePreviews} checked={imagePreviews}
disabled={!previews} disabled={!previews}
label="Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)" label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() => setImagePreviews(!imagePreviews)} onChange={() => setImagePreviews(!imagePreviews)}
className="ml-2 flex-grow" className="ml-2 flex-grow"
/> />
@@ -132,12 +141,20 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
<Icon <Icon
icon={previewOptionsOpen ? "chevron-down" : "chevron-right"} icon={previewOptionsOpen ? "chevron-down" : "chevron-right"}
/> />
<span>Preview Options</span> <span>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_options",
})}
</span>
</Button> </Button>
<Collapse in={previewOptionsOpen}> <Collapse in={previewOptionsOpen}>
<div> <div>
<Form.Group id="transcode-size"> <Form.Group id="transcode-size">
<h6>Preview encoding preset</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control <Form.Control
className="w-auto input-control" className="w-auto input-control"
as="select" as="select"
@@ -153,14 +170,18 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
))} ))}
</Form.Control> </Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
The preset regulates size, quality and encoding time of {intl.formatMessage({
preview generation. Presets beyond slow have diminishing id: "dialogs.scene_gen.preview_preset_desc",
returns and are not recommended. })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-segments"> <Form.Group id="preview-segments">
<h6>Number of segments in preview</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -172,12 +193,18 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Number of segments in preview files. {intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-segment-duration"> <Form.Group id="preview-segment-duration">
<h6>Preview segment duration</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -189,12 +216,18 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Duration of each preview segment, in seconds. {intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-exclude-start"> <Form.Group id="preview-exclude-start">
<h6>Exclude start time</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={previewExcludeStart} defaultValue={previewExcludeStart}
@@ -203,14 +236,18 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Exclude the first x seconds from scene previews. This can be {intl.formatMessage({
a value in seconds, or a percentage (eg 2%) of the total id: "dialogs.scene_gen.preview_exclude_start_time_desc",
scene duration. })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-exclude-start"> <Form.Group id="preview-exclude-start">
<h6>Exclude end time</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={previewExcludeEnd} defaultValue={previewExcludeEnd}
@@ -219,9 +256,9 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Exclude the last x seconds from scene previews. This can be {intl.formatMessage({
a value in seconds, or a percentage (eg 2%) of the total id: "dialogs.scene_gen.preview_exclude_end_time_desc",
scene duration. })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</div> </div>
@@ -230,25 +267,25 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
<Form.Check <Form.Check
id="sprite-task" id="sprite-task"
checked={sprites} checked={sprites}
label="Sprites (for the scene scrubber)" label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setSprites(!sprites)} onChange={() => setSprites(!sprites)}
/> />
<Form.Check <Form.Check
id="marker-task" id="marker-task"
checked={markers} checked={markers}
label="Markers (20 second videos which begin at the given timecode)" label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setMarkers(!markers)} onChange={() => setMarkers(!markers)}
/> />
<Form.Check <Form.Check
id="transcode-task" id="transcode-task"
checked={transcodes} checked={transcodes}
label="Transcodes (MP4 conversions of unsupported video formats)" label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setTranscodes(!transcodes)} onChange={() => setTranscodes(!transcodes)}
/> />
<Form.Check <Form.Check
id="phash-task" id="phash-task"
checked={phashes} checked={phashes}
label="Perceptual hashes (for deduplication)" label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setPhashes(!phashes)} onChange={() => setPhashes(!phashes)}
/> />
@@ -256,7 +293,7 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
<Form.Check <Form.Check
id="overwrite" id="overwrite"
checked={overwrite} checked={overwrite}
label="Overwrite existing generated files" label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
onChange={() => setOverwrite(!overwrite)} onChange={() => setOverwrite(!overwrite)}
/> />
</Form.Group> </Form.Group>

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import _ from "lodash"; import _ from "lodash";
import { useIntl } from "react-intl";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { import {
@@ -32,6 +33,7 @@ export const SceneList: React.FC<ISceneList> = ({
defaultSort, defaultSort,
persistState, persistState,
}) => { }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
@@ -39,26 +41,26 @@ export const SceneList: React.FC<ISceneList> = ({
const otherOperations = [ const otherOperations = [
{ {
text: "Play selected", text: intl.formatMessage({ id: "actions.play_selected" }),
onClick: playSelected, onClick: playSelected,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Play Random", text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom, onClick: playRandom,
}, },
{ {
text: "Generate...", text: intl.formatMessage({ id: "actions.generate" }),
onClick: generate, onClick: generate,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]; ];

View File

@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
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 { Icon, TruncatedText } from "src/components/Shared"; import { Icon, TruncatedText } from "src/components/Shared";
import { FormattedMessage } from "react-intl";
interface ISceneListTableProps { interface ISceneListTableProps {
scenes: GQL.SlimSceneDataFragment[]; scenes: GQL.SlimSceneDataFragment[];
@@ -97,14 +98,30 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
<thead> <thead>
<tr> <tr>
<th /> <th />
<th className="text-left">Title</th> <th className="text-left">
<th>Rating</th> <FormattedMessage id="title" />
<th>Duration</th> </th>
<th>Tags</th> <th>
<th>Performers</th> <FormattedMessage id="rating" />
<th>Studio</th> </th>
<th>Movies</th> <th>
<th>Gallery</th> <FormattedMessage id="duration" />
</th>
<th>
<FormattedMessage id="tags" />
</th>
<th>
<FormattedMessage id="performers" />
</th>
<th>
<FormattedMessage id="studio" />
</th>
<th>
<FormattedMessage id="movies" />
</th>
<th>
<FormattedMessage id="gallery" />
</th>
</tr> </tr>
</thead> </thead>
<tbody>{props.scenes.map(renderSceneRow)}</tbody> <tbody>{props.scenes.map(renderSceneRow)}</tbody>

View File

@@ -1,6 +1,7 @@
import _ from "lodash"; import _ from "lodash";
import React from "react"; import React from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useIntl } from "react-intl";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { FindSceneMarkersQueryResult } from "src/core/generated-graphql"; import { FindSceneMarkersQueryResult } from "src/core/generated-graphql";
import { queryFindSceneMarkers } from "src/core/StashService"; import { queryFindSceneMarkers } from "src/core/StashService";
@@ -16,10 +17,11 @@ interface ISceneMarkerList {
} }
export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => { export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const otherOperations = [ const otherOperations = [
{ {
text: "Play Random", text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom, onClick: playRandom,
}, },
]; ];

View File

@@ -2,6 +2,7 @@ import React from "react";
import queryString from "query-string"; import queryString from "query-string";
import { Card, Tab, Nav, Row, Col } from "react-bootstrap"; import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { SettingsAboutPanel } from "./SettingsAboutPanel"; import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel"; import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel"; import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
@@ -30,31 +31,47 @@ export const Settings: React.FC = () => {
<Col sm={3} md={2}> <Col sm={3} md={2}>
<Nav variant="pills" className="flex-column"> <Nav variant="pills" className="flex-column">
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="configuration">Configuration</Nav.Link> <Nav.Link eventKey="configuration">
<FormattedMessage id="configuration" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="interface">Interface</Nav.Link> <Nav.Link eventKey="interface">
<FormattedMessage id="config.categories.interface" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="tasks">Tasks</Nav.Link> <Nav.Link eventKey="tasks">
<FormattedMessage id="config.categories.tasks" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="dlna">DLNA</Nav.Link> <Nav.Link eventKey="dlna">DLNA</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="tools">Tools</Nav.Link> <Nav.Link eventKey="tools">
<FormattedMessage id="config.categories.tools" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scrapers">Scrapers</Nav.Link> <Nav.Link eventKey="scrapers">
<FormattedMessage id="config.categories.scrapers" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="plugins">Plugins</Nav.Link> <Nav.Link eventKey="plugins">
<FormattedMessage id="config.categories.plugins" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="logs">Logs</Nav.Link> <Nav.Link eventKey="logs">
<FormattedMessage id="config.categories.logs" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="about">About</Nav.Link> <Nav.Link eventKey="about">
<FormattedMessage id="config.categories.about" />
</Nav.Link>
</Nav.Item> </Nav.Item>
<hr className="d-sm-none" /> <hr className="d-sm-none" />
</Nav> </Nav>

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Button, Table } from "react-bootstrap"; import { Button, Table } from "react-bootstrap";
import { useIntl } from "react-intl";
import { LoadingIndicator } from "src/components/Shared"; import { LoadingIndicator } from "src/components/Shared";
import { useLatestVersion } from "src/core/StashService"; import { useLatestVersion } from "src/core/StashService";
@@ -8,6 +9,8 @@ export const SettingsAboutPanel: React.FC = () => {
const stashVersion = process.env.REACT_APP_STASH_VERSION; const stashVersion = process.env.REACT_APP_STASH_VERSION;
const buildTime = process.env.REACT_APP_DATE; const buildTime = process.env.REACT_APP_DATE;
const intl = useIntl();
const { const {
data: dataLatest, data: dataLatest,
error: errorLatest, error: errorLatest,
@@ -22,7 +25,7 @@ export const SettingsAboutPanel: React.FC = () => {
} }
return ( return (
<tr> <tr>
<td>Version:</td> <td>{intl.formatMessage({ id: "config.about.version" })}:</td>
<td>{stashVersion}</td> <td>{stashVersion}</td>
</tr> </tr>
); );
@@ -39,8 +42,13 @@ export const SettingsAboutPanel: React.FC = () => {
if (gitHash !== dataLatest.latestversion.shorthash) { if (gitHash !== dataLatest.latestversion.shorthash) {
return ( return (
<> <>
<strong>{dataLatest.latestversion.shorthash} [NEW] </strong> <strong>
<a href={dataLatest.latestversion.url}>Download</a> {dataLatest.latestversion.shorthash}{" "}
{intl.formatMessage({ id: "config.about.new_version_notice" })}{" "}
</strong>
<a href={dataLatest.latestversion.url}>
{intl.formatMessage({ id: "actions.download" })}
</a>
</> </>
); );
} }
@@ -53,12 +61,20 @@ export const SettingsAboutPanel: React.FC = () => {
<Table> <Table>
<tbody> <tbody>
<tr> <tr>
<td>Latest Version Build Hash: </td> <td>
{intl.formatMessage({
id: "config.about.latest_version_build_hash",
})}{" "}
</td>
<td>{maybeRenderLatestVersion()} </td> <td>{maybeRenderLatestVersion()} </td>
</tr> </tr>
<tr> <tr>
<td> <td>
<Button onClick={() => refetch()}>Check for new version</Button> <Button onClick={() => refetch()}>
{intl.formatMessage({
id: "config.about.check_for_new_version",
})}
</Button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -73,11 +89,11 @@ export const SettingsAboutPanel: React.FC = () => {
<tbody> <tbody>
{maybeRenderTag()} {maybeRenderTag()}
<tr> <tr>
<td>Build hash:</td> <td>{intl.formatMessage({ id: "config.about.build_hash" })}</td>
<td>{gitHash}</td> <td>{gitHash}</td>
</tr> </tr>
<tr> <tr>
<td>Build time:</td> <td>{intl.formatMessage({ id: "config.about.build_time" })}</td>
<td>{buildTime}</td> <td>{buildTime}</td>
</tr> </tr>
</tbody> </tbody>
@@ -87,57 +103,79 @@ export const SettingsAboutPanel: React.FC = () => {
} }
return ( return (
<> <>
<h4>About</h4> <h4>{intl.formatMessage({ id: "config.categories.about" })}</h4>
<Table> <Table>
<tbody> <tbody>
<tr> <tr>
<td> <td>
Stash home at{" "} {intl.formatMessage(
<a { id: "config.about.stash_home" },
href="https://github.com/stashapp/stash" {
rel="noopener noreferrer" url: (
target="_blank" <a
> href="https://github.com/stashapp/stash"
Github rel="noopener noreferrer"
</a> target="_blank"
>
GitHub
</a>
),
}
)}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
Stash{" "} {intl.formatMessage(
<a { id: "config.about.stash_wiki" },
href="https://github.com/stashapp/stash/wiki" {
rel="noopener noreferrer" url: (
target="_blank" <a
> href="https://github.com/stashapp/stash/wiki"
Wiki rel="noopener noreferrer"
</a>{" "} target="_blank"
page >
Wiki
</a>
),
}
)}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
Join our{" "} {intl.formatMessage(
<a { id: "config.about.stash_discord" },
href="https://discord.gg/2TsNFKt" {
rel="noopener noreferrer" url: (
target="_blank" <a
> href="https://discord.gg/2TsNFKt"
Discord rel="noopener noreferrer"
</a>{" "} target="_blank"
channel >
Discord
</a>
),
}
)}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
Support us through{" "} {intl.formatMessage(
<a { id: "config.about.stash_open_collective" },
href="https://opencollective.com/stashapp" {
rel="noopener noreferrer" url: (
target="_blank" <a
> href="https://opencollective.com/stashapp"
Open Collective rel="noopener noreferrer"
</a> target="_blank"
>
Open Collective
</a>
),
}
)}
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, InputGroup } from "react-bootstrap"; import { Button, Form, InputGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@@ -69,6 +70,7 @@ const ExclusionPatterns: React.FC<IExclusionPatternsProps> = (props) => {
}; };
export const SettingsConfigurationPanel: React.FC = () => { export const SettingsConfigurationPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
// Editing config state // Editing config state
const [stashes, setStashes] = useState<GQL.StashConfig[]>([]); const [stashes, setStashes] = useState<GQL.StashConfig[]>([]);
@@ -278,7 +280,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
const result = await updateGeneralConfig(); const result = await updateGeneralConfig();
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(result); console.log(result);
Toast.success({ content: "Updated config" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "configuration" })
.toLocaleLowerCase(),
}
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -363,7 +374,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
return ( return (
<> <>
<h4>Library</h4> <h4>
<FormattedMessage id="library" />
</h4>
<Form.Group> <Form.Group>
<Form.Group id="stashes"> <Form.Group id="stashes">
<h6>Stashes</h6> <h6>Stashes</h6>
@@ -372,12 +385,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
setStashes={(s) => setStashes(s)} setStashes={(s) => setStashes(s)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Directory locations to your content {intl.formatMessage({
id: "config.general.directory_locations_to_your_content",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="database-path"> <Form.Group id="database-path">
<h6>Database Path</h6> <h6>
<FormattedMessage id="config.general.db_path_head" />
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={databasePath} defaultValue={databasePath}
@@ -386,12 +403,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
File location for the SQLite database (requires restart) {intl.formatMessage({ id: "config.general.sqlite_location" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="generated-path"> <Form.Group id="generated-path">
<h6>Generated Path</h6> <h6>
<FormattedMessage id="config.general.generated_path_head" />
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={generatedPath} defaultValue={generatedPath}
@@ -400,13 +419,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Directory location for the generated files (scene markers, scene {intl.formatMessage({
previews, sprites, etc) id: "config.general.generated_files_location",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="cache-path"> <Form.Group id="cache-path">
<h6>Cache Path</h6> <h6>
<FormattedMessage id="config.general.cache_path_head" />
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={cachePath} defaultValue={cachePath}
@@ -415,12 +437,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Directory location of the cache {intl.formatMessage({ id: "config.general.cache_location" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="video-extensions"> <Form.Group id="video-extensions">
<h6>Video Extensions</h6> <h6>
<FormattedMessage id="config.general.video_ext_head" />
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={videoExtensions} defaultValue={videoExtensions}
@@ -429,13 +453,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Comma-delimited list of file extensions that will be identified as {intl.formatMessage({ id: "config.general.video_ext_desc" })}
videos.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="image-extensions"> <Form.Group id="image-extensions">
<h6>Image Extensions</h6> <h6>
<FormattedMessage id="config.general.image_ext_head" />
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={imageExtensions} defaultValue={imageExtensions}
@@ -444,13 +469,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Comma-delimited list of file extensions that will be identified as {intl.formatMessage({ id: "config.general.image_ext_desc" })}
images.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="gallery-extensions"> <Form.Group id="gallery-extensions">
<h6>Gallery zip Extensions</h6> <h6>
{intl.formatMessage({ id: "config.general.gallery_ext_head" })}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={galleryExtensions} defaultValue={galleryExtensions}
@@ -459,16 +485,21 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Comma-delimited list of file extensions that will be identified as {intl.formatMessage({ id: "config.general.gallery_ext_desc" })}
gallery zip files.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h6>Excluded Video Patterns</h6> <h6>
{intl.formatMessage({
id: "config.general.excluded_video_patterns_head",
})}
</h6>
<ExclusionPatterns excludes={excludes} setExcludes={setExcludes} /> <ExclusionPatterns excludes={excludes} setExcludes={setExcludes} />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Regexps of video files/paths to exclude from Scan and add to Clean {intl.formatMessage({
id: "config.general.excluded_video_patterns_desc",
})}
<a <a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration" href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -480,14 +511,19 @@ export const SettingsConfigurationPanel: React.FC = () => {
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h6>Excluded Image/Gallery Patterns</h6> <h6>
{intl.formatMessage({
id: "config.general.excluded_image_gallery_patterns_head",
})}
</h6>
<ExclusionPatterns <ExclusionPatterns
excludes={imageExcludes} excludes={imageExcludes}
setExcludes={setImageExcludes} setExcludes={setImageExcludes}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Regexps of image and gallery files/paths to exclude from Scan and {intl.formatMessage({
add to Clean id: "config.general.excluded_image_gallery_patterns_desc",
})}
<a <a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration" href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -502,13 +538,17 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check <Form.Check
id="log-terminal" id="log-terminal"
checked={createGalleriesFromFolders} checked={createGalleriesFromFolders}
label="Create galleries from folders containing images" label={intl.formatMessage({
id: "config.general.create_galleries_from_folders_label",
})}
onChange={() => onChange={() =>
setCreateGalleriesFromFolders(!createGalleriesFromFolders) setCreateGalleriesFromFolders(!createGalleriesFromFolders)
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
If true, creates galleries from folders containing images. {intl.formatMessage({
id: "config.general.create_galleries_from_folders_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -516,22 +556,28 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr /> <hr />
<Form.Group> <Form.Group>
<h4>Hashing</h4> <h4>{intl.formatMessage({ id: "config.general.hashing" })}</h4>
<Form.Group> <Form.Group>
<Form.Check <Form.Check
checked={calculateMD5} checked={calculateMD5}
label="Calculate MD5 for videos" label={intl.formatMessage({
id: "config.general.calculate_md5_and_ohash_label",
})}
onChange={() => setCalculateMD5(!calculateMD5)} onChange={() => setCalculateMD5(!calculateMD5)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Calculate MD5 checksum in addition to oshash. Enabling will cause {intl.formatMessage({
initial scans to be slower. File naming hash must be set to oshash id: "config.general.calculate_md5_and_ohash_desc",
to disable MD5 calculation. })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="transcode-size"> <Form.Group id="transcode-size">
<h6>Generated file naming hash</h6> <h6>
{intl.formatMessage({
id: "config.general.generated_file_naming_hash_head",
})}
</h6>
<Form.Control <Form.Control
className="w-auto input-control" className="w-auto input-control"
@@ -551,10 +597,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
</Form.Control> </Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Use MD5 or oshash for generated file naming. Changing this requires {intl.formatMessage({
that all scenes have the applicable MD5/oshash value populated. id: "config.general.generated_file_naming_hash_desc",
After changing this value, existing generated files will need to be })}
migrated or regenerated. See Tasks page for migration.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -562,9 +607,13 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr /> <hr />
<Form.Group> <Form.Group>
<h4>Video</h4> <h4>{intl.formatMessage({ id: "config.general.video_head" })}</h4>
<Form.Group id="transcode-size"> <Form.Group id="transcode-size">
<h6>Maximum transcode size</h6> <h6>
{intl.formatMessage({
id: "config.general.maximum_transcode_size_head",
})}
</h6>
<Form.Control <Form.Control
className="w-auto input-control" className="w-auto input-control"
as="select" as="select"
@@ -580,11 +629,17 @@ export const SettingsConfigurationPanel: React.FC = () => {
))} ))}
</Form.Control> </Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Maximum size for generated transcodes {intl.formatMessage({
id: "config.general.maximum_transcode_size_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="streaming-transcode-size"> <Form.Group id="streaming-transcode-size">
<h6>Maximum streaming transcode size</h6> <h6>
{intl.formatMessage({
id: "config.general.maximum_streaming_transcode_size_head",
})}
</h6>
<Form.Control <Form.Control
className="w-auto input-control" className="w-auto input-control"
as="select" as="select"
@@ -602,7 +657,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
))} ))}
</Form.Control> </Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Maximum size for transcoded streams {intl.formatMessage({
id: "config.general.maximum_streaming_transcode_size_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -610,10 +667,17 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr /> <hr />
<Form.Group> <Form.Group>
<h4>Parallel Scan/Generation</h4> <h4>
{intl.formatMessage({ id: "config.general.parallel_scan_head" })}
</h4>
<Form.Group id="parallel-tasks"> <Form.Group id="parallel-tasks">
<h6>Number of parallel task for scan/generation</h6> <h6>
{intl.formatMessage({
id:
"config.general.number_of_parallel_task_for_scan_generation_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -625,9 +689,10 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Set to 0 for auto-detection. Warning running more tasks than is {intl.formatMessage({
required to achieve 100% cpu utilisation will decrease performance id:
and potentially cause other issues. "config.general.number_of_parallel_task_for_scan_generation_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -635,10 +700,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr /> <hr />
<Form.Group> <Form.Group>
<h4>Preview Generation</h4> <h4>
{intl.formatMessage({ id: "config.general.preview_generation" })}
</h4>
<Form.Group id="transcode-size"> <Form.Group id="transcode-size">
<h6>Preview encoding preset</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control <Form.Control
className="w-auto input-control" className="w-auto input-control"
as="select" as="select"
@@ -654,9 +725,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
))} ))}
</Form.Control> </Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
The preset regulates size, quality and encoding time of preview {intl.formatMessage({
generation. Presets beyond slow have diminishing returns and are id: "dialogs.scene_gen.preview_preset_desc",
not recommended. })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -673,7 +744,11 @@ export const SettingsConfigurationPanel: React.FC = () => {
</Form.Group> </Form.Group>
<Form.Group id="preview-segments"> <Form.Group id="preview-segments">
<h6>Number of segments in preview</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -685,12 +760,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Number of segments in preview files. {intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-segment-duration"> <Form.Group id="preview-segment-duration">
<h6>Preview segment duration</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -702,12 +783,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Duration of each preview segment, in seconds. {intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-exclude-start"> <Form.Group id="preview-exclude-start">
<h6>Exclude start time</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={previewExcludeStart} defaultValue={previewExcludeStart}
@@ -716,13 +803,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Exclude the first x seconds from scene previews. This can be a value {intl.formatMessage({
in seconds, or a percentage (eg 2%) of the total scene duration. id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="preview-exclude-start"> <Form.Group id="preview-exclude-start">
<h6>Exclude end time</h6> <h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={previewExcludeEnd} defaultValue={previewExcludeEnd}
@@ -731,16 +823,19 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Exclude the last x seconds from scene previews. This can be a value {intl.formatMessage({
in seconds, or a percentage (eg 2%) of the total scene duration. id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h4>Scraping</h4> <h4>{intl.formatMessage({ id: "config.general.scraping" })}</h4>
<Form.Group id="scraperUserAgent"> <Form.Group id="scraperUserAgent">
<h6>Scraper User Agent</h6> <h6>
{intl.formatMessage({ id: "config.general.scraper_user_agent" })}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={scraperUserAgent} defaultValue={scraperUserAgent}
@@ -749,12 +844,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
User-Agent string used during scrape http requests {intl.formatMessage({
id: "config.general.scraper_user_agent_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="scraperCDPPath"> <Form.Group id="scraperCDPPath">
<h6>Chrome CDP path</h6> <h6>
{intl.formatMessage({ id: "config.general.chrome_cdp_path" })}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={scraperCDPPath} defaultValue={scraperCDPPath}
@@ -763,9 +862,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
File path to the Chrome executable, or a remote address (starting {intl.formatMessage({ id: "config.general.chrome_cdp_path_desc" })}
with http:// or https://, for example
http://localhost:9222/json/version) to a Chrome instance.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -773,13 +870,15 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check <Form.Check
id="scaper-cert-check" id="scaper-cert-check"
checked={scraperCertCheck} checked={scraperCertCheck}
label="Check for insecure certificates" label={intl.formatMessage({
id: "config.general.check_for_insecure_certificates",
})}
onChange={() => setScraperCertCheck(!scraperCertCheck)} onChange={() => setScraperCertCheck(!scraperCertCheck)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Some sites use insecure ssl certificates. When unticked the scraper {intl.formatMessage({
skips the insecure certificates check and allows scraping of those id: "config.general.check_for_insecure_certificates_desc",
sites. If you get a certificate error when scraping untick this. })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -787,16 +886,22 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr /> <hr />
<Form.Group id="stashbox"> <Form.Group id="stashbox">
<h4>Stash-box integration</h4> <h4>
{intl.formatMessage({
id: "config.general.auth.stash-box_integration",
})}
</h4>
<StashBoxConfiguration boxes={stashBoxes} saveBoxes={setStashBoxes} /> <StashBoxConfiguration boxes={stashBoxes} saveBoxes={setStashBoxes} />
</Form.Group> </Form.Group>
<hr /> <hr />
<Form.Group> <Form.Group>
<h4>Authentication</h4> <h4>
{intl.formatMessage({ id: "config.general.auth.authentication" })}
</h4>
<Form.Group id="username"> <Form.Group id="username">
<h6>Username</h6> <h6>{intl.formatMessage({ id: "config.general.auth.username" })}</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={username} defaultValue={username}
@@ -805,11 +910,11 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Username to access Stash. Leave blank to disable user authentication {intl.formatMessage({ id: "config.general.auth.username_desc" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="password"> <Form.Group id="password">
<h6>Password</h6> <h6>{intl.formatMessage({ id: "config.general.auth.password" })}</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="password" type="password"
@@ -819,12 +924,12 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Password to access Stash. Leave blank to disable user authentication {intl.formatMessage({ id: "config.general.auth.password_desc" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="apikey"> <Form.Group id="apikey">
<h6>API Key</h6> <h6>{intl.formatMessage({ id: "config.general.auth.api_key" })}</h6>
<InputGroup> <InputGroup>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
@@ -834,7 +939,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
<InputGroup.Append> <InputGroup.Append>
<Button <Button
className="" className=""
title="Generate API key" title={intl.formatMessage({
id: "config.general.auth.generate_api_key",
})}
onClick={() => onGenerateAPIKey()} onClick={() => onGenerateAPIKey()}
> >
<Icon icon="redo" /> <Icon icon="redo" />
@@ -842,7 +949,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Button <Button
className="" className=""
variant="danger" variant="danger"
title="Clear API key" title={intl.formatMessage({
id: "config.general.auth.clear_api_key",
})}
onClick={() => onClearAPIKey()} onClick={() => onClearAPIKey()}
> >
<Icon icon="minus" /> <Icon icon="minus" />
@@ -850,13 +959,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
API key for external systems. Only required when username/password {intl.formatMessage({ id: "config.general.auth.api_key_desc" })}
is configured. Username must be saved before generating API key.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="maxSessionAge"> <Form.Group id="maxSessionAge">
<h6>Maximum Session Age</h6> <h6>
{intl.formatMessage({
id: "config.general.auth.maximum_session_age",
})}
</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -868,16 +980,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Maximum idle time before a login session is expired, in seconds. {intl.formatMessage({
id: "config.general.auth.maximum_session_age_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
<hr /> <hr />
<h4>Logging</h4> <h4>{intl.formatMessage({ id: "config.general.logging" })}</h4>
<Form.Group id="log-file"> <Form.Group id="log-file">
<h6>Log file</h6> <h6>{intl.formatMessage({ id: "config.general.auth.log_file" })}</h6>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
defaultValue={logFile} defaultValue={logFile}
@@ -886,8 +1000,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Path to the file to output logging to. Blank to disable file logging. {intl.formatMessage({ id: "config.general.auth.log_file_desc" })}
Requires restart.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -895,17 +1008,20 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check <Form.Check
id="log-terminal" id="log-terminal"
checked={logOut} checked={logOut}
label="Log to terminal" label={intl.formatMessage({
id: "config.general.auth.log_to_terminal",
})}
onChange={() => setLogOut(!logOut)} onChange={() => setLogOut(!logOut)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Logs to the terminal in addition to a file. Always true if file {intl.formatMessage({
logging is disabled. Requires restart. id: "config.general.auth.log_to_terminal_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="log-level"> <Form.Group id="log-level">
<h6>Log Level</h6> <h6>{intl.formatMessage({ id: "config.logs.log_level" })}</h6>
<Form.Control <Form.Control
className="col col-sm-6 input-control" className="col col-sm-6 input-control"
as="select" as="select"
@@ -926,18 +1042,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check <Form.Check
id="log-http" id="log-http"
checked={logAccess} checked={logAccess}
label="Log http access" label={intl.formatMessage({ id: "config.general.auth.log_http" })}
onChange={() => setLogAccess(!logAccess)} onChange={() => setLogAccess(!logAccess)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Logs http access to the terminal. Requires restart. {intl.formatMessage({ id: "config.general.auth.log_http_desc" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<Button variant="primary" onClick={() => onSave()}> <Button variant="primary" onClick={() => onSave()}>
Save <FormattedMessage id="actions.save" />
</Button> </Button>
</> </>
); );

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Formik, useFormikContext } from "formik"; import { Formik, useFormikContext } from "formik";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { Prompt } from "react-router"; import { Prompt } from "react-router";
import { FormattedMessage, useIntl } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import { import {
useConfiguration, useConfiguration,
@@ -17,6 +18,7 @@ import { DurationInput, Icon, LoadingIndicator, Modal } from "../Shared";
import { StringListInput } from "../Shared/StringListInput"; import { StringListInput } from "../Shared/StringListInput";
export const SettingsDLNAPanel: React.FC = () => { export const SettingsDLNAPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
// undefined to hide dialog, true for enable, false for disable // undefined to hide dialog, true for enable, false for disable
@@ -74,7 +76,16 @@ export const SettingsDLNAPanel: React.FC = () => {
}, },
}); });
configRefetch(); configRefetch();
Toast.success({ content: "Updated config" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "configuration" })
.toLocaleLowerCase(),
}
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -166,7 +177,9 @@ export const SettingsDLNAPanel: React.FC = () => {
} }
const { dlnaStatus } = statusData; const { dlnaStatus } = statusData;
const runningText = dlnaStatus.running ? "running" : "not running"; const runningText = intl.formatMessage({
id: dlnaStatus.running ? "actions.running" : "actions.not_running",
});
return `${runningText} ${renderDeadline(dlnaStatus.until)}`; return `${runningText} ${renderDeadline(dlnaStatus.until)}`;
} }
@@ -181,14 +194,14 @@ export const SettingsDLNAPanel: React.FC = () => {
if (data?.configuration.dlna.enabled) { if (data?.configuration.dlna.enabled) {
return ( return (
<Button onClick={() => setEnableDisable(false)} className="mr-1"> <Button onClick={() => setEnableDisable(false)} className="mr-1">
Disable temporarily... <FormattedMessage id="actions.temp_disable" />
</Button> </Button>
); );
} }
return ( return (
<Button onClick={() => setEnableDisable(true)} className="mr-1"> <Button onClick={() => setEnableDisable(true)} className="mr-1">
Enable temporarily... <FormattedMessage id="actions.temp_enable" />
</Button> </Button>
); );
} }
@@ -267,7 +280,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<Form.Group> <Form.Group>
<Form.Check <Form.Check
checked={enableUntilRestart} checked={enableUntilRestart}
label="until restart" label={intl.formatMessage({ id: "config.dlna.until_restart" })}
onChange={() => setEnableUntilRestart(!enableUntilRestart)} onChange={() => setEnableUntilRestart(!enableUntilRestart)}
/> />
</Form.Group> </Form.Group>
@@ -290,10 +303,13 @@ export const SettingsDLNAPanel: React.FC = () => {
return ( return (
<Modal <Modal
show={tempIP !== undefined} show={tempIP !== undefined}
header={`Allow ${tempIP}`} header={intl.formatMessage(
{ id: "config.dlna.allow_temp_ip" },
{ tempIP }
)}
icon="clock" icon="clock"
accept={{ accept={{
text: "Allow", text: intl.formatMessage({ id: "actions.allow" }),
variant: "primary", variant: "primary",
onClick: onAllowTempIP, onClick: onAllowTempIP,
}} }}
@@ -306,7 +322,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<Form.Group> <Form.Group>
<Form.Check <Form.Check
checked={enableUntilRestart} checked={enableUntilRestart}
label="until restart" label={intl.formatMessage({ id: "config.dlna.until_restart" })}
onChange={() => setEnableUntilRestart(!enableUntilRestart)} onChange={() => setEnableUntilRestart(!enableUntilRestart)}
/> />
</Form.Group> </Form.Group>
@@ -333,7 +349,9 @@ export const SettingsDLNAPanel: React.FC = () => {
const { allowedIPAddresses } = statusData.dlnaStatus; const { allowedIPAddresses } = statusData.dlnaStatus;
return ( return (
<Form.Group> <Form.Group>
<h6>Allowed IP addresses</h6> <h6>
{intl.formatMessage({ id: "config.dlna.allowed_ip_addresses" })}
</h6>
<ul className="addresses"> <ul className="addresses">
{allowedIPAddresses.map((a) => ( {allowedIPAddresses.map((a) => (
@@ -347,7 +365,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<div className="buttons"> <div className="buttons">
<Button <Button
size="sm" size="sm"
title="Disallow" title={intl.formatMessage({ id: "actions.disallow" })}
variant="danger" variant="danger"
onClick={() => onDisallowTempIP(a.ipAddress)} onClick={() => onDisallowTempIP(a.ipAddress)}
> >
@@ -377,7 +395,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<div> <div>
<Button <Button
size="sm" size="sm"
title="Allow temporarily" title={intl.formatMessage({ id: "actions.allow_temporarily" })}
onClick={() => setTempIP(a)} onClick={() => setTempIP(a)}
> >
<Icon icon="user-clock" /> <Icon icon="user-clock" />
@@ -398,7 +416,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<div className="buttons"> <div className="buttons">
<Button <Button
size="sm" size="sm"
title="Allow temporarily" title={intl.formatMessage({ id: "actions.allow_temporarily" })}
onClick={() => setTempIP(ipEntry)} onClick={() => setTempIP(ipEntry)}
disabled={!ipEntry} disabled={!ipEntry}
> >
@@ -422,13 +440,15 @@ export const SettingsDLNAPanel: React.FC = () => {
<Form noValidate onSubmit={handleSubmit}> <Form noValidate onSubmit={handleSubmit}>
<Prompt <Prompt
when={dirty} when={dirty}
message="Unsaved changes. Are you sure you want to leave?" message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
/> />
<Form.Group> <Form.Group>
<h5>Settings</h5> <h5>{intl.formatMessage({ id: "settings" })}</h5>
<Form.Group> <Form.Group>
<Form.Label>Server Display Name</Form.Label> <Form.Label>
{intl.formatMessage({ id: "config.dlna.server_display_name" })}
</Form.Label>
<Form.Control <Form.Control
className="text-input server-name" className="text-input server-name"
value={values.serverName} value={values.serverName}
@@ -437,20 +457,26 @@ export const SettingsDLNAPanel: React.FC = () => {
} }
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Display name for the DLNA server. Defaults to <code>stash</code>{" "} {intl.formatMessage(
if empty. { id: "config.dlna.server_display_name_desc" },
{ server_name: <code>stash</code> }
)}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<Form.Check <Form.Check
checked={values.enabled} checked={values.enabled}
label="Enabled by default" label={intl.formatMessage({
id: "config.dlna.enabled_by_default",
})}
onChange={() => setFieldValue("enabled", !values.enabled)} onChange={() => setFieldValue("enabled", !values.enabled)}
/> />
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h6>Interfaces</h6> <h6>
{intl.formatMessage({ id: "config.dlna.network_interfaces" })}
</h6>
<StringListInput <StringListInput
value={values.interfaces} value={values.interfaces}
setValue={(value) => setFieldValue("interfaces", value)} setValue={(value) => setFieldValue("interfaces", value)}
@@ -458,13 +484,16 @@ export const SettingsDLNAPanel: React.FC = () => {
className="interfaces-input" className="interfaces-input"
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Interfaces to expose DLNA server on. An empty list results in {intl.formatMessage({
running on all interfaces. Requires DLNA restart after changing. id: "config.dlna.network_interfaces_desc",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h6>Default IP Whitelist</h6> <h6>
{intl.formatMessage({ id: "config.dlna.default_ip_whitelist" })}
</h6>
<StringListInput <StringListInput
value={values.whitelistedIPs} value={values.whitelistedIPs}
setValue={(value) => setFieldValue("whitelistedIPs", value)} setValue={(value) => setFieldValue("whitelistedIPs", value)}
@@ -472,8 +501,10 @@ export const SettingsDLNAPanel: React.FC = () => {
className="ip-whitelist-input" className="ip-whitelist-input"
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Default IP addresses allow to access DLNA. Use <code>*</code> to {intl.formatMessage(
allow all IP addresses. { id: "config.dlna.default_ip_whitelist_desc" },
{ wildcard: <code>*</code> }
)}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -481,7 +512,7 @@ export const SettingsDLNAPanel: React.FC = () => {
<hr /> <hr />
<Button variant="primary" type="submit" disabled={!dirty}> <Button variant="primary" type="submit" disabled={!dirty}>
Save <FormattedMessage id="actions.save" />
</Button> </Button>
</Form> </Form>
); );
@@ -495,11 +526,13 @@ export const SettingsDLNAPanel: React.FC = () => {
<h4>DLNA</h4> <h4>DLNA</h4>
<Form.Group> <Form.Group>
<h5>Status: {renderStatus()}</h5> <h5>
{intl.formatMessage({ id: "status" }, { statusText: renderStatus() })}
</h5>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Actions</h5> <h5>{intl.formatMessage({ id: "actions_name" })}</h5>
<Form.Group> <Form.Group>
{renderEnableButton()} {renderEnableButton()}
@@ -509,10 +542,14 @@ export const SettingsDLNAPanel: React.FC = () => {
{renderAllowedIPs()} {renderAllowedIPs()}
<Form.Group> <Form.Group>
<h6>Recent IP addresses</h6> <h6>
{intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
</h6>
<Form.Group>{renderRecentIPs()}</Form.Group> <Form.Group>{renderRecentIPs()}</Form.Group>
<Form.Group> <Form.Group>
<Button onClick={() => statusRefetch()}>Refresh</Button> <Button onClick={() => statusRefetch()}>
<FormattedMessage id="actions.refresh" />
</Button>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { DurationInput, LoadingIndicator } from "src/components/Shared"; import { DurationInput, LoadingIndicator } from "src/components/Shared";
import { useConfiguration, useConfigureInterface } from "src/core/StashService"; import { useConfiguration, useConfigureInterface } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -19,6 +20,7 @@ const allMenuItems = [
const SECONDS_TO_MS = 1000; const SECONDS_TO_MS = 1000;
export const SettingsInterfacePanel: React.FC = () => { export const SettingsInterfacePanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const { data: config, error, loading } = useConfiguration(); const { data: config, error, loading } = useConfiguration();
const [menuItemIds, setMenuItemIds] = useState<string[]>( const [menuItemIds, setMenuItemIds] = useState<string[]>(
@@ -84,7 +86,16 @@ export const SettingsInterfacePanel: React.FC = () => {
window.location.reload(); window.location.reload();
} }
Toast.success({ content: "Updated config" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "configuration" })
.toLocaleLowerCase(),
}
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -95,9 +106,9 @@ export const SettingsInterfacePanel: React.FC = () => {
return ( return (
<> <>
<h4>User Interface</h4> <h4>{intl.formatMessage({ id: "config.ui.title" })}</h4>
<Form.Group controlId="language"> <Form.Group controlId="language">
<h5>Language</h5> <h5>{intl.formatMessage({ id: "config.ui.language.heading" })}</h5>
<Form.Control <Form.Control
as="select" as="select"
className="col-4 input-control" className="col-4 input-control"
@@ -108,11 +119,11 @@ export const SettingsInterfacePanel: React.FC = () => {
> >
<option value="en-US">English (United States)</option> <option value="en-US">English (United States)</option>
<option value="en-GB">English (United Kingdom)</option> <option value="en-GB">English (United Kingdom)</option>
<option value="zh-TW">Chinese (Taiwan)</option> <option value="zh-TW"> ()</option>
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Menu items</h5> <h5>{intl.formatMessage({ id: "config.ui.menu_items.heading" })}</h5>
<CheckboxGroup <CheckboxGroup
groupId="menu-items" groupId="menu-items"
items={allMenuItems} items={allMenuItems}
@@ -120,25 +131,31 @@ export const SettingsInterfacePanel: React.FC = () => {
onChange={setMenuItemIds} onChange={setMenuItemIds}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Show or hide different types of content on the navigation bar {intl.formatMessage({ id: "config.ui.menu_items.description" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Scene / Marker Wall</h5> <h5>{intl.formatMessage({ id: "config.ui.scene_wall.heading" })}</h5>
<Form.Check <Form.Check
id="wall-show-title" id="wall-show-title"
checked={wallShowTitle} checked={wallShowTitle}
label="Display title and tags" label={intl.formatMessage({
id: "config.ui.scene_wall.options.display_title",
})}
onChange={() => setWallShowTitle(!wallShowTitle)} onChange={() => setWallShowTitle(!wallShowTitle)}
/> />
<Form.Check <Form.Check
id="wall-sound-enabled" id="wall-sound-enabled"
checked={soundOnPreview} checked={soundOnPreview}
label="Enable sound" label={intl.formatMessage({
id: "config.ui.scene_wall.options.toggle_sound",
})}
onChange={() => setSoundOnPreview(!soundOnPreview)} onChange={() => setSoundOnPreview(!soundOnPreview)}
/> />
<Form.Label htmlFor="wall-preview"> <Form.Label htmlFor="wall-preview">
<h6>Preview Type</h6> <h6>
{intl.formatMessage({ id: "config.ui.preview_type.heading" })}
</h6>
</Form.Label> </Form.Label>
<Form.Control <Form.Control
as="select" as="select"
@@ -149,21 +166,33 @@ export const SettingsInterfacePanel: React.FC = () => {
setWallPlayback(e.currentTarget.value) setWallPlayback(e.currentTarget.value)
} }
> >
<option value="video">Video</option> <option value="video">
<option value="animation">Animated Image</option> {intl.formatMessage({ id: "config.ui.preview_type.options.video" })}
<option value="image">Static Image</option> </option>
<option value="animation">
{intl.formatMessage({
id: "config.ui.preview_type.options.animated",
})}
</option>
<option value="image">
{intl.formatMessage({
id: "config.ui.preview_type.options.static",
})}
</option>
</Form.Control> </Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Configuration for wall items {intl.formatMessage({ id: "config.ui.preview_type.description" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Scene List</h5> <h5>{intl.formatMessage({ id: "config.ui.scene_list.heading" })}</h5>
<Form.Check <Form.Check
id="show-text-studios" id="show-text-studios"
checked={showStudioAsText} checked={showStudioAsText}
label="Show Studios as text" label={intl.formatMessage({
id: "config.ui.scene_list.options.show_studio_as_text",
})}
onChange={() => { onChange={() => {
setShowStudioAsText(!showStudioAsText); setShowStudioAsText(!showStudioAsText);
}} }}
@@ -171,11 +200,13 @@ export const SettingsInterfacePanel: React.FC = () => {
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Scene Player</h5> <h5>{intl.formatMessage({ id: "config.ui.scene_player.heading" })}</h5>
<Form.Group id="auto-start-video"> <Form.Group id="auto-start-video">
<Form.Check <Form.Check
checked={autostartVideo} checked={autostartVideo}
label="Auto-start video" label={intl.formatMessage({
id: "config.ui.scene_player.options.auto_start_video",
})}
onChange={() => { onChange={() => {
setAutostartVideo(!autostartVideo); setAutostartVideo(!autostartVideo);
}} }}
@@ -183,21 +214,26 @@ export const SettingsInterfacePanel: React.FC = () => {
</Form.Group> </Form.Group>
<Form.Group id="max-loop-duration"> <Form.Group id="max-loop-duration">
<h6>Maximum loop duration</h6> <h6>
{intl.formatMessage({ id: "config.ui.max_loop_duration.heading" })}
</h6>
<DurationInput <DurationInput
className="row col col-4" className="row col col-4"
numericValue={maximumLoopDuration} numericValue={maximumLoopDuration}
onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)} onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Maximum scene duration where scene player will loop the video - 0 to {intl.formatMessage({
disable id: "config.ui.max_loop_duration.description",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
<Form.Group id="slideshow-delay"> <Form.Group id="slideshow-delay">
<h5>Slideshow Delay</h5> <h5>
{intl.formatMessage({ id: "config.ui.slideshow_delay.heading" })}
</h5>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
type="number" type="number"
@@ -209,16 +245,18 @@ export const SettingsInterfacePanel: React.FC = () => {
}} }}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Slideshow is available in galleries when in wall view mode {intl.formatMessage({ id: "config.ui.slideshow_delay.description" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Custom CSS</h5> <h5>{intl.formatMessage({ id: "config.ui.custom_css.heading" })}</h5>
<Form.Check <Form.Check
id="custom-css" id="custom-css"
checked={cssEnabled} checked={cssEnabled}
label="Custom CSS enabled" label={intl.formatMessage({
id: "config.ui.custom_css.option_label",
})}
onChange={() => { onChange={() => {
setCSSEnabled(!cssEnabled); setCSSEnabled(!cssEnabled);
}} }}
@@ -234,12 +272,12 @@ export const SettingsInterfacePanel: React.FC = () => {
className="col col-sm-6 text-input code" className="col col-sm-6 text-input code"
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Page must be reloaded for changes to take effect. {intl.formatMessage({ id: "config.ui.custom_css.description" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Handy Connection Key</h5> <h5>{intl.formatMessage({ id: "config.ui.handy_connection_key" })}</h5>
<Form.Control <Form.Control
className="col col-sm-6 text-input" className="col col-sm-6 text-input"
value={handyKey} value={handyKey}
@@ -248,13 +286,13 @@ export const SettingsInterfacePanel: React.FC = () => {
}} }}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Handy connection key to use for interactive scenes. {intl.formatMessage({ id: "config.ui.handy_connection_key_desc" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<Button variant="primary" onClick={() => onSave()}> <Button variant="primary" onClick={() => onSave()}>
Save {intl.formatMessage({ id: "actions.save" })}
</Button> </Button>
</> </>
); );

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useReducer, useState } from "react"; import React, { useEffect, useReducer, useState } from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useLogs, useLoggingSubscribe } from "src/core/StashService"; import { useLogs, useLoggingSubscribe } from "src/core/StashService";
@@ -74,6 +75,7 @@ const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
]; ];
export const SettingsLogsPanel: React.FC = () => { export const SettingsLogsPanel: React.FC = () => {
const intl = useIntl();
const { data, error } = useLoggingSubscribe(); const { data, error } = useLoggingSubscribe();
const { data: existingData } = useLogs(); const { data: existingData } = useLogs();
const [currentData, dispatchLogUpdate] = useReducer(logReducer, []); const [currentData, dispatchLogUpdate] = useReducer(logReducer, []);
@@ -106,9 +108,11 @@ export const SettingsLogsPanel: React.FC = () => {
return ( return (
<> <>
<h4>Logs</h4> <h4>{intl.formatMessage({ id: "config.categories.logs" })}</h4>
<Form.Row id="log-level"> <Form.Row id="log-level">
<Form.Label className="col-6 col-sm-2">Log Level</Form.Label> <Form.Label className="col-6 col-sm-2">
{intl.formatMessage({ id: "config.logs.log_level" })}
</Form.Label>
<Form.Control <Form.Control
className="col-6 col-sm-2 input-control" className="col-6 col-sm-2 input-control"
as="select" as="select"

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { mutateReloadPlugins, usePlugins } from "src/core/StashService"; import { mutateReloadPlugins, usePlugins } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -8,6 +9,8 @@ import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
export const SettingsPluginsPanel: React.FC = () => { export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const { data, loading } = usePlugins(); const { data, loading } = usePlugins();
async function onReloadPlugins() { async function onReloadPlugins() {
@@ -58,11 +61,15 @@ export const SettingsPluginsPanel: React.FC = () => {
return ( return (
<div className="mt-2"> <div className="mt-2">
<h5>Hooks</h5> <h5>
<FormattedMessage id="config.plugins.hooks" />
</h5>
{hooks.map((h) => ( {hooks.map((h) => (
<div key={`${h.name}`} className="mb-3"> <div key={`${h.name}`} className="mb-3">
<h6>{h.name}</h6> <h6>{h.name}</h6>
<CollapseButton text="Triggers on"> <CollapseButton
text={intl.formatMessage({ id: "config.plugins.triggers_on" })}
>
<ul> <ul>
{h.hooks?.map((hh) => ( {h.hooks?.map((hh) => (
<li> <li>
@@ -82,14 +89,18 @@ export const SettingsPluginsPanel: React.FC = () => {
return ( return (
<> <>
<h3>Plugins</h3> <h3>
<FormattedMessage id="config.categories.plugins" />
</h3>
<hr /> <hr />
{renderPlugins()} {renderPlugins()}
<Button onClick={() => onReloadPlugins()}> <Button onClick={() => onReloadPlugins()}>
<span className="fa-icon"> <span className="fa-icon">
<Icon icon="sync-alt" /> <Icon icon="sync-alt" />
</span> </span>
<span>Reload plugins</span> <span>
<FormattedMessage id="actions.reload_plugins" />
</span>
</Button> </Button>
</> </>
); );

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { import {
mutateReloadScrapers, mutateReloadScrapers,
@@ -68,6 +69,7 @@ const URLList: React.FC<IURLList> = ({ urls }) => {
export const SettingsScrapersPanel: React.FC = () => { export const SettingsScrapersPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const { const {
data: performerScrapers, data: performerScrapers,
loading: loadingPerformers, loading: loadingPerformers,
@@ -95,7 +97,7 @@ export const SettingsScrapersPanel: React.FC = () => {
.map((t) => { .map((t) => {
switch (t) { switch (t) {
case ScrapeType.Name: case ScrapeType.Name:
return "Search by name"; return intl.formatMessage({ id: "config.scrapers.search_by_name" });
default: default:
return t; return t;
} }
@@ -114,7 +116,10 @@ export const SettingsScrapersPanel: React.FC = () => {
const typeStrings = types.map((t) => { const typeStrings = types.map((t) => {
switch (t) { switch (t) {
case ScrapeType.Fragment: case ScrapeType.Fragment:
return "Scene Metadata"; return intl.formatMessage(
{ id: "config.scrapers.entity_metadata" },
{ entityType: intl.formatMessage({ id: "scene" }) }
);
default: default:
return t; return t;
} }
@@ -133,7 +138,10 @@ export const SettingsScrapersPanel: React.FC = () => {
const typeStrings = types.map((t) => { const typeStrings = types.map((t) => {
switch (t) { switch (t) {
case ScrapeType.Fragment: case ScrapeType.Fragment:
return "Gallery Metadata"; return intl.formatMessage(
{ id: "config.scrapers.entity_metadata" },
{ entityType: intl.formatMessage({ id: "gallery" }) }
);
default: default:
return t; return t;
} }
@@ -152,7 +160,10 @@ export const SettingsScrapersPanel: React.FC = () => {
const typeStrings = types.map((t) => { const typeStrings = types.map((t) => {
switch (t) { switch (t) {
case ScrapeType.Fragment: case ScrapeType.Fragment:
return "Movie Metadata"; return intl.formatMessage(
{ id: "config.scrapers.entity_metadata" },
{ entityType: intl.formatMessage({ id: "movie" }) }
);
default: default:
return t; return t;
} }
@@ -182,7 +193,13 @@ export const SettingsScrapersPanel: React.FC = () => {
</tr> </tr>
)); ));
return renderTable("Scene scrapers", elements); return renderTable(
intl.formatMessage(
{ id: "config.scrapers.entity_scrapers" },
{ entityType: intl.formatMessage({ id: "scene" }) }
),
elements
);
} }
function renderGalleryScrapers() { function renderGalleryScrapers() {
@@ -198,7 +215,13 @@ export const SettingsScrapersPanel: React.FC = () => {
) )
); );
return renderTable("Gallery Scrapers", elements); return renderTable(
intl.formatMessage(
{ id: "config.scrapers.entity_scrapers" },
{ entityType: intl.formatMessage({ id: "gallery" }) }
),
elements
);
} }
function renderPerformerScrapers() { function renderPerformerScrapers() {
@@ -216,7 +239,13 @@ export const SettingsScrapersPanel: React.FC = () => {
) )
); );
return renderTable("Performer scrapers", elements); return renderTable(
intl.formatMessage(
{ id: "config.scrapers.entity_scrapers" },
{ entityType: intl.formatMessage({ id: "performer" }) }
),
elements
);
} }
function renderMovieScrapers() { function renderMovieScrapers() {
@@ -230,7 +259,13 @@ export const SettingsScrapersPanel: React.FC = () => {
</tr> </tr>
)); ));
return renderTable("Movie scrapers", elements); return renderTable(
intl.formatMessage(
{ id: "config.scrapers.entity_scrapers" },
{ entityType: intl.formatMessage({ id: "movie" }) }
),
elements
);
} }
function renderTable(title: string, elements: JSX.Element[]) { function renderTable(title: string, elements: JSX.Element[]) {
@@ -241,9 +276,15 @@ export const SettingsScrapersPanel: React.FC = () => {
<table className="scraper-table"> <table className="scraper-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>{intl.formatMessage({ id: "name" })}</th>
<th>Supported types</th> <th>
<th>URLs</th> {intl.formatMessage({
id: "config.scrapers.supported_types",
})}
</th>
<th>
{intl.formatMessage({ id: "config.scrapers.supported_urls" })}
</th>
</tr> </tr>
</thead> </thead>
<tbody>{elements}</tbody> <tbody>{elements}</tbody>
@@ -258,13 +299,15 @@ export const SettingsScrapersPanel: React.FC = () => {
return ( return (
<> <>
<h4>Scrapers</h4> <h4>{intl.formatMessage({ id: "config.categories.scrapers" })}</h4>
<div className="mb-3"> <div className="mb-3">
<Button onClick={() => onReloadScrapers()}> <Button onClick={() => onReloadScrapers()}>
<span className="fa-icon"> <span className="fa-icon">
<Icon icon="sync-alt" /> <Icon icon="sync-alt" />
</span> </span>
<span>Reload scrapers</span> <span>
<FormattedMessage id="actions.reload_scrapers" />
</span>
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Col, Form, Row } from "react-bootstrap"; import { Button, Col, Form, Row } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useConfiguration } from "src/core/StashService"; import { useConfiguration } from "src/core/StashService";
import { Icon, Modal } from "src/components/Shared"; import { Icon, Modal } from "src/components/Shared";
import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
@@ -11,6 +12,7 @@ interface IDirectorySelectionDialogProps {
export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps> = ( export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps> = (
props: IDirectorySelectionDialogProps props: IDirectorySelectionDialogProps
) => { ) => {
const intl = useIntl();
const { data } = useConfiguration(); const { data } = useConfiguration();
const libraryPaths = data?.configuration.general.stashes.map((s) => s.path); const libraryPaths = data?.configuration.general.stashes.map((s) => s.path);
@@ -42,7 +44,7 @@ export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps>
}} }}
cancel={{ cancel={{
onClick: () => props.onClose(), onClick: () => props.onClose(),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
> >
@@ -57,7 +59,7 @@ export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps>
className="ml-auto" className="ml-auto"
size="sm" size="sm"
variant="danger" variant="danger"
title="Delete" title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => removePath(p)} onClick={() => removePath(p)}
> >
<Icon icon="minus" /> <Icon icon="minus" />

View File

@@ -1,10 +1,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { mutateMetadataGenerate } from "src/core/StashService"; import { mutateMetadataGenerate } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
export const GenerateButton: React.FC = () => { export const GenerateButton: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const [sprites, setSprites] = useState(true); const [sprites, setSprites] = useState(true);
const [phashes, setPhashes] = useState(true); const [phashes, setPhashes] = useState(true);
const [previews, setPreviews] = useState(true); const [previews, setPreviews] = useState(true);
@@ -22,7 +24,11 @@ export const GenerateButton: React.FC = () => {
markers, markers,
transcodes, transcodes,
}); });
Toast.success({ content: "Added generation job to queue" }); Toast.success({
content: intl.formatMessage({
id: "toast.added_generation_job_to_queue",
}),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -34,7 +40,7 @@ export const GenerateButton: React.FC = () => {
<Form.Check <Form.Check
id="preview-task" id="preview-task"
checked={previews} checked={previews}
label="Previews (video previews which play when hovering over a scene)" label={intl.formatMessage({ id: "dialogs.scene_gen.video_previews" })}
onChange={() => setPreviews(!previews)} onChange={() => setPreviews(!previews)}
/> />
<div className="d-flex flex-row"> <div className="d-flex flex-row">
@@ -43,7 +49,9 @@ export const GenerateButton: React.FC = () => {
id="image-preview-task" id="image-preview-task"
checked={imagePreviews} checked={imagePreviews}
disabled={!previews} disabled={!previews}
label="Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)" label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() => setImagePreviews(!imagePreviews)} onChange={() => setImagePreviews(!imagePreviews)}
className="ml-2 flex-grow" className="ml-2 flex-grow"
/> />
@@ -51,25 +59,25 @@ export const GenerateButton: React.FC = () => {
<Form.Check <Form.Check
id="sprite-task" id="sprite-task"
checked={sprites} checked={sprites}
label="Sprites (for the scene scrubber)" label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setSprites(!sprites)} onChange={() => setSprites(!sprites)}
/> />
<Form.Check <Form.Check
id="marker-task" id="marker-task"
checked={markers} checked={markers}
label="Markers (20 second videos which begin at the given timecode)" label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setMarkers(!markers)} onChange={() => setMarkers(!markers)}
/> />
<Form.Check <Form.Check
id="transcode-task" id="transcode-task"
checked={transcodes} checked={transcodes}
label="Transcodes (MP4 conversions of unsupported video formats)" label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setTranscodes(!transcodes)} onChange={() => setTranscodes(!transcodes)}
/> />
<Form.Check <Form.Check
id="phash-task" id="phash-task"
checked={phashes} checked={phashes}
label="Phashes (for deduplication and scene identification)" label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setPhashes(!phashes)} onChange={() => setPhashes(!phashes)}
/> />
</Form.Group> </Form.Group>
@@ -80,10 +88,10 @@ export const GenerateButton: React.FC = () => {
type="submit" type="submit"
onClick={() => onGenerate()} onClick={() => onGenerate()}
> >
Generate <FormattedMessage id="actions.generate" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Generate supporting image, sprite, video, vtt and other files. {intl.formatMessage({ id: "config.tasks.generate_desc" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</> </>

View File

@@ -4,6 +4,7 @@ import { mutateImportObjects } from "src/core/StashService";
import { Modal } from "src/components/Shared"; import { Modal } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { useIntl } from "react-intl";
interface IImportDialogProps { interface IImportDialogProps {
onClose: () => void; onClose: () => void;
@@ -25,6 +26,7 @@ export const ImportDialog: React.FC<IImportDialogProps> = (
// Network state // Network state
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
function duplicateHandlingToString( function duplicateHandlingToString(
@@ -112,16 +114,16 @@ export const ImportDialog: React.FC<IImportDialogProps> = (
<Modal <Modal
show show
icon="pencil-alt" icon="pencil-alt"
header="Import" header={intl.formatMessage({ id: "actions.import" })}
accept={{ accept={{
onClick: () => { onClick: () => {
onImport(); onImport();
}, },
text: "Import", text: intl.formatMessage({ id: "actions.import" }),
}} }}
cancel={{ cancel={{
onClick: () => props.onClose(), onClick: () => props.onClose(),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
disabled={!file} disabled={!file}

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { import {
mutateMetadataImport, mutateMetadataImport,
@@ -24,6 +25,7 @@ type Plugin = Pick<GQL.Plugin, "id">;
type PluginTask = Pick<GQL.PluginTask, "name" | "description">; type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
export const SettingsTasksPanel: React.FC = () => { export const SettingsTasksPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false); const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false); const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
@@ -60,7 +62,12 @@ export const SettingsTasksPanel: React.FC = () => {
setIsImportAlertOpen(false); setIsImportAlertOpen(false);
try { try {
await mutateMetadataImport(); await mutateMetadataImport();
Toast.success({ content: "Added import task to queue" }); Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.import" }) }
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -71,13 +78,14 @@ export const SettingsTasksPanel: React.FC = () => {
<Modal <Modal
show={isImportAlertOpen} show={isImportAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: "Import", variant: "danger", onClick: onImport }} accept={{
text: intl.formatMessage({ id: "actions.import" }),
variant: "danger",
onClick: onImport,
}}
cancel={{ onClick: () => setIsImportAlertOpen(false) }} cancel={{ onClick: () => setIsImportAlertOpen(false) }}
> >
<p> <p>{intl.formatMessage({ id: "actions.tasks.import_warning" })}</p>
Are you sure you want to import? This will delete the database and
re-import from your exported metadata.
</p>
</Modal> </Modal>
); );
} }
@@ -93,16 +101,12 @@ export const SettingsTasksPanel: React.FC = () => {
let msg; let msg;
if (cleanDryRun) { if (cleanDryRun) {
msg = ( msg = (
<p> <p>{intl.formatMessage({ id: "actions.tasks.dry_mode_selected" })}</p>
Dry Mode selected. No actual deleting will take place, only logging.
</p>
); );
} else { } else {
msg = ( msg = (
<p> <p>
Are you sure you want to Clean? This will delete database information {intl.formatMessage({ id: "actions.tasks.clean_confirm_message" })}
and generated content for all scenes and galleries that are no longer
found in the filesystem.
</p> </p>
); );
} }
@@ -111,7 +115,11 @@ export const SettingsTasksPanel: React.FC = () => {
<Modal <Modal
show={isCleanAlertOpen} show={isCleanAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: "Clean", variant: "danger", onClick: onClean }} accept={{
text: intl.formatMessage({ id: "actions.clean" }),
variant: "danger",
onClick: onClean,
}}
cancel={{ onClick: () => setIsCleanAlertOpen(false) }} cancel={{ onClick: () => setIsCleanAlertOpen(false) }}
> >
{msg} {msg}
@@ -154,7 +162,12 @@ export const SettingsTasksPanel: React.FC = () => {
scanGenerateSprites, scanGenerateSprites,
scanGeneratePhashes, scanGeneratePhashes,
}); });
Toast.success({ content: "Added scan to job queue" }); Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.scan" }) }
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -189,7 +202,12 @@ export const SettingsTasksPanel: React.FC = () => {
async function onAutoTag(paths?: string[]) { async function onAutoTag(paths?: string[]) {
try { try {
await mutateMetadataAutoTag(getAutoTagInput(paths)); await mutateMetadataAutoTag(getAutoTagInput(paths));
Toast.success({ content: "Added Auto tagging job to queue" }); Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.auto_tag" }) }
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -197,7 +215,12 @@ export const SettingsTasksPanel: React.FC = () => {
async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) { async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) {
await mutateRunPluginTask(plugin.id, operation.name); await mutateRunPluginTask(plugin.id, operation.name);
Toast.success({ content: `Added ${operation.name} job to queue` }); Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: operation.name }
),
});
} }
function renderPluginTasks(plugin: Plugin, pluginTasks: PluginTask[]) { function renderPluginTasks(plugin: Plugin, pluginTasks: PluginTask[]) {
@@ -251,7 +274,7 @@ export const SettingsTasksPanel: React.FC = () => {
return ( return (
<> <>
<hr /> <hr />
<h5>Plugin Tasks</h5> <h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
{plugins.data.plugins.map((o) => { {plugins.data.plugins.map((o) => {
return ( return (
<div key={`${o.id}`} className="mb-3"> <div key={`${o.id}`} className="mb-3">
@@ -268,7 +291,16 @@ export const SettingsTasksPanel: React.FC = () => {
async function onMigrateHashNaming() { async function onMigrateHashNaming() {
try { try {
await mutateMigrateHashNaming(); await mutateMigrateHashNaming();
Toast.success({ content: "Added hash migration task to queue" }); Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{
operation_name: intl.formatMessage({
id: "actions.hash_migration",
}),
}
),
});
} catch (err) { } catch (err) {
Toast.error(err); Toast.error(err);
} }
@@ -277,14 +309,23 @@ export const SettingsTasksPanel: React.FC = () => {
async function onExport() { async function onExport() {
try { try {
await mutateMetadataExport(); await mutateMetadataExport();
Toast.success({ content: "Added export task to queue" }); Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.backup" }) }
),
});
} catch (err) { } catch (err) {
Toast.error(err); Toast.error(err);
} }
} }
if (isBackupRunning) { if (isBackupRunning) {
return <LoadingIndicator message="Backup up database" />; return (
<LoadingIndicator
message={intl.formatMessage({ id: "config.tasks.backing_up_database" })}
/>
);
} }
return ( return (
@@ -295,30 +336,36 @@ export const SettingsTasksPanel: React.FC = () => {
{renderScanDialog()} {renderScanDialog()}
{renderAutoTagDialog()} {renderAutoTagDialog()}
<h4>Job Queue</h4> <h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
<JobTable /> <JobTable />
<hr /> <hr />
<h5>Library</h5> <h5>{intl.formatMessage({ id: "library" })}</h5>
<Form.Group> <Form.Group>
<Form.Check <Form.Check
id="use-file-metadata" id="use-file-metadata"
checked={useFileMetadata} checked={useFileMetadata}
label="Set name, date, details from metadata (if present)" label={intl.formatMessage({
id: "config.tasks.set_name_date_details_from_metadata_if_present",
})}
onChange={() => setUseFileMetadata(!useFileMetadata)} onChange={() => setUseFileMetadata(!useFileMetadata)}
/> />
<Form.Check <Form.Check
id="strip-file-extension" id="strip-file-extension"
checked={stripFileExtension} checked={stripFileExtension}
label="Don't include file extension as part of the title" label={intl.formatMessage({
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
})}
onChange={() => setStripFileExtension(!stripFileExtension)} onChange={() => setStripFileExtension(!stripFileExtension)}
/> />
<Form.Check <Form.Check
id="scan-generate-previews" id="scan-generate-previews"
checked={scanGeneratePreviews} checked={scanGeneratePreviews}
label="Generate previews during scan (video previews which play when hovering over a scene)" label={intl.formatMessage({
id: "config.tasks.generate_video_previews_during_scan",
})}
onChange={() => setScanGeneratePreviews(!scanGeneratePreviews)} onChange={() => setScanGeneratePreviews(!scanGeneratePreviews)}
/> />
<div className="d-flex flex-row"> <div className="d-flex flex-row">
@@ -327,7 +374,9 @@ export const SettingsTasksPanel: React.FC = () => {
id="scan-generate-image-previews" id="scan-generate-image-previews"
checked={scanGenerateImagePreviews} checked={scanGenerateImagePreviews}
disabled={!scanGeneratePreviews} disabled={!scanGeneratePreviews}
label="Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)" label={intl.formatMessage({
id: "config.tasks.generate_previews_during_scan",
})}
onChange={() => onChange={() =>
setScanGenerateImagePreviews(!scanGenerateImagePreviews) setScanGenerateImagePreviews(!scanGenerateImagePreviews)
} }
@@ -337,13 +386,17 @@ export const SettingsTasksPanel: React.FC = () => {
<Form.Check <Form.Check
id="scan-generate-sprites" id="scan-generate-sprites"
checked={scanGenerateSprites} checked={scanGenerateSprites}
label="Generate sprites during scan (for the scene scrubber)" label={intl.formatMessage({
id: "config.tasks.generate_sprites_during_scan",
})}
onChange={() => setScanGenerateSprites(!scanGenerateSprites)} onChange={() => setScanGenerateSprites(!scanGenerateSprites)}
/> />
<Form.Check <Form.Check
id="scan-generate-phashes" id="scan-generate-phashes"
checked={scanGeneratePhashes} checked={scanGeneratePhashes}
label="Generate phashes during scan (for deduplication and scene identification)" label={intl.formatMessage({
id: "config.tasks.generate_phashes_during_scan",
})}
onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)} onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)}
/> />
</Form.Group> </Form.Group>
@@ -354,41 +407,41 @@ export const SettingsTasksPanel: React.FC = () => {
type="submit" type="submit"
onClick={() => onScan()} onClick={() => onScan()}
> >
Scan <FormattedMessage id="actions.scan" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
type="submit" type="submit"
onClick={() => setIsScanDialogOpen(true)} onClick={() => setIsScanDialogOpen(true)}
> >
Selective Scan <FormattedMessage id="actions.selective_scan" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Scan for new content and add it to the database. {intl.formatMessage({ id: "config.tasks.scan_for_content_desc" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h5>Auto Tagging</h5> <h5>{intl.formatMessage({ id: "config.tasks.auto_tagging" })}</h5>
<Form.Group> <Form.Group>
<Form.Check <Form.Check
id="autotag-performers" id="autotag-performers"
checked={autoTagPerformers} checked={autoTagPerformers}
label="Performers" label={intl.formatMessage({ id: "performers" })}
onChange={() => setAutoTagPerformers(!autoTagPerformers)} onChange={() => setAutoTagPerformers(!autoTagPerformers)}
/> />
<Form.Check <Form.Check
id="autotag-studios" id="autotag-studios"
checked={autoTagStudios} checked={autoTagStudios}
label="Studios" label={intl.formatMessage({ id: "studios" })}
onChange={() => setAutoTagStudios(!autoTagStudios)} onChange={() => setAutoTagStudios(!autoTagStudios)}
/> />
<Form.Check <Form.Check
id="autotag-tags" id="autotag-tags"
checked={autoTagTags} checked={autoTagTags}
label="Tags" label={intl.formatMessage({ id: "tags" })}
onChange={() => setAutoTagTags(!autoTagTags)} onChange={() => setAutoTagTags(!autoTagTags)}
/> />
</Form.Group> </Form.Group>
@@ -399,32 +452,34 @@ export const SettingsTasksPanel: React.FC = () => {
className="mr-2" className="mr-2"
onClick={() => onAutoTag()} onClick={() => onAutoTag()}
> >
Auto Tag <FormattedMessage id="actions.auto_tag" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
type="submit" type="submit"
onClick={() => setIsAutoTagDialogOpen(true)} onClick={() => setIsAutoTagDialogOpen(true)}
> >
Selective Auto Tag <FormattedMessage id="actions.selective_auto_tag" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Auto-tag content based on filenames. {intl.formatMessage({
id: "config.tasks.auto_tag_based_on_filenames",
})}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h5>Generated Content</h5> <h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
<GenerateButton /> <GenerateButton />
<hr /> <hr />
<h5>Maintenance</h5> <h5>{intl.formatMessage({ id: "config.tasks.maintenance" })}</h5>
<Form.Group> <Form.Group>
<Form.Check <Form.Check
id="clean-dryrun" id="clean-dryrun"
checked={cleanDryRun} checked={cleanDryRun}
label="Only perform a dry run. Don't remove anything" label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
onChange={() => setCleanDryRun(!cleanDryRun)} onChange={() => setCleanDryRun(!cleanDryRun)}
/> />
</Form.Group> </Form.Group>
@@ -434,17 +489,16 @@ export const SettingsTasksPanel: React.FC = () => {
variant="danger" variant="danger"
onClick={() => setIsCleanAlertOpen(true)} onClick={() => setIsCleanAlertOpen(true)}
> >
Clean <FormattedMessage id="actions.clean" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Check for missing files and remove them from the database. This is a {intl.formatMessage({ id: "config.tasks.cleanup_desc" })}
destructive action.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h5>Metadata</h5> <h5>{intl.formatMessage({ id: "metadata" })}</h5>
<Form.Group> <Form.Group>
<Button <Button
id="export" id="export"
@@ -452,11 +506,10 @@ export const SettingsTasksPanel: React.FC = () => {
type="submit" type="submit"
onClick={() => onExport()} onClick={() => onExport()}
> >
Full Export <FormattedMessage id="actions.full_export" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Exports the database content into JSON format in the metadata {intl.formatMessage({ id: "config.tasks.export_to_json" })}
directory.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -466,11 +519,10 @@ export const SettingsTasksPanel: React.FC = () => {
variant="danger" variant="danger"
onClick={() => setIsImportAlertOpen(true)} onClick={() => setIsImportAlertOpen(true)}
> >
Full Import <FormattedMessage id="actions.full_import" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Import from exported JSON in the metadata directory. Wipes the {intl.formatMessage({ id: "config.tasks.import_from_exported_json" })}
existing database.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -480,16 +532,16 @@ export const SettingsTasksPanel: React.FC = () => {
variant="danger" variant="danger"
onClick={() => setIsImportDialogOpen(true)} onClick={() => setIsImportDialogOpen(true)}
> >
Import from file <FormattedMessage id="actions.import_from_file" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Incremental import from a supplied export zip file. {intl.formatMessage({ id: "config.tasks.incremental_import" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h5>Backup</h5> <h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
<Form.Group> <Form.Group>
<Button <Button
id="backup" id="backup"
@@ -497,12 +549,19 @@ export const SettingsTasksPanel: React.FC = () => {
type="submit" type="submit"
onClick={() => onBackup()} onClick={() => onBackup()}
> >
Backup <FormattedMessage id="actions.backup" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Performs a backup of the database to the same directory as the {intl.formatMessage(
database, with the filename format{" "} { id: "config.tasks.backup_database" },
<code>[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]</code> {
filename_format: (
<code>
[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
</code>
),
}
)}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -513,10 +572,10 @@ export const SettingsTasksPanel: React.FC = () => {
type="submit" type="submit"
onClick={() => onBackup(true)} onClick={() => onBackup(true)}
> >
Download Backup <FormattedMessage id="actions.download_backup" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Performs a backup of the database and downloads the resulting file. {intl.formatMessage({ id: "config.tasks.backup_and_download" })}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
@@ -524,7 +583,7 @@ export const SettingsTasksPanel: React.FC = () => {
<hr /> <hr />
<h5>Migrations</h5> <h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
<Form.Group> <Form.Group>
<Button <Button
@@ -532,11 +591,10 @@ export const SettingsTasksPanel: React.FC = () => {
variant="danger" variant="danger"
onClick={() => onMigrateHashNaming()} onClick={() => onMigrateHashNaming()}
> >
Rename generated files <FormattedMessage id="actions.rename_gen_files" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Used after changing the Generated file naming hash to rename existing {intl.formatMessage({ id: "config.tasks.migrate_hash_files" })}
generated files to the new hash format.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</> </>

View File

@@ -1,18 +1,25 @@
import React from "react"; import React from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export const SettingsToolsPanel: React.FC = () => { export const SettingsToolsPanel: React.FC = () => {
return ( return (
<> <>
<h4>Scene Tools</h4> <h4>
<FormattedMessage id="config.tools.scene_tools" />
</h4>
<Form.Group> <Form.Group>
<Link to="/sceneFilenameParser">Scene Filename Parser</Link> <Link to="/sceneFilenameParser">
<FormattedMessage id="config.tools.scene_filename_parser.title" />
</Link>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<Link to="/sceneDuplicateChecker">Scene Duplicate Checker</Link> <Link to="/sceneDuplicateChecker">
<FormattedMessage id="config.tools.scene_duplicate_checker" />
</Link>
</Form.Group> </Form.Group>
</> </>
); );

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form, InputGroup } from "react-bootstrap"; import { Button, Form, InputGroup } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
interface IInstanceProps { interface IInstanceProps {
@@ -15,6 +16,7 @@ const Instance: React.FC<IInstanceProps> = ({
onDelete, onDelete,
isMulti, isMulti,
}) => { }) => {
const intl = useIntl();
const handleInput = (key: string, value: string) => { const handleInput = (key: string, value: string) => {
const newObj = { const newObj = {
...instance, ...instance,
@@ -27,7 +29,7 @@ const Instance: React.FC<IInstanceProps> = ({
<Form.Group className="row no-gutters"> <Form.Group className="row no-gutters">
<InputGroup className="col"> <InputGroup className="col">
<Form.Control <Form.Control
placeholder="Name" placeholder={intl.formatMessage({ id: "config.stashbox.name" })}
className="text-input col-3 stash-box-name" className="text-input col-3 stash-box-name"
value={instance?.name} value={instance?.name}
isValid={!isMulti || (instance?.name?.length ?? 0) > 0} isValid={!isMulti || (instance?.name?.length ?? 0) > 0}
@@ -36,7 +38,9 @@ const Instance: React.FC<IInstanceProps> = ({
} }
/> />
<Form.Control <Form.Control
placeholder="GraphQL endpoint" placeholder={intl.formatMessage({
id: "config.stashbox.graphql_endpoint",
})}
className="text-input col-3 stash-box-endpoint" className="text-input col-3 stash-box-endpoint"
value={instance?.endpoint} value={instance?.endpoint}
isValid={(instance?.endpoint?.length ?? 0) > 0} isValid={(instance?.endpoint?.length ?? 0) > 0}
@@ -45,7 +49,7 @@ const Instance: React.FC<IInstanceProps> = ({
} }
/> />
<Form.Control <Form.Control
placeholder="API key" placeholder={intl.formatMessage({ id: "config.stashbox.api_key" })}
className="text-input col-3 stash-box-apikey" className="text-input col-3 stash-box-apikey"
value={instance?.api_key} value={instance?.api_key}
isValid={(instance?.api_key?.length ?? 0) > 0} isValid={(instance?.api_key?.length ?? 0) > 0}
@@ -57,7 +61,7 @@ const Instance: React.FC<IInstanceProps> = ({
<Button <Button
className="" className=""
variant="danger" variant="danger"
title="Delete" title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => onDelete(instance.index)} onClick={() => onDelete(instance.index)}
> >
<Icon icon="minus" /> <Icon icon="minus" />
@@ -84,6 +88,7 @@ export const StashBoxConfiguration: React.FC<IStashBoxConfigurationProps> = ({
boxes, boxes,
saveBoxes, saveBoxes,
}) => { }) => {
const intl = useIntl();
const [index, setIndex] = useState(1000); const [index, setIndex] = useState(1000);
const handleSave = (instance: IStashBoxInstance) => const handleSave = (instance: IStashBoxInstance) =>
@@ -99,12 +104,18 @@ export const StashBoxConfiguration: React.FC<IStashBoxConfigurationProps> = ({
return ( return (
<Form.Group> <Form.Group>
<h6>Stash-box Endpoints</h6> <h6>{intl.formatMessage({ id: "config.stashbox.title" })}</h6>
{boxes.length > 0 && ( {boxes.length > 0 && (
<div className="row no-gutters"> <div className="row no-gutters">
<h6 className="col-3 ml-1">Name</h6> <h6 className="col-3 ml-1">
<h6 className="col-3 ml-1">Endpoint</h6> {intl.formatMessage({ id: "config.stashbox.name" })}
<h6 className="col-3 ml-1">API Key</h6> </h6>
<h6 className="col-3 ml-1">
{intl.formatMessage({ id: "config.stashbox.endpoint" })}
</h6>
<h6 className="col-3 ml-1">
{intl.formatMessage({ id: "config.general.auth.api_key" })}
</h6>
</div> </div>
)} )}
{boxes.map((instance) => ( {boxes.map((instance) => (
@@ -118,17 +129,13 @@ export const StashBoxConfiguration: React.FC<IStashBoxConfigurationProps> = ({
))} ))}
<Button <Button
className="minimal" className="minimal"
title="Add stash-box instance" title={intl.formatMessage({ id: "config.stashbox.add_instance" })}
onClick={handleAdd} onClick={handleAdd}
> >
<Icon icon="plus" /> <Icon icon="plus" />
</Button> </Button>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Stash-box facilitates automated tagging of scenes and performers based {intl.formatMessage({ id: "config.stashbox.description" })}
on fingerprints and filenames.
<br />
Endpoint and API key can be found on your account page on the stash-box
instance. Names are required when more than one instance is added.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
); );

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form, Row, Col } from "react-bootstrap"; import { Button, Form, Row, Col } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog"; import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
@@ -21,6 +22,7 @@ const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
onSave(newObj); onSave(newObj);
}; };
const intl = useIntl();
const classAdd = index % 2 === 1 ? "bg-dark" : ""; const classAdd = index % 2 === 1 ? "bg-dark" : "";
return ( return (
@@ -47,7 +49,7 @@ const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
<Button <Button
size="sm" size="sm"
variant="danger" variant="danger"
title="Delete" title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => onDelete()} onClick={() => onDelete()}
> >
<Icon icon="minus" /> <Icon icon="minus" />
@@ -103,9 +105,15 @@ export const StashConfiguration: React.FC<IStashConfigurationProps> = ({
<Form.Group> <Form.Group>
{stashes.length > 0 && ( {stashes.length > 0 && (
<Row> <Row>
<h6 className="col-4">Path</h6> <h6 className="col-4">
<h6 className="col-3">Exclude Video</h6> <FormattedMessage id="path" />
<h6 className="col-3">Exclude Image</h6> </h6>
<h6 className="col-3">
<FormattedMessage id="config.general.exclude_video" />
</h6>
<h6 className="col-3">
<FormattedMessage id="config.general.exclude_image" />
</h6>
</Row> </Row>
)} )}
{stashes.map((stash, index) => ( {stashes.map((stash, index) => (
@@ -122,7 +130,7 @@ export const StashConfiguration: React.FC<IStashConfigurationProps> = ({
variant="secondary" variant="secondary"
onClick={() => setIsDisplayingDialog(true)} onClick={() => setIsDisplayingDialog(true)}
> >
Add Directory <FormattedMessage id="actions.add_directory" />
</Button> </Button>
</Form.Group> </Form.Group>
</> </>

View File

@@ -24,24 +24,16 @@ interface IDeleteEntityDialogProps {
const messages = defineMessages({ const messages = defineMessages({
deleteHeader: { deleteHeader: {
id: "delete-header", id: "dialogs.delete_object_title",
defaultMessage:
"Delete {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}",
}, },
deleteToast: { deleteToast: {
id: "delete-toast", id: "toast.delete_past_tense",
defaultMessage:
"Deleted {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}",
}, },
deleteMessage: { deleteMessage: {
id: "delete-message", id: "dialogs.delete_object_desc",
defaultMessage:
"Are you sure you want to delete {count, plural, =1 {this {singularEntity}} other {these {pluralEntity}}}?",
}, },
overflowMessage: { overflowMessage: {
id: "overflow-message", id: "dialogs.delete_object_overflow",
defaultMessage:
"...and {count} other {count, plural, =1 {{ singularEntity}} other {{ pluralEntity }}}.",
}, },
}); });
@@ -87,10 +79,14 @@ const DeleteEntityDialog: React.FC<IDeleteEntityDialogProps> = ({
singularEntity, singularEntity,
pluralEntity, pluralEntity,
})} })}
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }} accept={{
variant: "danger",
onClick: onDelete,
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ cancel={{
onClick: () => onClose(false), onClick: () => onClose(false),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isDeleting} isRunning={isDeleting}

View File

@@ -1,5 +1,6 @@
import { Button, Modal } from "react-bootstrap"; import { Button, Modal } from "react-bootstrap";
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ImageInput } from "src/components/Shared"; import { ImageInput } from "src/components/Shared";
interface IProps { interface IProps {
@@ -21,6 +22,7 @@ interface IProps {
} }
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => { export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
const intl = useIntl();
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function renderEditButton() { function renderEditButton() {
@@ -31,7 +33,9 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
className="edit" className="edit"
onClick={() => props.onToggleEdit()} onClick={() => props.onToggleEdit()}
> >
{props.isEditing ? "Cancel" : "Edit"} {props.isEditing
? intl.formatMessage({ id: "actions.cancel" })
: intl.formatMessage({ id: "actions.edit" })}
</Button> </Button>
); );
} }
@@ -46,7 +50,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
disabled={props.saveDisabled} disabled={props.saveDisabled}
onClick={() => props.onSave()} onClick={() => props.onSave()}
> >
Save <FormattedMessage id="actions.save" />
</Button> </Button>
); );
} }
@@ -59,7 +63,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
className="delete d-none d-sm-block" className="delete d-none d-sm-block"
onClick={() => setIsDeleteAlertOpen(true)} onClick={() => setIsDeleteAlertOpen(true)}
> >
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
); );
} }
@@ -71,7 +75,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
return ( return (
<ImageInput <ImageInput
isEditing={props.isEditing} isEditing={props.isEditing}
text="Back image..." text={intl.formatMessage({ id: "actions.set_back_image" })}
onImageChange={props.onBackImageChange} onImageChange={props.onBackImageChange}
onImageURL={props.onBackImageChangeURL} onImageURL={props.onBackImageChangeURL}
/> />
@@ -91,7 +95,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
} }
}} }}
> >
Auto Tag <FormattedMessage id="actions.auto_tag" />
</Button> </Button>
); );
} }
@@ -101,17 +105,20 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
return ( return (
<Modal show={isDeleteAlertOpen}> <Modal show={isDeleteAlertOpen}>
<Modal.Body> <Modal.Body>
Are you sure you want to delete {props.objectName}? <FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: props.objectName }}
/>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="danger" onClick={props.onDelete}> <Button variant="danger" onClick={props.onDelete}>
Delete <FormattedMessage id="actions.delete" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setIsDeleteAlertOpen(false)} onClick={() => setIsDeleteAlertOpen(false)}
> >
Cancel <FormattedMessage id="actions.cancel" />
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
@@ -123,7 +130,11 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
{renderEditButton()} {renderEditButton()}
<ImageInput <ImageInput
isEditing={props.isEditing} isEditing={props.isEditing}
text={props.onBackImageChange ? "Front image..." : undefined} text={
props.onBackImageChange
? intl.formatMessage({ id: "actions.set_front_image" })
: undefined
}
onImageChange={props.onImageChange} onImageChange={props.onImageChange}
onImageURL={props.onImageChangeURL} onImageURL={props.onImageChangeURL}
acceptSVG={props.acceptSVG ?? false} acceptSVG={props.acceptSVG ?? false}
@@ -134,7 +145,9 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
variant="danger" variant="danger"
onClick={() => props.onClearImage!()} onClick={() => props.onClearImage!()}
> >
{props.onClearBackImage ? "Clear front image" : "Clear image"} {props.onClearBackImage
? intl.formatMessage({ id: "actions.clear_front_image" })
: intl.formatMessage({ id: "actions.clear_image" })}
</Button> </Button>
) : ( ) : (
"" ""
@@ -146,7 +159,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
variant="danger" variant="danger"
onClick={() => props.onClearBackImage!()} onClick={() => props.onClearBackImage!()}
> >
Clear back image {intl.formatMessage({ id: "actions.clear_back_image" })}
</Button> </Button>
) : ( ) : (
"" ""

View File

@@ -5,6 +5,7 @@ import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { downloadFile } from "src/utils"; import { downloadFile } from "src/utils";
import { ExportObjectsInput } from "src/core/generated-graphql"; import { ExportObjectsInput } from "src/core/generated-graphql";
import { useIntl } from "react-intl";
interface IExportDialogProps { interface IExportDialogProps {
exportInput: ExportObjectsInput; exportInput: ExportObjectsInput;
@@ -19,6 +20,7 @@ export const ExportDialog: React.FC<IExportDialogProps> = (
// Network state // Network state
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
async function onExport() { async function onExport() {
@@ -46,11 +48,14 @@ export const ExportDialog: React.FC<IExportDialogProps> = (
<Modal <Modal
show show
icon="cogs" icon="cogs"
header="Export" header={intl.formatMessage({ id: "dialogs.export_title" })}
accept={{ onClick: onExport, text: "Export" }} accept={{
onClick: onExport,
text: intl.formatMessage({ id: "actions.export" }),
}}
cancel={{ cancel={{
onClick: () => props.onClose(), onClick: () => props.onClose(),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
isRunning={isRunning} isRunning={isRunning}
@@ -60,7 +65,9 @@ export const ExportDialog: React.FC<IExportDialogProps> = (
<Form.Check <Form.Check
id="include-dependencies" id="include-dependencies"
checked={includeDependencies} checked={includeDependencies}
label="Include related objects in export" label={intl.formatMessage({
id: "dialogs.export_include_related_objects",
})}
onChange={() => setIncludeDependencies(!includeDependencies)} onChange={() => setIncludeDependencies(!includeDependencies)}
/> />
</Form.Group> </Form.Group>

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage } from "react-intl";
import { Button, Modal } from "react-bootstrap"; import { Button, Modal } from "react-bootstrap";
import { FolderSelect } from "./FolderSelect"; import { FolderSelect } from "./FolderSelect";
@@ -25,7 +26,7 @@ export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
variant="success" variant="success"
onClick={() => props.onClose(currentDirectory)} onClick={() => props.onClose(currentDirectory)}
> >
Add <FormattedMessage id="actions.add" />
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>

View File

@@ -7,6 +7,7 @@ import {
Popover, Popover,
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Modal } from "."; import { Modal } from ".";
import Icon from "./Icon"; import Icon from "./Icon";
@@ -27,6 +28,7 @@ export const ImageInput: React.FC<IImageInput> = ({
}) => { }) => {
const [isShowDialog, setIsShowDialog] = useState(false); const [isShowDialog, setIsShowDialog] = useState(false);
const [url, setURL] = useState(""); const [url, setURL] = useState("");
const intl = useIntl();
if (!isEditing) return <div />; if (!isEditing) return <div />;
@@ -58,13 +60,13 @@ export const ImageInput: React.FC<IImageInput> = ({
<Modal <Modal
show={!!isShowDialog} show={!!isShowDialog}
onHide={() => setIsShowDialog(false)} onHide={() => setIsShowDialog(false)}
header="Image URL" header={intl.formatMessage({ id: "dialogs.set_image_url_title" })}
accept={{ onClick: onConfirmURL, text: "Confirm" }} accept={{ onClick: onConfirmURL, text: "Confirm" }}
> >
<div className="dialog-content"> <div className="dialog-content">
<Form.Group controlId="url" as={Row}> <Form.Group controlId="url" as={Row}>
<Form.Label column xs={3}> <Form.Label column xs={3}>
URL {intl.formatMessage({ id: "url" })}
</Form.Label> </Form.Label>
<Col xs={9}> <Col xs={9}>
<Form.Control <Form.Control
@@ -73,7 +75,7 @@ export const ImageInput: React.FC<IImageInput> = ({
setURL(event.currentTarget.value) setURL(event.currentTarget.value)
} }
value={url} value={url}
placeholder="URL" placeholder={intl.formatMessage({ id: "url" })}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
@@ -90,7 +92,7 @@ export const ImageInput: React.FC<IImageInput> = ({
<Form.Label className="image-input"> <Form.Label className="image-input">
<Button variant="secondary"> <Button variant="secondary">
<Icon icon="file" className="fa-fw" /> <Icon icon="file" className="fa-fw" />
<span>From file...</span> <span>{intl.formatMessage({ id: "actions.from_file" })}</span>
</Button> </Button>
<Form.Control <Form.Control
type="file" type="file"
@@ -102,7 +104,7 @@ export const ImageInput: React.FC<IImageInput> = ({
<div> <div>
<Button className="minimal" onClick={() => setIsShowDialog(true)}> <Button className="minimal" onClick={() => setIsShowDialog(true)}>
<Icon icon="link" className="fa-fw" /> <Icon icon="link" className="fa-fw" />
<span>From URL...</span> <span>{intl.formatMessage({ id: "actions.from_url" })}</span>
</Button> </Button>
</div> </div>
</> </>
@@ -120,7 +122,7 @@ export const ImageInput: React.FC<IImageInput> = ({
rootClose rootClose
> >
<Button variant="secondary" className="mr-2"> <Button variant="secondary" className="mr-2">
{text ?? "Set image..."} {text ?? intl.formatMessage({ id: "actions.set_image" })}
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
</> </>

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Button, Modal, Spinner, ModalProps } from "react-bootstrap"; import { Button, Modal, Spinner, ModalProps } from "react-bootstrap";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { IconName } from "@fortawesome/fontawesome-svg-core"; import { IconName } from "@fortawesome/fontawesome-svg-core";
import { FormattedMessage } from "react-intl";
interface IButton { interface IButton {
text?: string; text?: string;
@@ -58,7 +59,13 @@ const ModalComponent: React.FC<IModal> = ({
onClick={cancel.onClick} onClick={cancel.onClick}
className="mr-2" className="mr-2"
> >
{cancel.text ?? "Cancel"} {cancel.text ?? (
<FormattedMessage
id="actions.cancel"
defaultMessage="Cancel"
description="Cancels the current action and dismisses the modal."
/>
)}
</Button> </Button>
) : ( ) : (
"" ""
@@ -72,7 +79,13 @@ const ModalComponent: React.FC<IModal> = ({
{isRunning ? ( {isRunning ? (
<Spinner animation="border" role="status" size="sm" /> <Spinner animation="border" role="status" size="sm" />
) : ( ) : (
accept?.text ?? "Close" accept?.text ?? (
<FormattedMessage
id="actions.close"
defaultMessage="Close"
description="Closes the current modal."
/>
)
)} )}
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
@@ -22,6 +23,7 @@ interface IMultiSetProps {
const MultiSet: React.FunctionComponent<IMultiSetProps> = ( const MultiSet: React.FunctionComponent<IMultiSetProps> = (
props: IMultiSetProps props: IMultiSetProps
) => { ) => {
const intl = useIntl();
const modes = [ const modes = [
GQL.BulkUpdateIdMode.Set, GQL.BulkUpdateIdMode.Set,
GQL.BulkUpdateIdMode.Add, GQL.BulkUpdateIdMode.Add,
@@ -35,11 +37,17 @@ const MultiSet: React.FunctionComponent<IMultiSetProps> = (
function getModeText(mode: GQL.BulkUpdateIdMode) { function getModeText(mode: GQL.BulkUpdateIdMode) {
switch (mode) { switch (mode) {
case GQL.BulkUpdateIdMode.Set: case GQL.BulkUpdateIdMode.Set:
return "Overwrite"; return intl.formatMessage({
id: "actions.overwrite",
defaultMessage: "Overwrite",
});
case GQL.BulkUpdateIdMode.Add: case GQL.BulkUpdateIdMode.Add:
return "Add"; return intl.formatMessage({ id: "actions.add", defaultMessage: "Add" });
case GQL.BulkUpdateIdMode.Remove: case GQL.BulkUpdateIdMode.Remove:
return "Remove"; return intl.formatMessage({
id: "actions.remove",
defaultMessage: "Remove",
});
} }
} }

View File

@@ -10,6 +10,7 @@ import {
} from "react-bootstrap"; } from "react-bootstrap";
import { CollapseButton, Icon, Modal } from "src/components/Shared"; import { CollapseButton, Icon, Modal } from "src/components/Shared";
import _ from "lodash"; import _ from "lodash";
import { FormattedMessage, useIntl } from "react-intl";
export class ScrapeResult<T> { export class ScrapeResult<T> {
public newValue?: T; public newValue?: T;
@@ -336,6 +337,7 @@ interface IScrapeDialogProps {
export const ScrapeDialog: React.FC<IScrapeDialogProps> = ( export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
props: IScrapeDialogProps props: IScrapeDialogProps
) => { ) => {
const intl = useIntl();
return ( return (
<Modal <Modal
show show
@@ -345,11 +347,11 @@ export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
onClick: () => { onClick: () => {
props.onClose(true); props.onClose(true);
}, },
text: "Apply", text: intl.formatMessage({ id: "actions.apply" }),
}} }}
cancel={{ cancel={{
onClick: () => props.onClose(), onClick: () => props.onClose(),
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary", variant: "secondary",
}} }}
modalProps={{ size: "lg", dialogClassName: "scrape-dialog" }} modalProps={{ size: "lg", dialogClassName: "scrape-dialog" }}
@@ -360,10 +362,10 @@ export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
<Col lg={{ span: 9, offset: 3 }}> <Col lg={{ span: 9, offset: 3 }}>
<Row> <Row>
<Form.Label column xs="6"> <Form.Label column xs="6">
Existing <FormattedMessage id="dialogs.scrape_results_existing" />
</Form.Label> </Form.Label>
<Form.Label column xs="6"> <Form.Label column xs="6">
Scraped <FormattedMessage id="dialogs.scrape_results_scraped" />
</Form.Label> </Form.Label>
</Row> </Row>
</Col> </Col>

View File

@@ -58,7 +58,7 @@ export const Stats: React.FC = () => {
<FormattedNumber value={data.stats.image_count} /> <FormattedNumber value={data.stats.image_count} />
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="images" defaultMessage="Images" /> <FormattedMessage id="images" />
</p> </p>
</div> </div>
</div> </div>
@@ -68,7 +68,7 @@ export const Stats: React.FC = () => {
<FormattedNumber value={data.stats.movie_count} /> <FormattedNumber value={data.stats.movie_count} />
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="movies" defaultMessage="Movies" /> <FormattedMessage id="movies" />
</p> </p>
</div> </div>
<div className="stats-element"> <div className="stats-element">
@@ -76,7 +76,7 @@ export const Stats: React.FC = () => {
<FormattedNumber value={data.stats.gallery_count} /> <FormattedNumber value={data.stats.gallery_count} />
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="galleries" defaultMessage="Galleries" /> <FormattedMessage id="galleries" />
</p> </p>
</div> </div>
<div className="stats-element"> <div className="stats-element">
@@ -84,7 +84,7 @@ export const Stats: React.FC = () => {
<FormattedNumber value={data.stats.performer_count} /> <FormattedNumber value={data.stats.performer_count} />
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="performers" defaultMessage="Performers" /> <FormattedMessage id="performers" />
</p> </p>
</div> </div>
<div className="stats-element"> <div className="stats-element">
@@ -92,7 +92,7 @@ export const Stats: React.FC = () => {
<FormattedNumber value={data.stats.studio_count} /> <FormattedNumber value={data.stats.studio_count} />
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="studios" defaultMessage="Studios" /> <FormattedMessage id="studios" />
</p> </p>
</div> </div>
<div className="stats-element"> <div className="stats-element">
@@ -100,7 +100,7 @@ export const Stats: React.FC = () => {
<FormattedNumber value={data.stats.tag_count} /> <FormattedNumber value={data.stats.tag_count} />
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="tags" defaultMessage="Tags" /> <FormattedMessage id="tags" />
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { Button, Table, Tabs, Tab } from "react-bootstrap"; import { Button, Table, Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom"; import { useParams, useHistory, Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -36,6 +37,7 @@ interface IStudioParams {
export const Studio: React.FC = () => { export const Studio: React.FC = () => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const { tab = "details", id = "new" } = useParams<IStudioParams>(); const { tab = "details", id = "new" } = useParams<IStudioParams>();
const isNew = id === "new"; const isNew = id === "new";
@@ -194,7 +196,9 @@ export const Studio: React.FC = () => {
if (!studio.id) return; if (!studio.id) return;
try { try {
await mutateMetadataAutoTag({ studios: [studio.id] }); await mutateMetadataAutoTag({ studios: [studio.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({
content: intl.formatMessage({ id: "toast.started_auto_tagging" }),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -229,10 +233,23 @@ export const Studio: React.FC = () => {
<Modal <Modal
show={isDeleteAlertOpen} show={isDeleteAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: "Delete", variant: "danger", onClick: onDelete }} accept={{
text: intl.formatMessage({ id: "actions.delete" }),
variant: "danger",
onClick: onDelete,
}}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }} cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
> >
<p>Are you sure you want to delete {name ?? "studio"}?</p> <p>
<FormattedMessage
id="dialogs.delete_confirm"
values={{
entityName:
name ??
intl.formatMessage({ id: "studio" }).toLocaleLowerCase(),
}}
/>
</p>
</Modal> </Modal>
); );
} }
@@ -266,7 +283,10 @@ export const Studio: React.FC = () => {
<Button <Button
variant="danger" variant="danger"
className="mr-2 py-0" className="mr-2 py-0"
title="Delete StashID" title={intl.formatMessage(
{ id: "actions.delete_entity" },
{ entityType: intl.formatMessage({ id: "stash_id" }) }
)}
onClick={() => removeStashID(stashID)} onClick={() => removeStashID(stashID)}
> >
<Icon icon="trash-alt" /> <Icon icon="trash-alt" />
@@ -339,7 +359,14 @@ export const Studio: React.FC = () => {
"col-8": isNew, "col-8": isNew,
})} })}
> >
{isNew && <h2>Add Studio</h2>} {isNew && (
<h2>
{intl.formatMessage(
{ id: "actions.add_entity" },
{ entityType: intl.formatMessage({ id: "studio" }) }
)}
</h2>
)}
<div className="text-center"> <div className="text-center">
{imageEncoding ? ( {imageEncoding ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
@@ -352,29 +379,29 @@ export const Studio: React.FC = () => {
<Table> <Table>
<tbody> <tbody>
{TableUtils.renderInputGroup({ {TableUtils.renderInputGroup({
title: "Name", title: intl.formatMessage({ id: "name" }),
value: name ?? "", value: name ?? "",
isEditing: !!isEditing, isEditing: !!isEditing,
onChange: setName, onChange: setName,
})} })}
{TableUtils.renderInputGroup({ {TableUtils.renderInputGroup({
title: "URL", title: intl.formatMessage({ id: "url" }),
value: url, value: url,
isEditing: !!isEditing, isEditing: !!isEditing,
onChange: setUrl, onChange: setUrl,
})} })}
{TableUtils.renderTextArea({ {TableUtils.renderTextArea({
title: "Details", title: intl.formatMessage({ id: "details" }),
value: details, value: details,
isEditing: !!isEditing, isEditing: !!isEditing,
onChange: setDetails, onChange: setDetails,
})} })}
<tr> <tr>
<td>Parent Studio</td> <td>{intl.formatMessage({ id: "parent_studios" })}</td>
<td>{renderStudio()}</td> <td>{renderStudio()}</td>
</tr> </tr>
<tr> <tr>
<td>Rating:</td> <td>{intl.formatMessage({ id: "rating" })}:</td>
<td> <td>
<RatingStars <RatingStars
value={rating} value={rating}
@@ -411,19 +438,28 @@ export const Studio: React.FC = () => {
activeKey={activeTabKey} activeKey={activeTabKey}
onSelect={setActiveTabKey} onSelect={setActiveTabKey}
> >
<Tab eventKey="scenes" title="Scenes"> <Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}>
<StudioScenesPanel studio={studio} /> <StudioScenesPanel studio={studio} />
</Tab> </Tab>
<Tab eventKey="galleries" title="Galleries"> <Tab
eventKey="galleries"
title={intl.formatMessage({ id: "galleries" })}
>
<StudioGalleriesPanel studio={studio} /> <StudioGalleriesPanel studio={studio} />
</Tab> </Tab>
<Tab eventKey="images" title="Images"> <Tab eventKey="images" title={intl.formatMessage({ id: "images" })}>
<StudioImagesPanel studio={studio} /> <StudioImagesPanel studio={studio} />
</Tab> </Tab>
<Tab eventKey="performers" title="Performers"> <Tab
eventKey="performers"
title={intl.formatMessage({ id: "performers" })}
>
<StudioPerformersPanel studio={studio} /> <StudioPerformersPanel studio={studio} />
</Tab> </Tab>
<Tab eventKey="childstudios" title="Child Studios"> <Tab
eventKey="childstudios"
title={intl.formatMessage({ id: "child_studios" })}
>
<StudioChildrenPanel studio={studio} /> <StudioChildrenPanel studio={studio} />
</Tab> </Tab>
</Tabs> </Tabs>

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useIntl } from "react-intl";
import _ from "lodash"; import _ from "lodash";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -23,22 +24,23 @@ export const StudioList: React.FC<IStudioList> = ({
fromParent, fromParent,
filterHook, filterHook,
}) => { }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const otherOperations = [ const otherOperations = [
{ {
text: "View Random", text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom, onClick: viewRandom,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]; ];
@@ -119,8 +121,8 @@ export const StudioList: React.FC<IStudioList> = ({
<DeleteEntityDialog <DeleteEntityDialog
selected={selectedStudios} selected={selectedStudios}
onClose={onClose} onClose={onClose}
singularEntity="studio" singularEntity={intl.formatMessage({ id: "studio" })}
pluralEntity="studios" pluralEntity={intl.formatMessage({ id: "studios" })}
destroyMutation={useStudiosDestroy} destroyMutation={useStudiosDestroy}
/> />
); );

View File

@@ -7,10 +7,11 @@ import {
Form, Form,
InputGroup, InputGroup,
} from "react-bootstrap"; } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { useConfiguration } from "src/core/StashService"; import { useConfiguration } from "src/core/StashService";
import { ITaggerConfig, ParseMode, ModeDesc } from "./constants"; import { ITaggerConfig, ParseMode } from "./constants";
interface IConfigProps { interface IConfigProps {
show: boolean; show: boolean;
@@ -19,6 +20,7 @@ interface IConfigProps {
} }
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => { const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
const intl = useIntl();
const stashConfig = useConfiguration(); const stashConfig = useConfiguration();
const blacklistRef = useRef<HTMLInputElement | null>(null); const blacklistRef = useRef<HTMLInputElement | null>(null);
@@ -59,24 +61,30 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
<Collapse in={show}> <Collapse in={show}>
<Card> <Card>
<div className="row"> <div className="row">
<h4 className="col-12">Configuration</h4> <h4 className="col-12">
<FormattedMessage id="configuration" />
</h4>
<hr className="w-100" /> <hr className="w-100" />
<Form className="col-md-6"> <Form className="col-md-6">
<Form.Group controlId="tag-males" className="align-items-center"> <Form.Group controlId="tag-males" className="align-items-center">
<Form.Check <Form.Check
label="Show male performers" label={
<FormattedMessage id="component_tagger.config.show_male_label" />
}
checked={config.showMales} checked={config.showMales}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfig({ ...config, showMales: e.currentTarget.checked }) setConfig({ ...config, showMales: e.currentTarget.checked })
} }
/> />
<Form.Text> <Form.Text>
Toggle whether male performers will be available to tag. <FormattedMessage id="component_tagger.config.show_male_desc" />
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group controlId="set-cover" className="align-items-center"> <Form.Group controlId="set-cover" className="align-items-center">
<Form.Check <Form.Check
label="Set scene cover image" label={
<FormattedMessage id="component_tagger.config.set_cover_label" />
}
checked={config.setCoverImage} checked={config.setCoverImage}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfig({ setConfig({
@@ -85,13 +93,17 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
}) })
} }
/> />
<Form.Text>Replace the scene cover if one is found.</Form.Text> <Form.Text>
<FormattedMessage id="component_tagger.config.set_cover_desc" />
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="align-items-center"> <Form.Group className="align-items-center">
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<Form.Check <Form.Check
id="tag-mode" id="tag-mode"
label="Set tags" label={
<FormattedMessage id="component_tagger.config.set_tag_label" />
}
className="mr-4" className="mr-4"
checked={config.setTags} checked={config.setTags}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -111,19 +123,25 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
} }
disabled={!config.setTags} disabled={!config.setTags}
> >
<option value="merge">Merge</option> <option value="merge">
<option value="overwrite">Overwrite</option> {intl.formatMessage({ id: "actions.merge" })}
</option>
<option value="overwrite">
{intl.formatMessage({ id: "actions.overwrite" })}
</option>
</Form.Control> </Form.Control>
</div> </div>
<Form.Text> <Form.Text>
Attach tags to scene, either by overwriting or merging with <FormattedMessage id="component_tagger.config.set_tag_desc" />
existing tags on scene.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group controlId="mode-select"> <Form.Group controlId="mode-select">
<div className="row no-gutters"> <div className="row no-gutters">
<Form.Label className="mr-4 mt-1">Query Mode:</Form.Label> <Form.Label className="mr-4 mt-1">
<FormattedMessage id="component_tagger.config.query_mode_label" />
:
</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
className="col-md-2 col-3 input-control" className="col-md-2 col-3 input-control"
@@ -135,28 +153,58 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
}) })
} }
> >
<option value="auto">Auto</option> <option value="auto">
<option value="filename">Filename</option> {intl.formatMessage({
<option value="dir">Dir</option> id: "component_tagger.config.query_mode_auto",
<option value="path">Path</option> })}
<option value="metadata">Metadata</option> </option>
<option value="filename">
{intl.formatMessage({
id: "component_tagger.config.query_mode_filename",
})}
</option>
<option value="dir">
{intl.formatMessage({
id: "component_tagger.config.query_mode_dir",
})}
</option>
<option value="path">
{intl.formatMessage({
id: "component_tagger.config.query_mode_path",
})}
</option>
<option value="metadata">
{intl.formatMessage({
id: "component_tagger.config.query_mode_metadata",
})}
</option>
</Form.Control> </Form.Control>
</div> </div>
<Form.Text>{ModeDesc[config.mode]}</Form.Text> <Form.Text>
{intl.formatMessage({
id: `component_tagger.config.query_mode_${config.mode}_desc`,
defaultMessage: "Unknown query mode",
})}
</Form.Text>
</Form.Group> </Form.Group>
</Form> </Form>
<div className="col-md-6"> <div className="col-md-6">
<h5>Blacklist</h5> <h5>
<FormattedMessage id="component_tagger.config.blacklist_label" />
</h5>
<InputGroup> <InputGroup>
<Form.Control className="text-input" ref={blacklistRef} /> <Form.Control className="text-input" ref={blacklistRef} />
<InputGroup.Append> <InputGroup.Append>
<Button onClick={handleBlacklistAddition}>Add</Button> <Button onClick={handleBlacklistAddition}>
<FormattedMessage id="actions.add" />
</Button>
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
<div> <div>
Blacklist items are excluded from queries. Note that they are {intl.formatMessage(
regular expressions and also case-insensitive. Certain characters { id: "component_tagger.config.blacklist_desc" },
must be escaped with a backslash: <code>[\^$.|?*+()</code> { chars_require_escape: <code>[\^$.|?*+()</code> }
)}
</div> </div>
{config.blacklist.map((item, index) => ( {config.blacklist.map((item, index) => (
<Badge <Badge
@@ -179,7 +227,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
className="align-items-center row no-gutters mt-4" className="align-items-center row no-gutters mt-4"
> >
<Form.Label className="mr-4"> <Form.Label className="mr-4">
Active stash-box instance: <FormattedMessage id="component_tagger.config.active_instance" />
</Form.Label> </Form.Label>
<Form.Control <Form.Control
as="select" as="select"

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Modal, Icon } from "src/components/Shared"; import { Modal, Icon } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
@@ -17,6 +18,7 @@ const PerformerFieldSelect: React.FC<IProps> = ({
excludedFields, excludedFields,
onSelect, onSelect,
}) => { }) => {
const intl = useIntl();
const [excluded, setExcluded] = useState<Record<string, boolean>>( const [excluded, setExcluded] = useState<Record<string, boolean>>(
excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
); );
@@ -46,7 +48,7 @@ const PerformerFieldSelect: React.FC<IProps> = ({
icon="list" icon="list"
dialogClassName="FieldSelect" dialogClassName="FieldSelect"
accept={{ accept={{
text: "Save", text: intl.formatMessage({ id: "actions.save" }),
onClick: () => onClick: () =>
onSelect(Object.keys(excluded).filter((f) => excluded[f])), onSelect(Object.keys(excluded).filter((f) => excluded[f])),
}} }}

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import { IconName } from "@fortawesome/fontawesome-svg-core"; import { IconName } from "@fortawesome/fontawesome-svg-core";
@@ -37,6 +38,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
create = false, create = false,
endpoint, endpoint,
}) => { }) => {
const intl = useIntl();
const [imageIndex, setImageIndex] = useState(0); const [imageIndex, setImageIndex] = useState(0);
const [imageState, setImageState] = useState< const [imageState, setImageState] = useState<
"loading" | "error" | "loaded" | "empty" "loading" | "error" | "loaded" | "empty"
@@ -109,7 +111,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<Modal <Modal
show={modalVisible} show={modalVisible}
accept={{ accept={{
text: "Save", text: intl.formatMessage({ id: "actions.save" }),
onClick: () => onClick: () =>
handlePerformerCreate( handlePerformerCreate(
imageIndex, imageIndex,

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import { SuccessIcon, PerformerSelect } from "src/components/Shared"; import { SuccessIcon, PerformerSelect } from "src/components/Shared";
@@ -112,12 +113,12 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
return ( return (
<div className="row no-gutters my-2"> <div className="row no-gutters my-2">
<div className="entity-name"> <div className="entity-name">
Performer: <FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">{performer.name}</b> <b className="ml-2">{performer.name}</b>
</div> </div>
<span className="ml-auto"> <span className="ml-auto">
<SuccessIcon /> <SuccessIcon />
Matched: <FormattedMessage id="component_tagger.verb_matched" />:
</span> </span>
<b className="col-3 text-right"> <b className="col-3 text-right">
{stashData.findPerformers.performers[0].name} {stashData.findPerformers.performers[0].name}
@@ -138,7 +139,7 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
endpoint={endpoint} endpoint={endpoint}
/> />
<div className="entity-name"> <div className="entity-name">
Performer: <FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">{performer.name}</b> <b className="ml-2">{performer.name}</b>
</div> </div>
<ButtonGroup> <ButtonGroup>
@@ -146,13 +147,13 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
variant={selectedSource === "create" ? "primary" : "secondary"} variant={selectedSource === "create" ? "primary" : "secondary"}
onClick={() => showModal(true)} onClick={() => showModal(true)}
> >
Create <FormattedMessage id="actions.create" />
</Button> </Button>
<Button <Button
variant={selectedSource === "skip" ? "primary" : "secondary"} variant={selectedSource === "skip" ? "primary" : "secondary"}
onClick={() => handlePerformerSkip()} onClick={() => handlePerformerSkip()}
> >
Skip <FormattedMessage id="actions.skip" />
</Button> </Button>
<PerformerSelect <PerformerSelect
ids={selectedPerformer ? [selectedPerformer] : []} ids={selectedPerformer ? [selectedPerformer] : []}

View File

@@ -1,6 +1,7 @@
import React, { useState, useReducer } from "react"; import React, { useState, useReducer } from "react";
import cx from "classnames"; import cx from "classnames";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { uniq } from "lodash"; import { uniq } from "lodash";
import { blobToBase64 } from "base64-blob"; import { blobToBase64 } from "base64-blob";
@@ -34,9 +35,14 @@ const getDurationStatus = (
let match; let match;
if (matchCount > 0) if (matchCount > 0)
match = `Duration matches ${matchCount}/${durations.length} fingerprints`; match = (
<FormattedMessage
id="component_tagger.results.fp_matches_multi"
values={{ matchCount, durationsLength: durations.length }}
/>
);
else if (Math.abs(scene.duration - stashDuration) < 5) else if (Math.abs(scene.duration - stashDuration) < 5)
match = "Duration is a match"; match = <FormattedMessage id="component_tagger.results.fp_matches" />;
if (match) if (match)
return ( return (
@@ -67,7 +73,16 @@ const getFingerprintStatus = (
return ( return (
<div className="font-weight-bold"> <div className="font-weight-bold">
<SuccessIcon className="mr-2" /> <SuccessIcon className="mr-2" />
{phashMatch ? "PHash" : "Checksum"} is a match <FormattedMessage
id="component_tagger.results.hash_matches"
values={{
hash_type: (
<FormattedMessage
id={`media_info.${phashMatch ? "phash" : "checksum"}`}
/>
),
}}
/>
</div> </div>
); );
}; };
@@ -116,6 +131,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{} {}
); );
const intl = useIntl();
const createStudio = useCreateStudio(); const createStudio = useCreateStudio();
const createPerformer = useCreatePerformer(); const createPerformer = useCreatePerformer();
const createTag = useCreateTag(); const createTag = useCreateTag();
@@ -400,7 +416,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{scene?.studio?.name} {scene?.date} {scene?.studio?.name} {scene?.date}
</h5> </h5>
<div> <div>
Performers: {scene?.performers?.map((p) => p.name).join(", ")} {intl.formatMessage(
{ id: "countables.performers" },
{ count: scene?.performers?.length }
)}
: {scene?.performers?.map((p) => p.name).join(", ")}
</div> </div>
{getDurationStatus(scene, stashScene.file?.duration)} {getDurationStatus(scene, stashScene.file?.duration)}
{getFingerprintStatus(scene, stashScene)} {getFingerprintStatus(scene, stashScene)}
@@ -440,7 +460,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{saveState ? ( {saveState ? (
<LoadingIndicator inline small message="" /> <LoadingIndicator inline small message="" />
) : ( ) : (
"Save" <FormattedMessage id="actions.save" />
)} )}
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, Dispatch, SetStateAction } from "react"; import React, { useEffect, useState, Dispatch, SetStateAction } from "react";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import { SuccessIcon, Modal, StudioSelect } from "src/components/Shared"; import { SuccessIcon, Modal, StudioSelect } from "src/components/Shared";
@@ -19,6 +20,7 @@ interface IStudioResultProps {
} }
const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => { const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
const intl = useIntl();
const [selectedStudio, setSelectedStudio] = useState<string | null>(); const [selectedStudio, setSelectedStudio] = useState<string | null>();
const [modalVisible, showModal] = useState(false); const [modalVisible, showModal] = useState(false);
const [selectedSource, setSelectedSource] = useState< const [selectedSource, setSelectedSource] = useState<
@@ -94,8 +96,11 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
return ( return (
<div className="row no-gutters my-2"> <div className="row no-gutters my-2">
<div className="entity-name"> <div className="entity-name">
Studio: <FormattedMessage
<b className="ml-2">{studio?.name}</b> id="countables.studios"
values={{ count: stashIDData?.findStudios.studios.length }}
/>
:<b className="ml-2">{studio?.name}</b>
</div> </div>
<span className="ml-auto"> <span className="ml-auto">
<SuccessIcon className="mr-2" /> <SuccessIcon className="mr-2" />
@@ -112,15 +117,22 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
<div className="row no-gutters align-items-center mt-2"> <div className="row no-gutters align-items-center mt-2">
<Modal <Modal
show={modalVisible} show={modalVisible}
accept={{ text: "Save", onClick: handleStudioCreate }} accept={{
text: intl.formatMessage({ id: "actions.save" }),
onClick: handleStudioCreate,
}}
cancel={{ onClick: () => showModal(false), variant: "secondary" }} cancel={{ onClick: () => showModal(false), variant: "secondary" }}
> >
<div className="row"> <div className="row">
<strong className="col-2">Name:</strong> <strong className="col-2">
<FormattedMessage id="name" />:
</strong>
<span className="col-10">{studio?.name}</span> <span className="col-10">{studio?.name}</span>
</div> </div>
<div className="row"> <div className="row">
<strong className="col-2">URL:</strong> <strong className="col-2">
<FormattedMessage id="url" />:
</strong>
<span className="col-10">{studio?.url ?? ""}</span> <span className="col-10">{studio?.url ?? ""}</span>
</div> </div>
<div className="row"> <div className="row">
@@ -132,21 +144,20 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
</Modal> </Modal>
<div className="entity-name"> <div className="entity-name">
Studio: <FormattedMessage id="studios" />:<b className="ml-2">{studio?.name}</b>
<b className="ml-2">{studio?.name}</b>
</div> </div>
<ButtonGroup> <ButtonGroup>
<Button <Button
variant={selectedSource === "create" ? "primary" : "secondary"} variant={selectedSource === "create" ? "primary" : "secondary"}
onClick={() => showModal(true)} onClick={() => showModal(true)}
> >
Create <FormattedMessage id="actions.create" />
</Button> </Button>
<Button <Button
variant={selectedSource === "skip" ? "primary" : "secondary"} variant={selectedSource === "skip" ? "primary" : "secondary"}
onClick={() => handleStudioSkip()} onClick={() => handleStudioSkip()}
> >
Skip <FormattedMessage id="actions.skip" />
</Button> </Button>
<StudioSelect <StudioSelect
ids={selectedStudio ? [selectedStudio] : []} ids={selectedStudio ? [selectedStudio] : []}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Button, Card, Form, InputGroup } from "react-bootstrap"; import { Button, Card, Form, InputGroup } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { HashLink } from "react-router-hash-link"; import { HashLink } from "react-router-hash-link";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { ScenePreview } from "src/components/Scenes/SceneCard"; import { ScenePreview } from "src/components/Scenes/SceneCard";
@@ -161,6 +162,7 @@ const TaggerList: React.FC<ITaggerListProps> = ({
queueFingerprintSubmission, queueFingerprintSubmission,
clearSubmissionQueue, clearSubmissionQueue,
}) => { }) => {
const intl = useIntl();
const [fingerprintError, setFingerprintError] = useState(""); const [fingerprintError, setFingerprintError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const queryString = useRef<Record<string, string>>({}); const queryString = useRef<Record<string, string>>({});
@@ -310,7 +312,10 @@ const TaggerList: React.FC<ITaggerListProps> = ({
const getFingerprintCountMessage = () => { const getFingerprintCountMessage = () => {
const count = getFingerprintCount(); const count = getFingerprintCount();
return `${count > 0 ? count : "No"} new fingerprint matches found`; return intl.formatMessage(
{ id: "component_tagger.results.fp_found" },
{ fpCount: count }
);
}; };
const toggleHideUnmatchedScenes = () => { const toggleHideUnmatchedScenes = () => {
@@ -359,14 +364,18 @@ const TaggerList: React.FC<ITaggerListProps> = ({
if (!isTagged && hasStashIDs) { if (!isTagged && hasStashIDs) {
mainContent = ( mainContent = (
<div className="text-right"> <div className="text-right">
<h5 className="text-bold">Scene already tagged</h5> <h5 className="text-bold">
<FormattedMessage id="component_tagger.results.match_failed_already_tagged" />
</h5>
</div> </div>
); );
} else if (!isTagged && !hasStashIDs) { } else if (!isTagged && !hasStashIDs) {
mainContent = ( mainContent = (
<InputGroup> <InputGroup>
<InputGroup.Prepend> <InputGroup.Prepend>
<InputGroup.Text>Query</InputGroup.Text> <InputGroup.Text>
<FormattedMessage id="component_tagger.noun_query" />
</InputGroup.Text>
</InputGroup.Prepend> </InputGroup.Prepend>
<Form.Control <Form.Control
className="text-input" className="text-input"
@@ -392,7 +401,7 @@ const TaggerList: React.FC<ITaggerListProps> = ({
) )
} }
> >
Search <FormattedMessage id="actions.search" />
</Button> </Button>
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
@@ -400,7 +409,9 @@ const TaggerList: React.FC<ITaggerListProps> = ({
} else if (isTagged) { } else if (isTagged) {
mainContent = ( mainContent = (
<div className="d-flex flex-column text-right"> <div className="d-flex flex-column text-right">
<h5>Scene successfully tagged:</h5> <h5>
<FormattedMessage id="component_tagger.results.match_success" />
</h5>
<h6> <h6>
<Link className="bold" to={sceneLink}> <Link className="bold" to={sceneLink}>
{taggedScenes[scene.id].title} {taggedScenes[scene.id].title}
@@ -438,7 +449,9 @@ const TaggerList: React.FC<ITaggerListProps> = ({
); );
} else if (searchResults[scene.id]?.length === 0) { } else if (searchResults[scene.id]?.length === 0) {
subContent = ( subContent = (
<div className="text-danger font-weight-bold">No results found.</div> <div className="text-danger font-weight-bold">
<FormattedMessage id="component_tagger.results.match_failed_no_result" />
</div>
); );
} }
@@ -544,7 +557,16 @@ const TaggerList: React.FC<ITaggerListProps> = ({
<div className="mr-2"> <div className="mr-2">
{(getFingerprintCount() > 0 || hideUnmatched) && ( {(getFingerprintCount() > 0 || hideUnmatched) && (
<Button onClick={toggleHideUnmatchedScenes}> <Button onClick={toggleHideUnmatchedScenes}>
{hideUnmatched ? "Show" : "Hide"} unmatched scenes <FormattedMessage
id="component_tagger.verb_toggle_unmatched"
values={{
toggle: (
<FormattedMessage
id={`actions.${hideUnmatched ? "hide" : "show"}`}
/>
),
}}
/>
</Button> </Button>
)} )}
</div> </div>
@@ -558,7 +580,10 @@ const TaggerList: React.FC<ITaggerListProps> = ({
<LoadingIndicator message="" inline small /> <LoadingIndicator message="" inline small />
) : ( ) : (
<span> <span>
Submit <b>{fingerprintQueue.length}</b> Fingerprints <FormattedMessage
id="component_tagger.verb_submit_fp"
values={{ fpCount: fingerprintQueue.length }}
/>
</span> </span>
)} )}
</Button> </Button>
@@ -568,7 +593,11 @@ const TaggerList: React.FC<ITaggerListProps> = ({
onClick={handleFingerprintSearch} onClick={handleFingerprintSearch}
disabled={!canFingerprintSearch() && !loadingFingerprints} disabled={!canFingerprintSearch() && !loadingFingerprints}
> >
{canFingerprintSearch() && <span>Match Fingerprints</span>} {canFingerprintSearch() && (
<span>
{intl.formatMessage({ id: "component_tagger.verb_match_fp" })}
</span>
)}
{!canFingerprintSearch() && getFingerprintCountMessage()} {!canFingerprintSearch() && getFingerprintCountMessage()}
{loadingFingerprints && <LoadingIndicator message="" inline small />} {loadingFingerprints && <LoadingIndicator message="" inline small />}
</Button> </Button>
@@ -638,7 +667,17 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
<> <>
<div className="row mb-2 no-gutters"> <div className="row mb-2 no-gutters">
<Button onClick={() => setShowConfig(!showConfig)} variant="link"> <Button onClick={() => setShowConfig(!showConfig)} variant="link">
{showConfig ? "Hide" : "Show"} Configuration <FormattedMessage
id="component_tagger.verb_toggle_config"
values={{
toggle: (
<FormattedMessage
id={`actions.${showConfig ? "hide" : "show"}`}
/>
),
configuration: <FormattedMessage id="configuration" />,
}}
/>
</Button> </Button>
<Button <Button
className="ml-auto" className="ml-auto"
@@ -646,7 +685,7 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
title="Help" title="Help"
variant="link" variant="link"
> >
Help <FormattedMessage id="help" />
</Button> </Button>
</div> </div>

View File

@@ -24,14 +24,6 @@ export const initialConfig: ITaggerConfig = {
}; };
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
export const ModeDesc = {
auto: "Uses metadata if present, or filename",
metadata: "Only uses metadata",
filename: "Only uses filename",
dir: "Only uses parent directory of video file",
path: "Uses entire file path",
};
export interface ITaggerConfig { export interface ITaggerConfig {
blacklist: string[]; blacklist: string[];
showMales: boolean; showMales: boolean;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { HashLink } from "react-router-hash-link"; import { HashLink } from "react-router-hash-link";
import { useLocalForage } from "src/hooks"; import { useLocalForage } from "src/hooks";
@@ -51,6 +52,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
onBatchAdd, onBatchAdd,
onBatchUpdate, onBatchUpdate,
}) => { }) => {
const intl = useIntl();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchResults, setSearchResults] = useState< const [searchResults, setSearchResults] = useState<
Record<string, IStashBoxPerformer[]> Record<string, IStashBoxPerformer[]>
@@ -245,7 +247,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
) )
} }
> >
Search <FormattedMessage id="actions.search" />
</Button> </Button>
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
@@ -384,7 +386,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
header="Update Performers" header="Update Performers"
accept={{ text: "Update Performers", onClick: handleBatchUpdate }} accept={{ text: "Update Performers", onClick: handleBatchUpdate }}
cancel={{ cancel={{
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger", variant: "danger",
onClick: () => setShowBatchUpdate(false), onClick: () => setShowBatchUpdate(false),
}} }}
@@ -454,7 +456,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
header="Add New Performers" header="Add New Performers"
accept={{ text: "Add Performers", onClick: handleBatchAdd }} accept={{ text: "Add Performers", onClick: handleBatchAdd }}
cancel={{ cancel={{
text: "Cancel", text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger", variant: "danger",
onClick: () => setShowBatchAdd(false), onClick: () => setShowBatchAdd(false),
}} }}

View File

@@ -23,7 +23,7 @@ export const parsePath = (filePath: string) => {
const ext = fileName.match(/\.[a-z0-9]*$/)?.[0] ?? ""; const ext = fileName.match(/\.[a-z0-9]*$/)?.[0] ?? "";
const file = fileName.slice(0, ext.length * -1); const file = fileName.slice(0, ext.length * -1);
const paths = const paths =
pathComponents.length > 2 pathComponents.length >= 2
? pathComponents.slice(0, pathComponents.length - 2) ? pathComponents.slice(0, pathComponents.length - 2)
: []; : [];

View File

@@ -1,6 +1,7 @@
import { Tabs, Tab } from "react-bootstrap"; import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -35,6 +36,7 @@ interface ITabParams {
export const Tag: React.FC = () => { export const Tag: React.FC = () => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl();
const { tab = "scenes", id = "new" } = useParams<ITabParams>(); const { tab = "scenes", id = "new" } = useParams<ITabParams>();
const isNew = id === "new"; const isNew = id === "new";
@@ -170,10 +172,23 @@ export const Tag: React.FC = () => {
<Modal <Modal
show={isDeleteAlertOpen} show={isDeleteAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: "Delete", variant: "danger", onClick: onDelete }} accept={{
text: intl.formatMessage({ id: "actions.delete" }),
variant: "danger",
onClick: onDelete,
}}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }} cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
> >
<p>Are you sure you want to delete {tag?.name ?? "tag"}?</p> <p>
<FormattedMessage
id="dialogs.delete_confirm"
values={{
entityName:
tag?.name ??
intl.formatMessage({ id: "tag" }).toLocaleLowerCase(),
}}
/>
</p>
</Modal> </Modal>
); );
} }
@@ -248,19 +263,28 @@ export const Tag: React.FC = () => {
activeKey={activeTabKey} activeKey={activeTabKey}
onSelect={setActiveTabKey} onSelect={setActiveTabKey}
> >
<Tab eventKey="scenes" title="Scenes"> <Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}>
<TagScenesPanel tag={tag} /> <TagScenesPanel tag={tag} />
</Tab> </Tab>
<Tab eventKey="images" title="Images"> <Tab eventKey="images" title={intl.formatMessage({ id: "images" })}>
<TagImagesPanel tag={tag} /> <TagImagesPanel tag={tag} />
</Tab> </Tab>
<Tab eventKey="galleries" title="Galleries"> <Tab
eventKey="galleries"
title={intl.formatMessage({ id: "galleries" })}
>
<TagGalleriesPanel tag={tag} /> <TagGalleriesPanel tag={tag} />
</Tab> </Tab>
<Tab eventKey="markers" title="Markers"> <Tab
eventKey="markers"
title={intl.formatMessage({ id: "markers" })}
>
<TagMarkersPanel tag={tag} /> <TagMarkersPanel tag={tag} />
</Tab> </Tab>
<Tab eventKey="performers" title="Performers"> <Tab
eventKey="performers"
title={intl.formatMessage({ id: "performers" })}
>
<TagPerformersPanel tag={tag} /> <TagPerformersPanel tag={tag} />
</Tab> </Tab>
</Tabs> </Tabs>

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Badge } from "react-bootstrap"; import { Badge } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface ITagDetails { interface ITagDetails {
@@ -14,7 +15,9 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
return ( return (
<dl className="row"> <dl className="row">
<dt className="col-3 col-xl-2">Aliases</dt> <dt className="col-3 col-xl-2">
<FormattedMessage id="aliases" />
</dt>
<dd className="col-9 col-xl-10"> <dd className="col-9 col-xl-10">
{tag.aliases.map((a) => ( {tag.aliases.map((a) => (
<Badge className="tag-item" variant="secondary"> <Badge className="tag-item" variant="secondary">

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
import { DetailsEditNavbar } from "src/components/Shared"; import { DetailsEditNavbar } from "src/components/Shared";
@@ -27,6 +28,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
onDelete, onDelete,
setImage, setImage,
}) => { }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const isNew = tag === undefined; const isNew = tag === undefined;
@@ -102,7 +104,14 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
// TODO: CSS class // TODO: CSS class
return ( return (
<div> <div>
{isNew && <h2>Add Tag</h2>} {isNew && (
<h2>
<FormattedMessage
id="actions.add_entity"
values={{ entityType: intl.formatMessage({ id: "tag" }) }}
/>
</h2>
)}
<Prompt <Prompt
when={formik.dirty} when={formik.dirty}
@@ -117,7 +126,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
<Form.Group controlId="name" as={Row}> <Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
Name <FormattedMessage id="name" />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
@@ -134,7 +143,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<Form.Group controlId="aliases" as={Row}> <Form.Group controlId="aliases" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
Aliases <FormattedMessage id="aliases" />
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<StringListInput <StringListInput

View File

@@ -19,7 +19,7 @@ import {
useTagsDestroy, useTagsDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
import { Icon, Modal, DeleteEntityDialog } from "src/components/Shared"; import { Icon, Modal, DeleteEntityDialog } from "src/components/Shared";
import { TagCard } from "./TagCard"; import { TagCard } from "./TagCard";
@@ -38,22 +38,23 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput); const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const otherOperations = [ const otherOperations = [
{ {
text: "View Random", text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom, onClick: viewRandom,
}, },
{ {
text: "Export...", text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{ {
text: "Export all...", text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
]; ];
@@ -134,8 +135,8 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
<DeleteEntityDialog <DeleteEntityDialog
selected={selectedTags} selected={selectedTags}
onClose={onClose} onClose={onClose}
singularEntity="tag" singularEntity={intl.formatMessage({ id: "tag" })}
pluralEntity="tags" pluralEntity={intl.formatMessage({ id: "tags" })}
destroyMutation={useTagsDestroy} destroyMutation={useTagsDestroy}
/> />
); );
@@ -164,7 +165,9 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
if (!tag) return; if (!tag) return;
try { try {
await mutateMetadataAutoTag({ tags: [tag.id] }); await mutateMetadataAutoTag({ tags: [tag.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({
content: intl.formatMessage({ id: "toast.started_auto_tagging" }),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@@ -173,7 +176,16 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
async function onDelete() { async function onDelete() {
try { try {
await deleteTag(); await deleteTag();
Toast.success({ content: "Deleted tag" }); Toast.success({
content: intl.formatMessage(
{ id: "toast.delete_past_tense" },
{
count: 1,
singularEntity: intl.formatMessage({ id: "tag" }),
pluralEntity: intl.formatMessage({ id: "tags" }),
}
),
});
setDeletingTag(null); setDeletingTag(null);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -212,11 +224,18 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
onHide={() => {}} onHide={() => {}}
show={!!deletingTag} show={!!deletingTag}
icon="trash-alt" icon="trash-alt"
accept={{ onClick: onDelete, variant: "danger", text: "Delete" }} accept={{
onClick: onDelete,
variant: "danger",
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ onClick: () => setDeletingTag(null) }} cancel={{ onClick: () => setDeletingTag(null) }}
> >
<span> <span>
Are you sure you want to delete {deletingTag && deletingTag.name}? <FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: deletingTag && deletingTag.name }}
/>
</span> </span>
</Modal> </Modal>
); );
@@ -232,14 +251,20 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
className="tag-list-button" className="tag-list-button"
onClick={() => onAutoTag(tag)} onClick={() => onAutoTag(tag)}
> >
Auto Tag <FormattedMessage id="actions.auto_tag" />
</Button> </Button>
<Button variant="secondary" className="tag-list-button"> <Button variant="secondary" className="tag-list-button">
<Link <Link
to={NavUtils.makeTagScenesUrl(tag)} to={NavUtils.makeTagScenesUrl(tag)}
className="tag-list-anchor" className="tag-list-anchor"
> >
Scenes: <FormattedNumber value={tag.scene_count ?? 0} /> <FormattedMessage
id="countables.scenes"
values={{
count: tag.scene_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_count ?? 0} />
</Link> </Link>
</Button> </Button>
<Button variant="secondary" className="tag-list-button"> <Button variant="secondary" className="tag-list-button">
@@ -247,12 +272,17 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
to={NavUtils.makeTagSceneMarkersUrl(tag)} to={NavUtils.makeTagSceneMarkersUrl(tag)}
className="tag-list-anchor" className="tag-list-anchor"
> >
Markers:{" "} <FormattedMessage
<FormattedNumber value={tag.scene_marker_count ?? 0} /> id="countables.markers"
values={{
count: tag.scene_marker_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
</Link> </Link>
</Button> </Button>
<span className="tag-list-count"> <span className="tag-list-count">
Total:{" "} <FormattedMessage id="total" />:{" "}
<FormattedNumber <FormattedNumber
value={(tag.scene_count || 0) + (tag.scene_marker_count || 0)} value={(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
/> />

View File

@@ -1,86 +0,0 @@
{
"age": "Age",
"aliases": "Aliases",
"average_resolution": "Average Resolution",
"birth_year": "Birth Year",
"birthdate": "Birthdate",
"bitrate": "Bit Rate",
"career_length": "Career Length",
"country": "Country",
"created_at": "Created At",
"criterion_modifier": {
"equals": "is",
"not_equals": "is not",
"greater_than": "is greater than",
"less_than": "is less than",
"is_null": "is null",
"not_null": "is not null",
"includes": "includes",
"includes_all": "includes all",
"excludes": "excludes",
"matches_regex": "matches regex",
"not_matches_regex": "not matches regex"
},
"date": "Date",
"death_year": "Death Year",
"developmentVersion": "Development Version",
"donate": "Donate",
"duration": "Duration",
"ethnicity": "Ethnicity",
"eye_color": "Eye Colour",
"fake_tits": "Fake Tits",
"favourite": "Favourite",
"file_mod_time": "File Modification Time",
"filesize": "File Size",
"framerate": "Frame Rate",
"galleries": "Galleries",
"gallery_count": "Gallery Count",
"gender": "Gender",
"hair_color": "Hair Colour",
"hasMarkers": "Has Markers",
"height": "Height",
"image_count": "Image Count",
"images": "Images",
"images-size": "Images size",
"isMissing": "Is Missing",
"interactive": "Interactive",
"library-size": "Library size",
"marker_count": "Marker Count",
"markers": "Markers",
"measurements": "Measurements",
"movie_scene_number": "Movie Scene Number",
"movies": "Movies",
"name": "Name",
"new": "New",
"none": "None",
"o_counter": "O-Counter",
"organized": "Organised",
"parent_studios": "Parent Studios",
"path": "Path",
"performerTags": "Performer Tags",
"performer_count": "Performer Count",
"performers": "Performers",
"piercings": "Piercings",
"random": "Random",
"rating": "Rating",
"resolution": "Resolution",
"sceneTagger": "Scene Tagger",
"sceneTags": "Scene Tags",
"scene_count": "Scene Count",
"scene_id": "Scene ID",
"scenes": "Scenes",
"scenes-size": "Scenes size",
"scenes_updated_at": "Scene Updated At",
"seconds": "Seconds",
"stash_id": "Stash ID",
"studio_depth": "Levels (empty for all)",
"studios": "Studios",
"tag_count": "Tag Count",
"tags": "Tags",
"tattoos": "Tattoos",
"title": "Title",
"up-dir": "Up a directory",
"updated_at": "Updated At",
"url": "URL",
"weight": "Weight"
}

View File

@@ -1,20 +0,0 @@
{
"developmentVersion": "開發版本",
"donate": "贊助",
"favourite": "最愛",
"images": "圖片",
"images-size": "圖片大小",
"galleries": "圖庫",
"library-size": "收藏庫大小",
"markers": "標記",
"movies": "影片",
"new": "新",
"organized": "已整理",
"performers": "演員",
"scenes": "場景",
"scenes-size": "場景收藏大小",
"sceneTagger": "標記場景",
"studios": "工作室",
"tags": "標籤",
"up-dir": "往上一層"
}

View File

@@ -0,0 +1,581 @@
{
"actions": {
"add": "Add",
"add_directory": "Add Directory",
"add_entity": "Add {entityType}",
"add_to_entity": "Add to {entityType}",
"allow": "Allow",
"allow_temporarily": "Allow temporarily",
"apply": "Apply",
"auto_tag": "Auto Tag",
"backup": "Backup",
"cancel": "Cancel",
"clean": "Clean",
"clear_back_image": "Clear back image",
"clear_front_image": "Clear front image",
"clear_image": "Clear Image",
"close": "Close",
"create": "Create",
"create_entity": "Create {entityType}",
"create_marker": "Create Marker",
"created_entity": "Created {entity_type}: {entity_name}",
"delete": "Delete",
"delete_entity": "Delete {entityType}",
"delete_file": "Delete file",
"delete_generated_supporting_files": "Delete generated supporting files",
"disallow": "Disallow",
"download": "Download",
"download_backup": "Download Backup",
"edit": "Edit",
"export": "Export…",
"export_all": "Export all…",
"find": "Find",
"from_file": "From file…",
"from_url": "From URL…",
"full_export": "Full Export",
"full_import": "Full Import",
"generate": "Generate",
"generate_thumb_default": "Generate default thumbnail",
"generate_thumb_from_current": "Generate thumbnail from current",
"hash_migration": "hash migration",
"hide": "Hide",
"import": "Import…",
"import_from_file": "Import from file",
"merge": "Merge",
"not_running": "not running",
"overwrite": "Overwrite",
"play_random": "Play Random",
"play_selected": "Play selected",
"preview": "Preview",
"refresh": "Refresh",
"reload_plugins": "Reload plugins",
"reload_scrapers": "Reload scrapers",
"remove": "Remove",
"rename_gen_files": "Rename generated files",
"rescan": "Rescan",
"reshuffle": "Reshuffle",
"running": "running",
"save": "Save",
"scan": "Scan",
"scrape_with": "Scrape with…",
"search": "Search",
"select_all": "Select All",
"select_none": "Select None",
"selective_auto_tag": "Selective Auto Tag",
"selective_scan": "Selective Scan",
"set_back_image": "Back image…",
"set_front_image": "Front image…",
"set_image": "Set image…",
"show": "Show",
"skip": "Skip",
"tasks": {
"clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.",
"dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.",
"import_warning": "Are you sure you want to import? This will delete the database and re-import from your exported metadata."
},
"temp_disable": "Disable temporarily…",
"temp_enable": "Enable temporarily…",
"view_random": "View Random"
},
"actions_name": "Actions",
"age": "Age",
"aliases": "Aliases",
"also_known_as": "Also known as",
"ascending": "Ascending",
"average_resolution": "Average Resolution",
"birth_year": "Birth Year",
"birthdate": "Birthdate",
"bitrate": "Bit Rate",
"career_length": "Career Length",
"child_studios": "Child Studios",
"component_tagger": {
"config": {
"active_instance": "Active stash-box instance:",
"blacklist_desc": "Blacklist items are excluded from queries. Note that they are regular expressions and also case-insensitive. Certain characters must be escaped with a backslash: {chars_require_escape}",
"blacklist_label": "Blacklist",
"query_mode_auto": "Auto",
"query_mode_auto_desc": "Uses metadata if present, or filename",
"query_mode_dir": "Dir",
"query_mode_dir_desc": "Only uses parent directory of video file",
"query_mode_filename": "Filename",
"query_mode_filename_desc": "Only uses filename",
"query_mode_label": "Query Mode",
"query_mode_metadata": "Metadata",
"query_mode_metadata_desc": "Only uses metadata",
"query_mode_path": "Path",
"query_mode_path_desc": "Uses entire file path",
"set_cover_desc": "Replace the scene cover if one is found.",
"set_cover_label": "Set scene cover image",
"set_tag_desc": "Attach tags to scene, either by overwriting or merging with existing tags on scene.",
"set_tag_label": "Set tags",
"show_male_desc": "Toggle whether male performers will be available to tag.",
"show_male_label": "Show male performers"
},
"noun_query": "Query",
"results": {
"fp_found": "{fpCount, plural, =0 {No new fingerprint matches found} other {# new fingerprint matches found}}",
"fp_matches": "Duration is a match",
"fp_matches_multi": "Duration matches {matchCount}/{durationsLength} fingerprint(s)",
"hash_matches": "{hash_type} is a match",
"match_failed_already_tagged": "Scene already tagged",
"match_failed_no_result": "No results found",
"match_success": "Scene successfully tagged"
},
"verb_match_fp": "Match Fingerprints",
"verb_matched": "Matched",
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",
"verb_toggle_config": "{toggle} {configuration}",
"verb_toggle_unmatched": "{toggle} unmatched scenes"
},
"config": {
"about": {
"build_hash": "Build hash:",
"build_time": "Build time:",
"check_for_new_version": "Check for new version",
"latest_version_build_hash": "Latest Version Build Hash:",
"new_version_notice": "[NEW]",
"stash_discord": "Join our {url} channel",
"stash_home": "Stash home at {url}",
"stash_open_collective": "Support us through {url}",
"stash_wiki": "Stash {url} page",
"version": "Version"
},
"categories": {
"about": "About",
"interface": "Interface",
"logs": "Logs",
"plugins": "Plugins",
"scrapers": "Scrapers",
"tasks": "Tasks",
"tools": "Tools"
},
"dlna": {
"allow_temp_ip": "Allow {tempIP}",
"allowed_ip_addresses": "Allowed IP addresses",
"default_ip_whitelist": "Default IP Whitelist",
"default_ip_whitelist_desc": "Default IP addresses allow to access DLNA. Use {wildcard} to allow all IP addresses.",
"enabled_by_default": "Enabled by default",
"network_interfaces": "Interfaces",
"network_interfaces_desc": "Interfaces to expose DLNA server on. An empty list results in running on all interfaces. Requires DLNA restart after changing.",
"recent_ip_addresses": "Recent IP addresses",
"server_display_name": "Server Display Name",
"server_display_name_desc": "Display name for the DLNA server. Defaults to {server_name} if empty.",
"until_restart": "until restart"
},
"general": {
"auth": {
"api_key": "API Key",
"api_key_desc": "API key for external systems. Only required when username/password is configured. Username must be saved before generating API key.",
"authentication": "Authentication",
"clear_api_key": "Clear API key",
"generate_api_key": "Generate API key",
"log_file": "Log file",
"log_file_desc": "Path to the file to output logging to. Blank to disable file logging. Requires restart.",
"log_http": "Log http access",
"log_http_desc": "Logs http access to the terminal. Requires restart.",
"log_to_terminal": "Log to terminal",
"log_to_terminal_desc": "Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart.",
"maximum_session_age": "Maximum Session Age",
"maximum_session_age_desc": "Maximum idle time before a login session is expired, in seconds.",
"password": "Password",
"password_desc": "Password to access Stash. Leave blank to disable user authentication",
"stash-box_integration": "Stash-box integration",
"username": "Username",
"username_desc": "Username to access Stash. Leave blank to disable user authentication"
},
"cache_location": "Directory location of the cache",
"cache_path_head": "Cache Path",
"calculate_md5_and_ohash_desc": "Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.",
"calculate_md5_and_ohash_label": "Calculate MD5 for videos",
"check_for_insecure_certificates": "Check for insecure certificates",
"check_for_insecure_certificates_desc": "Some sites use insecure ssl certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.",
"chrome_cdp_path": "Chrome CDP path",
"chrome_cdp_path_desc": "File path to the Chrome executable, or a remote address (starting with http:// or https://, for example http://localhost:9222/json/version) to a Chrome instance.",
"create_galleries_from_folders_desc": "If true, creates galleries from folders containing images.",
"create_galleries_from_folders_label": "Create galleries from folders containing images",
"db_path_head": "Database Path",
"directory_locations_to_your_content": "Directory locations to your content",
"exclude_image": "Exclude Image",
"exclude_video": "Exclude Video",
"excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean",
"excluded_image_gallery_patterns_head": "Excluded Image/Gallery Patterns",
"excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean",
"excluded_video_patterns_head": "Excluded Video Patterns",
"gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery zip files.",
"gallery_ext_head": "Gallery zip Extensions",
"generated_file_naming_hash_desc": "Use MD5 or oshash for generated file naming. Changing this requires that all scenes have the applicable MD5/oshash value populated. After changing this value, existing generated files will need to be migrated or regenerated. See Tasks page for migration.",
"generated_file_naming_hash_head": "Generated file naming hash",
"generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc)",
"generated_path_head": "Generated Path",
"hashing": "Hashing",
"image_ext_desc": "Comma-delimited list of file extensions that will be identified as images.",
"image_ext_head": "Image Extensions",
"logging": "Logging",
"maximum_streaming_transcode_size_desc": "Maximum size for transcoded streams",
"maximum_streaming_transcode_size_head": "Maximum streaming transcode size",
"maximum_transcode_size_desc": "Maximum size for generated transcodes",
"maximum_transcode_size_head": "Maximum transcode size",
"number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% cpu utilisation will decrease performance and potentially cause other issues.",
"number_of_parallel_task_for_scan_generation_head": "Number of parallel task for scan/generation",
"parallel_scan_head": "Parallel Scan/Generation",
"preview_generation": "Preview Generation",
"scraper_user_agent": "Scraper User Agent",
"scraper_user_agent_desc": "User-Agent string used during scrape http requests",
"scraping": "Scraping",
"sqlite_location": "File location for the SQLite database (requires restart)",
"video_ext_desc": "Comma-delimited list of file extensions that will be identified as videos.",
"video_ext_head": "Video Extensions",
"video_head": "Video"
},
"logs": {
"log_level": "Log Level"
},
"plugins": {
"hooks": "Hooks",
"triggers_on": "Triggers on"
},
"scrapers": {
"entity_metadata": "{entityType} Metadata",
"entity_scrapers": "{entityType} scrapers",
"search_by_name": "Search by name",
"supported_types": "Supported types",
"supported_urls": "URLs"
},
"stashbox": {
"add_instance": "Add stash-box instance",
"api_key": "API key",
"description": "Stash-box facilitates automated tagging of scenes and performers based on fingerprints and filenames.\nEndpoint and API key can be found on your account page on the stash-box instance. Names are required when more than one instance is added.",
"endpoint": "Endpoint",
"graphql_endpoint": "GraphQL endpoint",
"name": "Name",
"title": "Stash-box Endpoints"
},
"tasks": {
"added_job_to_queue": "Added {operation_name} to job queue",
"auto_tag_based_on_filenames": "Auto-tag content based on filenames.",
"auto_tagging": "Auto Tagging",
"backing_up_database": "Backing up database",
"backup_and_download": "Performs a backup of the database and downloads the resulting file.",
"backup_database": "Performs a backup of the database to the same directory as the database, with the filename format {filename_format}",
"cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.",
"dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title",
"export_to_json": "Exports the database content into JSON format in the metadata directory.",
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
"generate_phashes_during_scan": "Generate phashes during scan (for deduplication and scene identification)",
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
"generate_sprites_during_scan": "Generate sprites during scan (for the scene scrubber)",
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
"generated_content": "Generated Content",
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
"incremental_import": "Incremental import from a supplied export zip file.",
"job_queue": "Job Queue",
"maintenance": "Maintenance",
"migrate_hash_files": "Used after changing the Generated file naming hash to rename existing generated files to the new hash format.",
"migrations": "Migrations",
"only_dry_run": "Only perform a dry run. Don't remove anything",
"plugin_tasks": "Plugin Tasks",
"scan_for_content_desc": "Scan for new content and add it to the database.",
"set_name_date_details_from_metadata_if_present": "Set name, date, details from metadata (if present)"
},
"tools": {
"scene_duplicate_checker": "Scene Duplicate Checker",
"scene_filename_parser": {
"add_field": "Add Field",
"capitalize_title": "Capitalize title",
"display_fields": "Display fields",
"escape_chars": "Use \\ to escape literal characters",
"filename": "Filename",
"filename_pattern": "Filename Pattern",
"ignored_words": "Ignored words",
"matches_with": "Matches with {i}",
"select_parser_recipe": "Select Parser Recipe",
"title": "Scene Filename Parser",
"whitespace_chars": "Whitespace characters",
"whitespace_chars_desc": "These characters will be replaced with whitespace in the title"
},
"scene_tools": "Scene Tools"
},
"ui": {
"custom_css": {
"description": "Page must be reloaded for changes to take effect.",
"heading": "Custom CSS",
"option_label": "Custom CSS enabled"
},
"handy_connection_key": "Handy Connection Key",
"handy_connection_key_desc": "Handy connection key to use for interactive scenes.",
"language": {
"heading": "Language"
},
"max_loop_duration": {
"description": "Maximum scene duration where scene player will loop the video - 0 to disable",
"heading": "Maximum loop duration"
},
"menu_items": {
"description": "Show or hide different types of content on the navigation bar",
"heading": "Menu Items"
},
"preview_type": {
"description": "Configuration for wall items",
"heading": "Preview Type",
"options": {
"animated": "Animated Image",
"static": "Static Image",
"video": "Video"
}
},
"scene_list": {
"heading": "Scene List",
"options": {
"show_studio_as_text": "Show Studios as text"
}
},
"scene_player": {
"heading": "Scene Player",
"options": {
"auto_start_video": "Auto-start video"
}
},
"scene_wall": {
"heading": "Scene / Marker Wall",
"options": {
"display_title": "Display title and tags",
"toggle_sound": "Enable sound"
}
},
"slideshow_delay": {
"description": "Slideshow is available in galleries when in wall view mode",
"heading": "Slideshow Delay"
},
"title": "User Interface"
}
},
"configuration": "Configuration",
"countables": {
"galleries": "{count, plural, one {Gallery} other {Galleries}}",
"images": "{count, plural, one {Image} other {Images}}",
"markers": "{count, plural, one {Marker} other {Markers}}",
"movies": "{count, plural, one {Movie} other {Movies}}",
"performers": "{count, plural, one {Performer} other {Performers}}",
"scenes": "{count, plural, one {Scene} other {Scenes}}",
"studios": "{count, plural, one {Studio} other {Studios}}",
"tags": "{count, plural, one {Tag} other {Tags}}"
},
"country": "Country",
"cover_image": "Cover Image",
"created_at": "Created At",
"criterion_modifier": {
"equals": "is",
"excludes": "excludes",
"format_string": "{criterion} {modifierString} {valueString}",
"greater_than": "is greater than",
"includes": "includes",
"includes_all": "includes all",
"is_null": "is null",
"less_than": "is less than",
"matches_regex": "matches regex",
"not_equals": "is not",
"not_matches_regex": "not matches regex",
"not_null": "is not null"
},
"date": "Date",
"death_date": "Death Date",
"death_year": "Death Year",
"descending": "Descending",
"detail": "Detail",
"details": "Details",
"developmentVersion": "Development Version",
"dialogs": {
"delete_confirm": "Are you sure you want to delete {entityName}?",
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
"delete_entity_title": "{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}",
"delete_object_desc": "Are you sure you want to delete {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}?",
"delete_object_overflow": "…and {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.",
"delete_object_title": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"export_include_related_objects": "Include related objects in export",
"export_title": "Export",
"scene_gen": {
"image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
"markers": "Markers (20 second videos which begin at the given timecode)",
"overwrite": "Overwrite existing generated files",
"phash": "Perceptual hashes (for deduplication)",
"preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
"preview_exclude_end_time_head": "Exclude end time",
"preview_exclude_start_time_desc": "Exclude the first x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
"preview_exclude_start_time_head": "Exclude start time",
"preview_options": "Preview Options",
"preview_preset_desc": "The preset regulates size, quality and encoding time of preview generation. Presets beyond “slow” have diminishing returns and are not recommended.",
"preview_preset_head": "Preview encoding preset",
"preview_seg_count_desc": "Number of segments in preview files.",
"preview_seg_count_head": "Number of segments in preview",
"preview_seg_duration_desc": "Duration of each preview segment, in seconds.",
"preview_seg_duration_head": "Preview segment duration",
"sprites": "Sprites (for the scene scrubber)",
"transcodes": "Transcodes (MP4 conversions of unsupported video formats)",
"video_previews": "Previews (video previews which play when hovering over a scene)"
},
"scrape_entity_title": "{entity_type} Scrape Results",
"scrape_results_existing": "Existing",
"scrape_results_scraped": "Scraped",
"set_image_url_title": "Image URL",
"unsaved_changes": "Unsaved changes. Are you sure you want to leave?"
},
"dimensions": "Dimensions",
"director": "Director",
"display_mode": {
"grid": "Grid",
"list": "List",
"tagger": "Tagger",
"unknown": "Unknown",
"wall": "Wall"
},
"donate": "Donate",
"dupe_check": {
"description": "Levels below 'Exact' can take longer to calculate. False positives might also be returned on lower accuracy levels.",
"found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}",
"options": {
"exact": "Exact",
"high": "High",
"low": "Low",
"medium": "Medium"
},
"search_accuracy_label": "Search Accuracy",
"title": "Duplicate Scenes"
},
"duration": "Duration",
"effect_filters": {
"aspect": "Aspect",
"blue": "Blue",
"blur": "Blur",
"brightness": "Brightness",
"contrast": "Contrast",
"gamma": "Gamma",
"green": "Green",
"hue": "Hue",
"name": "Filters",
"name_transforms": "Transforms",
"red": "Red",
"reset_filters": "Reset Filters",
"reset_transforms": "Reset Transforms",
"rotate": "Rotate",
"rotate_left_and_scale": "Rotate Left & Scale",
"rotate_right_and_scale": "Rotate Right & Scale",
"saturation": "Saturation",
"scale": "Scale",
"warmth": "Warmth"
},
"ethnicity": "Ethnicity",
"eye_color": "Eye Colour",
"fake_tits": "Fake Tits",
"favourite": "Favourite",
"file_info": "File Info",
"file_mod_time": "File Modification Time",
"filesize": "File Size",
"framerate": "Frame Rate",
"galleries": "Galleries",
"gallery": "Gallery",
"gallery_count": "Gallery Count",
"gender": "Gender",
"hair_color": "Hair Colour",
"hasMarkers": "Has Markers",
"height": "Height",
"help": "Help",
"image": "Image",
"image_count": "Image Count",
"images": "Images",
"images-size": "Images size",
"instagram": "Instagram",
"interactive": "Interactive",
"isMissing": "Is Missing",
"library": "Library",
"loading": {
"generic": "Loading…"
},
"marker_count": "Marker Count",
"markers": "Markers",
"measurements": "Measurements",
"media_info": {
"audio_codec": "Audio Codec",
"checksum": "Checksum",
"downloaded_from": "Downloaded From",
"hash": "Hash",
"performer_card": {
"age": "{age} {years_old}",
"age_context": "{age} {years_old} in this scene"
},
"phash": "PHash",
"stream": "Stream",
"video_codec": "Video Codec"
},
"metadata": "Metadata",
"movie": "Movie",
"movie_scene_number": "Movie Scene Number",
"movies": "Movies",
"name": "Name",
"new": "New",
"none": "None",
"o_counter": "O-Counter",
"operations": "Operations",
"organized": "Organised",
"pagination": {
"first": "First",
"last": "Last",
"next": "Next",
"previous": "Previous"
},
"parent_studios": "Parent Studios",
"path": "Path",
"performer": "Performer",
"performer_count": "Performer Count",
"performer_image": "Performer Image",
"performers": "Performers",
"performerTags": "Performer Tags",
"piercings": "Piercings",
"queue": "Queue",
"random": "Random",
"rating": "Rating",
"resolution": "Resolution",
"scene": "Scene",
"scene_count": "Scene Count",
"scene_id": "Scene ID",
"scenes": "Scenes",
"scenes-size": "Scenes size",
"scenes_updated_at": "Scene Updated At",
"sceneTagger": "Scene Tagger",
"sceneTags": "Scene Tags",
"search_filter": {
"add_filter": "Add Filter",
"name": "Filter",
"update_filter": "Update Filter"
},
"seconds": "Seconds",
"settings": "Settings",
"stash_id": "Stash ID",
"status": "Status: {statusText}",
"studio": "Studio",
"studio_depth": "Levels (empty for all)",
"studios": "Studios",
"synopsis": "Synopsis",
"tag": "Tag",
"tag_count": "Tag Count",
"tags": "Tags",
"tattoos": "Tattoos",
"title": "Title",
"toast": {
"added_entity": "Added {entity}",
"added_generation_job_to_queue": "Added generation job to queue",
"delete_entity": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"generating_screenshot": "Generating screenshot…",
"rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…",
"started_auto_tagging": "Started auto tagging",
"updated_entity": "Updated {entity}"
},
"total": "Total",
"twitter": "Twitter",
"up-dir": "Up a directory",
"updated_at": "Updated At",
"url": "URL",
"weight": "Weight",
"years_old": "years old"
}

Some files were not shown because too many files have changed in this diff Show More