FR: Custom Fields Frontend (#6601)

* Add "custom-field-" prefix to custom field detail item ids
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
Gykes 2026-02-24 19:56:24 -08:00 committed by GitHub
parent cf04e854d6
commit 01d351c85d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 434 additions and 69 deletions

View file

@ -39,6 +39,8 @@ fragment GalleryData on Gallery {
scenes {
...SlimSceneData
}
custom_fields
}
fragment SelectGalleryData on Gallery {

View file

@ -39,6 +39,8 @@ fragment GroupData on Group {
id
title
}
custom_fields
}
# Lightweight fragment for list views - excludes expensive recursive counts

View file

@ -37,4 +37,6 @@ fragment ImageData on Image {
visual_files {
...VisualFileData
}
custom_fields
}

View file

@ -79,6 +79,8 @@ fragment SceneData on Scene {
mime_type
label
}
custom_fields
}
fragment SelectSceneData on Scene {

View file

@ -41,6 +41,7 @@ fragment StudioData on Studio {
...SlimTagData
}
o_counter
custom_fields
}
fragment SelectStudioData on Studio {

View file

@ -40,6 +40,14 @@ query FindScene($id: ID!, $checksum: String) {
}
}
query FindFullScenes($ids: [Int!]) {
findScenes(scene_ids: $ids) {
scenes {
...SceneData
}
}
}
query FindSceneMarkerTags($id: ID!) {
sceneMarkerTags(scene_id: $id) {
tag {

View file

@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { sortPerformers } from "src/core/performers";
import { PhotographerLink } from "src/components/Shared/Link";
import { CustomFields } from "src/components/Shared/CustomFields";
interface IGalleryDetailProps {
gallery: GQL.GalleryDataFragment;
@ -108,6 +109,7 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
{renderDetails()}
{renderTags()}
{renderPerformers()}
<CustomFields values={gallery.custom_fields} fullWidth />
</div>
</div>
</>

View file

@ -31,6 +31,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
@ -76,6 +81,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
tag_ids: yup.array(yup.string().required()).defined(),
scene_ids: yup.array(yup.string().required()).defined(),
details: yup.string().ensure(),
custom_fields: yup.object().required().defined(),
});
const initialValues = {
@ -89,15 +95,26 @@ export const GalleryEditPanel: React.FC<IProps> = ({
tag_ids: (gallery?.tags ?? []).map((t) => t.id),
scene_ids: (gallery?.scenes ?? []).map((s) => s.id),
details: gallery?.details ?? "",
custom_fields: cloneDeep(gallery?.custom_fields ?? {}),
};
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
onSubmit: submit,
});
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
@ -189,7 +206,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
const input = {
...schema.cast(formik.values),
custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),
};
onSave(input, true);
}
@ -455,7 +475,9 @@ export const GalleryEditPanel: React.FC<IProps> = ({
id="gallery-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
disabled={
!isEqual(formik.errors, {}) || customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
@ -468,7 +490,9 @@ export const GalleryEditPanel: React.FC<IProps> = ({
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
@ -523,6 +547,13 @@ export const GalleryEditPanel: React.FC<IProps> = ({
</Form.Label>
{cover}
</Form.Group>
<CustomFieldsInput
values={formik.values.custom_fields}
onChange={(v) => formik.setFieldValue("custom_fields", v)}
error={customFieldsError}
setError={(e) => setCustomFieldsError(e)}
/>
</Col>
</Row>
</Form>

View file

@ -208,6 +208,20 @@ $galleryTabWidth: 450px;
.form-group[data-field="urls"] .string-list-input input.form-control {
font-size: 0.85em;
}
@include media-breakpoint-up(xl) {
.custom-fields-input {
.custom-fields-field {
flex: 0 0 25%;
max-width: 25%;
}
.custom-fields-value {
flex: 0 0 75%;
max-width: 75%;
}
}
}
}
.gallery-cover {

View file

@ -6,6 +6,7 @@ import { DetailItem } from "src/components/Shared/DetailItem";
import { Link } from "react-router-dom";
import { DirectorLink } from "src/components/Shared/Link";
import { GroupLink, TagLink } from "src/components/Shared/TagLink";
import { CustomFields } from "src/components/Shared/CustomFields";
interface IGroupDescription {
group: GQL.SlimGroupDataFragment;
@ -101,6 +102,7 @@ export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({
fullWidth={fullWidth}
/>
)}
<CustomFields values={group.custom_fields} />
</div>
);
};

View file

@ -28,6 +28,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { Group } from "src/components/Groups/GroupSelect";
import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
interface IGroupEditPanel {
group: Partial<GQL.GroupDataFragment>;
@ -84,6 +89,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
synopsis: yup.string().ensure(),
front_image: yup.string().nullable().optional(),
back_image: yup.string().nullable().optional(),
custom_fields: yup.object().required().defined(),
});
const initialValues = {
@ -99,15 +105,26 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
director: group?.director ?? "",
urls: group?.urls ?? [],
synopsis: group?.synopsis ?? "",
custom_fields: cloneDeep(group?.custom_fields ?? {}),
};
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
onSubmit: submit,
});
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
@ -220,7 +237,10 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
const input = {
...schema.cast(formik.values),
custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),
};
onSave(input, true);
}
@ -458,6 +478,13 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
{renderURLListField("urls", onScrapeGroupURL, urlScrapable)}
{renderInputField("synopsis", "textarea")}
{renderTagsField()}
<CustomFieldsInput
values={formik.values.custom_fields}
onChange={(v) => formik.setFieldValue("custom_fields", v)}
error={customFieldsError}
setError={(e) => setCustomFieldsError(e)}
/>
</Form>
<DetailsEditNavbar
@ -468,7 +495,11 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
saveDisabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onImageChange={onFrontImageChange}
onImageChangeURL={onFrontImageLoad}
onClearImage={() => onFrontImageLoad(null)}

View file

@ -7,6 +7,7 @@ import { sortPerformers } from "src/core/performers";
import { FormattedMessage, useIntl } from "react-intl";
import { PhotographerLink } from "src/components/Shared/Link";
import { PatchComponent } from "../../../patch";
import { CustomFields } from "src/components/Shared/CustomFields";
interface IImageDetailProps {
image: GQL.ImageDataFragment;
}
@ -132,6 +133,7 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = PatchComponent(
{renderDetails()}
{renderTags()}
{renderPerformers()}
<CustomFields values={props.image.custom_fields} fullWidth />
</div>
</div>
</>

View file

@ -35,6 +35,11 @@ import {
} from "src/components/Galleries/GallerySelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
interface IProps {
image: GQL.ImageDataFragment;
@ -86,6 +91,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
tag_ids: yup.array(yup.string().required()).defined(),
custom_fields: yup.object().required().defined(),
});
const initialValues = {
@ -99,15 +105,26 @@ export const ImageEditPanel: React.FC<IProps> = ({
studio_id: image.studio?.id ?? null,
performer_ids: (image.performers ?? []).map((p) => p.id),
tag_ids: (image.tags ?? []).map((t) => t.id),
custom_fields: cloneDeep(image.custom_fields ?? {}),
};
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
onSubmit: submit,
});
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
@ -444,7 +461,9 @@ export const ImageEditPanel: React.FC<IProps> = ({
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
@ -492,6 +511,13 @@ export const ImageEditPanel: React.FC<IProps> = ({
</Col>
<Col lg={5} xl={12}>
{renderDetailsField()}
<CustomFieldsInput
values={formik.values.custom_fields}
onChange={(v) => formik.setFieldValue("custom_fields", v)}
error={customFieldsError}
setError={(e) => setCustomFieldsError(e)}
/>
</Col>
</Row>
</Form>

View file

@ -179,6 +179,20 @@ $imageTabWidth: 450px;
.form-group[data-field="urls"] .string-list-input input.form-control {
font-size: 0.85em;
}
@include media-breakpoint-up(xl) {
.custom-fields-input {
.custom-fields-field {
flex: 0 0 25%;
max-width: 25%;
}
.custom-fields-value {
flex: 0 0 75%;
max-width: 75%;
}
}
}
}
.image-file-card.card {

View file

@ -48,7 +48,10 @@ import {
yupUniqueStringList,
} from "src/utils/yup";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { CustomFieldsInput } from "src/components/Shared/CustomFields";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
const isScraper = (
@ -67,16 +70,6 @@ interface IPerformerDetails {
setEncodingImage: (loading: boolean) => void;
}
function customFieldInput(isNew: boolean, input: {}) {
if (isNew) {
return input;
} else {
return {
full: input,
};
}
}
export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
performer,
isVisible,
@ -173,7 +166,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: customFieldInput(isNew, values.custom_fields),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
@ -368,7 +361,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const { values } = formik;
const input = {
...schema.cast(values),
custom_fields: customFieldInput(isNew, values.custom_fields),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input, true);
}

View file

@ -82,11 +82,6 @@
font-weight: 700;
padding-left: 0;
}
.custom-fields .detail-item-title,
.custom-fields .detail-item-value {
font-family: "Courier New", Courier, monospace;
}
/* stylelint-enable selector-class-pattern */
}

View file

@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { sortPerformers } from "src/core/performers";
import { DirectorLink } from "src/components/Shared/Link";
import { CustomFields } from "src/components/Shared/CustomFields";
interface ISceneDetailProps {
scene: GQL.SceneDataFragment;
@ -103,6 +104,7 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
{renderDetails()}
{renderTags()}
{renderPerformers()}
<CustomFields values={props.scene.custom_fields} fullWidth />
</div>
</div>
</>

View file

@ -50,6 +50,11 @@ import { Group } from "src/components/Groups/GroupSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@ -140,6 +145,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
details: yup.string().ensure(),
cover_image: yup.string().nullable().optional(),
custom_fields: yup.object().required().defined(),
});
const initialValues = useMemo(
@ -159,17 +165,28 @@ export const SceneEditPanel: React.FC<IProps> = ({
stash_ids: getStashIDs(scene.stash_ids),
details: scene.details ?? "",
cover_image: initialCoverImage,
custom_fields: cloneDeep(scene.custom_fields ?? {}),
}),
[scene, initialCoverImage]
);
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
onSubmit: submit,
});
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
@ -288,7 +305,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
const input = {
...schema.cast(formik.values),
custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),
};
onSave(input, true);
}
@ -759,7 +779,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
id="scene-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
disabled={
!isEqual(formik.errors, {}) || customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
@ -772,7 +794,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
@ -863,6 +887,13 @@ export const SceneEditPanel: React.FC<IProps> = ({
onReset={scene.id ? onResetCover : undefined}
/>
</Form.Group>
<CustomFieldsInput
values={formik.values.custom_fields}
onChange={(v) => formik.setFieldValue("custom_fields", v)}
error={customFieldsError}
setError={(e) => setCustomFieldsError(e)}
/>
</Col>
</Row>
</Form>

View file

@ -7,12 +7,16 @@ import { StringListSelect, GallerySelect } from "../Shared/Select";
import * as FormUtils from "src/utils/form";
import ImageUtils from "src/utils/image";
import TextUtils from "src/utils/text";
import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService";
import {
mutateSceneMerge,
queryFindFullScenesByID,
} from "src/core/StashService";
import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import {
ScrapeDialogRow,
ScrapedCustomFieldRows,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
@ -24,6 +28,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ModalComponent } from "../Shared/Modal";
import { IHasStoredID, sortStoredIdObjects } from "src/utils/data";
import {
CustomFieldScrapeResults,
ObjectListScrapeResult,
ScrapeResult,
ZeroableScrapeResult,
@ -52,8 +57,8 @@ type MergeOptions = {
};
interface ISceneMergeDetailsProps {
sources: GQL.SlimSceneDataFragment[];
dest: GQL.SlimSceneDataFragment;
sources: GQL.SceneDataFragment[];
dest: GQL.SceneDataFragment;
onClose: (options?: MergeOptions) => void;
}
@ -173,6 +178,10 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
new ScrapeResult<string>(dest.paths.screenshot)
);
const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(
new Map()
);
// calculate the values for everything
// uses the first set value for single value fields, and combines all
useEffect(() => {
@ -309,28 +318,64 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
)
);
const customFieldNames = new Set<string>(
Object.keys(dest.custom_fields ?? {})
);
for (const s of sources) {
for (const n of Object.keys(s.custom_fields ?? {})) {
customFieldNames.add(n);
}
}
setCustomFields(
new Map(
Array.from(customFieldNames)
.sort()
.map((field) => {
return [
field,
new ScrapeResult(
dest.custom_fields?.[field],
sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[
field
],
dest.custom_fields?.[field] === undefined
),
];
})
)
);
loadImages();
}, [sources, dest]);
const hasCustomFieldValues = useMemo(() => {
return hasScrapedValues(Array.from(customFields.values()));
}, [customFields]);
// ensure this is updated if fields are changed
const hasValues = useMemo(() => {
return hasScrapedValues([
title,
code,
url,
date,
rating,
oCounter,
galleries,
studio,
performers,
groups,
tags,
details,
organized,
stashIDs,
image,
]);
return (
hasCustomFieldValues ||
hasScrapedValues([
title,
code,
url,
date,
rating,
oCounter,
galleries,
studio,
performers,
groups,
tags,
details,
organized,
stashIDs,
image,
])
);
}, [
title,
code,
@ -347,6 +392,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
organized,
stashIDs,
image,
hasCustomFieldValues,
]);
function renderScrapeRows() {
@ -566,6 +612,12 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
result={image}
onChange={(value) => setImage(value)}
/>
{hasCustomFieldValues && (
<ScrapedCustomFieldRows
results={customFields}
onChange={(newCustomFields) => setCustomFields(newCustomFields)}
/>
)}
</>
);
}
@ -606,6 +658,13 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
organized: organized.getNewValue(),
stash_ids: stashIDs.getNewValue(),
cover_image: coverImage,
custom_fields: {
partial: Object.fromEntries(
Array.from(customFields.entries()).flatMap(([field, v]) =>
v.useNewValue ? [[field, v.getNewValue()]] : []
)
),
},
},
includeViewHistory: playCount.getNewValue() !== undefined,
includeOHistory: oCounter.getNewValue() !== undefined,
@ -655,10 +714,10 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
const [sourceScenes, setSourceScenes] = useState<Scene[]>([]);
const [destScene, setDestScene] = useState<Scene[]>([]);
const [loadedSources, setLoadedSources] = useState<
GQL.SlimSceneDataFragment[]
>([]);
const [loadedDest, setLoadedDest] = useState<GQL.SlimSceneDataFragment>();
const [loadedSources, setLoadedSources] = useState<GQL.SceneDataFragment[]>(
[]
);
const [loadedDest, setLoadedDest] = useState<GQL.SceneDataFragment>();
const [running, setRunning] = useState(false);
const [secondStep, setSecondStep] = useState(false);
@ -684,7 +743,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
async function loadScenes() {
const sceneIDs = sourceScenes.map((s) => parseInt(s.id));
sceneIDs.push(parseInt(destScene[0].id));
const query = await queryFindScenesByID(sceneIDs);
const query = await queryFindFullScenesByID(sceneIDs);
const { scenes: loadedScenes } = query.data.findScenes;
setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id));

