{page === "preferences" ? (
-
+ loggedInData.settings ? (
+
+ ) : (
+
+
+ No game settings exist yet for this profile. Submit a score or
+ import first, then return here.
+
+
+ )
) : page === "showcase" ? (
-
+ loggedInData.settings ? (
+
+ ) : (
+
+
+ No game settings exist yet for this profile. Submit a score or
+ import first, then return here.
+
+
+ )
) : (
)}
@@ -117,7 +141,7 @@ function PreferencesForm({
game,
playtype,
loggedInData,
-}: { loggedInData: UGPTData } & UGPT) {
+}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
@@ -127,17 +151,17 @@ function PreferencesForm({
const formik = useFormik({
initialValues: {
preferredScoreAlg:
- settings!.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
+ settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
preferredProfileAlg:
- settings!.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
+ settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
preferredSessionAlg:
- settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
+ settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- gameSpecific: settings!.preferences.gameSpecific as any,
- defaultTable: settings!.preferences.defaultTable,
+ gameSpecific: settings.preferences.gameSpecific as any,
+ defaultTable: settings.preferences.defaultTable,
preferredDefaultEnum:
- settings!.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
- preferredRanking: settings!.preferences.preferredRanking ?? "global",
+ settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
+ preferredRanking: settings.preferences.preferredRanking ?? "global",
},
onSubmit: async (values) => {
const rj = await APIFetchV1
(
@@ -177,7 +201,7 @@ function PreferencesForm({
}
const displayableTables = tables.filter(
- (e) => !e.inactive || settings?.preferences.defaultTable === e.tableID,
+ (e) => !e.inactive || settings.preferences.defaultTable === e.tableID,
);
const formGroupClassNames = "d-flex flex-column";
@@ -407,12 +431,12 @@ function ShowcaseForm({
game,
playtype,
loggedInData,
-}: { loggedInData: UGPTData } & UGPT) {
+}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
const { setLoggedInData } = useContext(UGPTContext);
const settings = loggedInData.settings;
- const [stats, setStats] = useState(settings!.preferences.stats);
+ const [stats, setStats] = useState(settings.preferences.stats);
const [show, setShow] = useState(false);
const SaveChanges = async () => {
diff --git a/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx b/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx
index f636107a9..56e838754 100644
--- a/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx
+++ b/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx
@@ -28,7 +28,7 @@ export default function IIDXStyleSongChartInfoFormat({
function LevelText(chart: MONGO_ChartDocument, game: GameGroup) {
if ("tableFolders" in chart.data) {
- const hasLevel = chart.data.tableFolders.length > 0;
+ const hasLevel = Object.keys(chart.data.tableFolders).length > 0;
return hasLevel ? FormatTables(chart.data.tableFolders) : "No Level";
}
return FormatDifficulty(chart, game);
diff --git a/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx b/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx
index 8adcf3fcb..e3a3bb227 100644
--- a/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx
+++ b/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx
@@ -100,7 +100,7 @@ function Component({ game, playtype }: UGPT) {
BMS_TABLES.map((e) => [
e.asciiPrefix,
(d: DatasetElement) =>
- !!d.data.tableFolders.find((k) => k.table === e.prefix),
+ !!Object.keys(d.data.tableFolders).find((k) => k === e.prefix),
]),
)}
/>
diff --git a/typescript/client/src/components/layout/CustomScrollbar.module.scss b/typescript/client/src/components/layout/CustomScrollbar.module.scss
new file mode 100644
index 000000000..e5a78e307
--- /dev/null
+++ b/typescript/client/src/components/layout/CustomScrollbar.module.scss
@@ -0,0 +1,37 @@
+.track {
+ position: fixed;
+ top: 0;
+ right: 0;
+ // Above .fixed-top (1030), below modal/offcanvas layers (~1050+)
+ z-index: 1035;
+ box-sizing: border-box;
+ width: var(--custom-scrollbar-track-width, 12px);
+ height: 100vh;
+ height: 100dvh;
+ padding: 2px;
+ pointer-events: auto;
+ touch-action: pan-y;
+}
+
+.trackInner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border-radius: 999px;
+ background-color: rgba(var(--bs-tertiary-bg-rgb), 0.35);
+}
+
+.thumb {
+ position: absolute;
+ right: 0;
+ left: 0;
+ cursor: grab;
+ touch-action: none;
+ border-radius: 999px;
+ background-color: rgba(var(--bs-secondary-rgb), 0.45);
+ transition: background-color 0.12s ease;
+
+ &:hover {
+ background-color: rgba(var(--bs-secondary-rgb), 0.65);
+ }
+}
diff --git a/typescript/client/src/components/layout/CustomScrollbar.tsx b/typescript/client/src/components/layout/CustomScrollbar.tsx
new file mode 100644
index 000000000..057239eed
--- /dev/null
+++ b/typescript/client/src/components/layout/CustomScrollbar.tsx
@@ -0,0 +1,134 @@
+import React, { useCallback, useEffect, useRef, useState } from "react";
+
+import classes from "./CustomScrollbar.module.scss";
+
+const THUMB_MIN_PX = 32;
+
+function readMetrics() {
+ const root = document.documentElement;
+ return {
+ scrollTop: root.scrollTop,
+ scrollHeight: root.scrollHeight,
+ clientHeight: root.clientHeight,
+ };
+}
+
+/**
+ * Hides the native document scrollbar via global CSS; renders a fixed overlay
+ * track so layout width stays stable while scrolling.
+ */
+export function CustomScrollbar() {
+ const [metrics, setMetrics] = useState(readMetrics);
+ const thumbRef = useRef(null);
+
+ const update = useCallback(() => {
+ setMetrics(readMetrics());
+ }, []);
+
+ useEffect(() => {
+ update();
+ window.addEventListener("scroll", update, { passive: true });
+ window.addEventListener("resize", update);
+ const ro = new ResizeObserver(update);
+ ro.observe(document.documentElement);
+ ro.observe(document.body);
+ return () => {
+ window.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ ro.disconnect();
+ };
+ }, [update]);
+
+ const { scrollTop, scrollHeight, clientHeight } = metrics;
+ const scrollable = Math.max(0, scrollHeight - clientHeight);
+ const show = scrollable > 1;
+
+ const thumbH =
+ scrollable <= 0 ? 0 : Math.max(THUMB_MIN_PX, (clientHeight / scrollHeight) * clientHeight);
+ const thumbTravel = Math.max(0, clientHeight - thumbH);
+ const thumbTop = scrollable <= 0 ? 0 : (scrollTop / scrollable) * thumbTravel;
+
+ const onThumbPointerDown = (e: React.PointerEvent) => {
+ if (e.button !== 0) {
+ return;
+ }
+ e.preventDefault();
+ const startY = e.clientY;
+ const startScrollTop = document.documentElement.scrollTop;
+
+ const onMove = (ev: PointerEvent) => {
+ const cur = readMetrics();
+ const sc = Math.max(0, cur.scrollHeight - cur.clientHeight);
+ if (sc <= 0) {
+ return;
+ }
+ const tH = Math.max(
+ THUMB_MIN_PX,
+ (cur.clientHeight / cur.scrollHeight) * cur.clientHeight,
+ );
+ const tTrack = Math.max(0, cur.clientHeight - tH);
+ if (tTrack <= 0) {
+ return;
+ }
+ const deltaY = ev.clientY - startY;
+ const next = startScrollTop + (deltaY / tTrack) * sc;
+ document.documentElement.scrollTop = Math.max(0, Math.min(sc, next));
+ };
+
+ const onUp = (ev: PointerEvent) => {
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ window.removeEventListener("pointercancel", onUp);
+ document.body.style.removeProperty("user-select");
+ if (thumbRef.current?.hasPointerCapture(ev.pointerId)) {
+ thumbRef.current.releasePointerCapture(ev.pointerId);
+ }
+ };
+
+ thumbRef.current?.setPointerCapture(e.pointerId);
+ document.body.style.userSelect = "none";
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ window.addEventListener("pointercancel", onUp);
+ };
+
+ const onTrackPointerDown = (e: React.PointerEvent) => {
+ if (e.button !== 0 || thumbRef.current?.contains(e.target as Node)) {
+ return;
+ }
+ const thumbEl = thumbRef.current;
+ if (!thumbEl) {
+ return;
+ }
+ const thumbRect = thumbEl.getBoundingClientRect();
+ const mid = thumbRect.top + thumbRect.height / 2;
+ const page = clientHeight * 0.85;
+ if (e.clientY < mid) {
+ document.documentElement.scrollBy({ top: -page, behavior: "smooth" });
+ } else {
+ document.documentElement.scrollBy({ top: page, behavior: "smooth" });
+ }
+ };
+
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/typescript/client/src/components/layout/LocalDevMissingSeedsBanner.tsx b/typescript/client/src/components/layout/LocalDevMissingSeedsBanner.tsx
new file mode 100644
index 000000000..9e7f9fe01
--- /dev/null
+++ b/typescript/client/src/components/layout/LocalDevMissingSeedsBanner.tsx
@@ -0,0 +1,39 @@
+import { APIFetchV1 } from "#util/api";
+import React, { useEffect, useState } from "react";
+import Alert from "react-bootstrap/Alert";
+
+/**
+ * Shown only when the Vite local-dev client talks to a dev server whose Postgres `song`
+ * table is empty (seeds were never loaded).
+ */
+export function LocalDevMissingSeedsBanner() {
+ const [show, setShow] = useState(false);
+
+ useEffect(() => {
+ if (!import.meta.env.VITE_IS_LOCAL_DEV) {
+ return;
+ }
+
+ void APIFetchV1<{ missingSongSeeds: boolean }>("/localdev/song-seed-status").then((r) => {
+ if (r.success && r.body.missingSongSeeds) {
+ setShow(true);
+ }
+ });
+ }, []);
+
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+ There are no seeds in the database.
+ Have you forgotten to run just db-load-seeds?
+
+ );
+}
diff --git a/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx b/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx
index b5740abbb..0c3f9e9ea 100644
--- a/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx
+++ b/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx
@@ -25,7 +25,7 @@ export default function BMSOrPMSDifficultyCell({
settings: MONGO_UGPTSettingsDocument<"bms:7K" | "bms:14K">;
};
- const hasLevel = chart.data.tableFolders.length > 0;
+ const hasLevel = Object.keys(chart.data.tableFolders).length > 0;
const aiLevel =
game === "bms" && (chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">).data.aiLevel;
@@ -33,18 +33,35 @@ export default function BMSOrPMSDifficultyCell({
let levelText = "No Rating";
let backgroundColour = hasLevel ? COLOUR_SET.red : COLOUR_SET.gray;
- let filteredTables: Array<{ level: string; table: string }> = [];
+ let filteredTables: Record = {};
if (hasLevel) {
let tables = chart.data.tableFolders;
if (settings?.preferences.gameSpecific.displayTables) {
- filteredTables = tables.filter(
- (e) => !settings.preferences.gameSpecific.displayTables!.includes(e.table),
- );
- tables = tables.filter((e) =>
- settings.preferences.gameSpecific.displayTables!.includes(e.table),
- );
+ filteredTables = Object.entries(tables)
+ .filter(
+ ([table, _]) =>
+ !settings.preferences.gameSpecific.displayTables!.includes(table),
+ )
+ .reduce(
+ (acc, [table, level]) => {
+ acc[table] = level;
+ return acc;
+ },
+ {} as Record,
+ );
+ tables = Object.entries(tables)
+ .filter(([table, _]) =>
+ settings.preferences.gameSpecific.displayTables!.includes(table),
+ )
+ .reduce(
+ (acc, [table, level]) => {
+ acc[table] = level;
+ return acc;
+ },
+ {} as Record,
+ );
}
levelText = FormatTables(tables);
@@ -71,7 +88,7 @@ export default function BMSOrPMSDifficultyCell({
>
- ) : filteredTables.length > 0 ? (
+ ) : Object.keys(filteredTables).length > 0 ? (