Rating system patched components (#5912)

This commit is contained in:
QxxxGit
2025-06-10 21:46:05 -04:00
committed by GitHub
parent 46b0b8cba4
commit 709fdb14de
5 changed files with 363 additions and 351 deletions

View File

@@ -4,6 +4,7 @@ import { Icon } from "../Icon";
import { faPencil, faStar } from "@fortawesome/free-solid-svg-icons"; import { faPencil, faStar } from "@fortawesome/free-solid-svg-icons";
import { useFocusOnce } from "src/utils/focus"; import { useFocusOnce } from "src/utils/focus";
import { useStopWheelScroll } from "src/utils/form"; import { useStopWheelScroll } from "src/utils/form";
import { PatchComponent } from "src/patch";
export interface IRatingNumberProps { export interface IRatingNumberProps {
value: number | null; value: number | null;
@@ -14,151 +15,152 @@ export interface IRatingNumberProps {
withoutContext?: boolean; withoutContext?: boolean;
} }
export const RatingNumber: React.FC<IRatingNumberProps> = ( export const RatingNumber = PatchComponent(
props: IRatingNumberProps "RatingNumber",
) => { (props: IRatingNumberProps) => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [valueStage, setValueStage] = useState<number | null>(props.value); const [valueStage, setValueStage] = useState<number | null>(props.value);
useEffect(() => { useEffect(() => {
setValueStage(props.value); setValueStage(props.value);
}, [props.value]); }, [props.value]);
const showTextField = !props.disabled && (editing || !props.clickToRate); const showTextField = !props.disabled && (editing || !props.clickToRate);
const [ratingRef] = useFocusOnce(editing, true); const [ratingRef] = useFocusOnce(editing, true);
useStopWheelScroll(ratingRef); useStopWheelScroll(ratingRef);
const effectiveValue = editing ? valueStage : props.value; const effectiveValue = editing ? valueStage : props.value;
const text = ((effectiveValue ?? 0) / 10).toFixed(1); const text = ((effectiveValue ?? 0) / 10).toFixed(1);
const useValidation = useRef(true); const useValidation = useRef(true);
function stepChange() { function stepChange() {
useValidation.current = false; useValidation.current = false;
}
function nonStepChange() {
useValidation.current = true;
}
function setCursorPosition(
target: HTMLInputElement,
pos: number,
endPos?: number
) {
// This is a workaround to a missing feature where you can't set cursor position in input numbers.
// See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements
target.type = "text";
target.setSelectionRange(pos, endPos ?? pos);
target.type = "number";
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!props.onSetRating) {
return;
} }
const setRating = editing ? setValueStage : props.onSetRating; function nonStepChange() {
let val = e.target.value;
if (!useValidation.current) {
e.target.value = Number(val).toFixed(1);
const tempVal = Number(val) * 10;
setRating(tempVal || null);
useValidation.current = true; useValidation.current = true;
return;
} }
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val); function setCursorPosition(
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? ""); target: HTMLInputElement,
pos: number,
endPos?: number
) {
// This is a workaround to a missing feature where you can't set cursor position in input numbers.
// See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements
target.type = "text";
if (match == null) { target.setSelectionRange(pos, endPos ?? pos);
return; target.type = "number";
} }
if (match[2] && !(match[2] == "0" && match[1] == "1")) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
match[2] = ""; if (!props.onSetRating) {
} return;
if (match[4] == null || match[4] == "") {
match[4] = "0";
}
let value = match[1] + match[2] + "." + match[4];
e.target.value = value;
if (val.length > 0) {
if (Number(value) > 10) {
value = "10.0";
}
e.target.value = Number(value).toFixed(1);
let tempVal = Number(value) * 10;
setRating(tempVal || null);
let cursorPosition = 0;
if (match[2] && !match[4]) {
cursorPosition = 3;
} else if (matchOld != null && match[1] !== matchOld[1]) {
cursorPosition = 2;
} else if (
matchOld != null &&
match[1] === matchOld[1] &&
match[2] === matchOld[2] &&
match[4] === matchOld[4]
) {
cursorPosition = 2;
} }
setCursorPosition(e.target, cursorPosition); const setRating = editing ? setValueStage : props.onSetRating;
}
}
function onBlur() { let val = e.target.value;
if (editing) { if (!useValidation.current) {
setEditing(false); e.target.value = Number(val).toFixed(1);
if (props.onSetRating && valueStage !== props.value) { const tempVal = Number(val) * 10;
props.onSetRating(valueStage); setRating(tempVal || null);
useValidation.current = true;
return;
}
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
if (match == null) {
return;
}
if (match[2] && !(match[2] == "0" && match[1] == "1")) {
match[2] = "";
}
if (match[4] == null || match[4] == "") {
match[4] = "0";
}
let value = match[1] + match[2] + "." + match[4];
e.target.value = value;
if (val.length > 0) {
if (Number(value) > 10) {
value = "10.0";
}
e.target.value = Number(value).toFixed(1);
let tempVal = Number(value) * 10;
setRating(tempVal || null);
let cursorPosition = 0;
if (match[2] && !match[4]) {
cursorPosition = 3;
} else if (matchOld != null && match[1] !== matchOld[1]) {
cursorPosition = 2;
} else if (
matchOld != null &&
match[1] === matchOld[1] &&
match[2] === matchOld[2] &&
match[4] === matchOld[4]
) {
cursorPosition = 2;
}
setCursorPosition(e.target, cursorPosition);
} }
} }
}
if (!showTextField) { function onBlur() {
return ( if (editing) {
<div className="rating-number disabled"> setEditing(false);
{props.withoutContext && <Icon icon={faStar} />} if (props.onSetRating && valueStage !== props.value) {
<span>{Number((effectiveValue ?? 0) / 10).toFixed(1)}</span> props.onSetRating(valueStage);
{!props.disabled && props.clickToRate && ( }
<Button }
variant="minimal" }
size="sm"
className="edit-rating-button" if (!showTextField) {
onClick={() => setEditing(true)} return (
> <div className="rating-number disabled">
<Icon className="text-primary" icon={faPencil} /> {props.withoutContext && <Icon icon={faStar} />}
</Button> <span>{Number((effectiveValue ?? 0) / 10).toFixed(1)}</span>
)} {!props.disabled && props.clickToRate && (
</div> <Button
); variant="minimal"
} else { size="sm"
return ( className="edit-rating-button"
<div className="rating-number"> onClick={() => setEditing(true)}
<input >
ref={ratingRef} <Icon className="text-primary" icon={faPencil} />
className="text-input form-control" </Button>
name="ratingnumber" )}
type="number" </div>
onMouseDown={stepChange} );
onKeyDown={nonStepChange} } else {
onChange={handleChange} return (
onBlur={onBlur} <div className="rating-number">
value={text} <input
min="0.0" ref={ratingRef}
step="0.1" className="text-input form-control"
max="10" name="ratingnumber"
placeholder="0.0" type="number"
/> onMouseDown={stepChange}
</div> onKeyDown={nonStepChange}
); onChange={handleChange}
onBlur={onBlur}
value={text}
min="0.0"
step="0.1"
max="10"
placeholder="0.0"
/>
</div>
);
}
} }
}; );

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import { useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons";
@@ -11,6 +11,7 @@ import {
RatingSystemType, RatingSystemType,
} from "src/utils/rating"; } from "src/utils/rating";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { PatchComponent } from "src/patch";
export interface IRatingStarsProps { export interface IRatingStarsProps {
value: number | null; value: number | null;
@@ -20,234 +21,235 @@ export interface IRatingStarsProps {
valueRequired?: boolean; valueRequired?: boolean;
} }
export const RatingStars: React.FC<IRatingStarsProps> = ( export const RatingStars = PatchComponent(
props: IRatingStarsProps "RatingStars",
) => { (props: IRatingStarsProps) => {
const intl = useIntl(); const intl = useIntl();
const [hoverRating, setHoverRating] = useState<number | undefined>(); const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = props.disabled || !props.onSetRating; const disabled = props.disabled || !props.onSetRating;
const rating = convertToRatingFormat(props.value, { const rating = convertToRatingFormat(props.value, {
type: RatingSystemType.Stars, type: RatingSystemType.Stars,
starPrecision: props.precision, starPrecision: props.precision,
}); });
const stars = rating ? Math.floor(rating) : 0; const stars = rating ? Math.floor(rating) : 0;
// the upscaling was necesary to fix rounding issue present with tenth place precision // the upscaling was necesary to fix rounding issue present with tenth place precision
const fraction = rating ? ((rating * 10) % 10) / 10 : 0; const fraction = rating ? ((rating * 10) % 10) / 10 : 0;
const max = 5; const max = 5;
const precision = getRatingPrecision(props.precision); const precision = getRatingPrecision(props.precision);
function newToggleFraction() { function newToggleFraction() {
if (precision !== 1) { if (precision !== 1) {
if (fraction !== precision) { if (fraction !== precision) {
if (fraction == 0) { if (fraction == 0) {
return 1 - precision; return 1 - precision;
}
return fraction - precision;
}
}
}
function setRating(thisStar: number) {
if (!props.onSetRating) {
return;
}
let newRating: number | undefined = thisStar;
// toggle rating fraction if we're clicking on the current rating
if (
(stars === thisStar && !fraction) ||
(stars + 1 === thisStar && fraction)
) {
const f = newToggleFraction();
if (!f) {
if (props.valueRequired) {
if (fraction) {
newRating = stars + 1;
} else {
newRating = stars;
} }
return fraction - precision;
}
}
}
function setRating(thisStar: number) {
if (!props.onSetRating) {
return;
}
let newRating: number | undefined = thisStar;
// toggle rating fraction if we're clicking on the current rating
if (
(stars === thisStar && !fraction) ||
(stars + 1 === thisStar && fraction)
) {
const f = newToggleFraction();
if (!f) {
if (props.valueRequired) {
if (fraction) {
newRating = stars + 1;
} else {
newRating = stars;
}
} else {
newRating = undefined;
}
} else if (fraction) {
// we're toggling from an existing fraction so use the stars value
newRating = stars + f;
} else { } else {
newRating = undefined; // we're toggling from a whole value, so decrement from current rating
newRating = stars - 1 + f;
} }
} else if (fraction) {
// we're toggling from an existing fraction so use the stars value
newRating = stars + f;
} else {
// we're toggling from a whole value, so decrement from current rating
newRating = stars - 1 + f;
} }
}
// set the hover rating to undefined so that it doesn't immediately clear // set the hover rating to undefined so that it doesn't immediately clear
// the stars // the stars
setHoverRating(undefined);
if (!newRating) {
props.onSetRating(null);
return;
}
props.onSetRating(
convertFromRatingFormat(newRating, RatingSystemType.Stars)
);
}
function onMouseOver(thisStar: number) {
if (!disabled) {
setHoverRating(thisStar);
}
}
function onMouseOut(thisStar: number) {
if (!disabled && hoverRating === thisStar) {
setHoverRating(undefined); setHoverRating(undefined);
}
}
function getClassName(thisStar: number) { if (!newRating) {
if (hoverRating && hoverRating >= thisStar) { props.onSetRating(null);
if (hoverRating === stars) { return;
return "unsetting";
} }
return "setting"; props.onSetRating(
convertFromRatingFormat(newRating, RatingSystemType.Stars)
);
} }
if (stars && stars >= thisStar) { function onMouseOver(thisStar: number) {
return "set"; if (!disabled) {
} setHoverRating(thisStar);
return "unset";
}
function getTooltip(thisStar: number, current: RatingFraction | undefined) {
if (disabled) {
if (rating) {
// always return current rating for disabled control
return rating.toString();
} }
return undefined;
} }
// adjust tooltip to use fractions function onMouseOut(thisStar: number) {
if (!current) { if (!disabled && hoverRating === thisStar) {
return intl.formatMessage({ id: "actions.unset" }); setHoverRating(undefined);
}
} }
return (current.rating + current.fraction).toString(); function getClassName(thisStar: number) {
} if (hoverRating && hoverRating >= thisStar) {
if (hoverRating === stars) {
type RatingFraction = { return "unsetting";
rating: number;
fraction: number;
};
function getCurrentSelectedRating(): RatingFraction | undefined {
let r: number = hoverRating ? hoverRating : stars;
let f: number | undefined = fraction;
if (hoverRating) {
if (hoverRating === stars && precision === 1) {
if (props.valueRequired) {
return { rating: r, fraction: 0 };
} }
// unsetting return "setting";
return undefined;
} }
if (hoverRating === stars + 1 && fraction && fraction === precision) {
if (props.valueRequired) { if (stars && stars >= thisStar) {
return { rating: r, fraction: 0 }; return "set";
}
return "unset";
}
function getTooltip(thisStar: number, current: RatingFraction | undefined) {
if (disabled) {
if (rating) {
// always return current rating for disabled control
return rating.toString();
} }
// unsetting
return undefined; return undefined;
} }
if (f && hoverRating === stars + 1) { // adjust tooltip to use fractions
f = newToggleFraction(); if (!current) {
r--; return intl.formatMessage({ id: "actions.unset" });
} else if (!f && hoverRating === stars) {
f = newToggleFraction();
r--;
} else {
f = 0;
} }
return (current.rating + current.fraction).toString();
} }
return { rating: r, fraction: f ?? 0 }; type RatingFraction = {
} rating: number;
fraction: number;
};
function getButtonClassName( function getCurrentSelectedRating(): RatingFraction | undefined {
thisStar: number, let r: number = hoverRating ? hoverRating : stars;
current: RatingFraction | undefined let f: number | undefined = fraction;
) {
if (!current || thisStar > current.rating + 1) { if (hoverRating) {
return "star-fill-0"; if (hoverRating === stars && precision === 1) {
if (props.valueRequired) {
return { rating: r, fraction: 0 };
}
// unsetting
return undefined;
}
if (hoverRating === stars + 1 && fraction && fraction === precision) {
if (props.valueRequired) {
return { rating: r, fraction: 0 };
}
// unsetting
return undefined;
}
if (f && hoverRating === stars + 1) {
f = newToggleFraction();
r--;
} else if (!f && hoverRating === stars) {
f = newToggleFraction();
r--;
} else {
f = 0;
}
}
return { rating: r, fraction: f ?? 0 };
} }
if (thisStar <= current.rating) { function getButtonClassName(
return "star-fill-100"; thisStar: number,
} current: RatingFraction | undefined
let w = current.fraction * 100;
return `star-fill-${w}`;
}
const renderRatingButton = (thisStar: number) => {
const ratingFraction = getCurrentSelectedRating();
return (
<Button
disabled={disabled}
className={`minimal ${getButtonClassName(thisStar, ratingFraction)}`}
onClick={() => setRating(thisStar)}
variant="secondary"
onMouseEnter={() => onMouseOver(thisStar)}
onMouseLeave={() => onMouseOut(thisStar)}
onFocus={() => onMouseOver(thisStar)}
onBlur={() => onMouseOut(thisStar)}
title={getTooltip(thisStar, ratingFraction)}
key={`star-${thisStar}`}
>
<div className="filled-star">
<Icon icon={fasStar} className="set" />
</div>
<div className="unfilled-star">
<Icon icon={farStar} className={getClassName(thisStar)} />
</div>
</Button>
);
};
const maybeRenderStarRatingNumber = () => {
const ratingFraction = getCurrentSelectedRating();
if (
!ratingFraction ||
(ratingFraction.rating == 0 && ratingFraction.fraction == 0)
) { ) {
return; if (!current || thisStar > current.rating + 1) {
return "star-fill-0";
}
if (thisStar <= current.rating) {
return "star-fill-100";
}
let w = current.fraction * 100;
return `star-fill-${w}`;
} }
const renderRatingButton = (thisStar: number) => {
const ratingFraction = getCurrentSelectedRating();
return (
<Button
disabled={disabled}
className={`minimal ${getButtonClassName(thisStar, ratingFraction)}`}
onClick={() => setRating(thisStar)}
variant="secondary"
onMouseEnter={() => onMouseOver(thisStar)}
onMouseLeave={() => onMouseOut(thisStar)}
onFocus={() => onMouseOver(thisStar)}
onBlur={() => onMouseOut(thisStar)}
title={getTooltip(thisStar, ratingFraction)}
key={`star-${thisStar}`}
>
<div className="filled-star">
<Icon icon={fasStar} className="set" />
</div>
<div className="unfilled-star">
<Icon icon={farStar} className={getClassName(thisStar)} />
</div>
</Button>
);
};
const maybeRenderStarRatingNumber = () => {
const ratingFraction = getCurrentSelectedRating();
if (
!ratingFraction ||
(ratingFraction.rating == 0 && ratingFraction.fraction == 0)
) {
return;
}
return (
<span className="star-rating-number">
{ratingFraction.rating + ratingFraction.fraction}
</span>
);
};
const precisionClassName = `rating-stars-precision-${props.precision}`;
return ( return (
<span className="star-rating-number"> <div className={`rating-stars ${precisionClassName}`}>
{ratingFraction.rating + ratingFraction.fraction} {Array.from(Array(max)).map((value, index) =>
</span> renderRatingButton(index + 1)
)}
{maybeRenderStarRatingNumber()}
</div>
); );
}; }
);
const precisionClassName = `rating-stars-precision-${props.precision}`;
return (
<div className={`rating-stars ${precisionClassName}`}>
{Array.from(Array(max)).map((value, index) =>
renderRatingButton(index + 1)
)}
{maybeRenderStarRatingNumber()}
</div>
);
};

