diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index e332244ad80..e6f2d3fe0ee 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,8 +1,7 @@ import React, { useState } from "react"; import { useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; -import { Table } from "react-bootstrap"; -import { Link, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { @@ -18,7 +17,7 @@ import GalleryWallCard from "./GalleryWallCard"; import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; -import { galleryTitle } from "src/core/galleries"; +import { GalleryListTable } from "./GalleryListTable"; const GalleryItemList = makeItemList({ filterMode: GQL.FilterMode.Galleries, @@ -152,40 +151,11 @@ export const GalleryList: React.FC = ({ } if (filter.displayMode === DisplayMode.List) { return ( - - - - - - - - - {result.data.findGalleries.galleries.map((gallery) => ( - - - - - ))} - -
{intl.formatMessage({ id: "actions.preview" })} - {intl.formatMessage({ id: "title" })} -
- - {gallery.cover ? ( - {gallery.title - ) : undefined} - - - - {galleryTitle(gallery)} ({gallery.image_count}{" "} - {gallery.image_count === 1 ? "image" : "images"}) - -
+ ); } if (filter.displayMode === DisplayMode.Wall) { diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx new file mode 100644 index 00000000000..b86552582f8 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -0,0 +1,256 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import NavUtils from "src/utils/navigation"; +import { useIntl } from "react-intl"; +import { objectTitle } from "src/core/files"; +import { galleryTitle } from "src/core/galleries"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { useGalleryUpdate } from "src/core/StashService"; +import { IColumn, ListTable } from "../List/ListTable"; +import { useTableColumns } from "src/hooks/useTableColumns"; + +interface IGalleryListTableProps { + galleries: GQL.SlimGalleryDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const TABLE_NAME = "galleries"; + +export const GalleryListTable: React.FC = ( + props: IGalleryListTableProps +) => { + const intl = useIntl(); + + const [updateGallery] = useGalleryUpdate(); + + function setRating(v: number | null, galleryId: string) { + if (galleryId) { + updateGallery({ + variables: { + input: { + id: galleryId, + rating100: v, + }, + }, + }); + } + } + + const CoverImageCell = (gallery: GQL.SlimGalleryDataFragment) => { + const title = galleryTitle(gallery); + + return ( + + {gallery.cover ? ( + {title} + ) : undefined} + + ); + }; + + const TitleCell = (gallery: GQL.SlimGalleryDataFragment) => { + const title = galleryTitle(gallery); + + return ( + + {title} + + ); + }; + + const DateCell = (gallery: GQL.SlimGalleryDataFragment) => ( + <>{gallery.date} + ); + + const RatingCell = (gallery: GQL.SlimGalleryDataFragment) => ( + setRating(value, gallery.id)} + /> + ); + + const ImagesCell = (gallery: GQL.SlimGalleryDataFragment) => { + return ( + + {gallery.image_count} + + ); + }; + + const TagCell = (gallery: GQL.SlimGalleryDataFragment) => ( +
    + {gallery.tags.map((tag) => ( +
  • + + {tag.name} + +
  • + ))} +
+ ); + + const PerformersCell = (gallery: GQL.SlimGalleryDataFragment) => ( +
    + {gallery.performers.map((performer) => ( +
  • + + {performer.name} + +
  • + ))} +
+ ); + + const StudioCell = (gallery: GQL.SlimGalleryDataFragment) => { + if (gallery.studio) { + return ( + + {gallery.studio.name} + + ); + } + }; + + const SceneCell = (gallery: GQL.SlimGalleryDataFragment) => ( +
    + {gallery.scenes.map((galleryScene) => ( +
  • + + {objectTitle(galleryScene)} + +
  • + ))} +
+ ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: ( + gallery: GQL.SlimGalleryDataFragment, + index: number + ) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "cover_image", + label: intl.formatMessage({ id: "cover_image" }), + defaultShow: true, + render: CoverImageCell, + }, + { + value: "title", + label: intl.formatMessage({ id: "title" }), + defaultShow: true, + mandatory: true, + render: TitleCell, + }, + { + value: "date", + label: intl.formatMessage({ id: "date" }), + defaultShow: true, + render: DateCell, + }, + { + value: "rating", + label: intl.formatMessage({ id: "rating" }), + defaultShow: true, + render: RatingCell, + }, + { + value: "code", + label: intl.formatMessage({ id: "scene_code" }), + render: (s) => <>{s.code}, + }, + { + value: "images", + label: intl.formatMessage({ id: "images" }), + defaultShow: true, + render: ImagesCell, + }, + { + value: "tags", + label: intl.formatMessage({ id: "tags" }), + defaultShow: true, + render: TagCell, + }, + { + value: "performers", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformersCell, + }, + { + value: "studio", + label: intl.formatMessage({ id: "studio" }), + defaultShow: true, + render: StudioCell, + }, + { + value: "scenes", + label: intl.formatMessage({ id: "scenes" }), + defaultShow: true, + render: SceneCell, + }, + { + value: "photographer", + label: intl.formatMessage({ id: "photographer" }), + render: (s) => <>{s.photographer}, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (gallery: GQL.SlimGalleryDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + gallery: GQL.SlimGalleryDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(gallery, index); + } + + return ( + saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> + ); +}; diff --git a/ui/v2.5/src/components/List/ListTable.tsx b/ui/v2.5/src/components/List/ListTable.tsx new file mode 100644 index 00000000000..893c8597b57 --- /dev/null +++ b/ui/v2.5/src/components/List/ListTable.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from "react"; +import { Table, Form } from "react-bootstrap"; +import { CheckBoxSelect } from "../Shared/Select"; +import cx from "classnames"; + +export interface IColumn { + label: string; + value: string; + mandatory?: boolean; +} + +export const ColumnSelector: React.FC<{ + selected: string[]; + allColumns: IColumn[]; + setSelected: (selected: string[]) => void; +}> = ({ selected, allColumns, setSelected }) => { + const disableOptions = useMemo(() => { + return allColumns.map((col) => { + return { + ...col, + disabled: col.mandatory, + }; + }); + }, [allColumns]); + + const selectedColumns = useMemo(() => { + return disableOptions.filter((col) => selected.includes(col.value)); + }, [selected, disableOptions]); + + return ( + { + setSelected(v.map((col) => col.value)); + }} + /> + ); +}; + +interface IListTableProps { + className?: string; + items: T[]; + columns: string[]; + setColumns: (columns: string[]) => void; + allColumns: IColumn[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + renderCell: (column: IColumn, item: T, index: number) => React.ReactNode; +} + +export const ListTable = ( + props: IListTableProps +) => { + const { + className, + items, + columns, + setColumns, + allColumns, + selectedIds, + onSelectChange, + renderCell, + } = props; + + const visibleColumns = useMemo(() => { + return allColumns.filter( + (col) => col.mandatory || columns.includes(col.value) + ); + }, [columns, allColumns]); + + const renderObjectRow = (item: T, index: number) => { + let shiftKey = false; + + return ( + + + + + + {visibleColumns.map((column) => ( + + {renderCell(column, item, index)} + + ))} + + ); + }; + + const columnHeaders = useMemo(() => { + return visibleColumns.map((column) => ( + + {column.label} + + )); + }, [visibleColumns]); + + return ( +
+ + + + + + {columnHeaders} + + + + + + {items.map(renderObjectRow)} +
+
+ +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 2b193cad389..12e6ddc075a 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -370,3 +370,138 @@ input[type="range"].zoom-slider { .tilted { transform: rotate(45deg); } + +.table-list { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + margin-bottom: 1rem; + margin-left: 0; + margin-right: 0; + max-height: 78dvh; + min-width: min-content; + overflow-x: auto; + position: relative; + + table { + margin: 0; + + thead { + background-color: #202b33; + position: sticky; + top: 0; + z-index: 100; + } + } + + .column-select { + margin: 0; + padding: 7px; + } + + .comma-list { + list-style: none; + margin: 0; + overflow: hidden; + padding: 4px 2px; + text-overflow: ellipsis; + white-space: nowrap; + width: 190px; + + li { + display: inline; + } + + li::after { + content: ", "; + } + + li:last-child::after { + content: ""; + } + } + + .comma-list:hover { + background: #28343c; + border: 1px solid #414c53; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.28); + display: block; + height: auto; + margin-left: -0.4rem; + margin-top: -0.9rem; + max-width: 40rem; + overflow: hidden; + padding: 0.1rem 0.5rem; + position: absolute; + top: auto; + white-space: normal; + width: max-content; + z-index: 100; + } + + .comma-list li .ellips-data:hover { + max-width: fit-content; + } + + td { + position: relative; + text-align: left; + white-space: nowrap; + + .ellips-data { + display: block; + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .star-rating-number { + display: none; + } + + a { + font-weight: 600; + white-space: nowrap; + } + } + + td.select-col { + text-align: center; + } + + .table thead th { + border: none; + white-space: nowrap; + } + + tr { + border-collapse: collapse; + } + + .date-head { + width: 97px; + } +} + +.table-list tbody tr:hover { + background-color: #2d3942; +} + +.table-list a { + color: $text-color; +} + +.table-list .table-striped td, +.table-list .table-striped th { + font-size: 1rem; + vertical-align: middle; + + h5, + h6 { + font-size: 1rem; + } + + &:first-child { + border-left: none; + } +} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 7fae0ded733..2a330a6f1f9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -3,10 +3,16 @@ import { useIntl } from "react-intl"; import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import { DetailItem } from "src/components/Shared/DetailItem"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import { StashIDPill } from "src/components/Shared/StashID"; +import { + FormatAge, + FormatCircumcised, + FormatHeight, + FormatPenisLength, + FormatWeight, +} from "../PerformerList"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -51,123 +57,6 @@ export const PerformerDetailsPanel: React.FC = ({ ); } - const formatHeight = (height?: number | null) => { - if (!height) { - return ""; - } - - const [feet, inches] = cmToImperial(height); - - return ( - - - {intl.formatNumber(height, { - style: "unit", - unit: "centimeter", - unitDisplay: "short", - })} - - - {intl.formatNumber(feet, { - style: "unit", - unit: "foot", - unitDisplay: "narrow", - })} - {intl.formatNumber(inches, { - style: "unit", - unit: "inch", - unitDisplay: "narrow", - })} - - - ); - }; - - const formatAge = (birthdate?: string | null, deathdate?: string | null) => { - if (!birthdate) { - return ""; - } - - const age = TextUtils.age(birthdate, deathdate); - - return ( - - {age} - ({birthdate}) - - ); - }; - - const formatWeight = (weight?: number | null) => { - if (!weight) { - return ""; - } - - const lbs = kgToLbs(weight); - - return ( - - - {intl.formatNumber(weight, { - style: "unit", - unit: "kilogram", - unitDisplay: "short", - })} - - - {intl.formatNumber(lbs, { - style: "unit", - unit: "pound", - unitDisplay: "short", - })} - - - ); - }; - - const formatPenisLength = (penis_length?: number | null) => { - if (!penis_length) { - return ""; - } - - const inches = cmToInches(penis_length); - - return ( - - - {intl.formatNumber(penis_length, { - style: "unit", - unit: "centimeter", - unitDisplay: "short", - maximumFractionDigits: 2, - })} - - - {intl.formatNumber(inches, { - style: "unit", - unit: "inch", - unitDisplay: "narrow", - maximumFractionDigits: 2, - })} - - - ); - }; - - const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { - if (!circumcised) { - return ""; - } - - return ( - - {intl.formatMessage({ - id: "circumcised_types." + performer.circumcised, - })} - - ); - }; - function maybeRenderExtraDetails() { if (!collapsed) { /* Remove extra urls provided in details since they will be present by perfomr name */ @@ -224,7 +113,7 @@ export const PerformerDetailsPanel: React.FC = ({ value={ !fullWidth ? TextUtils.age(performer.birthdate, performer.death_date) - : formatAge(performer.birthdate, performer.death_date) + : FormatAge(performer.birthdate, performer.death_date) } title={ !fullWidth @@ -266,22 +155,22 @@ export const PerformerDetailsPanel: React.FC = ({ /> { + const intl = useIntl(); + if (!height) { + return ""; + } + + const [feet, inches] = cmToImperial(height); + + return ( + + + {intl.formatNumber(height, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + })} + + + {intl.formatNumber(feet, { + style: "unit", + unit: "foot", + unitDisplay: "narrow", + })} + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + })} + + + ); +}; + +export const FormatAge = ( + birthdate?: string | null, + deathdate?: string | null +) => { + if (!birthdate) { + return ""; + } + const age = TextUtils.age(birthdate, deathdate); + + return ( + + {age} + ({birthdate}) + + ); +}; + +export const FormatWeight = (weight?: number | null) => { + const intl = useIntl(); + if (!weight) { + return ""; + } + + const lbs = kgToLbs(weight); + + return ( + + + {intl.formatNumber(weight, { + style: "unit", + unit: "kilogram", + unitDisplay: "short", + })} + + + {intl.formatNumber(lbs, { + style: "unit", + unit: "pound", + unitDisplay: "short", + })} + + + ); +}; + +export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { + const intl = useIntl(); + if (!circumcised) { + return ""; + } + + return ( + + {intl.formatMessage({ + id: "circumcised_types." + circumcised, + })} + + ); +}; + +export const FormatPenisLength = (penis_length?: number | null) => { + const intl = useIntl(); + if (!penis_length) { + return ""; + } + + const inches = cmToInches(penis_length); + + return ( + + + {intl.formatNumber(penis_length, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + maximumFractionDigits: 2, + })} + + + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + maximumFractionDigits: 2, + })} + + + ); +}; + interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; persistState?: PersistanceLevel; @@ -158,6 +283,8 @@ export const PerformerList: React.FC = ({ return ( ); } diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 7b9e7615467..183cfbf3a42 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -2,129 +2,399 @@ import React from "react"; import { useIntl } from "react-intl"; -import { Button, Table } from "react-bootstrap"; +import { Button } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import NavUtils from "src/utils/navigation"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; -import { cmToImperial } from "src/utils/units"; +import { usePerformerUpdate } from "src/core/StashService"; +import { useTableColumns } from "src/hooks/useTableColumns"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import cx from "classnames"; +import { + FormatCircumcised, + FormatHeight, + FormatPenisLength, + FormatWeight, +} from "./PerformerList"; +import TextUtils from "src/utils/text"; +import { getCountryByISO } from "src/utils/country"; +import { IColumn, ListTable } from "../List/ListTable"; interface IPerformerListTableProps { performers: GQL.PerformerDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } +const TABLE_NAME = "performers"; + export const PerformerListTable: React.FC = ( props: IPerformerListTableProps ) => { const intl = useIntl(); - const formatHeight = (height?: number | null) => { - if (!height) { - return ""; + const [updatePerformer] = usePerformerUpdate(); + + function setRating(v: number | null, performerId: string) { + if (performerId) { + updatePerformer({ + variables: { + input: { + id: performerId, + rating100: v, + }, + }, + }); + } + } + + function setFavorite(v: boolean, performerId: string) { + if (performerId) { + updatePerformer({ + variables: { + input: { + id: performerId, + favorite: v, + }, + }, + }); } + } - const [feet, inches] = cmToImperial(height); + const ImageCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.name + + ); + + const NameCell = (performer: GQL.PerformerDataFragment) => ( + +
+ {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +
+ + ); + const AliasesCell = (performer: GQL.PerformerDataFragment) => { + let aliases = performer.alias_list ? performer.alias_list.join(", ") : ""; return ( - - - {intl.formatNumber(height, { - style: "unit", - unit: "centimeter", - unitDisplay: "short", - })} - - - {intl.formatNumber(feet, { - style: "unit", - unit: "foot", - unitDisplay: "narrow", - })} - {intl.formatNumber(inches, { - style: "unit", - unit: "inch", - unitDisplay: "narrow", - })} - + + {aliases} ); }; - const renderPerformerRow = (performer: GQL.PerformerDataFragment) => ( - - - - {performer.name - - - - -
- {performer.name} - {performer.disambiguation && ( - - {` (${performer.disambiguation})`} - - )} -
- - - {performer.alias_list ? performer.alias_list.join(", ") : ""} - - {performer.favorite && ( - - )} - - - -
{performer.scene_count}
- - - - -
{performer.image_count}
- - - - -
{performer.gallery_count}
- - - -
{performer.o_counter}
- - {performer.birthdate} - {!!performer.height_cm && formatHeight(performer.height_cm)} - + const GenderCell = (performer: GQL.PerformerDataFragment) => ( + <> + {performer.gender + ? intl.formatMessage({ id: "gender_types." + performer.gender }) + : ""} + + ); + + const RatingCell = (performer: GQL.PerformerDataFragment) => ( + setRating(value, performer.id)} + /> + ); + + const AgeCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.birthdate + ? TextUtils.age(performer.birthdate, performer.death_date) + : ""} + + ); + + const DeathdateCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.death_date} + ); + + const FavoriteCell = (performer: GQL.PerformerDataFragment) => ( + + ); + + const CountryCell = (performer: GQL.PerformerDataFragment) => { + const { locale } = useIntl(); + return ( + + {getCountryByISO(performer.country, locale)} + + ); + }; + + const EthnicityCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.ethnicity} + ); + + const MeasurementsCell = (performer: GQL.PerformerDataFragment) => ( + {performer.measurements} + ); + + const FakeTitsCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.fake_tits} + ); + + const PenisLengthCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatPenisLength(performer.penis_length)} ); + const CircumcisedCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatCircumcised(performer.circumcised)} + ); + + const HairColorCell = (performer: GQL.PerformerDataFragment) => ( + {performer.hair_color} + ); + + const EyeColorCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.eye_color} + ); + + const HeightCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatHeight(performer.height_cm)} + ); + + const WeightCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatWeight(performer.weight)} + ); + + const CareerLengthCell = (performer: GQL.PerformerDataFragment) => ( + {performer.career_length} + ); + + const SceneCountCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.scene_count} + + ); + + const GalleryCountCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.gallery_count} + + ); + + const ImageCountCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.image_count} + + ); + + const OCounterCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.o_counter} + ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: ( + scene: GQL.PerformerDataFragment, + index: number + ) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "image", + label: intl.formatMessage({ id: "image" }), + defaultShow: true, + render: ImageCell, + }, + { + value: "name", + label: intl.formatMessage({ id: "name" }), + mandatory: true, + defaultShow: true, + render: NameCell, + }, + { + value: "aliases", + label: intl.formatMessage({ id: "aliases" }), + defaultShow: true, + render: AliasesCell, + }, + { + value: "gender", + label: intl.formatMessage({ id: "gender" }), + defaultShow: true, + render: GenderCell, + }, + { + value: "rating", + label: intl.formatMessage({ id: "rating" }), + defaultShow: true, + render: RatingCell, + }, + { + value: "age", + label: intl.formatMessage({ id: "age" }), + defaultShow: true, + render: AgeCell, + }, + { + value: "death_date", + label: intl.formatMessage({ id: "death_date" }), + render: DeathdateCell, + }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, + { + value: "country", + label: intl.formatMessage({ id: "country" }), + defaultShow: true, + render: CountryCell, + }, + { + value: "ethnicity", + label: intl.formatMessage({ id: "ethnicity" }), + defaultShow: true, + render: EthnicityCell, + }, + { + value: "hair_color", + label: intl.formatMessage({ id: "hair_color" }), + render: HairColorCell, + }, + { + value: "eye_color", + label: intl.formatMessage({ id: "eye_color" }), + render: EyeColorCell, + }, + { + value: "height_cm", + label: intl.formatMessage({ id: "height_cm" }), + render: HeightCell, + }, + { + value: "weight_kg", + label: intl.formatMessage({ id: "weight_kg" }), + render: WeightCell, + }, + { + value: "penis_length_cm", + label: intl.formatMessage({ id: "penis_length_cm" }), + render: PenisLengthCell, + }, + { + value: "circumcised", + label: intl.formatMessage({ id: "circumcised" }), + render: CircumcisedCell, + }, + { + value: "measurements", + label: intl.formatMessage({ id: "measurements" }), + render: MeasurementsCell, + }, + { + value: "fake_tits", + label: intl.formatMessage({ id: "fake_tits" }), + render: FakeTitsCell, + }, + { + value: "career_length", + label: intl.formatMessage({ id: "career_length" }), + defaultShow: true, + render: CareerLengthCell, + }, + { + value: "scene_count", + label: intl.formatMessage({ id: "scene_count" }), + defaultShow: true, + render: SceneCountCell, + }, + { + value: "gallery_count", + label: intl.formatMessage({ id: "gallery_count" }), + defaultShow: true, + render: GalleryCountCell, + }, + { + value: "image_count", + label: intl.formatMessage({ id: "image_count" }), + defaultShow: true, + render: ImageCountCell, + }, + { + value: "o_counter", + label: intl.formatMessage({ id: "o_counter" }), + defaultShow: true, + render: OCounterCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (scene: GQL.PerformerDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + performer: GQL.PerformerDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(performer, index); + } + return ( -
- - - - - - - - - - - - - - - {props.performers.map(renderPerformerRow)} -
- {intl.formatMessage({ id: "name" })}{intl.formatMessage({ id: "aliases" })}{intl.formatMessage({ id: "favourite" })}{intl.formatMessage({ id: "scene_count" })}{intl.formatMessage({ id: "image_count" })}{intl.formatMessage({ id: "gallery_count" })}{intl.formatMessage({ id: "o_counter" })}{intl.formatMessage({ id: "birthdate" })}{intl.formatMessage({ id: "height" })}
-
+ saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> ); }; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 6ca07e5bec7..7cc9214900f 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -206,11 +206,22 @@ } } +.favourite-data .favorite { + color: #ff7373; +} + +.performer-table .height-imperial, +.performer-table .weight-imperial, +.performer-table .penis-length-imperial, .performer-disambiguation { color: $text-muted; font-size: 0.875em; } +.performer-table .age-data span { + border-bottom: 1px dotted #f5f8fa; +} + .performer-result .performer-details > span { &::after { content: " • "; diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 1af99476f18..7d8a44202de 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -1,13 +1,16 @@ import React from "react"; -import { Table, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import SceneQueue from "src/models/sceneQueue"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { useSceneUpdate } from "src/core/StashService"; +import { IColumn, ListTable } from "../List/ListTable"; +import { useTableColumns } from "src/hooks/useTableColumns"; interface ISceneListTableProps { scenes: GQL.SlimSceneDataFragment[]; @@ -16,140 +19,368 @@ interface ISceneListTableProps { onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } +const TABLE_NAME = "scenes"; + export const SceneListTable: React.FC = ( props: ISceneListTableProps ) => { - const renderTags = (tags: Partial[]) => - tags.map((tag) => ( - -
{tag.name}
- - )); + const intl = useIntl(); - const renderPerformers = (performers: Partial[]) => - performers.map((performer) => ( - -
{performer.name}
- - )); - - const renderMovies = (scene: GQL.SlimSceneDataFragment) => - scene.movies.map((sceneMovie) => ( - -
{sceneMovie.movie.name}
- - )); + const [updateScene] = useSceneUpdate(); + + function setRating(v: number | null, sceneId: string) { + if (sceneId) { + updateScene({ + variables: { + input: { + id: sceneId, + rating100: v, + }, + }, + }); + } + } + + const CoverImageCell = (scene: GQL.SlimSceneDataFragment, index: number) => { + const title = objectTitle(scene); + const sceneLink = props.queue + ? props.queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; - const renderGalleries = (scene: GQL.SlimSceneDataFragment) => - scene.galleries.map((gallery) => ( - -
{galleryTitle(gallery)}
+ return ( + + {title} - )); + ); + }; - const renderSceneRow = (scene: GQL.SlimSceneDataFragment, index: number) => { + const TitleCell = (scene: GQL.SlimSceneDataFragment, index: number) => { + const title = objectTitle(scene); const sceneLink = props.queue ? props.queue.makeLink(scene.id, { sceneIndex: index }) : `/scenes/${scene.id}`; - let shiftKey = false; + return ( + + {title} + + ); + }; + + const DateCell = (scene: GQL.SlimSceneDataFragment) => <>{scene.date}; + + const RatingCell = (scene: GQL.SlimSceneDataFragment) => ( + setRating(value, scene.id)} + /> + ); + const DurationCell = (scene: GQL.SlimSceneDataFragment) => { const file = scene.files.length > 0 ? scene.files[0] : undefined; + return file?.duration && TextUtils.secondsToTimestamp(file.duration); + }; - const title = objectTitle(scene); - return ( - - - - - - - {title} + const TagCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.tags.map((tag) => ( +
  • + + {tag.name} - - - -
    {title}
    +
  • + ))} +
