16 Commits

Author SHA1 Message Date
7144db7e73 Update dependency @types/node to v22 2024-10-29 00:05:33 +00:00
e9c03a662e add is prod check to Sentry
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m46s
2024-10-28 22:14:00 +00:00
18aaba86be testing sentry stuff
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m49s
2024-10-28 21:53:37 +00:00
72a9cee7af testing sentry stuff
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m3s
2024-10-28 21:44:15 +00:00
314ade1457 testing sentry stuff
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m19s
2024-10-28 21:38:23 +00:00
01cc5d8c48 testing sentry stuff 2024-10-28 21:36:39 +00:00
e5f0bd0595 bundle analyzer
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m37s
2024-10-28 21:31:04 +00:00
3c7cedc529 fix tge fix
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m19s
2024-10-28 21:08:02 +00:00
e88fb50b14 change song author and mapper name sizing
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m32s
2024-10-28 21:05:29 +00:00
6b30c6efed cleanup
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m33s
2024-10-28 21:01:12 +00:00
ebb91344bc fix previous score timestamp hover
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-28 19:10:30 +00:00
02015525e3 oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-28 19:01:47 +00:00
c1f33578d7 fix vs for mobile
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-28 16:33:21 +00:00
0ec1fc9d41 oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m19s
2024-10-28 16:30:12 +00:00
1c2214a659 made the player page look much nicer
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-28 16:22:33 +00:00
f156b7f582 fix previous score
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-28 15:47:41 +00:00
31 changed files with 355 additions and 192 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -56,15 +56,10 @@ export async function initDiscordBot() {
export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) {
try {
const channel = await client.channels.fetch(channelId);
if (channel == undefined) {
throw new Error(`Channel "${channelId}" not found`);
if (channel != undefined && channel.isSendable()) {
channel.send({ embeds: [message] });
}
if (!channel.isSendable()) {
throw new Error(`Channel "${channelId}" is not sendable`);
}
channel.send({ embeds: [message] });
} catch (error) {
console.error(error);
} catch {
/* empty */
}
}

View File

@ -64,7 +64,7 @@ export const app = new Elysia();
app.use(
cron({
name: "player-statistics-tracker-cron",
pattern: "0 1 * * * *", // Every day at 00:01
pattern: "0 1 * * *", // Every day at 00:01
timezone: "Europe/London", // UTC time
protect: true,
run: async () => {

View File

@ -488,6 +488,8 @@ export class ScoreService {
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
if (!score) return undefined;
console.log("boobs");
// Fetch additional data, previous score, and BeatSaver map concurrently
const [additionalData, previousScore, beatSaverMap] = await Promise.all([
this.getAdditionalScoreData(
@ -679,7 +681,9 @@ export class ScoreService {
leaderboardId: string,
timestamp: Date
): Promise<ScoreSaberPreviousScore | undefined> {
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId });
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId }).sort({
timestamp: -1,
});
if (scores == null || scores.length == 0) {
return undefined;
}
@ -689,7 +693,7 @@ export class ScoreService {
if (scoreIndex == -1 || score == undefined) {
return undefined;
}
const previousScore = scores[scoreIndex - 1];
const previousScore = scores[scoreIndex + 1];
if (previousScore == undefined) {
return undefined;
}
@ -704,6 +708,7 @@ export class ScoreService {
pp: previousScore.pp,
weight: previousScore.weight,
maxCombo: previousScore.maxCombo,
timestamp: previousScore.timestamp,
change: {
score: score.score - previousScore.score,
accuracy: score.accuracy - previousScore.accuracy,

View File

@ -11,5 +11,6 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react",
},
"incremental": true
}
}

View File

@ -35,4 +35,9 @@ export type PreviousScore = {
* The full combo of the previous score.
*/
fullCombo?: boolean;
/**
* When the previous score was set.
*/
timestamp: Date;
};

View File

@ -14,6 +14,8 @@ WORKDIR /app
ENV NODE_ENV production
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
# Copy the depends
COPY --from=depends /app/package.json* /app/bun.lockb* ./

View File

@ -1,6 +1,8 @@
import { withSentryConfig } from "@sentry/nextjs";
import { format } from "@formkit/tempo";
import nextBundleAnalyzer from "@next/bundle-analyzer";
import type { NextConfig } from "next";
import { isProduction } from "@/common/website-utils";
const nextConfig: NextConfig = {
experimental: {
@ -36,22 +38,22 @@ const nextConfig: NextConfig = {
},
};
export default withSentryConfig(nextConfig, {
org: "scoresaber-reloaded",
project: "frontend",
sentryUrl: "https://glitchtip.fascinated.cc/",
silent: !process.env.CI,
reactComponentAnnotation: {
enabled: true,
},
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
sourcemaps: {
disable: true,
},
release: {
create: false,
finalize: false,
},
const withBundleAnalyzer = nextBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const config = withBundleAnalyzer(nextConfig);
export default isProduction()
? withSentryConfig(config, {
org: "fascinatedcc",
project: "scoresaber-reloaded",
silent: !process.env.CI,
widenClientFileUpload: true,
reactComponentAnnotation: {
enabled: true,
},
hideSourceMaps: true,
disableLogger: true,
})
: config;

View File

@ -6,6 +6,7 @@
"dev": "next dev --turbo",
"dev-debug": "cross-env NODE_OPTIONS='--inspect' next dev --turbo",
"build": "next build",
"build:analyze": "cross-env ANALYZE=true next build",
"start": "next start",
"lint": "next lint"
},
@ -13,6 +14,7 @@
"@formkit/tempo": "^0.1.2",
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0",
"@next/bundle-analyzer": "^15.0.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
@ -22,7 +24,7 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@sentry/nextjs": "8.35.0",
"@sentry/nextjs": "8",
"@ssr/common": "workspace:*",
"@tanstack/react-query": "^5.55.4",
"@uidotdev/usehooks": "^2.4.1",
@ -30,12 +32,13 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"comlink": "^4.4.1",
"cross-env": "^7.0.3",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^11.5.4",
"js-cookie": "^3.0.5",
"ky": "^1.7.2",
"lucide-react": "^0.454.0",
"lucide-react": "^0.453.0",
"next": "^15.0.1",
"next-build-id": "^3.0.0",
"next-themes": "^0.3.0",
@ -52,7 +55,7 @@
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/node": "^22.0.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",

View File

@ -5,8 +5,24 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://69aed8b4a32e45db8fcb1b4285b4370f@glitchtip.fascinated.cc/13",
tracesSampleRate: 0.1,
dsn: "https://2b0d6c2e72099dee7db2ce9c030651bd@o4508202509205504.ingest.de.sentry.io/4508202511302736",
// Add optional integrations for additional features
integrations: [
Sentry.replayIntegration(),
],
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV === "production",
});

View File

@ -6,8 +6,11 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://69aed8b4a32e45db8fcb1b4285b4370f@glitchtip.fascinated.cc/13",
tracesSampleRate: 0.1,
dsn: "https://2b0d6c2e72099dee7db2ce9c030651bd@o4508202509205504.ingest.de.sentry.io/4508202511302736",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV === "production",
});

View File

@ -5,8 +5,11 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://69aed8b4a32e45db8fcb1b4285b4370f@glitchtip.fascinated.cc/13",
tracesSampleRate: 0.1,
dsn: "https://2b0d6c2e72099dee7db2ce9c030651bd@o4508202509205504.ingest.de.sentry.io/4508202511302736",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV === "production",
});

View File

@ -56,6 +56,7 @@ export default function PlayerScoreAccuracyChart({ scoreStats, leaderboard }: Pr
axisConfig: {
reverse: false,
display: true,
hideOnMobile: true,
displayName: "PP",
position: "right",
},

View File

@ -21,7 +21,7 @@ export function HandAccuracyBadge({ score, hand }: HandAccuracyProps) {
const formattedHand = capitalizeFirstLetter(hand);
return (
<div className="flex flex-col items-center justify-center">
<div className="flex gap-1 items-center justify-center">
<Tooltip
display={
<>

View File

@ -1,9 +1,7 @@
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import { PauseIcon } from "@heroicons/react/24/solid";
import { ScoreBadgeProps } from "@/components/score/badges/badge-props";
import { ScoreMissesTooltip } from "@/components/score/score-misses-tooltip";
import { Misses } from "@ssr/common/model/additional-score-data/misses";
import Tooltip from "@/components/tooltip";
type ScoreMissesBadgeProps = ScoreBadgeProps & {
/**
@ -34,69 +32,82 @@ export default function ScoreMissesAndPausesBadge({ score, hideXMark }: ScoreMis
return (
<div className="flex flex-col justify-center items-center w-full">
<div className="flex items-center gap-1">
<ScoreMissesTooltip
missedNotes={score.missedNotes}
badCuts={score.badCuts}
bombCuts={misses?.bombCuts}
wallsHit={misses?.wallsHit}
fullCombo={score.fullCombo}
>
<div className="flex items-center">
<div className="flex items-center gap-1">
<ScoreMissesTooltip
missedNotes={score.missedNotes}
badCuts={score.badCuts}
bombCuts={misses?.bombCuts}
wallsHit={misses?.wallsHit}
fullCombo={score.fullCombo}
>
<p>
{score.fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.misses)}
{!hideXMark && !score.fullCombo && <span>x</span>}
</p>
</div>
</ScoreMissesTooltip>
{additionalData && !!pauses && pauses > 0 && (
<>
<p>|</p>
<Tooltip
display={
<p>
{pauses}x Pause{pauses > 1 ? "s" : ""}
</p>
}
</ScoreMissesTooltip>
{additionalData && previousScoreMisses && scoreImprovement && misses && isMissImprovement && (
<ScoreMissesTooltip
missedNotes={previousScoreMisses.missedNotes}
badCuts={previousScoreMisses.badCuts}
bombCuts={previousScoreMisses.bombCuts}
wallsHit={previousScoreMisses.wallsHit}
fullCombo={previousScoreFc}
>
<div className="flex gap-1 items-center">
<p>{pauses && pauses}</p>
<PauseIcon className="w-4 h-4" />
<div className="text-xs flex flex-row gap-1">
<p>(vs {previousScoreFc ? "FC" : formatNumberWithCommas(previousScoreMisses.misses)}x)</p>
</div>
</Tooltip>
</>
)}
</div>
{additionalData && previousScoreMisses && scoreImprovement && misses && isMissImprovement && (
<div className="flex gap-2 items-center justify-center">
<ScoreMissesTooltip
missedNotes={previousScoreMisses.missedNotes}
badCuts={previousScoreMisses.badCuts}
bombCuts={previousScoreMisses.bombCuts}
wallsHit={previousScoreMisses.wallsHit}
fullCombo={previousScoreFc}
>
<div className="flex gap-1 items-center text-xs">
{previousScoreFc ? (
<p className="text-green-400">FC</p>
) : (
formatNumberWithCommas(previousScoreMisses.misses)
)}
</div>
</ScoreMissesTooltip>
<p>-&gt;</p>
<ScoreMissesTooltip
missedNotes={misses.missedNotes}
badCuts={misses.badCuts}
bombCuts={misses.bombCuts}
wallsHit={misses.wallsHit}
fullCombo={additionalData.fullCombo}
>
<div className="flex gap-1 items-center text-xs">
{additionalData.fullCombo ? <p className="text-green-400">FC</p> : formatNumberWithCommas(misses.misses)}
</div>
</ScoreMissesTooltip>
</ScoreMissesTooltip>
)}
</div>
)}
{/*{additionalData && !!pauses && pauses > 0 && (*/}
{/* <>*/}
{/* <p>|</p>*/}
{/* <Tooltip*/}
{/* display={*/}
{/* <p>*/}
{/* {pauses}x Pause{pauses > 1 ? "s" : ""}*/}
{/* </p>*/}
{/* }*/}
{/* >*/}
{/* <div className="flex gap-1 items-center">*/}
{/* <p>{pauses && pauses}</p>*/}
{/* <PauseIcon className="w-4 h-4" />*/}
{/* </div>*/}
{/* </Tooltip>*/}
{/* </>*/}
{/*)}*/}
</div>
{/*{additionalData && previousScoreMisses && scoreImprovement && misses && isMissImprovement && (*/}
{/* <div className="flex gap-2 items-center justify-center">*/}
{/* <ScoreMissesTooltip*/}
{/* missedNotes={previousScoreMisses.missedNotes}*/}
{/* badCuts={previousScoreMisses.badCuts}*/}
{/* bombCuts={previousScoreMisses.bombCuts}*/}
{/* wallsHit={previousScoreMisses.wallsHit}*/}
{/* fullCombo={previousScoreFc}*/}
{/* >*/}
{/* <div className="flex gap-1 items-center text-xs">*/}
{/* {previousScoreFc ? (*/}
{/* <p className="text-green-400">FC</p>*/}
{/* ) : (*/}
{/* formatNumberWithCommas(previousScoreMisses.misses)*/}
{/* )}*/}
{/* </div>*/}
{/* </ScoreMissesTooltip>*/}
{/* <p>-&gt;</p>*/}
{/* <ScoreMissesTooltip*/}
{/* missedNotes={misses.missedNotes}*/}
{/* badCuts={misses.badCuts}*/}
{/* bombCuts={misses.bombCuts}*/}
{/* wallsHit={misses.wallsHit}*/}
{/* fullCombo={additionalData.fullCombo}*/}
{/* >*/}
{/* <div className="flex gap-1 items-center text-xs">*/}
{/* {additionalData.fullCombo ? <p className="text-green-400">FC</p> : formatNumberWithCommas(misses.misses)}*/}
{/* </div>*/}
{/* </ScoreMissesTooltip>*/}
{/* </div>*/}
{/*)}*/}
</div>
);
}

View File

@ -0,0 +1,21 @@
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
import ScoreButton from "@/components/score/button/score-button";
import * as React from "react";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
type BeatSaverMapProps = {
beatSaverMap: BeatSaverMap;
};
export function BeatSaverMapButton({ beatSaverMap }: BeatSaverMapProps) {
return (
<ScoreButton
onClick={() => {
window.open(`https://beatsaver.com/maps/${beatSaverMap.bsr}`, "_blank");
}}
tooltip={<p>Click to open the map</p>}
>
<BeatSaverLogo />
</ScoreButton>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { copyToClipboard } from "@/common/browser-utils";
import ScoreButton from "@/components/score/button/score-button";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import * as React from "react";
import { toast } from "@/hooks/use-toast";
type ScoreBsrButtonProps = {
beatSaverMap: BeatSaverMap;
};
export function ScoreBsrButton({ beatSaverMap }: ScoreBsrButtonProps) {
return (
<ScoreButton
onClick={() => {
toast({
title: "Copied!",
description: `Copied "!bsr ${beatSaverMap.bsr}" to your clipboard!`,
});
copyToClipboard(`!bsr ${beatSaverMap.bsr}`);
}}
tooltip={<p>Click to copy the bsr code</p>}
>
<p>!</p>
</ScoreButton>
);
}

View File

@ -6,8 +6,8 @@ import { Slider } from "@/components/ui/slider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ResetIcon } from "@radix-ui/react-icons";
import Tooltip from "@/components/tooltip";
import { ScoreSaberLeaderboard } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
type ScoreEditorButtonProps = {
score: ScoreSaberScore;

View File

@ -0,0 +1,21 @@
import BeatSaberPepeLogo from "@/components/logos/beatsaber-pepe-logo";
import ScoreButton from "@/components/score/button/score-button";
import * as React from "react";
import { AdditionalScoreData } from "@ssr/common/model/additional-score-data/additional-score-data";
type ScoreReplayButton = {
additionalData: AdditionalScoreData;
};
export function ScoreReplayButton({ additionalData }: ScoreReplayButton) {
return (
<ScoreButton
onClick={() => {
window.open(`https://replay.beatleader.xyz/?scoreId=${additionalData.scoreId}`, "_blank");
}}
tooltip={<p>Click to view the score replay!</p>}
>
<BeatSaberPepeLogo />
</ScoreButton>
);
}

View File

@ -0,0 +1,25 @@
import { songNameToYouTubeLink } from "@/common/youtube-utils";
import YouTubeLogo from "@/components/logos/youtube-logo";
import ScoreButton from "@/components/score/button/score-button";
import { ScoreSaberLeaderboard } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import * as React from "react";
type SongOpenInYoutubeButtonProps = {
leaderboard: ScoreSaberLeaderboard;
};
export function SongOpenInYoutubeButton({ leaderboard }: SongOpenInYoutubeButtonProps) {
return (
<ScoreButton
onClick={() => {
window.open(
songNameToYouTubeLink(leaderboard.songName, leaderboard.songSubName, leaderboard.songAuthorName),
"_blank"
);
}}
tooltip={<p>Click to open the song in YouTube</p>}
>
<YouTubeLogo />
</ScoreButton>
);
}

View File

@ -1,6 +1,6 @@
import StatValue from "@/components/stat-value";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import clsx from "clsx";
/**
* A badge to display in the score stats.
@ -27,6 +27,19 @@ export function ScoreBadges({ badges, score, leaderboard }: ScoreBadgeProps) {
if (toRender === undefined) {
return <div key={index} />;
}
return <StatValue key={index} color={color} value={toRender} />;
return (
<div
key={index}
className={clsx(
"flex h-fit p-1 items-center justify-center rounded-md text-sm cursor-default",
color ? color : "bg-accent"
)}
style={{
backgroundColor: (!color?.includes("bg") && color) || undefined,
}}
>
{toRender}
</div>
);
});
}

View File

@ -1,20 +1,17 @@
"use client";
import { songNameToYouTubeLink } from "@/common/youtube-utils";
import BeatSaverLogo from "@/components/logos/beatsaver-logo";
import YouTubeLogo from "@/components/logos/youtube-logo";
import { useToast } from "@/hooks/use-toast";
import * as React from "react";
import { useState } from "react";
import ScoreButton from "./score-button";
import { copyToClipboard } from "@/common/browser-utils";
import { ArrowDownIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import ScoreEditorButton from "@/components/score/score-editor-button";
import ScoreEditorButton from "@/components/score/button/score-editor-button";
import { ScoreBsrButton } from "@/components/score/button/score-bsr-button";
import { BeatSaverMapButton } from "@/components/score/button/beat-saver-map-button";
import { SongOpenInYoutubeButton } from "@/components/score/button/song-open-in-youtube-button";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import { ScoreSaberLeaderboard } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import BeatSaberPepeLogo from "@/components/logos/beatsaber-pepe-logo";
import { ScoreReplayButton } from "@/components/score/button/score-replay-button";
type Props = {
score?: ScoreSaberScore;
@ -33,78 +30,39 @@ export default function ScoreButtons({
leaderboard,
beatSaverMap,
alwaysSingleLine,
setIsLeaderboardExpanded,
isLeaderboardLoading,
updateScore,
hideLeaderboardDropdown,
hideAccuracyChanger,
isLeaderboardLoading,
setIsLeaderboardExpanded,
updateScore,
}: Props) {
const [leaderboardExpanded, setLeaderboardExpanded] = useState(false);
const { toast } = useToast();
const additionalData = score?.additionalData;
return (
<div className={`flex justify-end gap-2 items-center`}>
<div
className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`}
>
<div className={`flex justify-end gap-2 items-center mr-1`}>
<div className={`flex lg:grid grid-cols-3 gap-1 items-center justify-center min-w-[92px]`}>
{beatSaverMap != undefined && (
<>
{/* Copy BSR */}
<ScoreButton
onClick={() => {
toast({
title: "Copied!",
description: `Copied "!bsr ${beatSaverMap.bsr}" to your clipboard!`,
});
copyToClipboard(`!bsr ${beatSaverMap.bsr}`);
}}
tooltip={<p>Click to copy the bsr code</p>}
>
<p>!</p>
</ScoreButton>
{/* Open map in BeatSaver */}
<ScoreButton
onClick={() => {
window.open(`https://beatsaver.com/maps/${beatSaverMap.bsr}`, "_blank");
}}
tooltip={<p>Click to open the map</p>}
>
<BeatSaverLogo />
</ScoreButton>
<ScoreBsrButton beatSaverMap={beatSaverMap} />
<BeatSaverMapButton beatSaverMap={beatSaverMap} />
</>
)}
{/* Open song in YouTube */}
<ScoreButton
onClick={() => {
window.open(
songNameToYouTubeLink(leaderboard.songName, leaderboard.songSubName, leaderboard.songAuthorName),
"_blank"
);
}}
tooltip={<p>Click to open the song in YouTube</p>}
>
<YouTubeLogo />
</ScoreButton>
<SongOpenInYoutubeButton leaderboard={leaderboard} />
<div className="hidden lg:block" />
<div className="hidden lg:block" />
{additionalData != undefined && (
<>
{/* Open score replay */}
<ScoreButton
onClick={() => {
window.open(`https://replay.beatleader.xyz/?scoreId=${additionalData.scoreId}`, "_blank");
}}
tooltip={<p>Click to view the score replay!</p>}
>
<BeatSaberPepeLogo />
</ScoreButton>
<ScoreReplayButton additionalData={additionalData} />
</>
)}
</div>
<div
className={`flex gap-2 ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center`}
className={`flex gap-2 ${alwaysSingleLine ? "flex-row" : "flex-row lg:flex-col"} items-center justify-center pr-1`}
>
{/* Edit score button */}
{score && leaderboard && updateScore && !hideAccuracyChanger && (

View File

@ -5,13 +5,14 @@ import { getPageFromRank } from "@ssr/common/utils/utils";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import { ScoreTimeSet } from "@/components/score/score-time-set";
import { ScoreTimeSetVs } from "@/components/score/score-time-set-vs";
type Props = {
score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboard;
};
export default function ScoreRankInfo({ score, leaderboard }: Props) {
export default function ScoreRankAndDateInfo({ score, leaderboard }: Props) {
return (
<div className="flex w-full flex-row justify-between lg:w-[125px] lg:flex-col lg:justify-center items-center">
<div className="flex gap-1 items-center">
@ -22,7 +23,10 @@ export default function ScoreRankInfo({ score, leaderboard }: Props) {
</p>
</Link>
</div>
<ScoreTimeSet score={score} />
<div className="flex items-center gap-2 lg:flex-col lg:gap-0">
<ScoreTimeSet score={score} />
<ScoreTimeSetVs score={score} />
</div>
</div>
);
}

View File

@ -56,18 +56,20 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
/>
</div>
<div className="flex">
<div className="overflow-y-clip">
<div className="overflow-y-clip flex flex-col gap-1">
<Link
href={`/leaderboard/${leaderboard.id}`}
className="cursor-pointer select-none hover:brightness-[66%] transform-gpu transition-all text-ssr w-fit"
>
{leaderboard.songName} {leaderboard.songSubName}
</Link>
<div className="flex flex-col text-sm">
<div className="flex flex-row text-sm gap-1.5 items-end leading-none">
<p className="text-gray-400">{leaderboard.songAuthorName}</p>
<FallbackLink
href={mappersProfile}
className={mappersProfile && "hover:brightness-[66%] transform-gpu transition-all w-fit"}
className={
mappersProfile && "hover:brightness-[66%] transform-gpu transition-all w-fit text-xs leading-none"
}
>
{leaderboard.levelAuthorName}
</FallbackLink>

View File

@ -72,8 +72,10 @@ type Props = {
export default function ScoreStats({ score, leaderboard }: Props) {
return (
<div className={`grid grid-cols-3 grid-rows-2 gap-1 ml-0 lg:ml-2 `}>
<ScoreBadges badges={badges} score={score} leaderboard={leaderboard} />
<div className="flex flex-col justify-center h-full">
<div className={`grid grid-cols-3 gap-1 justify-center`}>
<ScoreBadges badges={badges} score={score} leaderboard={leaderboard} />
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { format } from "@formkit/tempo";
import { timeAgo } from "@ssr/common/utils/time-utils";
import Tooltip from "@/components/tooltip";
import { ScoreSaberScore } from "@ssr/common/model/score/impl/scoresaber-score";
type ScoreTimeSetProps = {
/**
* The score that was set.
*/
score: ScoreSaberScore;
};
export function ScoreTimeSetVs({ score }: ScoreTimeSetProps) {
if (!score.previousScore) {
return undefined;
}
return (
<Tooltip
display={
<p>
{format({
date: new Date(score.previousScore.timestamp),
format: "DD MMMM YYYY HH:mm a",
})}
</p>
}
>
<div>
<div className="text-xs text-gray-400 flex flex-row gap-2 items-center">
<p>vs</p>
<p>{timeAgo(new Date(score.previousScore.timestamp))}</p>
</div>
</div>
</Tooltip>
);
}

View File

@ -7,7 +7,7 @@ import { CubeIcon } from "@heroicons/react/24/solid";
import { TrendingUpIcon } from "lucide-react";
import ScoreButtons from "./score-buttons";
import ScoreSongInfo from "./score-song-info";
import ScoreRankInfo from "./score-rank-info";
import ScoreRankAndDateInfo from "./score-rank-and-date-info";
import ScoreStats from "./score-stats";
import Card from "@/components/card";
import { MapStats } from "@/components/score/map-stats";
@ -120,13 +120,13 @@ export default function Score({ leaderboard, beatSaverMap, score, settings, high
};
const gridColsClass = settings?.noScoreButtons
? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" // Fewer columns if no buttons
: "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; // Original with buttons
? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_350px]" // Fewer columns if no buttons
: "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_350px]"; // Original with buttons
return (
<div className="pb-2 pt-2">
<div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
<ScoreRankInfo score={score} leaderboard={leaderboard} />
<ScoreRankAndDateInfo score={score} leaderboard={leaderboard} />
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
{!settings?.noScoreButtons && (
<ScoreButtons

View File

@ -16,18 +16,24 @@ type Props = {
*/
color?: string;
/**
* The additional classes for the stat.
*/
className?: string;
/**
* The value of the stat.
*/
value: React.ReactNode;
};
export default function StatValue({ name, icon, color, value }: Props) {
export default function StatValue({ name, icon, color, className, value }: Props) {
return (
<div
className={clsx(
"flex min-w-16 gap-2 h-full p-1 items-center justify-center rounded-md text-sm cursor-default",
color ? color : "bg-accent"
color ? color : "bg-accent",
className
)}
style={{
backgroundColor: (!color?.includes("bg") && color) || undefined,

View File

@ -2,7 +2,7 @@
import { Tooltip as ShadCnTooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useState } from "react";
import { cn } from "@/common/utils";
import { clsx } from "clsx";
type Props = {
/**
@ -36,16 +36,15 @@ export default function Tooltip({ children, display, asChild = true, side = "top
return (
<ShadCnTooltip open={open}>
<TooltipTrigger className={className} asChild={asChild}>
<div
className={cn("cursor-default", className)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onTouchStart={() => setOpen(!open)}
onTouchEnd={() => setOpen(!open)}
>
{children}
</div>
<TooltipTrigger
className={clsx("cursor-default", className)}
asChild={asChild}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onTouchStart={() => setOpen(!open)}
onTouchEnd={() => setOpen(!open)}
>
{children}
</TooltipTrigger>
<TooltipContent className="max-w-[350px]" side={side}>
{display}

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
export function useIsMobile() {
const checkMobile = () => {
return window.innerWidth < 768;
return window.innerWidth <= 1024;
};
const [isMobile, setIsMobile] = useState(checkMobile());