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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { useFocusOnce } from "src/utils/focus";
import { useStopWheelScroll } from "src/utils/form";
import { PatchComponent } from "src/patch";
export interface IRatingNumberProps {
value: number | null;
@ -14,151 +15,152 @@ export interface IRatingNumberProps {
withoutContext?: boolean;
}
export const RatingNumber: React.FC<IRatingNumberProps> = (
props: IRatingNumberProps
) => {
const [editing, setEditing] = useState(false);
const [valueStage, setValueStage] = useState<number | null>(props.value);
export const RatingNumber = PatchComponent(
"RatingNumber",
(props: IRatingNumberProps) => {
const [editing, setEditing] = useState(false);
const [valueStage, setValueStage] = useState<number | null>(props.value);
useEffect(() => {
setValueStage(props.value);
}, [props.value]);
useEffect(() => {
setValueStage(props.value);
}, [props.value]);
const showTextField = !props.disabled && (editing || !props.clickToRate);
const showTextField = !props.disabled && (editing || !props.clickToRate);
const [ratingRef] = useFocusOnce(editing, true);
useStopWheelScroll(ratingRef);
const [ratingRef] = useFocusOnce(editing, true);
useStopWheelScroll(ratingRef);
const effectiveValue = editing ? valueStage : props.value;
const effectiveValue = editing ? valueStage : props.value;
const text = ((effectiveValue ?? 0) / 10).toFixed(1);
const useValidation = useRef(true);
const text = ((effectiveValue ?? 0) / 10).toFixed(1);
const useValidation = useRef(true);
function stepChange() {
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;
function stepChange() {
useValidation.current = false;
}
const setRating = editing ? setValueStage : props.onSetRating;
let val = e.target.value;
if (!useValidation.current) {
e.target.value = Number(val).toFixed(1);
const tempVal = Number(val) * 10;
setRating(tempVal || null);
function nonStepChange() {
useValidation.current = true;
return;
}
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
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";
if (match == null) {
return;
target.setSelectionRange(pos, endPos ?? pos);
target.type = "number";
}
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;
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!props.onSetRating) {
return;
}
setCursorPosition(e.target, cursorPosition);
}
}
const setRating = editing ? setValueStage : props.onSetRating;
function onBlur() {
if (editing) {
setEditing(false);
if (props.onSetRating && valueStage !== props.value) {
props.onSetRating(valueStage);
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;
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) {
return (
<div className="rating-number disabled">
{props.withoutContext && <Icon icon={faStar} />}
<span>{Number((effectiveValue ?? 0) / 10).toFixed(1)}</span>
{!props.disabled && props.clickToRate && (
<Button
variant="minimal"
size="sm"
className="edit-rating-button"
onClick={() => setEditing(true)}
>
<Icon className="text-primary" icon={faPencil} />
</Button>
)}
</div>
);
} else {
return (
<div className="rating-number">
<input
ref={ratingRef}
className="text-input form-control"
name="ratingnumber"
type="number"
onMouseDown={stepChange}
onKeyDown={nonStepChange}
onChange={handleChange}
onBlur={onBlur}
value={text}
min="0.0"
step="0.1"
max="10"
placeholder="0.0"
/>
</div>
);
function onBlur() {
if (editing) {
setEditing(false);
if (props.onSetRating && valueStage !== props.value) {
props.onSetRating(valueStage);
}
}
}
if (!showTextField) {
return (
<div className="rating-number disabled">
{props.withoutContext && <Icon icon={faStar} />}
<span>{Number((effectiveValue ?? 0) / 10).toFixed(1)}</span>
{!props.disabled && props.clickToRate && (
<Button
variant="minimal"
size="sm"
className="edit-rating-button"
onClick={() => setEditing(true)}
>
<Icon className="text-primary" icon={faPencil} />
</Button>
)}
</div>
);
} else {
return (
<div className="rating-number">
<input
ref={ratingRef}
className="text-input form-control"
name="ratingnumber"
type="number"
onMouseDown={stepChange}
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 { Icon } from "../Icon";
import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons";
@ -11,6 +11,7 @@ import {
RatingSystemType,
} from "src/utils/rating";
import { useIntl } from "react-intl";
import { PatchComponent } from "src/patch";
export interface IRatingStarsProps {
value: number | null;
@ -20,234 +21,235 @@ export interface IRatingStarsProps {
valueRequired?: boolean;
}
export const RatingStars: React.FC<IRatingStarsProps> = (
props: IRatingStarsProps
) => {
const intl = useIntl();
const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = props.disabled || !props.onSetRating;
export const RatingStars = PatchComponent(
"RatingStars",
(props: IRatingStarsProps) => {
const intl = useIntl();
const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = props.disabled || !props.onSetRating;
const rating = convertToRatingFormat(props.value, {
type: RatingSystemType.Stars,
starPrecision: props.precision,
});
const stars = rating ? Math.floor(rating) : 0;
// the upscaling was necesary to fix rounding issue present with tenth place precision
const fraction = rating ? ((rating * 10) % 10) / 10 : 0;
const rating = convertToRatingFormat(props.value, {
type: RatingSystemType.Stars,
starPrecision: props.precision,
});
const stars = rating ? Math.floor(rating) : 0;
// the upscaling was necesary to fix rounding issue present with tenth place precision
const fraction = rating ? ((rating * 10) % 10) / 10 : 0;
const max = 5;
const precision = getRatingPrecision(props.precision);
const max = 5;
const precision = getRatingPrecision(props.precision);
function newToggleFraction() {
if (precision !== 1) {
if (fraction !== precision) {
if (fraction == 0) {
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;
function newToggleFraction() {
if (precision !== 1) {
if (fraction !== precision) {
if (fraction == 0) {
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;
}
} else {
newRating = undefined;
}
} else if (fraction) {
// we're toggling from an existing fraction so use the stars value
newRating = stars + f;
} 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
// 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) {
// set the hover rating to undefined so that it doesn't immediately clear
// the stars
setHoverRating(undefined);
}
}
function getClassName(thisStar: number) {
if (hoverRating && hoverRating >= thisStar) {
if (hoverRating === stars) {
return "unsetting";
if (!newRating) {
props.onSetRating(null);
return;
}
return "setting";
props.onSetRating(
convertFromRatingFormat(newRating, RatingSystemType.Stars)
);
}
if (stars && stars >= thisStar) {
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();
function onMouseOver(thisStar: number) {
if (!disabled) {
setHoverRating(thisStar);
}
return undefined;
}
// adjust tooltip to use fractions
if (!current) {
return intl.formatMessage({ id: "actions.unset" });
function onMouseOut(thisStar: number) {
if (!disabled && hoverRating === thisStar) {
setHoverRating(undefined);
}
}
return (current.rating + current.fraction).toString();
}
type RatingFraction = {
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 };
function getClassName(thisStar: number) {
if (hoverRating && hoverRating >= thisStar) {
if (hoverRating === stars) {
return "unsetting";
}
// unsetting
return undefined;
return "setting";
}
if (hoverRating === stars + 1 && fraction && fraction === precision) {
if (props.valueRequired) {
return { rating: r, fraction: 0 };
if (stars && stars >= thisStar) {
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;
}
if (f && hoverRating === stars + 1) {
f = newToggleFraction();
r--;
} else if (!f && hoverRating === stars) {
f = newToggleFraction();
r--;
} else {
f = 0;
// adjust tooltip to use fractions
if (!current) {
return intl.formatMessage({ id: "actions.unset" });
}
return (current.rating + current.fraction).toString();
}
return { rating: r, fraction: f ?? 0 };
}
type RatingFraction = {
rating: number;
fraction: number;
};
function getButtonClassName(
thisStar: number,
current: RatingFraction | undefined
) {
if (!current || thisStar > current.rating + 1) {
return "star-fill-0";
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 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) {
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)
function getButtonClassName(
thisStar: number,
current: RatingFraction | undefined
) {
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 (
<span className="star-rating-number">
{ratingFraction.rating + ratingFraction.fraction}
</span>
<div className={`rating-stars ${precisionClassName}`}>
{Array.from(Array(max)).map((value, index) =>
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";
import { RatingNumber } from "./RatingNumber";
import { RatingStars } from "./RatingStars";
import { PatchComponent } from "src/patch";
export interface IRatingSystemProps {
value: number | null | undefined;
@ -19,34 +20,35 @@ export interface IRatingSystemProps {
withoutContext?: boolean;
}
export const RatingSystem: React.FC<IRatingSystemProps> = (
props: IRatingSystemProps
) => {
const { configuration: config } = React.useContext(ConfigurationContext);
const ratingSystemOptions =
config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
export const RatingSystem = PatchComponent(
"RatingSystem",
(props: IRatingSystemProps) => {
const { configuration: config } = React.useContext(ConfigurationContext);
const ratingSystemOptions =
config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
if (ratingSystemOptions.type === RatingSystemType.Stars) {
return (
<RatingStars
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
precision={
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
}
valueRequired={props.valueRequired}
/>
);
} else {
return (
<RatingNumber
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
clickToRate={props.clickToRate}
withoutContext={props.withoutContext}
/>
);
if (ratingSystemOptions.type === RatingSystemType.Stars) {
return (
<RatingStars
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
precision={
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
}
valueRequired={props.valueRequired}
/>
);
} else {
return (
<RatingNumber
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
clickToRate={props.clickToRate}
withoutContext={props.withoutContext}
/>
);
}
}
};
);

View File

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

View File

@ -727,6 +727,9 @@ declare namespace PluginApi {
"GalleryCard.Image": React.FC<any>;
"GalleryCard.Overlays": 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;
namespace utils {