View file

@ -562,6 +562,20 @@ input[type="range"].blue-slider {
.form-group[data-field="urls"] .string-list-input input.form-control {
font-size: 0.85em;
}
@include media-breakpoint-up(xl) {
.custom-fields-input {
.custom-fields-field {
flex: 0 0 25%;
max-width: 25%;
}
.custom-fields-value {
flex: 0 0 75%;
max-width: 75%;
}
}
}
}
.scene-markers-panel {

View file

@ -18,6 +18,7 @@ export type CustomFieldMap = {
interface ICustomFields {
values: CustomFieldMap;
fullWidth?: boolean;
}
function convertValue(value: unknown): string {
@ -41,7 +42,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({
const valueStr = convertValue(value);
// replace spaces with hyphen characters for css id
const id = field.toLowerCase().replace(/ /g, "-");
const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`;
return (
<DetailItem
@ -57,7 +58,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({
export const CustomFields: React.FC<ICustomFields> = PatchComponent(
"CustomFields",
({ values }) => {
({ values, fullWidth }) => {
const intl = useIntl();
if (Object.keys(values).length === 0) {
return null;
@ -65,7 +66,7 @@ export const CustomFields: React.FC<ICustomFields> = PatchComponent(
return (
// according to linter rule CSS classes shouldn't use underscores
<div className="custom-fields">
<div className={cx("custom-fields", { "full-width": fullWidth })}>
<CollapseButton
text={intl.formatMessage({ id: "custom_fields.title" })}
>
@ -125,7 +126,7 @@ const CustomFieldInput: React.FC<{
<Row
className={cx("custom-fields-row", { "custom-fields-new": isNew })}
>
<Col sm={3} xl={2} className="custom-fields-field">
<Col className="custom-fields-field">
{isNew ? (
<>
<Form.Control
@ -146,7 +147,7 @@ const CustomFieldInput: React.FC<{
<Form.Label title={currentField}>{currentField}</Form.Label>
)}
</Col>
<Col sm={9} xl={7}>
<Col className="custom-fields-value">
<InputGroup>
<Form.Control
ref={valueRef}
@ -189,6 +190,16 @@ interface ICustomFieldsInput {
setError: (error?: string) => void;
}
export function formatCustomFieldInput(isNew: boolean, input: {}) {
if (isNew) {
return input;
} else {
return {
full: input,
};
}
}
export const CustomFieldsInput: React.FC<ICustomFieldsInput> = PatchComponent(
"CustomFieldsInput",
({ values, error, onChange, setError }) => {
@ -282,10 +293,10 @@ export const CustomFieldsInput: React.FC<ICustomFieldsInput> = PatchComponent(
<Row>
<Col xl={12}>
<Row className="custom-fields-input-header">
<Form.Label column sm={3} xl={2}>
<Form.Label column className="custom-fields-field">
<FormattedMessage id="custom_fields.field" />
</Form.Label>
<Form.Label column sm={9} xl={7}>
<Form.Label column className="custom-fields-value">
<FormattedMessage id="custom_fields.value" />
</Form.Label>
</Row>

View file

@ -795,6 +795,11 @@ button.btn.favorite-button {
.detail-item {
max-width: 100%;
}
.detail-item-title,
.detail-item-value {
font-family: "Courier New", Courier, monospace;
}
}
.custom-fields .detail-item .detail-item-title {
@ -816,6 +821,36 @@ button.btn.favorite-button {
font-weight: 700;
}
.custom-fields-input {
.custom-fields-field {
flex: 0 0 100%;
max-width: 100%;
@include media-breakpoint-up(sm) {
flex: 0 0 25%;
max-width: 25%;
}
@include media-breakpoint-up(xl) {
flex: 0 0 16.667%;
max-width: 16.667%;
}
}
.custom-fields-value {
flex: 0 0 100%;
max-width: 100%;
@include media-breakpoint-up(sm) {
flex: 0 0 75%;
max-width: 75%;
}
@include media-breakpoint-up(xl) {
flex: 0 0 58.33%;
max-width: 58.33%;
}
}
}
.custom-fields-row {
align-items: center;
font-family: "Courier New", Courier, monospace;

View file

@ -4,6 +4,7 @@ import * as GQL from "src/core/generated-graphql";
import { DetailItem } from "src/components/Shared/DetailItem";
import { StashIDPill } from "src/components/Shared/StashID";
import { PatchComponent } from "src/patch";
import { CustomFields } from "src/components/Shared/CustomFields";
import { Link } from "react-router-dom";
interface IStudioDetailsPanel {
@ -87,6 +88,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = PatchComponent(
value={renderStashIDs()}
fullWidth={fullWidth}
/>
<CustomFields values={studio.custom_fields} />
</div>
);
}

View file

@ -21,6 +21,11 @@ import { Studio, StudioSelect } from "../StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { Icon } from "src/components/Shared/Icon";
import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
@ -63,6 +68,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(),
custom_fields: yup.object().required().defined(),
});
const initialValues = {
@ -75,15 +81,26 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
tag_ids: (studio.tags ?? []).map((t) => t.id),
ignore_auto_tag: studio.ignore_auto_tag ?? false,
stash_ids: getStashIDs(studio.stash_ids),
custom_fields: cloneDeep(studio.custom_fields ?? {}),
};
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
onSubmit: submit,
});
const { tagsControl } = useTagsEdit(studio.tags, (ids) =>
@ -144,7 +161,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
const input = {
...schema.cast(formik.values),
custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),
};
onSave(input, true);
}
@ -242,6 +262,14 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<Icon icon={faPlus} />
</Button>
)}
<CustomFieldsInput
values={formik.values.custom_fields}
onChange={(v) => formik.setFieldValue("custom_fields", v)}
error={customFieldsError}
setError={(e) => setCustomFieldsError(e)}
/>
<hr />
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
@ -254,7 +282,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
saveDisabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onImageChange={onImageChange}
onImageChangeURL={onImageLoad}
onClearImage={() => onImageLoad(null)}

View file

@ -3,6 +3,7 @@ import { TagLink } from "src/components/Shared/TagLink";
import { DetailItem } from "src/components/Shared/DetailItem";
import { StashIDPill } from "src/components/Shared/StashID";
import * as GQL from "src/core/generated-graphql";
import { CustomFields } from "src/components/Shared/CustomFields";
interface ITagDetails {
tag: GQL.TagDataFragment;
@ -90,6 +91,7 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
value={renderStashIDs()}
fullWidth={fullWidth}
/>
<CustomFields values={tag.custom_fields} />
</div>
);
};

View file

@ -20,6 +20,11 @@ import { addUpdateStashID, getStashIDs } from "src/utils/stashIds";
import { Tag, TagSelect } from "../TagSelect";
import { Icon } from "src/components/Shared/Icon";
import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
import {
CustomFieldsInput,
formatCustomFieldInput,
} from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities";
interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>;
@ -63,6 +68,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(),
custom_fields: yup.object().required().defined(),
});
const initialValues = {
@ -74,15 +80,26 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
child_ids: (tag?.children ?? []).map((t) => t.id),
ignore_auto_tag: tag?.ignore_auto_tag ?? false,
stash_ids: getStashIDs(tag?.stash_ids),
custom_fields: cloneDeep(tag?.custom_fields ?? {}),
};
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
onSave(input);
}
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
onSubmit: submit,
});
function onSetParentTags(items: Tag[]) {
@ -134,7 +151,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
const input = {
...schema.cast(formik.values),
custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),
};
onSave(input, true);
}
@ -266,6 +286,14 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<Icon icon={faPlus} />
</Button>
)}
<CustomFieldsInput
values={formik.values.custom_fields}
onChange={(v) => formik.setFieldValue("custom_fields", v)}
error={customFieldsError}
setError={(e) => setCustomFieldsError(e)}
/>
<hr />
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
@ -279,7 +307,9 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onImageChange={onImageChange}
onImageChangeURL={onImageLoad}

View file

@ -166,6 +166,14 @@ export const queryFindScenesByID = (sceneIDs: number[]) =>
},
});
export const queryFindFullScenesByID = (sceneIDs: number[]) =>
client.query<GQL.FindFullScenesQuery>({
query: GQL.FindFullScenesDocument,
variables: {
ids: sceneIDs,
},
});
export const queryFindScenesForSelect = (filter: ListFilterModel) =>
client.query<GQL.FindScenesForSelectQuery>({
query: GQL.FindScenesForSelectDocument,

View file

@ -21,6 +21,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
import { DisplayMode } from "./types";
import { RatingCriterionOption } from "./criteria/rating";
import { PathCriterionOption } from "./criteria/path";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "path";
@ -71,6 +72,7 @@ const criterionOptions = [
createDateCriterionOption("date"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const GalleryListFilterOptions = new ListFilterOptions(

View file

@ -17,6 +17,7 @@ import {
ContainingGroupsCriterionOption,
SubGroupsCriterionOption,
} from "./criteria/groups";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "name";
@ -66,6 +67,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("scene_count"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const GroupListFilterOptions = new ListFilterOptions(

View file

@ -23,6 +23,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
import { DisplayMode } from "./types";
import { GalleriesCriterionOption } from "./criteria/galleries";
import { PhashCriterionOption } from "./criteria/phash";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "path";
@ -73,6 +74,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("file_count"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const ImageListFilterOptions = new ListFilterOptions(
defaultSortBy,

View file

@ -35,6 +35,7 @@ import { StashIDCriterionOption } from "./criteria/stash-ids";
import { RatingCriterionOption } from "./criteria/rating";
import { PathCriterionOption } from "./criteria/path";
import { OrientationCriterionOption } from "./criteria/orientation";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "date";
const sortByOptions = [
@ -141,6 +142,7 @@ const criterionOptions = [
createDateCriterionOption("date"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const SceneListFilterOptions = new ListFilterOptions(

View file

@ -13,6 +13,7 @@ import { ParentStudiosCriterionOption } from "./criteria/studios";
import { TagsCriterionOption } from "./criteria/tags";
import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "name";
const sortByOptions = [
@ -67,6 +68,7 @@ const criterionOptions = [
),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const StudioListFilterOptions = new ListFilterOptions(

View file

@ -15,6 +15,7 @@ import {
} from "./criteria/tags";
import { FavoriteTagCriterionOption } from "./criteria/favorite";
import { StashIDCriterionOption } from "./criteria/stash-ids";
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
const defaultSortBy = "name";
const sortByOptions = ["name", "random", "scenes_duration"]
@ -77,6 +78,7 @@ const criterionOptions = [
new MandatoryNumberCriterionOption("sub_tag_count", "child_count"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
CustomFieldsCriterionOption,
];
export const TagListFilterOptions = new ListFilterOptions(