Improved list view for scenes, galleries and performers (#4368)

Co-authored-by: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
CJ 2024-01-16 17:46:09 -06:00 committed by GitHub
parent e7311a60d2
commit dd8da7f339
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1553 additions and 423 deletions

View file

@ -1,8 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import { Table } from "react-bootstrap"; import { useHistory } from "react-router-dom";
import { Link, useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@ -18,7 +17,7 @@ import GalleryWallCard from "./GalleryWallCard";
import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { EditGalleriesDialog } from "./EditGalleriesDialog";
import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
import { ExportDialog } from "../Shared/ExportDialog"; import { ExportDialog } from "../Shared/ExportDialog";
import { galleryTitle } from "src/core/galleries"; import { GalleryListTable } from "./GalleryListTable";
const GalleryItemList = makeItemList({ const GalleryItemList = makeItemList({
filterMode: GQL.FilterMode.Galleries, filterMode: GQL.FilterMode.Galleries,
@ -152,40 +151,11 @@ export const GalleryList: React.FC<IGalleryList> = ({
} }
if (filter.displayMode === DisplayMode.List) { if (filter.displayMode === DisplayMode.List) {
return ( return (
<Table className="col col-sm-6 mx-auto"> <GalleryListTable
<thead> galleries={result.data.findGalleries.galleries}
<tr> selectedIds={selectedIds}
<th>{intl.formatMessage({ id: "actions.preview" })}</th> onSelectChange={onSelectChange}
<th className="d-none d-sm-none"> />
{intl.formatMessage({ id: "title" })}
</th>
</tr>
</thead>
<tbody>
{result.data.findGalleries.galleries.map((gallery) => (
<tr key={gallery.id}>
<td>
<Link to={`/galleries/${gallery.id}`}>
{gallery.cover ? (
<img
loading="lazy"
alt={gallery.title ?? ""}
className="w-100 w-sm-auto"
src={`${gallery.cover.paths.thumbnail}`}
/>
) : undefined}
</Link>
</td>
<td className="d-none d-sm-block">
<Link to={`/galleries/${gallery.id}`}>
{galleryTitle(gallery)} ({gallery.image_count}{" "}
{gallery.image_count === 1 ? "image" : "images"})
</Link>
</td>
</tr>
))}
</tbody>
</Table>
); );
} }
if (filter.displayMode === DisplayMode.Wall) { if (filter.displayMode === DisplayMode.Wall) {

View file

@ -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<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const TABLE_NAME = "galleries";
export const GalleryListTable: React.FC<IGalleryListTableProps> = (
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 (
<Link to={`/galleries/${gallery.id}`}>
{gallery.cover ? (
<img
loading="lazy"
alt={title}
className="image-thumbnail"
src={`${gallery.cover.paths.thumbnail}`}
/>
) : undefined}
</Link>
);
};
const TitleCell = (gallery: GQL.SlimGalleryDataFragment) => {
const title = galleryTitle(gallery);
return (
<Link to={`/galleries/${gallery.id}`}>
<span className="ellips-data">{title}</span>
</Link>
);
};
const DateCell = (gallery: GQL.SlimGalleryDataFragment) => (
<>{gallery.date}</>
);
const RatingCell = (gallery: GQL.SlimGalleryDataFragment) => (
<RatingSystem
value={gallery.rating100}
onSetRating={(value) => setRating(value, gallery.id)}
/>
);
const ImagesCell = (gallery: GQL.SlimGalleryDataFragment) => {
return (
<Link to={NavUtils.makeGalleryImagesUrl(gallery)}>
<span>{gallery.image_count}</span>
</Link>
);
};
const TagCell = (gallery: GQL.SlimGalleryDataFragment) => (
<ul className="comma-list">
{gallery.tags.map((tag) => (
<li key={tag.id}>
<Link to={NavUtils.makeTagGalleriesUrl(tag)}>
<span>{tag.name}</span>
</Link>
</li>
))}
</ul>
);
const PerformersCell = (gallery: GQL.SlimGalleryDataFragment) => (
<ul className="comma-list">
{gallery.performers.map((performer) => (
<li key={performer.id}>
<Link to={NavUtils.makePerformerGalleriesUrl(performer)}>
<span>{performer.name}</span>
</Link>
</li>
))}
</ul>
);
const StudioCell = (gallery: GQL.SlimGalleryDataFragment) => {
if (gallery.studio) {
return (
<Link
to={NavUtils.makeStudioGalleriesUrl(gallery.studio)}
title={gallery.studio.name}
>
<span className="ellips-data">{gallery.studio.name}</span>
</Link>
);
}
};
const SceneCell = (gallery: GQL.SlimGalleryDataFragment) => (
<ul className="comma-list">
{gallery.scenes.map((galleryScene) => (
<li key={galleryScene.id}>
<Link to={`/scenes/${galleryScene.id}`}>
<span className="ellips-data">{objectTitle(galleryScene)}</span>
</Link>
</li>
))}
</ul>
);
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 (
<ListTable
className="gallery-table"
items={props.galleries}
allColumns={allColumns}
columns={selectedColumns}
setColumns={(c) => saveColumns(c)}
selectedIds={props.selectedIds}
onSelectChange={props.onSelectChange}
renderCell={renderCell}
/>
);
};

View file

@ -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 (
<CheckBoxSelect
options={disableOptions}
selectedOptions={selectedColumns}
onChange={(v) => {
setSelected(v.map((col) => col.value));
}}
/>
);
};
interface IListTableProps<T> {
className?: string;
items: T[];
columns: string[];
setColumns: (columns: string[]) => void;
allColumns: IColumn[];
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
renderCell: (column: IColumn, item: T, index: number) => React.ReactNode;
}
export const ListTable = <T extends { id: string }>(
props: IListTableProps<T>
) => {
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 (
<tr key={item.id}>
<td className="select-col">
<label>
<Form.Control
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() =>
onSelectChange(item.id, !selectedIds.has(item.id), shiftKey)
}
onClick={(
event: React.MouseEvent<HTMLInputElement, MouseEvent>
) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
</label>
</td>
{visibleColumns.map((column) => (
<td key={column.value} className={`${column.value}-data`}>
{renderCell(column, item, index)}
</td>
))}
</tr>
);
};
const columnHeaders = useMemo(() => {
return visibleColumns.map((column) => (
<th key={column.value} className={`${column.value}-head`}>
{column.label}
</th>
));
}, [visibleColumns]);
return (
<div className={cx("table-list", className)}>
<Table striped bordered>
<thead>
<tr>
<th className="select-col">
<div
className="d-inline-block"
data-toggle="popover"
data-trigger="focus"
>
<ColumnSelector
allColumns={allColumns}
selected={columns}
setSelected={setColumns}
/>
</div>
</th>
{columnHeaders}
</tr>
<tr>
<th className="border-row" colSpan={100}></th>
</tr>
</thead>
<tbody>{items.map(renderObjectRow)}</tbody>
</Table>
</div>
);
};

View file

@ -370,3 +370,138 @@ input[type="range"].zoom-slider {
.tilted { .tilted {
transform: rotate(45deg); 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;
}
}

View file

@ -3,10 +3,16 @@ import { useIntl } from "react-intl";
import { TagLink } from "src/components/Shared/TagLink"; import { TagLink } from "src/components/Shared/TagLink";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
import { DetailItem } from "src/components/Shared/DetailItem"; import { DetailItem } from "src/components/Shared/DetailItem";
import { CountryFlag } from "src/components/Shared/CountryFlag"; import { CountryFlag } from "src/components/Shared/CountryFlag";
import { StashIDPill } from "src/components/Shared/StashID"; import { StashIDPill } from "src/components/Shared/StashID";
import {
FormatAge,
FormatCircumcised,
FormatHeight,
FormatPenisLength,
FormatWeight,
} from "../PerformerList";
interface IPerformerDetails { interface IPerformerDetails {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@ -51,123 +57,6 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
); );
} }
const formatHeight = (height?: number | null) => {
if (!height) {
return "";
}
const [feet, inches] = cmToImperial(height);
return (
<span className="performer-height">
<span className="height-metric">
{intl.formatNumber(height, {
style: "unit",
unit: "centimeter",
unitDisplay: "short",
})}
</span>
<span className="height-imperial">
{intl.formatNumber(feet, {
style: "unit",
unit: "foot",
unitDisplay: "narrow",
})}
{intl.formatNumber(inches, {
style: "unit",
unit: "inch",
unitDisplay: "narrow",
})}
</span>
</span>
);
};
const formatAge = (birthdate?: string | null, deathdate?: string | null) => {
if (!birthdate) {
return "";
}
const age = TextUtils.age(birthdate, deathdate);
return (
<span className="performer-age">
<span className="age">{age}</span>
<span className="birthdate"> ({birthdate})</span>
</span>
);
};
const formatWeight = (weight?: number | null) => {
if (!weight) {
return "";
}
const lbs = kgToLbs(weight);
return (
<span className="performer-weight">
<span className="weight-metric">
{intl.formatNumber(weight, {
style: "unit",
unit: "kilogram",
unitDisplay: "short",
})}
</span>
<span className="weight-imperial">
{intl.formatNumber(lbs, {
style: "unit",
unit: "pound",
unitDisplay: "short",
})}
</span>
</span>
);
};
const formatPenisLength = (penis_length?: number | null) => {
if (!penis_length) {
return "";
}
const inches = cmToInches(penis_length);
return (
<span className="performer-penis-length">
<span className="penis-length-metric">
{intl.formatNumber(penis_length, {
style: "unit",
unit: "centimeter",
unitDisplay: "short",
maximumFractionDigits: 2,
})}
</span>
<span className="penis-length-imperial">
{intl.formatNumber(inches, {
style: "unit",
unit: "inch",
unitDisplay: "narrow",
maximumFractionDigits: 2,
})}
</span>
</span>
);
};
const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
if (!circumcised) {
return "";
}
return (
<span className="penis-circumcised">
{intl.formatMessage({
id: "circumcised_types." + performer.circumcised,
})}
</span>
);
};
function maybeRenderExtraDetails() { function maybeRenderExtraDetails() {
if (!collapsed) { if (!collapsed) {
/* Remove extra urls provided in details since they will be present by perfomr name */ /* Remove extra urls provided in details since they will be present by perfomr name */
@ -224,7 +113,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
value={ value={
!fullWidth !fullWidth
? TextUtils.age(performer.birthdate, performer.death_date) ? TextUtils.age(performer.birthdate, performer.death_date)
: formatAge(performer.birthdate, performer.death_date) : FormatAge(performer.birthdate, performer.death_date)
} }
title={ title={
!fullWidth !fullWidth
@ -266,22 +155,22 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
/> />
<DetailItem <DetailItem
id="height" id="height"
value={formatHeight(performer.height_cm)} value={FormatHeight(performer.height_cm)}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem <DetailItem
id="weight" id="weight"
value={formatWeight(performer.weight)} value={FormatWeight(performer.weight)}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem <DetailItem
id="penis_length" id="penis_length"
value={formatPenisLength(performer.penis_length)} value={FormatPenisLength(performer.penis_length)}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem <DetailItem
id="circumcised" id="circumcised"
value={formatCircumcised(performer.circumcised)} value={FormatCircumcised(performer.circumcised)}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem <DetailItem

View file

@ -22,6 +22,8 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard"; import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable"; import { PerformerListTable } from "./PerformerListTable";
import { EditPerformersDialog } from "./EditPerformersDialog"; import { EditPerformersDialog } from "./EditPerformersDialog";
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
import TextUtils from "src/utils/text";
const PerformerItemList = makeItemList({ const PerformerItemList = makeItemList({
filterMode: GQL.FilterMode.Performers, filterMode: GQL.FilterMode.Performers,
@ -34,6 +36,129 @@ const PerformerItemList = makeItemList({
}, },
}); });
export const FormatHeight = (height?: number | null) => {
const intl = useIntl();
if (!height) {
return "";
}
const [feet, inches] = cmToImperial(height);
return (
<span className="performer-height">
<span className="height-metric">
{intl.formatNumber(height, {
style: "unit",
unit: "centimeter",
unitDisplay: "short",
})}
</span>
<span className="height-imperial">
{intl.formatNumber(feet, {
style: "unit",
unit: "foot",
unitDisplay: "narrow",
})}
{intl.formatNumber(inches, {
style: "unit",
unit: "inch",
unitDisplay: "narrow",
})}
</span>
</span>
);
};
export const FormatAge = (
birthdate?: string | null,
deathdate?: string | null
) => {
if (!birthdate) {
return "";
}
const age = TextUtils.age(birthdate, deathdate);
return (
<span className="performer-age">
<span className="age">{age}</span>
<span className="birthdate"> ({birthdate})</span>
</span>
);
};
export const FormatWeight = (weight?: number | null) => {
const intl = useIntl();
if (!weight) {
return "";
}
const lbs = kgToLbs(weight);
return (
<span className="performer-weight">
<span className="weight-metric">
{intl.formatNumber(weight, {
style: "unit",
unit: "kilogram",
unitDisplay: "short",
})}
</span>
<span className="weight-imperial">
{intl.formatNumber(lbs, {
style: "unit",
unit: "pound",
unitDisplay: "short",
})}
</span>
</span>
);
};
export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
const intl = useIntl();
if (!circumcised) {
return "";
}
return (
<span className="penis-circumcised">
{intl.formatMessage({
id: "circumcised_types." + circumcised,
})}
</span>
);
};
export const FormatPenisLength = (penis_length?: number | null) => {
const intl = useIntl();
if (!penis_length) {
return "";
}
const inches = cmToInches(penis_length);
return (
<span className="performer-penis-length">
<span className="penis-length-metric">
{intl.formatNumber(penis_length, {
style: "unit",
unit: "centimeter",
unitDisplay: "short",
maximumFractionDigits: 2,
})}
</span>
<span className="penis-length-imperial">
{intl.formatNumber(inches, {
style: "unit",
unit: "inch",
unitDisplay: "narrow",
maximumFractionDigits: 2,
})}
</span>
</span>
);
};
interface IPerformerList { interface IPerformerList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
persistState?: PersistanceLevel; persistState?: PersistanceLevel;
@ -158,6 +283,8 @@ export const PerformerList: React.FC<IPerformerList> = ({
return ( return (
<PerformerListTable <PerformerListTable
performers={result.data.findPerformers.performers} performers={result.data.findPerformers.performers}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/> />
); );
} }

View file

@ -2,129 +2,399 @@
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { Button, Table } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import NavUtils from "src/utils/navigation"; import NavUtils from "src/utils/navigation";
import { faHeart } from "@fortawesome/free-solid-svg-icons"; 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 { interface IPerformerListTableProps {
performers: GQL.PerformerDataFragment[]; performers: GQL.PerformerDataFragment[];
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
} }
const TABLE_NAME = "performers";
export const PerformerListTable: React.FC<IPerformerListTableProps> = ( export const PerformerListTable: React.FC<IPerformerListTableProps> = (
props: IPerformerListTableProps props: IPerformerListTableProps
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const formatHeight = (height?: number | null) => { const [updatePerformer] = usePerformerUpdate();
if (!height) {
return ""; function setRating(v: number | null, performerId: string) {
if (performerId) {
updatePerformer({
variables: {
input: {
id: performerId,
rating100: v,
},
},
});
} }
}
const [feet, inches] = cmToImperial(height); function setFavorite(v: boolean, performerId: string) {
if (performerId) {
updatePerformer({
variables: {
input: {
id: performerId,
favorite: v,
},
},
});
}
}
const ImageCell = (performer: GQL.PerformerDataFragment) => (
<Link to={`/performers/${performer.id}`}>
<img
loading="lazy"
className="image-thumbnail"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</Link>
);
const NameCell = (performer: GQL.PerformerDataFragment) => (
<Link to={`/performers/${performer.id}`}>
<div className="ellips-data" title={performer.name}>
{performer.name}
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
</span>
)}
</div>
</Link>
);
const AliasesCell = (performer: GQL.PerformerDataFragment) => {
let aliases = performer.alias_list ? performer.alias_list.join(", ") : "";
return ( return (
<span className="performer-height"> <span className="ellips-data" title={aliases}>
<span className="height-metric"> {aliases}
{intl.formatNumber(height, {
style: "unit",
unit: "centimeter",
unitDisplay: "short",
})}
</span>
<span className="height-imperial">
{intl.formatNumber(feet, {
style: "unit",
unit: "foot",
unitDisplay: "narrow",
})}
{intl.formatNumber(inches, {
style: "unit",
unit: "inch",
unitDisplay: "narrow",
})}
</span>
</span> </span>
); );
}; };
const renderPerformerRow = (performer: GQL.PerformerDataFragment) => ( const GenderCell = (performer: GQL.PerformerDataFragment) => (
<tr key={performer.id}> <>
<td> {performer.gender
<Link to={`/performers/${performer.id}`}> ? intl.formatMessage({ id: "gender_types." + performer.gender })
<img : ""}
loading="lazy" </>
className="image-thumbnail"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</Link>
</td>
<td className="text-left">
<Link to={`/performers/${performer.id}`}>
<h5>
{performer.name}
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
</span>
)}
</h5>
</Link>
</td>
<td>{performer.alias_list ? performer.alias_list.join(", ") : ""}</td>
<td>
{performer.favorite && (
<Button disabled className="favorite">
<Icon icon={faHeart} />
</Button>
)}
</td>
<td>
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.scene_count}</h6>
</Link>
</td>
<td>
<Link to={NavUtils.makePerformerImagesUrl(performer)}>
<h6>{performer.image_count}</h6>
</Link>
</td>
<td>
<Link to={NavUtils.makePerformerGalleriesUrl(performer)}>
<h6>{performer.gallery_count}</h6>
</Link>
</td>
<td>
<h6>{performer.o_counter}</h6>
</td>
<td>{performer.birthdate}</td>
<td>{!!performer.height_cm && formatHeight(performer.height_cm)}</td>
</tr>
); );
const RatingCell = (performer: GQL.PerformerDataFragment) => (
<RatingSystem
value={performer.rating100}
onSetRating={(value) => setRating(value, performer.id)}
/>
);
const AgeCell = (performer: GQL.PerformerDataFragment) => (
<span
title={
performer.birthdate
? TextUtils.formatDate(intl, performer.birthdate ?? undefined)
: ""
}
>
{performer.birthdate
? TextUtils.age(performer.birthdate, performer.death_date)
: ""}
</span>
);
const DeathdateCell = (performer: GQL.PerformerDataFragment) => (
<>{performer.death_date}</>
);
const FavoriteCell = (performer: GQL.PerformerDataFragment) => (
<Button
className={cx(
"minimal",
performer.favorite ? "favorite" : "not-favorite"
)}
onClick={() => setFavorite(!performer.favorite, performer.id)}
>
<Icon icon={faHeart} />
</Button>
);
const CountryCell = (performer: GQL.PerformerDataFragment) => {
const { locale } = useIntl();
return (
<span className="ellips-data">
{getCountryByISO(performer.country, locale)}
</span>
);
};
const EthnicityCell = (performer: GQL.PerformerDataFragment) => (
<>{performer.ethnicity}</>
);
const MeasurementsCell = (performer: GQL.PerformerDataFragment) => (
<span className="ellips-data">{performer.measurements}</span>
);
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) => (
<span className="ellips-data">{performer.hair_color}</span>
);
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) => (
<span className="ellips-data">{performer.career_length}</span>
);
const SceneCountCell = (performer: GQL.PerformerDataFragment) => (
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<span>{performer.scene_count}</span>
</Link>
);
const GalleryCountCell = (performer: GQL.PerformerDataFragment) => (
<Link to={NavUtils.makePerformerGalleriesUrl(performer)}>
<span>{performer.gallery_count}</span>
</Link>
);
const ImageCountCell = (performer: GQL.PerformerDataFragment) => (
<Link to={NavUtils.makePerformerImagesUrl(performer)}>
<span>{performer.image_count}</span>
</Link>
);
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 ( return (
<div className="row justify-content-center table-list"> <ListTable
<Table bordered striped> className="performer-table"
<thead> items={props.performers}
<tr> allColumns={allColumns}
<th /> columns={selectedColumns}
<th>{intl.formatMessage({ id: "name" })}</th> setColumns={(c) => saveColumns(c)}
<th>{intl.formatMessage({ id: "aliases" })}</th> selectedIds={props.selectedIds}
<th>{intl.formatMessage({ id: "favourite" })}</th> onSelectChange={props.onSelectChange}
<th>{intl.formatMessage({ id: "scene_count" })}</th> renderCell={renderCell}
<th>{intl.formatMessage({ id: "image_count" })}</th> />
<th>{intl.formatMessage({ id: "gallery_count" })}</th>
<th>{intl.formatMessage({ id: "o_counter" })}</th>
<th>{intl.formatMessage({ id: "birthdate" })}</th>
<th>{intl.formatMessage({ id: "height" })}</th>
</tr>
</thead>
<tbody>{props.performers.map(renderPerformerRow)}</tbody>
</Table>
</div>
); );
}; };

View file

@ -206,11 +206,22 @@
} }
} }
.favourite-data .favorite {
color: #ff7373;
}
.performer-table .height-imperial,
.performer-table .weight-imperial,
.performer-table .penis-length-imperial,
.performer-disambiguation { .performer-disambiguation {
color: $text-muted; color: $text-muted;
font-size: 0.875em; font-size: 0.875em;
} }
.performer-table .age-data span {
border-bottom: 1px dotted #f5f8fa;
}
.performer-result .performer-details > span { .performer-result .performer-details > span {
&::after { &::after {
content: ""; content: "";

View file

@ -1,13 +1,16 @@
import React from "react"; import React from "react";
import { Table, Form } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import NavUtils from "src/utils/navigation"; import NavUtils from "src/utils/navigation";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { FormattedMessage } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import SceneQueue from "src/models/sceneQueue"; 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 { interface ISceneListTableProps {
scenes: GQL.SlimSceneDataFragment[]; scenes: GQL.SlimSceneDataFragment[];
@ -16,140 +19,368 @@ interface ISceneListTableProps {
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
} }
const TABLE_NAME = "scenes";
export const SceneListTable: React.FC<ISceneListTableProps> = ( export const SceneListTable: React.FC<ISceneListTableProps> = (
props: ISceneListTableProps props: ISceneListTableProps
) => { ) => {
const renderTags = (tags: Partial<GQL.TagDataFragment>[]) => const intl = useIntl();
tags.map((tag) => (
<Link key={tag.id} to={NavUtils.makeTagScenesUrl(tag)}>
<h6>{tag.name}</h6>
</Link>
));
const renderPerformers = (performers: Partial<GQL.PerformerDataFragment>[]) => const [updateScene] = useSceneUpdate();
performers.map((performer) => (
<Link key={performer.id} to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.name}</h6>
</Link>
));
const renderMovies = (scene: GQL.SlimSceneDataFragment) => function setRating(v: number | null, sceneId: string) {
scene.movies.map((sceneMovie) => ( if (sceneId) {
<Link updateScene({
key={sceneMovie.movie.id} variables: {
to={NavUtils.makeMovieScenesUrl(sceneMovie.movie)} input: {
> id: sceneId,
<h6>{sceneMovie.movie.name}</h6> rating100: v,
</Link> },
)); },
});
}
}
const renderGalleries = (scene: GQL.SlimSceneDataFragment) => const CoverImageCell = (scene: GQL.SlimSceneDataFragment, index: number) => {
scene.galleries.map((gallery) => ( const title = objectTitle(scene);
<Link key={gallery.id} to={`/galleries/${gallery.id}`}>
<h6>{galleryTitle(gallery)}</h6>
</Link>
));
const renderSceneRow = (scene: GQL.SlimSceneDataFragment, index: number) => {
const sceneLink = props.queue const sceneLink = props.queue
? props.queue.makeLink(scene.id, { sceneIndex: index }) ? props.queue.makeLink(scene.id, { sceneIndex: index })
: `/scenes/${scene.id}`; : `/scenes/${scene.id}`;
let shiftKey = false;
const file = scene.files.length > 0 ? scene.files[0] : undefined;
const title = objectTitle(scene);
return ( return (
<tr key={scene.id}> <Link to={sceneLink}>
<td> <img
<label> loading="lazy"
<Form.Control className="image-thumbnail"
type="checkbox" alt={title}
checked={props.selectedIds.has(scene.id)} src={scene.paths.screenshot ?? ""}
onChange={() => />
props.onSelectChange( </Link>
scene.id,
!props.selectedIds.has(scene.id),
shiftKey
)
}
onClick={(
event: React.MouseEvent<HTMLInputElement, MouseEvent>
) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
</label>
</td>
<td>
<Link to={sceneLink}>
<img
loading="lazy"
className="image-thumbnail"
alt={title}
src={scene.paths.screenshot ?? ""}
/>
</Link>
</td>
<td className="text-left">
<Link to={sceneLink}>
<h5>{title}</h5>
</Link>
</td>
<td>{scene.rating100 ? scene.rating100 : ""}</td>
<td>{file?.duration && TextUtils.secondsToTimestamp(file.duration)}</td>
<td>{renderTags(scene.tags)}</td>
<td>{renderPerformers(scene.performers)}</td>
<td>
{scene.studio && (
<Link to={NavUtils.makeStudioScenesUrl(scene.studio)}>
<h6>{scene.studio.name}</h6>
</Link>
)}
</td>
<td>{renderMovies(scene)}</td>
<td>{renderGalleries(scene)}</td>
</tr>
); );
}; };
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}`;
return (
<Link to={sceneLink} title={title}>
<span className="ellips-data">{title}</span>
</Link>
);
};
const DateCell = (scene: GQL.SlimSceneDataFragment) => <>{scene.date}</>;
const RatingCell = (scene: GQL.SlimSceneDataFragment) => (
<RatingSystem
value={scene.rating100}
onSetRating={(value) => 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 TagCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.tags.map((tag) => (
<li key={tag.id}>
<Link to={NavUtils.makeTagScenesUrl(tag)}>
<span>{tag.name}</span>
</Link>
</li>
))}
</ul>
);
const PerformersCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.performers.map((performer) => (
<li key={performer.id}>
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<span>{performer.name}</span>
</Link>
</li>
))}
</ul>
);
const StudioCell = (scene: GQL.SlimSceneDataFragment) => {
if (scene.studio) {
return (
<Link
to={NavUtils.makeStudioScenesUrl(scene.studio)}
title={scene.studio.name}
>
<span className="ellips-data">{scene.studio.name}</span>
</Link>
);
}
};
const MovieCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.movies.map((sceneMovie) => (
<li key={sceneMovie.movie.id}>
<Link to={NavUtils.makeMovieScenesUrl(sceneMovie.movie)}>
<span className="ellips-data">{sceneMovie.movie.name}</span>
</Link>
</li>
))}
</ul>
);
const GalleriesCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.galleries.map((gallery) => (
<li key={gallery.id}>
<Link to={`/galleries/${gallery.id}`}>
<span>{galleryTitle(gallery)}</span>
</Link>
</li>
))}
</ul>
);
const PlayCountCell = (scene: GQL.SlimSceneDataFragment) => (
<FormattedMessage
id="plays"
values={{ value: intl.formatNumber(scene.play_count ?? 0) }}
/>
);
const PlayDurationCell = (scene: GQL.SlimSceneDataFragment) => (
<>{TextUtils.secondsToTimestamp(scene.play_duration ?? 0)}</>
);
const ResolutionCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
<li key={file.id}>
<span> {TextUtils.resolution(file?.width, file?.height)}</span>
</li>
))}
</ul>
);
const FrameRateCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
<li key={file.id}>
<span>
<FormattedMessage
id="frames_per_second"
values={{ value: intl.formatNumber(file.frame_rate ?? 0) }}
/>
</span>
</li>
))}
</ul>
);
const BitRateCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
<li key={file.id}>
<span>
<FormattedMessage
id="megabits_per_second"
values={{
value: intl.formatNumber((file.bit_rate ?? 0) / 1000000, {
maximumFractionDigits: 2,
}),
}}
/>
</span>
</li>
))}
</ul>
);
const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
<li key={file.id}>
<span>{file.audio_codec}</span>
</li>
))}
</ul>
);
const VideoCodecCell = (scene: GQL.SlimSceneDataFragment) => (
<ul className="comma-list">
{scene.files.map((file) => (
<li key={file.id}>
<span>{file.video_codec}</span>
</li>
))}
</ul>
);
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 ( return (
<div className="row scene-table table-list justify-content-center"> <ListTable
<Table striped bordered> className="scene-table"
<thead> items={props.scenes}
<tr> allColumns={allColumns}
<th /> columns={selectedColumns}
<th /> setColumns={(c) => saveColumns(c)}
<th className="text-left"> selectedIds={props.selectedIds}
<FormattedMessage id="title" /> onSelectChange={props.onSelectChange}
</th> renderCell={renderCell}
<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="galleries" />
</th>
</tr>
</thead>
<tbody>{props.scenes.map(renderSceneRow)}</tbody>
</Table>
</div>
); );
}; };

View file

@ -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 { input[type="range"].filter-slider {
height: 100%; height: 100%;
margin: 0; margin: 0;
@ -684,25 +690,20 @@ input[type="range"].blue-slider {
} }
.scene-table { .scene-table {
table,
tr,
td,
label,
input {
height: 100%;
}
td:first-child { td:first-child {
padding: 0; padding: 0;
} }
label { label {
display: block;
margin: 0; margin: 0;
padding: 0.5rem; padding: 0.5rem;
} }
} }
.select-col {
width: 20px;
}
.scrape-dialog .rating-number.disabled { .scrape-dialog .rating-number.disabled {
padding-left: 0.5em; padding-left: 0.5em;
} }

View file

@ -8,6 +8,7 @@ import Select, {
MenuListProps, MenuListProps,
GroupBase, GroupBase,
OptionsOrGroups, OptionsOrGroups,
DropdownIndicatorProps,
} from "react-select"; } from "react-select";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
@ -32,6 +33,8 @@ import { defaultMaxOptionsShown, IUIConfig } from "src/core/config";
import { useDebounce } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
import { Placement } from "react-bootstrap/esm/Overlay"; import { Placement } from "react-bootstrap/esm/Overlay";
import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { PerformerIDSelect } from "../Performers/PerformerSelect";
import { Icon } from "./Icon";
import { faTableColumns } from "@fortawesome/free-solid-svg-icons";
export type SelectObject = { export type SelectObject = {
id: string; id: string;
@ -979,3 +982,91 @@ export const ListSelect = <T extends {}>(props: IListSelect<T>) => {
/> />
); );
}; };
type DisableOption = Option & {
disabled?: boolean;
};
interface ICheckBoxSelectProps {
options: DisableOption[];
selectedOptions?: DisableOption[];
onChange: (item: OnChangeValue<DisableOption, true>) => void;
}
export const CheckBoxSelect: React.FC<ICheckBoxSelectProps> = ({
options,
selectedOptions,
onChange,
}) => {
const Option = (props: OptionProps<DisableOption, true>) => (
<reactSelectComponents.Option {...props}>
<input
type="checkbox"
disabled={props.data.disabled}
checked={props.isSelected}
onChange={() => null}
className="mr-1"
/>
<label>{props.label}</label>
</reactSelectComponents.Option>
);
const DropdownIndicator = (
props: DropdownIndicatorProps<DisableOption, true>
) => (
<reactSelectComponents.DropdownIndicator {...props}>
<Icon icon={faTableColumns} className="column-select" />
</reactSelectComponents.DropdownIndicator>
);
return (
<Select
className="CheckBoxSelect"
options={options}
value={selectedOptions}
isMulti
closeMenuOnSelect={false}
hideSelectedOptions={false}
isSearchable={false}
isClearable={false}
components={{
DropdownIndicator,
Option,
ValueContainer: () => null,
IndicatorSeparator: () => null,
}}
onChange={onChange}
styles={{
control: (base) => ({
...base,
height: "25px",
width: "25px",
backgroundColor: "none",
border: "none",
transition: "none",
cursor: "pointer",
}),
dropdownIndicator: (base) => ({
...base,
color: "rgb(255, 255, 255)",
padding: "0",
}),
menu: (base) => ({
...base,
backgroundColor: "rgb(57, 75, 89)",
}),
option: (base, fprops) => ({
...base,
backgroundColor: fprops.isFocused
? "rgb(37, 49, 58)"
: "rgb(57, 75, 89)",
padding: "0px 12px",
}),
menuList: (base) => ({
...base,
position: "fixed",
}),
}}
/>
);
};

View file

@ -0,0 +1,37 @@
import { useContext } from "react";
import { useConfigureUI } from "src/core/StashService";
import { ConfigurationContext } from "src/hooks/Config";
import { useToast } from "./Toast";
export const useTableColumns = (
tableName: string,
defaultColumns: string[]
) => {
const Toast = useToast();
const { configuration } = useContext(ConfigurationContext);
const [saveUI] = useConfigureUI();
const selectedColumns: string[] =
configuration?.ui?.tableColumns?.[tableName] ?? defaultColumns;
async function saveColumns(updatedColumns: readonly string[]) {
try {
await saveUI({
variables: {
input: {
...configuration?.ui,
tableColumns: {
...configuration?.ui?.tableColumns,
[tableName]: updatedColumns,
},
},
},
});
} catch (e) {
Toast.error(e);
}
}
return { selectedColumns, saveColumns };
};

View file

@ -462,35 +462,6 @@ textarea.text-input {
overflow-y: scroll; overflow-y: scroll;
} }
.table-list {
min-width: min-content;
}
.table-list a {
color: $text-color;
}
.table-list table {
width: inherit;
}
.table-list td,
.table-list th {
border-left: 1px solid #414c53;
font-size: 1rem;
text-align: center;
vertical-align: middle;
h5,
h6 {
font-size: 1rem;
}
&:first-child {
border-left: none;
}
}
/* stylelint-disable declaration-no-important */ /* stylelint-disable declaration-no-important */
.border-row { .border-row {
background-color: #414c53; background-color: #414c53;

View file

@ -997,7 +997,7 @@
"filters": "Filters", "filters": "Filters",
"folder": "Folder", "folder": "Folder",
"framerate": "Frame Rate", "framerate": "Frame Rate",
"frames_per_second": "{value} frames per second", "frames_per_second": "{value} fps",
"front_page": { "front_page": {
"types": { "types": {
"premade_filter": "Premade Filter", "premade_filter": "Premade Filter",
@ -1068,7 +1068,7 @@
"stream": "Stream", "stream": "Stream",
"video_codec": "Video Codec" "video_codec": "Video Codec"
}, },
"megabits_per_second": "{value} megabits per second", "megabits_per_second": "{value} mbps",
"metadata": "Metadata", "metadata": "Metadata",
"movie": "Movie", "movie": "Movie",
"movie_scene_number": "Movie Scene Number", "movie_scene_number": "Movie Scene Number",
@ -1171,6 +1171,7 @@
"performers": "Performers", "performers": "Performers",
"photographer": "Photographer", "photographer": "Photographer",
"piercings": "Piercings", "piercings": "Piercings",
"plays": "{value} plays",
"play_count": "Play Count", "play_count": "Play Count",
"play_duration": "Play Duration", "play_duration": "Play Duration",
"primary_file": "Primary file", "primary_file": "Primary file",

View file

@ -21,6 +21,7 @@ import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashCriterion } from "src/models/list-filter/criteria/phash";
import { ILabeledId } from "src/models/list-filter/types"; import { ILabeledId } from "src/models/list-filter/types";
import { IntlShape } from "react-intl"; import { IntlShape } from "react-intl";
import { galleryTitle } from "src/core/galleries";
function addExtraCriteria( function addExtraCriteria(
dest: Criterion<CriterionValue>[], dest: Criterion<CriterionValue>[],
@ -348,9 +349,7 @@ const makeGalleryImagesUrl = (
if (!gallery.id) return "#"; if (!gallery.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
const criterion = new GalleriesCriterion(); const criterion = new GalleriesCriterion();
criterion.value = [ criterion.value = [{ id: gallery.id, label: galleryTitle(gallery) }];
{ id: gallery.id, label: gallery.title || `Gallery ${gallery.id}` },
];
filter.criteria.push(criterion); filter.criteria.push(criterion);
addExtraCriteria(filter.criteria, extraCriteria); addExtraCriteria(filter.criteria, extraCriteria);
return `/images?${filter.makeQueryParameters()}`; return `/images?${filter.makeQueryParameters()}`;