View File

@@ -7,6 +7,7 @@ import {
} from "src/utils/rating"; } from "src/utils/rating";
import { RatingNumber } from "./RatingNumber"; import { RatingNumber } from "./RatingNumber";
import { RatingStars } from "./RatingStars"; import { RatingStars } from "./RatingStars";
import { PatchComponent } from "src/patch";
export interface IRatingSystemProps { export interface IRatingSystemProps {
value: number | null | undefined; value: number | null | undefined;
@@ -19,34 +20,35 @@ export interface IRatingSystemProps {
withoutContext?: boolean; withoutContext?: boolean;
} }
export const RatingSystem: React.FC<IRatingSystemProps> = ( export const RatingSystem = PatchComponent(
props: IRatingSystemProps "RatingSystem",
) => { (props: IRatingSystemProps) => {
const { configuration: config } = React.useContext(ConfigurationContext); const { configuration: config } = React.useContext(ConfigurationContext);
const ratingSystemOptions = const ratingSystemOptions =
config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
if (ratingSystemOptions.type === RatingSystemType.Stars) { if (ratingSystemOptions.type === RatingSystemType.Stars) {
return ( return (
<RatingStars <RatingStars
value={props.value ?? null} value={props.value ?? null}
onSetRating={props.onSetRating} onSetRating={props.onSetRating}
disabled={props.disabled} disabled={props.disabled}
precision={ precision={
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
} }
valueRequired={props.valueRequired} valueRequired={props.valueRequired}
/> />
); );
} else { } else {
return ( return (
<RatingNumber <RatingNumber
value={props.value ?? null} value={props.value ?? null}
onSetRating={props.onSetRating} onSetRating={props.onSetRating}
disabled={props.disabled} disabled={props.disabled}
clickToRate={props.clickToRate} clickToRate={props.clickToRate}
withoutContext={props.withoutContext} withoutContext={props.withoutContext}
/> />
); );
}
} }
}; );

View File

@@ -196,6 +196,9 @@ Returns `void`.
- `PerformerImagesPanel` - `PerformerImagesPanel`
- `PerformerScenesPanel` - `PerformerScenesPanel`
- `PluginRoutes` - `PluginRoutes`
- `RatingNumber`
- `RatingStars`
- `RatingSystem`
- `SceneCard` - `SceneCard`
- `SceneCard.Details` - `SceneCard.Details`
- `SceneCard.Image` - `SceneCard.Image`

View File

@@ -727,6 +727,9 @@ declare namespace PluginApi {
"GalleryCard.Image": React.FC<any>; "GalleryCard.Image": React.FC<any>;
"GalleryCard.Overlays": React.FC<any>; "GalleryCard.Overlays": React.FC<any>;
"GalleryCard.Popovers": React.FC<any>; "GalleryCard.Popovers": React.FC<any>;
RatingNumber: React.FC<any>;
RatingStars: React.FC<any>;
RatingSystem: React.FC<any>;
}; };
type PatchableComponentNames = keyof typeof components | string; type PatchableComponentNames = keyof typeof components | string;
namespace utils { namespace utils {