{donateBanner} -
+

Donations

+ {donors?.map((donor) => (
@@ -137,12 +140,10 @@ function Donors() {
{donor.musicbrainz_id && (donor.is_listenbrainz_user ? ( - - {donor.musicbrainz_id} - + /> ) : ( + + {similarUsers?.length ? ( similarUsers?.map((row, index) => ( diff --git a/frontend/js/src/recent/RecentListens.tsx b/frontend/js/src/recent/RecentListens.tsx index 30e6f83a24..7deaf36106 100644 --- a/frontend/js/src/recent/RecentListens.tsx +++ b/frontend/js/src/recent/RecentListens.tsx @@ -10,6 +10,7 @@ import Card from "../components/Card"; import { getTrackName } from "../utils/utils"; import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext"; import RecentDonorsCard from "./components/RecentDonors"; +import FlairsExplanationButton from "../common/flairs/FlairsExplanationButton"; export type RecentListensProps = { listens: Array; @@ -64,9 +65,10 @@ export default class RecentListens extends React.Component< users - + +
{!listens.length && ( diff --git a/frontend/js/src/recent/components/RecentDonors.tsx b/frontend/js/src/recent/components/RecentDonors.tsx index 68eedd7c45..e997d6c474 100644 --- a/frontend/js/src/recent/components/RecentDonors.tsx +++ b/frontend/js/src/recent/components/RecentDonors.tsx @@ -7,6 +7,7 @@ import { getTrackName, pinnedRecordingToListen, } from "../../utils/utils"; +import Username from "../../common/Username"; type RecentDonorsCardProps = { donors: DonationInfoWithPinnedRecording[]; @@ -39,12 +40,10 @@ function RecentDonorsCard(props: RecentDonorsCardProps) {
{donor.musicbrainz_id && (donor.is_listenbrainz_user ? ( - - {donor.musicbrainz_id} - + /> ) : (
{username && (
- {row[0]} + - {row[1]} + {row[2]}
{index + 1} - {row[0]} + diff --git a/frontend/js/src/settings/Settings.tsx b/frontend/js/src/settings/Settings.tsx index 37845a3d63..b4c41addf4 100644 --- a/frontend/js/src/settings/Settings.tsx +++ b/frontend/js/src/settings/Settings.tsx @@ -5,9 +5,13 @@ import { toast } from "react-toastify"; import { Helmet } from "react-helmet"; import { ToastMsg } from "../notifications/Notifications"; import GlobalAppContext from "../utils/GlobalAppContext"; +import Username from "../common/Username"; +import FlairsSettings from "./flairs/FlairsSettings"; export default function Settings() { - const { currentUser } = React.useContext(GlobalAppContext); + const globalContext = React.useContext(GlobalAppContext); + const { currentUser } = globalContext; + const { auth_token: authToken, name } = currentUser; const [showToken, setShowToken] = React.useState(false); @@ -53,10 +57,34 @@ export default function Settings() { return ( <> - User {currentUser?.name} + User {name}
-

{name}

+

User Settings

+ + +

User token

@@ -95,7 +123,7 @@ export default function Settings() {

If you want to reset your token, click below

- + Reset token diff --git a/frontend/js/src/settings/flairs/FlairsSettings.tsx b/frontend/js/src/settings/flairs/FlairsSettings.tsx new file mode 100644 index 0000000000..36c447f4c6 --- /dev/null +++ b/frontend/js/src/settings/flairs/FlairsSettings.tsx @@ -0,0 +1,191 @@ +import * as React from "react"; + +import { Link } from "react-router-dom"; +import { toast } from "react-toastify"; +import { findKey, startCase } from "lodash"; +import Select, { OptionProps, components } from "react-select"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowRight, + faQuestionCircle, +} from "@fortawesome/free-solid-svg-icons"; +import ReactTooltip from "react-tooltip"; +import GlobalAppContext from "../../utils/GlobalAppContext"; +import { FlairEnum, Flair } from "../../utils/constants"; +import type { FlairName } from "../../utils/constants"; +import Username from "../../common/Username"; +import queryClient from "../../utils/QueryClient"; +import useUserFlairs from "../../utils/FlairLoader"; + +function CustomOption( + props: OptionProps<{ value: Flair; label: FlairName; username: string }> +) { + const { label, data } = props; + return ( + +

+ {label} + + + + +
+ + ); +} + +export default function FlairsSettings() { + /* Cast enum keys to array so we can map them to select options */ + const flairNames = Object.keys(FlairEnum) as FlairName[]; + + const globalContext = React.useContext(GlobalAppContext); + const { currentUser, APIService, flair: currentFlair } = globalContext; + const { name } = currentUser; + + const [selectedFlair, setSelectedFlair] = React.useState( + currentFlair ?? FlairEnum.None + ); + // If this has a value it should tell us if the flair is active, + // as calculated on the back-end + const currentUnlockedFlair = useUserFlairs(name); + // However we also hit the metabrainz nag-check endpoint to comfirm that + // and get a number of days left + const [flairUnlocked, setFlairUnlocked] = React.useState( + Boolean(currentUnlockedFlair) + ); + const [unlockDaysLeft, setUnlockDaysLeft] = React.useState(0); + React.useEffect(() => { + async function fetchNagStatus() { + try { + const response = await fetch( + `https://metabrainz.org/donations/nag-check?editor=${name}` + ); + const values = await response.text(); + const [shouldNag, daysLeft] = values.split(","); + setFlairUnlocked(Number(shouldNag) === 1); + setUnlockDaysLeft(Number(daysLeft)); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Could not fetch nag status:", error); + } + } + fetchNagStatus(); + }, [name]); + + const submitFlairPreferences = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentUser?.auth_token) { + toast.error("You must be logged in to update your preferences"); + return; + } + try { + const response = await APIService.submitFlairPreferences( + currentUser?.auth_token, + selectedFlair + ); + toast.success("Flair saved successfully"); + globalContext.flair = selectedFlair; + queryClient.invalidateQueries({ queryKey: ["flair"] }); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to update flair preferences:", error); + toast.error("Failed to update flair preferences. Please try again."); + } + }; + + return ( +
+
+ + Every $5 donation unlocks flairs for 1 month, +
+ with larger donations extending the duration. +
+ Donations stack up, adding more months +
+ of unlocked flairs with each contribution. +
+

Flair Settings

+

+ Unlock for a month or more by donating +   + + .
+ Some flairs are only visible on hover. +

+ {flairUnlocked ? ( +
+ Your flair is unlocked for another{" "} + {Math.round(unlockDaysLeft)} days. +
+ ) : ( +
+ Flairs are currently locked; you can choose a flair below but it + will not be shown on the website until your next donation.{" "} + +
+ )} +
+
+