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",
"typescript.preferences.importModuleSpecifier": "relative",
"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 { Route, Switch, useRouteMatch } from "react-router-dom";
import { IntlProvider } from "react-intl";
import { merge } from "lodash";
import { ToastProvider } from "src/hooks/Toast";
import LightboxProvider from "src/hooks/Lightbox/context";
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { initPolyfills } from "src/polyfills";
import locales from "src/locale";
import locales from "src/locales";
import { useConfiguration, useSystemStatus } from "src/core/StashService";
import { flattenMessages } from "src/utils";
import Mousetrap from "mousetrap";
@@ -58,12 +59,12 @@ export const App: React.FC = () => {
const messageLanguage = languageMessageString(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
...(locales as any)[defaultMessageLanguage],
(locales as any)[defaultMessageLanguage],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(locales as any)[messageLanguage],
};
(locales as any)[messageLanguage]
);
const messages = flattenMessages(mergedMessages);
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))
### 🎨 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))
* 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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedNumber, useIntl } from "react-intl";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
interface IPaginationProps {
itemsPerPage: number;
@@ -75,7 +75,9 @@ export const Pagination: React.FC<IPaginationProps> = ({
disabled={currentPage === 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>
</Button>
<Button
@@ -84,7 +86,7 @@ export const Pagination: React.FC<IPaginationProps> = ({
disabled={currentPage === 1}
onClick={() => onChangePage(currentPage - 1)}
>
Previous
<FormattedMessage id="pagination.previous" />
</Button>
{pageButtons}
<Button
@@ -93,14 +95,16 @@ export const Pagination: React.FC<IPaginationProps> = ({
disabled={currentPage === totalPages}
onClick={() => onChangePage(currentPage + 1)}
>
Next
<FormattedMessage id="pagination.next" />
</Button>
<Button
variant="secondary"
disabled={currentPage === 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>
</Button>
</ButtonGroup>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
@@ -24,6 +25,7 @@ interface IMovieParams {
}
export const Movie: React.FC = () => {
const intl = useIntl();
const history = useHistory();
const Toast = useToast();
const { id = "new" } = useParams<IMovieParams>();
@@ -141,10 +143,23 @@ export const Movie: React.FC = () => {
<Modal
show={isDeleteAlertOpen}
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) }}
>
<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>
);
}

View File

@@ -17,7 +17,9 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
if (movie.aliases) {
return (
<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>
</div>
);
@@ -31,7 +33,9 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
return (
<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">
<RatingStars value={movie.rating} disabled />
</dd>
@@ -50,31 +54,31 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
<div>
<TextField
name="Duration"
id="duration"
value={
movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
}
/>
<TextField
name="Date"
id="date"
value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""}
/>
<URLField
name="Studio"
id="studio"
value={movie.studio?.name}
url={`/studios/${movie.studio?.id}`}
/>
<TextField name="Director" value={movie.director} />
<TextField id="director" value={movie.director} />
{renderRatingField()}
<URLField
name="URL"
id="url"
value={movie.url}
url={TextUtils.sanitiseURL(movie.url ?? "")}
/>
<TextField name="Synopsis" value={movie.synopsis} />
<TextField id="synopsis" value={movie.synopsis} />
</div>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
import {
@@ -39,11 +40,22 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
onSelectedChanged,
extraCriteria,
}) => {
const intl = useIntl();
const age = TextUtils.age(
performer.birthdate,
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() {
if (performer.favorite === false) {
@@ -143,7 +155,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
performer.rating ? `rating-${performer.rating}` : ""
}`}
>
RATING: {performer.rating}
<FormattedMessage id="rating" />: {performer.rating}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash";
import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { Modal, LoadingIndicator } from "src/components/Shared";
@@ -24,6 +25,7 @@ const PerformerScrapeModal: React.FC<IProps> = ({
onHide,
onSelectPerformer,
}) => {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState<string>(name ?? "");
const { data, loading } = useScrapePerformerList(scraper.id, query);
@@ -41,7 +43,11 @@ const PerformerScrapeModal: React.FC<IProps> = ({
show
onHide={onHide}
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}>
<Form.Control

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash";
import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { Modal, LoadingIndicator } from "src/components/Shared";
@@ -24,6 +25,7 @@ const PerformerStashBoxModal: React.FC<IProps> = ({
onHide,
onSelectPerformer,
}) => {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState<string>(name ?? "");
const { data, loading } = GQL.useQueryStashBoxPerformerQuery({
@@ -49,7 +51,11 @@ const PerformerStashBoxModal: React.FC<IProps> = ({
show
onHide={onHide}
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}>
<Form.Control

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
Form,
InputGroup,
} from "react-bootstrap";
import { useIntl } from "react-intl";
import { ParserField } from "./ParserField";
import { ShowFields } from "./ShowFields";
@@ -83,6 +84,7 @@ interface IParserInputProps {
export const ParserInput: React.FC<IParserInputProps> = (
props: IParserInputProps
) => {
const intl = useIntl();
const [pattern, setPattern] = useState<string>(props.input.pattern);
const [ignoreWords, setIgnoreWords] = useState<string>(
props.input.ignoreWords.join(" ")
@@ -127,7 +129,9 @@ export const ParserInput: React.FC<IParserInputProps> = (
<Form.Group>
<Form.Group className="row">
<Form.Label htmlFor="filename-pattern" className="col-2">
Filename Pattern
{intl.formatMessage({
id: "config.tools.scene_filename_parser.filename_pattern",
})}
</Form.Label>
<InputGroup className="col-8">
<Form.Control
@@ -139,7 +143,12 @@ export const ParserInput: React.FC<IParserInputProps> = (
value={pattern}
/>
<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) => (
<Dropdown.Item
key={item.field}
@@ -153,12 +162,18 @@ export const ParserInput: React.FC<IParserInputProps> = (
</InputGroup.Append>
</InputGroup>
<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.Group>
<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">
<Form.Control
className="text-input"
@@ -169,14 +184,18 @@ export const ParserInput: React.FC<IParserInputProps> = (
/>
</InputGroup>
<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.Group>
<h5>Title</h5>
<h5>{intl.formatMessage({ id: "title" })}</h5>
<Form.Group className="row">
<Form.Label htmlFor="whitespace-characters" className="col-2">
Whitespace characters:
{intl.formatMessage({
id: "config.tools.scene_filename_parser.whitespace_chars",
})}
</Form.Label>
<InputGroup className="col-8">
<Form.Control
@@ -188,7 +207,9 @@ export const ParserInput: React.FC<IParserInputProps> = (
/>
</InputGroup>
<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.Group>
<Form.Group>
@@ -199,7 +220,11 @@ export const ParserInput: React.FC<IParserInputProps> = (
checked={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>
{/* TODO - mapping stuff will go here */}
@@ -208,7 +233,9 @@ export const ParserInput: React.FC<IParserInputProps> = (
<DropdownButton
variant="secondary"
id="recipe-select"
title="Select Parser Recipe"
title={intl.formatMessage({
id: "config.tools.scene_filename_parser.select_parser_recipe",
})}
drop="up"
>
{builtInRecipes.map((item) => (
@@ -232,7 +259,7 @@ export const ParserInput: React.FC<IParserInputProps> = (
<Form.Group className="row">
<Button variant="secondary" className="ml-3 col-1" onClick={onFind}>
Find
{intl.formatMessage({ id: "actions.find" })}
</Button>
<Form.Control
as="select"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, InputGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import {
@@ -69,6 +70,7 @@ const ExclusionPatterns: React.FC<IExclusionPatternsProps> = (props) => {
};
export const SettingsConfigurationPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
// Editing config state
const [stashes, setStashes] = useState<GQL.StashConfig[]>([]);
@@ -278,7 +280,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
const result = await updateGeneralConfig();
// eslint-disable-next-line no-console
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) {
Toast.error(e);
}
@@ -363,7 +374,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
return (
<>
<h4>Library</h4>
<h4>
<FormattedMessage id="library" />
</h4>
<Form.Group>
<Form.Group id="stashes">
<h6>Stashes</h6>
@@ -372,12 +385,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
setStashes={(s) => setStashes(s)}
/>
<Form.Text className="text-muted">
Directory locations to your content
{intl.formatMessage({
id: "config.general.directory_locations_to_your_content",
})}
</Form.Text>
</Form.Group>
<Form.Group id="database-path">
<h6>Database Path</h6>
<h6>
<FormattedMessage id="config.general.db_path_head" />
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={databasePath}
@@ -386,12 +403,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
File location for the SQLite database (requires restart)
{intl.formatMessage({ id: "config.general.sqlite_location" })}
</Form.Text>
</Form.Group>
<Form.Group id="generated-path">
<h6>Generated Path</h6>
<h6>
<FormattedMessage id="config.general.generated_path_head" />
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={generatedPath}
@@ -400,13 +419,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Directory location for the generated files (scene markers, scene
previews, sprites, etc)
{intl.formatMessage({
id: "config.general.generated_files_location",
})}
</Form.Text>
</Form.Group>
<Form.Group id="cache-path">
<h6>Cache Path</h6>
<h6>
<FormattedMessage id="config.general.cache_path_head" />
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={cachePath}
@@ -415,12 +437,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Directory location of the cache
{intl.formatMessage({ id: "config.general.cache_location" })}
</Form.Text>
</Form.Group>
<Form.Group id="video-extensions">
<h6>Video Extensions</h6>
<h6>
<FormattedMessage id="config.general.video_ext_head" />
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={videoExtensions}
@@ -429,13 +453,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Comma-delimited list of file extensions that will be identified as
videos.
{intl.formatMessage({ id: "config.general.video_ext_desc" })}
</Form.Text>
</Form.Group>
<Form.Group id="image-extensions">
<h6>Image Extensions</h6>
<h6>
<FormattedMessage id="config.general.image_ext_head" />
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={imageExtensions}
@@ -444,13 +469,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Comma-delimited list of file extensions that will be identified as
images.
{intl.formatMessage({ id: "config.general.image_ext_desc" })}
</Form.Text>
</Form.Group>
<Form.Group id="gallery-extensions">
<h6>Gallery zip Extensions</h6>
<h6>
{intl.formatMessage({ id: "config.general.gallery_ext_head" })}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={galleryExtensions}
@@ -459,16 +485,21 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Comma-delimited list of file extensions that will be identified as
gallery zip files.
{intl.formatMessage({ id: "config.general.gallery_ext_desc" })}
</Form.Text>
</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} />
<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
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer"
@@ -480,14 +511,19 @@ export const SettingsConfigurationPanel: React.FC = () => {
</Form.Group>
<Form.Group>
<h6>Excluded Image/Gallery Patterns</h6>
<h6>
{intl.formatMessage({
id: "config.general.excluded_image_gallery_patterns_head",
})}
</h6>
<ExclusionPatterns
excludes={imageExcludes}
setExcludes={setImageExcludes}
/>
<Form.Text className="text-muted">
Regexps of image and gallery files/paths to exclude from Scan and
add to Clean
{intl.formatMessage({
id: "config.general.excluded_image_gallery_patterns_desc",
})}
<a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer"
@@ -502,13 +538,17 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check
id="log-terminal"
checked={createGalleriesFromFolders}
label="Create galleries from folders containing images"
label={intl.formatMessage({
id: "config.general.create_galleries_from_folders_label",
})}
onChange={() =>
setCreateGalleriesFromFolders(!createGalleriesFromFolders)
}
/>
<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.Group>
</Form.Group>
@@ -516,22 +556,28 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr />
<Form.Group>
<h4>Hashing</h4>
<h4>{intl.formatMessage({ id: "config.general.hashing" })}</h4>
<Form.Group>
<Form.Check
checked={calculateMD5}
label="Calculate MD5 for videos"
label={intl.formatMessage({
id: "config.general.calculate_md5_and_ohash_label",
})}
onChange={() => setCalculateMD5(!calculateMD5)}
/>
<Form.Text className="text-muted">
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.
{intl.formatMessage({
id: "config.general.calculate_md5_and_ohash_desc",
})}
</Form.Text>
</Form.Group>
<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
className="w-auto input-control"
@@ -551,10 +597,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
</Form.Control>
<Form.Text className="text-muted">
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.
{intl.formatMessage({
id: "config.general.generated_file_naming_hash_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
@@ -562,9 +607,13 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr />
<Form.Group>
<h4>Video</h4>
<h4>{intl.formatMessage({ id: "config.general.video_head" })}</h4>
<Form.Group id="transcode-size">
<h6>Maximum transcode size</h6>
<h6>
{intl.formatMessage({
id: "config.general.maximum_transcode_size_head",
})}
</h6>
<Form.Control
className="w-auto input-control"
as="select"
@@ -580,11 +629,17 @@ export const SettingsConfigurationPanel: React.FC = () => {
))}
</Form.Control>
<Form.Text className="text-muted">
Maximum size for generated transcodes
{intl.formatMessage({
id: "config.general.maximum_transcode_size_desc",
})}
</Form.Text>
</Form.Group>
<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
className="w-auto input-control"
as="select"
@@ -602,7 +657,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
))}
</Form.Control>
<Form.Text className="text-muted">
Maximum size for transcoded streams
{intl.formatMessage({
id: "config.general.maximum_streaming_transcode_size_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
@@ -610,10 +667,17 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr />
<Form.Group>
<h4>Parallel Scan/Generation</h4>
<h4>
{intl.formatMessage({ id: "config.general.parallel_scan_head" })}
</h4>
<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
className="col col-sm-6 text-input"
type="number"
@@ -625,9 +689,10 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
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.
{intl.formatMessage({
id:
"config.general.number_of_parallel_task_for_scan_generation_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
@@ -635,10 +700,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr />
<Form.Group>
<h4>Preview Generation</h4>
<h4>
{intl.formatMessage({ id: "config.general.preview_generation" })}
</h4>
<Form.Group id="transcode-size">
<h6>Preview encoding preset</h6>
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control
className="w-auto input-control"
as="select"
@@ -654,9 +725,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
))}
</Form.Control>
<Form.Text className="text-muted">
The preset regulates size, quality and encoding time of preview
generation. Presets beyond slow have diminishing returns and are
not recommended.
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_desc",
})}
</Form.Text>
</Form.Group>
@@ -673,7 +744,11 @@ export const SettingsConfigurationPanel: React.FC = () => {
</Form.Group>
<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
className="col col-sm-6 text-input"
type="number"
@@ -685,12 +760,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Number of segments in preview files.
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text>
</Form.Group>
<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
className="col col-sm-6 text-input"
type="number"
@@ -702,12 +783,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<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.Group>
<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
className="col col-sm-6 text-input"
defaultValue={previewExcludeStart}
@@ -716,13 +803,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
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.
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text>
</Form.Group>
<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
className="col col-sm-6 text-input"
defaultValue={previewExcludeEnd}
@@ -731,16 +823,19 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
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.
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
<Form.Group>
<h4>Scraping</h4>
<h4>{intl.formatMessage({ id: "config.general.scraping" })}</h4>
<Form.Group id="scraperUserAgent">
<h6>Scraper User Agent</h6>
<h6>
{intl.formatMessage({ id: "config.general.scraper_user_agent" })}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={scraperUserAgent}
@@ -749,12 +844,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<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.Group>
<Form.Group id="scraperCDPPath">
<h6>Chrome CDP path</h6>
<h6>
{intl.formatMessage({ id: "config.general.chrome_cdp_path" })}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={scraperCDPPath}
@@ -763,9 +862,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
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.
{intl.formatMessage({ id: "config.general.chrome_cdp_path_desc" })}
</Form.Text>
</Form.Group>
@@ -773,13 +870,15 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check
id="scaper-cert-check"
checked={scraperCertCheck}
label="Check for insecure certificates"
label={intl.formatMessage({
id: "config.general.check_for_insecure_certificates",
})}
onChange={() => setScraperCertCheck(!scraperCertCheck)}
/>
<Form.Text className="text-muted">
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.
{intl.formatMessage({
id: "config.general.check_for_insecure_certificates_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
@@ -787,16 +886,22 @@ export const SettingsConfigurationPanel: React.FC = () => {
<hr />
<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} />
</Form.Group>
<hr />
<Form.Group>
<h4>Authentication</h4>
<h4>
{intl.formatMessage({ id: "config.general.auth.authentication" })}
</h4>
<Form.Group id="username">
<h6>Username</h6>
<h6>{intl.formatMessage({ id: "config.general.auth.username" })}</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={username}
@@ -805,11 +910,11 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<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.Group>
<Form.Group id="password">
<h6>Password</h6>
<h6>{intl.formatMessage({ id: "config.general.auth.password" })}</h6>
<Form.Control
className="col col-sm-6 text-input"
type="password"
@@ -819,12 +924,12 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<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.Group>
<Form.Group id="apikey">
<h6>API Key</h6>
<h6>{intl.formatMessage({ id: "config.general.auth.api_key" })}</h6>
<InputGroup>
<Form.Control
className="col col-sm-6 text-input"
@@ -834,7 +939,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
<InputGroup.Append>
<Button
className=""
title="Generate API key"
title={intl.formatMessage({
id: "config.general.auth.generate_api_key",
})}
onClick={() => onGenerateAPIKey()}
>
<Icon icon="redo" />
@@ -842,7 +949,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Button
className=""
variant="danger"
title="Clear API key"
title={intl.formatMessage({
id: "config.general.auth.clear_api_key",
})}
onClick={() => onClearAPIKey()}
>
<Icon icon="minus" />
@@ -850,13 +959,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
</InputGroup.Append>
</InputGroup>
<Form.Text className="text-muted">
API key for external systems. Only required when username/password
is configured. Username must be saved before generating API key.
{intl.formatMessage({ id: "config.general.auth.api_key_desc" })}
</Form.Text>
</Form.Group>
<Form.Group id="maxSessionAge">
<h6>Maximum Session Age</h6>
<h6>
{intl.formatMessage({
id: "config.general.auth.maximum_session_age",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
@@ -868,16 +980,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<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.Group>
</Form.Group>
<hr />
<h4>Logging</h4>
<h4>{intl.formatMessage({ id: "config.general.logging" })}</h4>
<Form.Group id="log-file">
<h6>Log file</h6>
<h6>{intl.formatMessage({ id: "config.general.auth.log_file" })}</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={logFile}
@@ -886,8 +1000,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
}
/>
<Form.Text className="text-muted">
Path to the file to output logging to. Blank to disable file logging.
Requires restart.
{intl.formatMessage({ id: "config.general.auth.log_file_desc" })}
</Form.Text>
</Form.Group>
@@ -895,17 +1008,20 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check
id="log-terminal"
checked={logOut}
label="Log to terminal"
label={intl.formatMessage({
id: "config.general.auth.log_to_terminal",
})}
onChange={() => setLogOut(!logOut)}
/>
<Form.Text className="text-muted">
Logs to the terminal in addition to a file. Always true if file
logging is disabled. Requires restart.
{intl.formatMessage({
id: "config.general.auth.log_to_terminal_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="log-level">
<h6>Log Level</h6>
<h6>{intl.formatMessage({ id: "config.logs.log_level" })}</h6>
<Form.Control
className="col col-sm-6 input-control"
as="select"
@@ -926,18 +1042,18 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Check
id="log-http"
checked={logAccess}
label="Log http access"
label={intl.formatMessage({ id: "config.general.auth.log_http" })}
onChange={() => setLogAccess(!logAccess)}
/>
<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.Group>
<hr />
<Button variant="primary" onClick={() => onSave()}>
Save
<FormattedMessage id="actions.save" />
</Button>
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useState, useReducer } from "react";
import cx from "classnames";
import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { uniq } from "lodash";
import { blobToBase64 } from "base64-blob";
@@ -34,9 +35,14 @@ const getDurationStatus = (
let match;
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)
match = "Duration is a match";
match = <FormattedMessage id="component_tagger.results.fp_matches" />;
if (match)
return (
@@ -67,7 +73,16 @@ const getFingerprintStatus = (
return (
<div className="font-weight-bold">
<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>
);
};
@@ -116,6 +131,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{}
);
const intl = useIntl();
const createStudio = useCreateStudio();
const createPerformer = useCreatePerformer();
const createTag = useCreateTag();
@@ -400,7 +416,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{scene?.studio?.name} {scene?.date}
</h5>
<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>
{getDurationStatus(scene, stashScene.file?.duration)}
{getFingerprintStatus(scene, stashScene)}
@@ -440,7 +460,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{saveState ? (
<LoadingIndicator inline small message="" />
) : (
"Save"
<FormattedMessage id="actions.save" />
)}
</Button>
</div>

View File

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

View File

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

View File

@@ -24,14 +24,6 @@ export const initialConfig: ITaggerConfig = {
};
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 {
blacklist: string[];
showMales: boolean;

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames";
import Mousetrap from "mousetrap";
@@ -35,6 +36,7 @@ interface ITabParams {
export const Tag: React.FC = () => {
const history = useHistory();
const Toast = useToast();
const intl = useIntl();
const { tab = "scenes", id = "new" } = useParams<ITabParams>();
const isNew = id === "new";
@@ -170,10 +172,23 @@ export const Tag: React.FC = () => {
<Modal
show={isDeleteAlertOpen}
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) }}
>
<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>
);
}
@@ -248,19 +263,28 @@ export const Tag: React.FC = () => {
activeKey={activeTabKey}
onSelect={setActiveTabKey}
>
<Tab eventKey="scenes" title="Scenes">
<Tab eventKey="scenes" title={intl.formatMessage({ id: "scenes" })}>
<TagScenesPanel tag={tag} />
</Tab>
<Tab eventKey="images" title="Images">
<Tab eventKey="images" title={intl.formatMessage({ id: "images" })}>
<TagImagesPanel tag={tag} />
</Tab>
<Tab eventKey="galleries" title="Galleries">
<Tab
eventKey="galleries"
title={intl.formatMessage({ id: "galleries" })}
>
<TagGalleriesPanel tag={tag} />
</Tab>
<Tab eventKey="markers" title="Markers">
<Tab
eventKey="markers"
title={intl.formatMessage({ id: "markers" })}
>
<TagMarkersPanel tag={tag} />
</Tab>
<Tab eventKey="performers" title="Performers">
<Tab
eventKey="performers"
title={intl.formatMessage({ id: "performers" })}
>
<TagPerformersPanel tag={tag} />
</Tab>
</Tabs>

View File

@@ -1,5 +1,6 @@
import React from "react";
import { Badge } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql";
interface ITagDetails {
@@ -14,7 +15,9 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
return (
<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">
{tag.aliases.map((a) => (
<Badge className="tag-item" variant="secondary">

View File

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

View File

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