+ ); + + const PerformersCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.performers.map((performer) => ( +
  • + + {performer.name} - - {scene.rating100 ? scene.rating100 : ""} - {file?.duration && TextUtils.secondsToTimestamp(file.duration)} - {renderTags(scene.tags)} - {renderPerformers(scene.performers)} - - {scene.studio && ( - -
    {scene.studio.name}
    - - )} - - {renderMovies(scene)} - {renderGalleries(scene)} - - ); +
  • + ))} +
+ ); + + const StudioCell = (scene: GQL.SlimSceneDataFragment) => { + if (scene.studio) { + return ( + + {scene.studio.name} + + ); + } }; + const MovieCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.movies.map((sceneMovie) => ( +
  • + + {sceneMovie.movie.name} + +
  • + ))} +
+ ); + + const GalleriesCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.galleries.map((gallery) => ( +
  • + + {galleryTitle(gallery)} + +
  • + ))} +
+ ); + + const PlayCountCell = (scene: GQL.SlimSceneDataFragment) => ( + + ); + + const PlayDurationCell = (scene: GQL.SlimSceneDataFragment) => ( + <>{TextUtils.secondsToTimestamp(scene.play_duration ?? 0)} + ); + + const ResolutionCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {TextUtils.resolution(file?.width, file?.height)} +
  • + ))} +
