mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
e7311a60d2
commit
dd8da7f339
15 changed files with 1553 additions and 423 deletions
|
|
@ -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<IGalleryList> = ({
|
|||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
return (
|
||||
<Table className="col col-sm-6 mx-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{intl.formatMessage({ id: "actions.preview" })}</th>
|
||||
<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}`}
|
||||
<GalleryListTable
|
||||
galleries={result.data.findGalleries.galleries}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
) : 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) {
|
||||
|
|
|
|||
256
ui/v2.5/src/components/Galleries/GalleryListTable.tsx
Normal file
256
ui/v2.5/src/components/Galleries/GalleryListTable.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
141
ui/v2.5/src/components/List/ListTable.tsx
Normal file
141
ui/v2.5/src/components/List/ListTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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<IPerformerDetails> = ({
|
|||
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<IPerformerDetails> = ({
|
|||
/>
|
||||
<DetailItem
|
||||
id="height"
|
||||
value={formatHeight(performer.height_cm)}
|
||||
value={FormatHeight(performer.height_cm)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="weight"
|
||||
value={formatWeight(performer.weight)}
|
||||
value={FormatWeight(performer.weight)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="penis_length"
|
||||
value={formatPenisLength(performer.penis_length)}
|
||||
value={FormatPenisLength(performer.penis_length)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="circumcised"
|
||||
value={formatCircumcised(performer.circumcised)}
|
||||
value={FormatCircumcised(performer.circumcised)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
|
|||
import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard";
|
||||
import { PerformerListTable } from "./PerformerListTable";
|
||||
import { EditPerformersDialog } from "./EditPerformersDialog";
|
||||
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
|
||||
import TextUtils from "src/utils/text";
|
||||
|
||||
const PerformerItemList = makeItemList({
|
||||
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 {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
persistState?: PersistanceLevel;
|
||||
|
|
@ -158,6 +283,8 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
return (
|
||||
<PerformerListTable
|
||||
performers={result.data.findPerformers.performers}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,58 +2,68 @@
|
|||
|
||||
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<string>;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
const TABLE_NAME = "performers";
|
||||
|
||||
export const PerformerListTable: React.FC<IPerformerListTableProps> = (
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [feet, inches] = cmToImperial(height);
|
||||
function setFavorite(v: boolean, performerId: string) {
|
||||
if (performerId) {
|
||||
updatePerformer({
|
||||
variables: {
|
||||
input: {
|
||||
id: performerId,
|
||||
favorite: v,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 renderPerformerRow = (performer: GQL.PerformerDataFragment) => (
|
||||
<tr key={performer.id}>
|
||||
<td>
|
||||
const ImageCell = (performer: GQL.PerformerDataFragment) => (
|
||||
<Link to={`/performers/${performer.id}`}>
|
||||
<img
|
||||
loading="lazy"
|
||||
|
|
@ -62,69 +72,329 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
|
|||
src={performer.image_path ?? ""}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
);
|
||||
|
||||
const NameCell = (performer: GQL.PerformerDataFragment) => (
|
||||
<Link to={`/performers/${performer.id}`}>
|
||||
<h5>
|
||||
<div className="ellips-data" title={performer.name}>
|
||||
{performer.name}
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
</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 AliasesCell = (performer: GQL.PerformerDataFragment) => {
|
||||
let aliases = performer.alias_list ? performer.alias_list.join(", ") : "";
|
||||
return (
|
||||
<div className="row justify-content-center table-list">
|
||||
<Table bordered striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{intl.formatMessage({ id: "name" })}</th>
|
||||
<th>{intl.formatMessage({ id: "aliases" })}</th>
|
||||
<th>{intl.formatMessage({ id: "favourite" })}</th>
|
||||
<th>{intl.formatMessage({ id: "scene_count" })}</th>
|
||||
<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>
|
||||
<span className="ellips-data" title={aliases}>
|
||||
{aliases}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const GenderCell = (performer: GQL.PerformerDataFragment) => (
|
||||
<>
|
||||
{performer.gender
|
||||
? intl.formatMessage({ id: "gender_types." + performer.gender })
|
||||
: ""}
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<ListTable
|
||||
className="performer-table"
|
||||
items={props.performers}
|
||||
allColumns={allColumns}
|
||||
columns={selectedColumns}
|
||||
setColumns={(c) => saveColumns(c)}
|
||||
selectedIds={props.selectedIds}
|
||||
onSelectChange={props.onSelectChange}
|
||||
renderCell={renderCell}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: " • ";
|
||||
|
|
|
|||
|
|
@ -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,74 +19,35 @@ interface ISceneListTableProps {
|
|||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
const TABLE_NAME = "scenes";
|
||||
|
||||
export const SceneListTable: React.FC<ISceneListTableProps> = (
|
||||
props: ISceneListTableProps
|
||||
) => {
|
||||
const renderTags = (tags: Partial<GQL.TagDataFragment>[]) =>
|
||||
tags.map((tag) => (
|
||||
<Link key={tag.id} to={NavUtils.makeTagScenesUrl(tag)}>
|
||||
<h6>{tag.name}</h6>
|
||||
</Link>
|
||||
));
|
||||
const intl = useIntl();
|
||||
|
||||
const renderPerformers = (performers: Partial<GQL.PerformerDataFragment>[]) =>
|
||||
performers.map((performer) => (
|
||||
<Link key={performer.id} to={NavUtils.makePerformerScenesUrl(performer)}>
|
||||
<h6>{performer.name}</h6>
|
||||
</Link>
|
||||
));
|
||||
const [updateScene] = useSceneUpdate();
|
||||
|
||||
const renderMovies = (scene: GQL.SlimSceneDataFragment) =>
|
||||
scene.movies.map((sceneMovie) => (
|
||||
<Link
|
||||
key={sceneMovie.movie.id}
|
||||
to={NavUtils.makeMovieScenesUrl(sceneMovie.movie)}
|
||||
>
|
||||
<h6>{sceneMovie.movie.name}</h6>
|
||||
</Link>
|
||||
));
|
||||
function setRating(v: number | null, sceneId: string) {
|
||||
if (sceneId) {
|
||||
updateScene({
|
||||
variables: {
|
||||
input: {
|
||||
id: sceneId,
|
||||
rating100: v,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const renderGalleries = (scene: GQL.SlimSceneDataFragment) =>
|
||||
scene.galleries.map((gallery) => (
|
||||
<Link key={gallery.id} to={`/galleries/${gallery.id}`}>
|
||||
<h6>{galleryTitle(gallery)}</h6>
|
||||
</Link>
|
||||
));
|
||||
|
||||
const renderSceneRow = (scene: GQL.SlimSceneDataFragment, index: number) => {
|
||||
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}`;
|
||||
|
||||
let shiftKey = false;
|
||||
|
||||
const file = scene.files.length > 0 ? scene.files[0] : undefined;
|
||||
|
||||
const title = objectTitle(scene);
|
||||
return (
|
||||
<tr key={scene.id}>
|
||||
<td>
|
||||
<label>
|
||||
<Form.Control
|
||||
type="checkbox"
|
||||
checked={props.selectedIds.has(scene.id)}
|
||||
onChange={() =>
|
||||
props.onSelectChange(
|
||||
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"
|
||||
|
|
@ -92,64 +56,331 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
|||
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 (
|
||||
<div className="row scene-table table-list justify-content-center">
|
||||
<Table striped bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="text-left">
|
||||
<FormattedMessage id="title" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="rating" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="duration" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="tags" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="performers" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="studio" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="movies" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="galleries" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{props.scenes.map(renderSceneRow)}</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<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 (
|
||||
<ListTable
|
||||
className="scene-table"
|
||||
items={props.scenes}
|
||||
allColumns={allColumns}
|
||||
columns={selectedColumns}
|
||||
setColumns={(c) => saveColumns(c)}
|
||||
selectedIds={props.selectedIds}
|
||||
onSelectChange={props.onSelectChange}
|
||||
renderCell={renderCell}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <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",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
37
ui/v2.5/src/hooks/useTableColumns.ts
Normal file
37
ui/v2.5/src/hooks/useTableColumns.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -462,35 +462,6 @@ textarea.text-input {
|
|||
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 */
|
||||
.border-row {
|
||||
background-color: #414c53;
|
||||
|
|
|
|||
|
|
@ -997,7 +997,7 @@
|
|||
"filters": "Filters",
|
||||
"folder": "Folder",
|
||||
"framerate": "Frame Rate",
|
||||
"frames_per_second": "{value} frames per second",
|
||||
"frames_per_second": "{value} fps",
|
||||
"front_page": {
|
||||
"types": {
|
||||
"premade_filter": "Premade Filter",
|
||||
|
|
@ -1068,7 +1068,7 @@
|
|||
"stream": "Stream",
|
||||
"video_codec": "Video Codec"
|
||||
},
|
||||
"megabits_per_second": "{value} megabits per second",
|
||||
"megabits_per_second": "{value} mbps",
|
||||
"metadata": "Metadata",
|
||||
"movie": "Movie",
|
||||
"movie_scene_number": "Movie Scene Number",
|
||||
|
|
@ -1171,6 +1171,7 @@
|
|||
"performers": "Performers",
|
||||
"photographer": "Photographer",
|
||||
"piercings": "Piercings",
|
||||
"plays": "{value} plays",
|
||||
"play_count": "Play Count",
|
||||
"play_duration": "Play Duration",
|
||||
"primary_file": "Primary file",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
|
|||
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
|
||||
import { ILabeledId } from "src/models/list-filter/types";
|
||||
import { IntlShape } from "react-intl";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
|
||||
function addExtraCriteria(
|
||||
dest: Criterion<CriterionValue>[],
|
||||
|
|
@ -348,9 +349,7 @@ const makeGalleryImagesUrl = (
|
|||
if (!gallery.id) return "#";
|
||||
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
|
||||
const criterion = new GalleriesCriterion();
|
||||
criterion.value = [
|
||||
{ id: gallery.id, label: gallery.title || `Gallery ${gallery.id}` },
|
||||
];
|
||||
criterion.value = [{ id: gallery.id, label: galleryTitle(gallery) }];
|
||||
filter.criteria.push(criterion);
|
||||
addExtraCriteria(filter.criteria, extraCriteria);
|
||||
return `/images?${filter.makeQueryParameters()}`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue