From 709fdb14de647f85aefd12312b838ea2d2091e1c Mon Sep 17 00:00:00 2001 From: QxxxGit <71350626+QxxxGit@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:46:05 -0400 Subject: [PATCH] Rating system patched components (#5912) --- .../components/Shared/Rating/RatingNumber.tsx | 256 ++++++------ .../components/Shared/Rating/RatingStars.tsx | 392 +++++++++--------- .../components/Shared/Rating/RatingSystem.tsx | 60 +-- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 3 + ui/v2.5/src/pluginApi.d.ts | 3 + 5 files changed, 363 insertions(+), 351 deletions(-) diff --git a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx index 69195ff40..11238cda4 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx @@ -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 = ( - props: IRatingNumberProps -) => { - const [editing, setEditing] = useState(false); - const [valueStage, setValueStage] = useState(props.value); +export const RatingNumber = PatchComponent( + "RatingNumber", + (props: IRatingNumberProps) => { + const [editing, setEditing] = useState(false); + const [valueStage, setValueStage] = useState(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) { - 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) { + 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 ( -
- {props.withoutContext && } - {Number((effectiveValue ?? 0) / 10).toFixed(1)} - {!props.disabled && props.clickToRate && ( - - )} -
- ); - } else { - return ( -
- -
- ); + function onBlur() { + if (editing) { + setEditing(false); + if (props.onSetRating && valueStage !== props.value) { + props.onSetRating(valueStage); + } + } + } + + if (!showTextField) { + return ( +
+ {props.withoutContext && } + {Number((effectiveValue ?? 0) / 10).toFixed(1)} + {!props.disabled && props.clickToRate && ( + + )} +
+ ); + } else { + return ( +
+ +
+ ); + } } -}; +); diff --git a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx index a6f5d34f6..8b4aa1e4e 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx @@ -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 = ( - props: IRatingStarsProps -) => { - const intl = useIntl(); - const [hoverRating, setHoverRating] = useState(); - const disabled = props.disabled || !props.onSetRating; +export const RatingStars = PatchComponent( + "RatingStars", + (props: IRatingStarsProps) => { + const intl = useIntl(); + const [hoverRating, setHoverRating] = useState(); + 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 ( - - ); - }; - - 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 ( + + ); + }; + + const maybeRenderStarRatingNumber = () => { + const ratingFraction = getCurrentSelectedRating(); + if ( + !ratingFraction || + (ratingFraction.rating == 0 && ratingFraction.fraction == 0) + ) { + return; + } + + return ( + + {ratingFraction.rating + ratingFraction.fraction} + + ); + }; + + const precisionClassName = `rating-stars-precision-${props.precision}`; + return ( - - {ratingFraction.rating + ratingFraction.fraction} - +
+ {Array.from(Array(max)).map((value, index) => + renderRatingButton(index + 1) + )} + {maybeRenderStarRatingNumber()} +
); - }; - - const precisionClassName = `rating-stars-precision-${props.precision}`; - - return ( -
- {Array.from(Array(max)).map((value, index) => - renderRatingButton(index + 1) - )} - {maybeRenderStarRatingNumber()} -
- ); -}; + } +); diff --git a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx index ef3aa8834..a0a11c363 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx @@ -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 = ( - 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 ( - - ); - } else { - return ( - - ); + if (ratingSystemOptions.type === RatingSystemType.Stars) { + return ( + + ); + } else { + return ( + + ); + } } -}; +); diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 1e3b0abbb..c42a498a9 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -196,6 +196,9 @@ Returns `void`. - `PerformerImagesPanel` - `PerformerScenesPanel` - `PluginRoutes` +- `RatingNumber` +- `RatingStars` +- `RatingSystem` - `SceneCard` - `SceneCard.Details` - `SceneCard.Image` diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index d9f755790..8a9fd7152 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -727,6 +727,9 @@ declare namespace PluginApi { "GalleryCard.Image": React.FC; "GalleryCard.Overlays": React.FC; "GalleryCard.Popovers": React.FC; + RatingNumber: React.FC; + RatingStars: React.FC; + RatingSystem: React.FC; }; type PatchableComponentNames = keyof typeof components | string; namespace utils {