+ ); + + const FrameRateCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + + + +
  • + ))} +
+ ); + + const BitRateCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + + + +
  • + ))} +
+ ); + + const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file.audio_codec} +
  • + ))} +
+ ); + + const VideoCodecCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file.video_codec} +
  • + ))} +
+ ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: ( + scene: GQL.SlimSceneDataFragment, + index: number + ) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "cover_image", + label: intl.formatMessage({ id: "cover_image" }), + defaultShow: true, + render: CoverImageCell, + }, + { + value: "title", + label: intl.formatMessage({ id: "title" }), + defaultShow: true, + mandatory: true, + render: TitleCell, + }, + { + value: "date", + label: intl.formatMessage({ id: "date" }), + defaultShow: true, + render: DateCell, + }, + { + value: "rating", + label: intl.formatMessage({ id: "rating" }), + defaultShow: true, + render: RatingCell, + }, + { + value: "scene_code", + label: intl.formatMessage({ id: "scene_code" }), + render: (s) => <>{s.code}, + }, + { + value: "duration", + label: intl.formatMessage({ id: "duration" }), + defaultShow: true, + render: DurationCell, + }, + { + value: "tags", + label: intl.formatMessage({ id: "tags" }), + defaultShow: true, + render: TagCell, + }, + { + value: "performers", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformersCell, + }, + { + value: "studio", + label: intl.formatMessage({ id: "studio" }), + defaultShow: true, + render: StudioCell, + }, + { + value: "movies", + label: intl.formatMessage({ id: "movies" }), + defaultShow: true, + render: MovieCell, + }, + { + value: "galleries", + label: intl.formatMessage({ id: "galleries" }), + defaultShow: true, + render: GalleriesCell, + }, + { + value: "play_count", + label: intl.formatMessage({ id: "play_count" }), + render: PlayCountCell, + }, + { + value: "play_duration", + label: intl.formatMessage({ id: "play_duration" }), + render: PlayDurationCell, + }, + { + value: "o_counter", + label: intl.formatMessage({ id: "o_counter" }), + render: (s) => <>{s.o_counter}, + }, + { + value: "resolution", + label: intl.formatMessage({ id: "resolution" }), + render: ResolutionCell, + }, + { + value: "framerate", + label: intl.formatMessage({ id: "framerate" }), + render: FrameRateCell, + }, + { + value: "bitrate", + label: intl.formatMessage({ id: "bitrate" }), + render: BitRateCell, + }, + { + value: "video_codec", + label: intl.formatMessage({ id: "video_codec" }), + render: VideoCodecCell, + }, + { + value: "audio_codec", + label: intl.formatMessage({ id: "audio_codec" }), + render: AudioCodecCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (scene: GQL.SlimSceneDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + scene: GQL.SlimSceneDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(scene, index); + } + return ( -
- - - - - - - - - - - - - - {props.scenes.map(renderSceneRow)} -
- - - - - - - - - - - - - - - - - -
-
+ saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> ); }; diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 086e3821e67..147472f578e 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -281,6 +281,12 @@ textarea.scene-description { } } +/* stylelint-disable selector-class-pattern */ +.table .cover_image-head, +.table .cover_image-data { + text-align: center; +} + input[type="range"].filter-slider { height: 100%; margin: 0; @@ -684,25 +690,20 @@ input[type="range"].blue-slider { } .scene-table { - table, - tr, - td, - label, - input { - height: 100%; - } - td:first-child { padding: 0; } label { - display: block; margin: 0; padding: 0.5rem; } } +.select-col { + width: 20px; +} + .scrape-dialog .rating-number.disabled { padding-left: 0.5em; } diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index a3459830fef..c42018172ec 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -8,6 +8,7 @@ import Select, { MenuListProps, GroupBase, OptionsOrGroups, + DropdownIndicatorProps, } from "react-select"; import CreatableSelect from "react-select/creatable"; @@ -32,6 +33,8 @@ import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; import { useDebounce } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerIDSelect } from "../Performers/PerformerSelect"; +import { Icon } from "./Icon"; +import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; export type SelectObject = { id: string; @@ -979,3 +982,91 @@ export const ListSelect = (props: IListSelect) => { /> ); }; + +type DisableOption = Option & { + disabled?: boolean; +}; + +interface ICheckBoxSelectProps { + options: DisableOption[]; + selectedOptions?: DisableOption[]; + onChange: (item: OnChangeValue) => void; +} + +export const CheckBoxSelect: React.FC = ({ + options, + selectedOptions, + onChange, +}) => { + const Option = (props: OptionProps) => ( + + null} + className="mr-1" + /> + + + ); + + const DropdownIndicator = ( + props: DropdownIndicatorProps + ) => ( + + + + ); + + return ( +