rhythia-api 217.0.0 → 226.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,20 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi } from "../utils/requestUtils";
4
- import { getCacheValue, setCacheValue } from "../utils/cache";
5
- import { supabase } from "../utils/supabase";
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi } from "../utils/requestUtils";
4
+ import { getCacheValue, setCacheValue } from "../utils/cache";
5
+ import { supabase } from "../utils/supabase";
6
+ import { getActiveProfileIdSet } from "../utils/activityStatus";
6
7
 
7
- export const Schema = {
8
- input: z.strictObject({
9
- session: z.string(),
10
- mapId: z.string(),
11
- limit: z.number().min(1).max(200).default(50),
12
- }),
13
- output: z.object({
14
- error: z.string().optional(),
15
- scores: z
16
- .array(
8
+ export const Schema = {
9
+ input: z.strictObject({
10
+ session: z.string(),
11
+ mapId: z.string(),
12
+ limit: z.number().min(1).max(200).default(50),
13
+ }),
14
+ output: z.object({
15
+ error: z.string().optional(),
16
+ scores: z
17
+ .array(
17
18
  z.object({
18
19
  id: z.number(),
19
20
  awarded_sp: z.number().nullable(),
@@ -64,14 +65,14 @@ export async function POST(request: Request): Promise<NextResponse> {
64
65
  });
65
66
  }
66
67
 
67
- export async function handler(
68
- data: (typeof Schema)["input"]["_type"],
69
- req: Request
70
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
71
- const limit = data.limit ?? 50;
72
-
73
- let { data: beatmapPage, error: errorlast } = await supabase
74
- .from("beatmapPages")
68
+ export async function handler(
69
+ data: (typeof Schema)["input"]["_type"],
70
+ req: Request
71
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
72
+ const limit = data.limit ?? 50;
73
+
74
+ let { data: beatmapPage, error: errorlast } = await supabase
75
+ .from("beatmapPages")
75
76
  .select(
76
77
  `
77
78
  *,
@@ -93,46 +94,54 @@ export async function handler(
93
94
  )
94
95
  `
95
96
  )
96
- .eq("latestBeatmapHash", data.mapId)
97
- .single();
98
-
99
- if (!beatmapPage) return NextResponse.json({});
100
-
101
- const beatmapHash = beatmapPage?.latestBeatmapHash || "";
102
- const isCacheable =
103
- beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
104
- const cacheKey = `beatmap-scores:${beatmapHash}:limit=${limit}`;
105
-
106
- let scoreData: any[] | null = null;
107
-
108
- if (isCacheable && beatmapHash) {
109
- scoreData = await getCacheValue<any[]>(cacheKey);
110
- }
111
-
112
- if (!scoreData) {
113
- const { data: rpcScores, error } = await supabase.rpc(
114
- "get_top_scores_for_beatmap",
115
- { beatmap_hash: beatmapHash }
116
- );
117
-
118
- if (error) {
119
- return NextResponse.json({ error: JSON.stringify(error) });
120
- }
121
-
122
- scoreData = (rpcScores || []).slice(0, limit);
123
-
124
- if (isCacheable && beatmapHash) {
125
- await setCacheValue(cacheKey, scoreData);
126
- }
127
- }
128
-
129
- return NextResponse.json({
130
- scores: (scoreData || []).map((score: any) => ({
131
- id: score.id,
132
- awarded_sp: score.awarded_sp,
133
- created_at: score.created_at,
134
- misses: score.misses,
135
- mods: score.mods,
97
+ .eq("latestBeatmapHash", data.mapId)
98
+ .single();
99
+
100
+ if (!beatmapPage) return NextResponse.json({});
101
+
102
+ const beatmapHash = beatmapPage?.latestBeatmapHash || "";
103
+ const isCacheable =
104
+ beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
105
+ const cacheKey = `beatmap-scores:${beatmapHash}`;
106
+
107
+ let scoreData: any[] | null = null;
108
+
109
+ if (isCacheable && beatmapHash) {
110
+ scoreData = await getCacheValue<any[]>(cacheKey);
111
+ }
112
+
113
+ if (!scoreData) {
114
+ const { data: rpcScores, error } = await supabase.rpc(
115
+ "get_top_scores_for_beatmap",
116
+ { beatmap_hash: beatmapHash }
117
+ );
118
+
119
+ if (error) {
120
+ return NextResponse.json({ error: JSON.stringify(error) });
121
+ }
122
+
123
+ scoreData = (rpcScores || []).slice(0, 200);
124
+
125
+ if (isCacheable && beatmapHash) {
126
+ await setCacheValue(cacheKey, scoreData);
127
+ }
128
+ }
129
+
130
+ const userIds = Array.from(
131
+ new Set((scoreData || []).map((score) => score.userid).filter(Boolean))
132
+ );
133
+ const activeUserIds = await getActiveProfileIdSet(userIds);
134
+ const visibleScores = (scoreData || [])
135
+ .filter((score) => activeUserIds.has(score.userid))
136
+ .slice(0, limit);
137
+
138
+ return NextResponse.json({
139
+ scores: visibleScores.map((score: any) => ({
140
+ id: score.id,
141
+ awarded_sp: score.awarded_sp,
142
+ created_at: score.created_at,
143
+ misses: score.misses,
144
+ mods: score.mods,
136
145
  passed: score.passed,
137
146
  songId: score.songid,
138
147
  speed: score.speed,
@@ -4,6 +4,9 @@ import { protectedApi } from "../utils/requestUtils";
4
4
  import { supabase } from "../utils/supabase";
5
5
  import { getUserBySession } from "../utils/getUserBySession";
6
6
  import { User } from "@supabase/supabase-js";
7
+ import { getScoreActivityCutoffIso } from "../utils/activityStatus";
8
+ import { getCacheValue, setCacheValue } from "../utils/cache";
9
+ import { LEADERBOARD_CACHE_INVALIDATE_KEY } from "../utils/leaderboardCache";
7
10
 
8
11
  export const Schema = {
9
12
  input: z.strictObject({
@@ -11,6 +14,7 @@ export const Schema = {
11
14
  page: z.number().default(1),
12
15
  flag: z.string().optional(),
13
16
  spin: z.boolean().default(false),
17
+ include_inactive: z.boolean().optional().default(false),
14
18
  }),
15
19
  output: z.object({
16
20
  error: z.string().optional(),
@@ -23,6 +27,7 @@ export const Schema = {
23
27
  z.object({
24
28
  flag: z.string().nullable(),
25
29
  id: z.number(),
30
+ avatar_url: z.string().nullable(),
26
31
  username: z.string().nullable(),
27
32
  play_count: z.number().nullable(),
28
33
  skill_points: z.number().nullable(),
@@ -58,74 +63,112 @@ export async function handler(
58
63
  data.page,
59
64
  data.session,
60
65
  data.spin,
61
- data.flag
66
+ data.flag,
67
+ data.include_inactive
62
68
  );
63
69
  return NextResponse.json(result);
64
70
  }
65
71
 
66
72
  const VIEW_PER_PAGE = 50;
73
+ const CACHE_TTL_MS = 4 * 60 * 1000;
67
74
 
68
75
  export async function getLeaderboard(
69
76
  page = 1,
70
77
  session: string,
71
78
  spin: boolean,
72
- flag?: string
79
+ flag?: string,
80
+ includeInactive = false
73
81
  ) {
74
- const getUserData = (await getUserBySession(session)) as User;
82
+ const cutoffIso = getScoreActivityCutoffIso();
83
+ const userPromise = getUserBySession(session) as Promise<User | null>;
84
+ const invalidateAtPromise = getCacheValue<number>(
85
+ LEADERBOARD_CACHE_INVALIDATE_KEY
86
+ );
75
87
 
76
- let leaderPosition = 0;
88
+ const startPage = (page - 1) * VIEW_PER_PAGE;
89
+ const endPage = startPage + VIEW_PER_PAGE - 1;
90
+ const cacheKey = `leaderboard:page=${page}:spin=${spin ? 1 : 0}:flag=${
91
+ flag || "all"
92
+ }:include_inactive=${includeInactive ? 1 : 0}`;
93
+ const cachedPagePromise = getCacheValue<{
94
+ cachedAt: number;
95
+ total: number;
96
+ leaderboard: (typeof Schema)["output"]["_type"]["leaderboard"];
97
+ }>(cacheKey);
77
98
 
78
- if (getUserData) {
79
- let { data: queryData, error } = await supabase
80
- .from("profiles")
81
- .select("*")
82
- .eq("uid", getUserData.id)
83
- .single();
99
+ const [user, invalidateAt, cachedPage] = await Promise.all([
100
+ userPromise,
101
+ invalidateAtPromise,
102
+ cachedPagePromise,
103
+ ]);
84
104
 
85
- if (queryData) {
86
- const { count: playersWithMorePoints, error: rankError } = await supabase
105
+ const cutoffInvalidation = invalidateAt || 0;
106
+ const now = Date.now();
107
+ const cacheFresh = Boolean(
108
+ cachedPage &&
109
+ cachedPage.cachedAt >= cutoffInvalidation &&
110
+ now - cachedPage.cachedAt < CACHE_TTL_MS
111
+ );
112
+
113
+ const pageDataPromise = (async () => {
114
+ if (cachedPage && cacheFresh) {
115
+ return cachedPage;
116
+ }
117
+
118
+ let countQuery;
119
+ let query;
120
+
121
+ if (includeInactive) {
122
+ countQuery = supabase
123
+ .from("profiles")
124
+ .select("id", { count: "exact", head: true })
125
+ .neq("ban", "excluded");
126
+
127
+ query = supabase
128
+ .from("profiles")
129
+ .select(
130
+ "flag,id,avatar_url,username,play_count,skill_points,spin_skill_points,total_score,verified,clans:clan(id, acronym)"
131
+ )
132
+ .neq("ban", "excluded");
133
+ } else {
134
+ countQuery = supabase
87
135
  .from("profiles")
88
- .select("*", { count: "exact", head: true })
136
+ .select("id,scores!inner(id)", { count: "exact", head: true })
89
137
  .neq("ban", "excluded")
90
- .gt("skill_points", queryData.skill_points);
138
+ .gte("scores.created_at", cutoffIso);
91
139
 
92
- leaderPosition = (playersWithMorePoints || 0) + 1;
140
+ query = supabase
141
+ .from("profiles")
142
+ .select(
143
+ "flag,id,avatar_url,username,play_count,skill_points,spin_skill_points,total_score,verified,clans:clan(id, acronym),scores!inner(id)"
144
+ )
145
+ .neq("ban", "excluded")
146
+ .gte("scores.created_at", cutoffIso)
147
+ .limit(1, { foreignTable: "scores" });
93
148
  }
94
- }
95
-
96
- const startPage = (page - 1) * VIEW_PER_PAGE;
97
- const endPage = startPage + VIEW_PER_PAGE - 1;
98
- const countQuery = await supabase
99
- .from("profiles")
100
- .select("ban", { count: "exact", head: true })
101
- .neq("ban", "excluded");
102
149
 
103
- let query = supabase
104
- .from("profiles")
105
- .select("*,clans:clan(id, acronym)")
106
- .neq("ban", "excluded");
150
+ if (flag) {
151
+ countQuery.eq("flag", flag);
152
+ query.eq("flag", flag);
153
+ }
107
154
 
108
- if (flag) {
109
- query.eq("flag", flag);
110
- }
155
+ if (spin) {
156
+ query.order("spin_skill_points", { ascending: false });
157
+ } else {
158
+ query.order("skill_points", { ascending: false });
159
+ }
111
160
 
112
- if (spin) {
113
- query.order("spin_skill_points", { ascending: false });
114
- } else {
115
- query.order("skill_points", { ascending: false });
116
- }
161
+ query.range(startPage, endPage);
117
162
 
118
- query.range(startPage, endPage);
163
+ const [countQueryResult, { data: queryData }] = await Promise.all([
164
+ countQuery,
165
+ query,
166
+ ]);
119
167
 
120
- let { data: queryData, error } = await query;
121
- return {
122
- total: countQuery.count || 0,
123
- viewPerPage: VIEW_PER_PAGE,
124
- currentPage: page,
125
- userPosition: leaderPosition,
126
- leaderboard: queryData?.map((user) => ({
168
+ const leaderboard = queryData?.map((user) => ({
127
169
  flag: user.flag,
128
170
  id: user.id,
171
+ avatar_url: user.avatar_url,
129
172
  play_count: user.play_count,
130
173
  skill_points: user.skill_points,
131
174
  spin_skill_points: user.spin_skill_points,
@@ -133,6 +176,92 @@ export async function getLeaderboard(
133
176
  username: user.username,
134
177
  clans: user.clans as any,
135
178
  verified: user.verified,
136
- })),
179
+ }));
180
+
181
+ const data = {
182
+ cachedAt: Date.now(),
183
+ total: countQueryResult.count || 0,
184
+ leaderboard,
185
+ };
186
+
187
+ await setCacheValue(cacheKey, data);
188
+
189
+ return data;
190
+ })();
191
+
192
+ const leaderPositionPromise = (async () => {
193
+ if (!user) return 0;
194
+
195
+ const posCacheKey = `leaderboard:userPosition:uid=${user.id}:include_inactive=${
196
+ includeInactive ? 1 : 0
197
+ }`;
198
+
199
+ const cachedPosition = await getCacheValue<{
200
+ cachedAt: number;
201
+ position: number;
202
+ }>(posCacheKey);
203
+
204
+ const positionCacheFresh =
205
+ cachedPosition &&
206
+ cachedPosition.cachedAt >= cutoffInvalidation &&
207
+ now - cachedPosition.cachedAt < CACHE_TTL_MS;
208
+
209
+ if (positionCacheFresh) {
210
+ return cachedPosition.position;
211
+ }
212
+
213
+ if (includeInactive) {
214
+ const { data: profile } = await supabase
215
+ .from("profiles")
216
+ .select("id,skill_points")
217
+ .eq("uid", user.id)
218
+ .maybeSingle();
219
+
220
+ if (!profile) return 0;
221
+
222
+ const { count: playersWithMorePoints } = await supabase
223
+ .from("profiles")
224
+ .select("id", { count: "exact", head: true })
225
+ .neq("ban", "excluded")
226
+ .gt("skill_points", profile.skill_points);
227
+
228
+ const position = (playersWithMorePoints || 0) + 1;
229
+ await setCacheValue(posCacheKey, { cachedAt: Date.now(), position });
230
+ return position;
231
+ }
232
+
233
+ const { data: profile } = await supabase
234
+ .from("profiles")
235
+ .select("id,skill_points,scores!inner(id)")
236
+ .eq("uid", user.id)
237
+ .gte("scores.created_at", cutoffIso)
238
+ .limit(1, { foreignTable: "scores" })
239
+ .maybeSingle();
240
+
241
+ if (!profile) return 0;
242
+
243
+ const { count: playersWithMorePoints } = await supabase
244
+ .from("profiles")
245
+ .select("id,scores!inner(id)", { count: "exact", head: true })
246
+ .neq("ban", "excluded")
247
+ .gte("scores.created_at", cutoffIso)
248
+ .gt("skill_points", profile.skill_points);
249
+
250
+ const position = (playersWithMorePoints || 0) + 1;
251
+ await setCacheValue(posCacheKey, { cachedAt: Date.now(), position });
252
+ return position;
253
+ })();
254
+
255
+ const [pageData, leaderPosition] = await Promise.all([
256
+ pageDataPromise,
257
+ leaderPositionPromise,
258
+ ]);
259
+
260
+ return {
261
+ total: pageData.total,
262
+ viewPerPage: VIEW_PER_PAGE,
263
+ currentPage: page,
264
+ userPosition: leaderPosition,
265
+ leaderboard: pageData.leaderboard,
137
266
  };
138
267
  }
@@ -1,78 +1,78 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
5
-
6
- const ONLINE_WINDOW_MS = 30 * 60 * 1000;
7
-
8
- export const Schema = {
9
- input: z.strictObject({}),
10
- output: z.object({
11
- error: z.string().optional(),
12
- players: z.array(
13
- z.object({
14
- id: z.number(),
15
- name: z.string().nullable(),
16
- profilePictureUrl: z.string().nullable(),
17
- })
18
- ),
19
- }),
20
- };
21
-
22
- export async function POST(request: Request): Promise<NextResponse> {
23
- return protectedApi({
24
- request,
25
- schema: Schema,
26
- authorization: () => {},
27
- activity: handler,
28
- });
29
- }
30
-
31
- export async function handler(
32
- _data: (typeof Schema)["input"]["_type"]
33
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
34
- const cutoff = Date.now() - ONLINE_WINDOW_MS;
35
-
36
- const { data: activityRows, error: activityError } = await supabase
37
- .from("profileActivities")
38
- .select("uid")
39
- .gt("last_activity", cutoff);
40
-
41
- if (activityError) {
42
- return NextResponse.json(
43
- { players: [], error: "Failed to load online players" },
44
- { status: 500 }
45
- );
46
- }
47
-
48
- const onlineUids = Array.from(
49
- new Set((activityRows || []).map((row) => row.uid).filter(Boolean))
50
- );
51
-
52
- if (!onlineUids.length) {
53
- return NextResponse.json({ players: [] });
54
- }
55
-
56
- const { data: profiles, error: profilesError } = await supabase
57
- .from("profiles")
58
- .select("id,username,avatar_url,profile_image,skill_points")
59
- .in("uid", onlineUids)
60
- .neq("ban", "excluded")
61
- .order("skill_points", { ascending: false });
62
-
63
- if (profilesError) {
64
- return NextResponse.json(
65
- { players: [], error: "Failed to load online players" },
66
- { status: 500 }
67
- );
68
- }
69
-
70
- const players =
71
- profiles?.map((profile) => ({
72
- id: profile.id,
73
- name: profile.username,
74
- profilePictureUrl: profile.profile_image || profile.avatar_url,
75
- })) || [];
76
-
77
- return NextResponse.json({ players });
78
- }
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+
6
+ const ONLINE_WINDOW_MS = 30 * 60 * 1000;
7
+
8
+ export const Schema = {
9
+ input: z.strictObject({}),
10
+ output: z.object({
11
+ error: z.string().optional(),
12
+ players: z.array(
13
+ z.object({
14
+ id: z.number(),
15
+ name: z.string().nullable(),
16
+ profilePictureUrl: z.string().nullable(),
17
+ })
18
+ ),
19
+ }),
20
+ };
21
+
22
+ export async function POST(request: Request): Promise<NextResponse> {
23
+ return protectedApi({
24
+ request,
25
+ schema: Schema,
26
+ authorization: () => {},
27
+ activity: handler,
28
+ });
29
+ }
30
+
31
+ export async function handler(
32
+ _data: (typeof Schema)["input"]["_type"]
33
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
34
+ const cutoff = Date.now() - ONLINE_WINDOW_MS;
35
+
36
+ const { data: activityRows, error: activityError } = await supabase
37
+ .from("profileActivities")
38
+ .select("uid")
39
+ .gt("last_activity", cutoff);
40
+
41
+ if (activityError) {
42
+ return NextResponse.json(
43
+ { players: [], error: "Failed to load online players" },
44
+ { status: 500 }
45
+ );
46
+ }
47
+
48
+ const onlineUids = Array.from(
49
+ new Set((activityRows || []).map((row) => row.uid).filter(Boolean))
50
+ );
51
+
52
+ if (!onlineUids.length) {
53
+ return NextResponse.json({ players: [] });
54
+ }
55
+
56
+ const { data: profiles, error: profilesError } = await supabase
57
+ .from("profiles")
58
+ .select("id,username,avatar_url,profile_image,skill_points")
59
+ .in("uid", onlineUids)
60
+ .neq("ban", "excluded")
61
+ .order("skill_points", { ascending: false });
62
+
63
+ if (profilesError) {
64
+ return NextResponse.json(
65
+ { players: [], error: "Failed to load online players" },
66
+ { status: 500 }
67
+ );
68
+ }
69
+
70
+ const players =
71
+ profiles?.map((profile) => ({
72
+ id: profile.id,
73
+ name: profile.username,
74
+ profilePictureUrl: profile.avatar_url,
75
+ })) || [];
76
+
77
+ return NextResponse.json({ players });
78
+ }
package/api/getProfile.ts CHANGED
@@ -6,6 +6,10 @@ import { protectedApi } from "../utils/requestUtils";
6
6
  import { supabase } from "../utils/supabase";
7
7
  import { getUserBySession } from "../utils/getUserBySession";
8
8
  import { User } from "@supabase/supabase-js";
9
+ import {
10
+ getActivityStatusForUserId,
11
+ getScoreActivityCutoffIso,
12
+ } from "../utils/activityStatus";
9
13
 
10
14
  export const Schema = {
11
15
  input: z.strictObject({
@@ -33,6 +37,7 @@ export const Schema = {
33
37
  squares_hit: z.number().nullable(),
34
38
  total_score: z.number().nullable(),
35
39
  position: z.number().nullable(),
40
+ activity_status: z.enum(["active", "inactive"]),
36
41
  is_online: z.boolean(),
37
42
  clans: z
38
43
  .object({
@@ -114,6 +119,8 @@ export async function handler(
114
119
 
115
120
  const user = profiles[0];
116
121
 
122
+ const activityStatus = await getActivityStatusForUserId(user.id);
123
+
117
124
  const { data: activityData } = await supabase
118
125
  .from("profileActivities")
119
126
  .select("*")
@@ -125,12 +132,18 @@ export async function handler(
125
132
  isOnline = Date.now() - activityData.last_activity < 1800000;
126
133
  }
127
134
 
128
- // Query to count how many players have more skill points than the specific player
129
- const { count: playersWithMorePoints, error: rankError } = await supabase
130
- .from("profiles")
131
- .select(`*`, { count: "exact", head: true })
132
- .neq("ban", "excluded")
133
- .gt("skill_points", user.skill_points);
135
+ let position: number | null = null;
136
+ if (activityStatus === "active") {
137
+ const cutoffIso = getScoreActivityCutoffIso();
138
+ const { count: playersWithMorePoints } = await supabase
139
+ .from("profiles")
140
+ .select("id,scores!inner(id)", { count: "exact", head: true })
141
+ .neq("ban", "excluded")
142
+ .gte("scores.created_at", cutoffIso)
143
+ .gt("skill_points", user.skill_points);
144
+
145
+ position = (playersWithMorePoints || 0) + 1;
146
+ }
134
147
 
135
148
  if (user.verificationDeadline < Date.now()) {
136
149
  await supabase
@@ -145,7 +158,8 @@ export async function handler(
145
158
  return NextResponse.json({
146
159
  user: {
147
160
  ...user,
148
- position: (playersWithMorePoints || 0) + 1,
161
+ position,
162
+ activity_status: activityStatus,
149
163
  is_online: isOnline,
150
164
  },
151
165
  });
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import z from "zod";
3
3
  import { protectedApi } from "../utils/requestUtils";
4
4
  import { supabase } from "../utils/supabase";
5
+ import { getScoreActivityCutoffIso } from "../utils/activityStatus";
5
6
 
6
7
  export const Schema = {
7
8
  input: z.strictObject({}),
@@ -103,10 +104,12 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
103
104
 
104
105
  let { data: topUsers } = await supabase
105
106
  .from("profiles")
106
- .select("*")
107
+ .select("id,username,avatar_url,skill_points,scores!inner(id)")
107
108
  .neq("ban", "excluded")
109
+ .gte("scores.created_at", getScoreActivityCutoffIso())
108
110
  .order("skill_points", { ascending: false })
109
- .limit(3);
111
+ .limit(3)
112
+ .limit(1, { foreignTable: "scores" });
110
113
 
111
114
  let { data: comments } = await supabase
112
115
  .from("beatmapPageComments")