rhythia-api 183.0.0 → 184.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.
Files changed (58) hide show
  1. package/.prettierrc.json +6 -6
  2. package/api/addCollectionMap.ts +82 -82
  3. package/api/approveMap.ts +78 -78
  4. package/api/chartPublicStats.ts +32 -32
  5. package/api/createBeatmap.ts +168 -168
  6. package/api/createBeatmapPage.ts +64 -64
  7. package/api/createClan.ts +81 -81
  8. package/api/createCollection.ts +58 -58
  9. package/api/deleteBeatmapPage.ts +77 -77
  10. package/api/deleteCollection.ts +59 -59
  11. package/api/deleteCollectionMap.ts +71 -71
  12. package/api/editAboutMe.ts +91 -91
  13. package/api/editClan.ts +90 -90
  14. package/api/editCollection.ts +77 -77
  15. package/api/editProfile.ts +123 -123
  16. package/api/getAvatarUploadUrl.ts +85 -85
  17. package/api/getBadgedUsers.ts +56 -56
  18. package/api/getBeatmapComments.ts +57 -57
  19. package/api/getBeatmapPage.ts +106 -106
  20. package/api/getBeatmapPageById.ts +99 -99
  21. package/api/getBeatmapStarRating.ts +53 -53
  22. package/api/getBeatmaps.ts +159 -159
  23. package/api/getClan.ts +77 -77
  24. package/api/getCollection.ts +130 -130
  25. package/api/getCollections.ts +126 -128
  26. package/api/getLeaderboard.ts +136 -136
  27. package/api/getMapUploadUrl.ts +93 -93
  28. package/api/getPassToken.ts +55 -55
  29. package/api/getProfile.ts +146 -146
  30. package/api/getPublicStats.ts +180 -180
  31. package/api/getRawStarRating.ts +57 -57
  32. package/api/getScore.ts +85 -85
  33. package/api/getTimestamp.ts +23 -23
  34. package/api/getUserScores.ts +175 -175
  35. package/api/nominateMap.ts +82 -82
  36. package/api/postBeatmapComment.ts +59 -59
  37. package/api/rankMapsArchive.ts +64 -64
  38. package/api/searchUsers.ts +56 -56
  39. package/api/setPasskey.ts +59 -59
  40. package/api/submitScore.ts +433 -433
  41. package/api/updateBeatmapPage.ts +229 -229
  42. package/handleApi.ts +22 -20
  43. package/index.html +2 -2
  44. package/index.ts +864 -865
  45. package/package-lock.json +8913 -0
  46. package/package.json +2 -2
  47. package/types/database.ts +0 -39
  48. package/utils/getUserBySession.ts +48 -48
  49. package/utils/requestUtils.ts +87 -87
  50. package/utils/security.ts +20 -20
  51. package/utils/star-calc/index.ts +72 -72
  52. package/utils/star-calc/osuUtils.ts +53 -53
  53. package/utils/star-calc/sspmParser.ts +398 -398
  54. package/utils/star-calc/sspmv1Parser.ts +165 -165
  55. package/utils/supabase.ts +13 -13
  56. package/utils/test +4 -4
  57. package/utils/validateToken.ts +7 -7
  58. package/vercel.json +12 -12
package/api/getProfile.ts CHANGED
@@ -1,146 +1,146 @@
1
- import { geolocation } from "@vercel/edge";
2
- import { NextResponse } from "next/server";
3
- import z from "zod";
4
- import { Database } from "../types/database";
5
- import { protectedApi } from "../utils/requestUtils";
6
- import { supabase } from "../utils/supabase";
7
- import { getUserBySession } from "../utils/getUserBySession";
8
- import { User } from "@supabase/supabase-js";
9
-
10
- export const Schema = {
11
- input: z.strictObject({
12
- session: z.string(),
13
- id: z.number().nullable().optional(),
14
- }),
15
- output: z.object({
16
- error: z.string().optional(),
17
- user: z
18
- .object({
19
- about_me: z.string().nullable(),
20
- avatar_url: z.string().nullable(),
21
- profile_image: z.string().nullable(),
22
- badges: z.any().nullable(),
23
- created_at: z.number().nullable(),
24
- flag: z.string().nullable(),
25
- id: z.number(),
26
- uid: z.string().nullable(),
27
- ban: z.string().nullable(),
28
- username: z.string().nullable(),
29
- verified: z.boolean().nullable(),
30
- play_count: z.number().nullable(),
31
- skill_points: z.number().nullable(),
32
- squares_hit: z.number().nullable(),
33
- total_score: z.number().nullable(),
34
- position: z.number().nullable(),
35
- is_online: z.boolean(),
36
- clans: z
37
- .object({
38
- id: z.number(),
39
- acronym: z.string(),
40
- })
41
- .optional()
42
- .nullable(),
43
- })
44
- .optional(),
45
- }),
46
- };
47
-
48
- export async function POST(request: Request): Promise<NextResponse> {
49
- return protectedApi({
50
- request,
51
- schema: Schema,
52
- authorization: () => {},
53
- activity: handler,
54
- });
55
- }
56
-
57
- export async function handler(
58
- data: (typeof Schema)["input"]["_type"],
59
- req: Request
60
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
61
- let profiles: Database["public"]["Tables"]["profiles"]["Row"][] = [];
62
- let isOnline = false;
63
- // Fetch by id
64
- if (data.id !== undefined && data.id !== null) {
65
- let { data: queryData, error } = await supabase
66
- .from("profiles")
67
- .select(`*,clans:clan(id,acronym)`)
68
- .eq("id", data.id);
69
-
70
- console.log(profiles, error);
71
-
72
- if (!queryData?.length) {
73
- return NextResponse.json(
74
- {
75
- error: "User not found",
76
- },
77
- { status: 404 }
78
- );
79
- }
80
-
81
- profiles = queryData;
82
- } else {
83
- // Fetch by session id
84
- const user = (await getUserBySession(data.session)) as User;
85
-
86
- if (user) {
87
- let { data: queryData, error } = await supabase
88
- .from("profiles")
89
- .select("*")
90
- .eq("uid", user.id);
91
-
92
- if (!queryData?.length) {
93
- const geo = geolocation(req);
94
- const data = await supabase.from("profiles").upsert({
95
- uid: user.id,
96
- about_me: "",
97
- avatar_url:
98
- "https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/user-avatar-1725309193296-72002e6b-321c-4f60-a692-568e0e75147d",
99
- badges: [],
100
- username: `${user.user_metadata.full_name.slice(0, 20)}${Math.round(
101
- Math.random() * 900000 + 100000
102
- )}`,
103
- computedUsername: `${user.user_metadata.full_name.slice(
104
- 0,
105
- 20
106
- )}${Math.round(Math.random() * 900000 + 100000)}`.toLowerCase(),
107
- flag: (geo.country || "US").toUpperCase(),
108
- created_at: Date.now(),
109
- }).select(`
110
- *,clans:clan(id,acronym)`);
111
-
112
- profiles = data.data!;
113
- } else {
114
- profiles = queryData;
115
- }
116
- }
117
- }
118
-
119
- const user = profiles[0];
120
-
121
- const { data: activityData } = await supabase
122
- .from("profileActivities")
123
- .select("*")
124
- .eq("uid", user.uid || "")
125
- .single();
126
-
127
- //last 30 minutes
128
- if (activityData && activityData.last_activity) {
129
- isOnline = Date.now() - activityData.last_activity < 1800000;
130
- }
131
-
132
- // Query to count how many players have more skill points than the specific player
133
- const { count: playersWithMorePoints, error: rankError } = await supabase
134
- .from("profiles")
135
- .select(`*`, { count: "exact", head: true })
136
- .neq("ban", "excluded")
137
- .gt("skill_points", user.skill_points);
138
-
139
- return NextResponse.json({
140
- user: {
141
- ...user,
142
- position: (playersWithMorePoints || 0) + 1,
143
- is_online: isOnline,
144
- },
145
- });
146
- }
1
+ import { geolocation } from "@vercel/edge";
2
+ import { NextResponse } from "next/server";
3
+ import z from "zod";
4
+ import { Database } from "../types/database";
5
+ import { protectedApi } from "../utils/requestUtils";
6
+ import { supabase } from "../utils/supabase";
7
+ import { getUserBySession } from "../utils/getUserBySession";
8
+ import { User } from "@supabase/supabase-js";
9
+
10
+ export const Schema = {
11
+ input: z.strictObject({
12
+ session: z.string(),
13
+ id: z.number().nullable().optional(),
14
+ }),
15
+ output: z.object({
16
+ error: z.string().optional(),
17
+ user: z
18
+ .object({
19
+ about_me: z.string().nullable(),
20
+ avatar_url: z.string().nullable(),
21
+ profile_image: z.string().nullable(),
22
+ badges: z.any().nullable(),
23
+ created_at: z.number().nullable(),
24
+ flag: z.string().nullable(),
25
+ id: z.number(),
26
+ uid: z.string().nullable(),
27
+ ban: z.string().nullable(),
28
+ username: z.string().nullable(),
29
+ verified: z.boolean().nullable(),
30
+ play_count: z.number().nullable(),
31
+ skill_points: z.number().nullable(),
32
+ squares_hit: z.number().nullable(),
33
+ total_score: z.number().nullable(),
34
+ position: z.number().nullable(),
35
+ is_online: z.boolean(),
36
+ clans: z
37
+ .object({
38
+ id: z.number(),
39
+ acronym: z.string(),
40
+ })
41
+ .optional()
42
+ .nullable(),
43
+ })
44
+ .optional(),
45
+ }),
46
+ };
47
+
48
+ export async function POST(request: Request): Promise<NextResponse> {
49
+ return protectedApi({
50
+ request,
51
+ schema: Schema,
52
+ authorization: () => {},
53
+ activity: handler,
54
+ });
55
+ }
56
+
57
+ export async function handler(
58
+ data: (typeof Schema)["input"]["_type"],
59
+ req: Request
60
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
61
+ let profiles: Database["public"]["Tables"]["profiles"]["Row"][] = [];
62
+ let isOnline = false;
63
+ // Fetch by id
64
+ if (data.id !== undefined && data.id !== null) {
65
+ let { data: queryData, error } = await supabase
66
+ .from("profiles")
67
+ .select(`*,clans:clan(id,acronym)`)
68
+ .eq("id", data.id);
69
+
70
+ console.log(profiles, error);
71
+
72
+ if (!queryData?.length) {
73
+ return NextResponse.json(
74
+ {
75
+ error: "User not found",
76
+ },
77
+ { status: 404 }
78
+ );
79
+ }
80
+
81
+ profiles = queryData;
82
+ } else {
83
+ // Fetch by session id
84
+ const user = (await getUserBySession(data.session)) as User;
85
+
86
+ if (user) {
87
+ let { data: queryData, error } = await supabase
88
+ .from("profiles")
89
+ .select("*")
90
+ .eq("uid", user.id);
91
+
92
+ if (!queryData?.length) {
93
+ const geo = geolocation(req);
94
+ const data = await supabase.from("profiles").upsert({
95
+ uid: user.id,
96
+ about_me: "",
97
+ avatar_url:
98
+ "https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/user-avatar-1725309193296-72002e6b-321c-4f60-a692-568e0e75147d",
99
+ badges: [],
100
+ username: `${user.user_metadata.full_name.slice(0, 20)}${Math.round(
101
+ Math.random() * 900000 + 100000
102
+ )}`,
103
+ computedUsername: `${user.user_metadata.full_name.slice(
104
+ 0,
105
+ 20
106
+ )}${Math.round(Math.random() * 900000 + 100000)}`.toLowerCase(),
107
+ flag: (geo.country || "US").toUpperCase(),
108
+ created_at: Date.now(),
109
+ }).select(`
110
+ *,clans:clan(id,acronym)`);
111
+
112
+ profiles = data.data!;
113
+ } else {
114
+ profiles = queryData;
115
+ }
116
+ }
117
+ }
118
+
119
+ const user = profiles[0];
120
+
121
+ const { data: activityData } = await supabase
122
+ .from("profileActivities")
123
+ .select("*")
124
+ .eq("uid", user.uid || "")
125
+ .single();
126
+
127
+ //last 30 minutes
128
+ if (activityData && activityData.last_activity) {
129
+ isOnline = Date.now() - activityData.last_activity < 1800000;
130
+ }
131
+
132
+ // Query to count how many players have more skill points than the specific player
133
+ const { count: playersWithMorePoints, error: rankError } = await supabase
134
+ .from("profiles")
135
+ .select(`*`, { count: "exact", head: true })
136
+ .neq("ban", "excluded")
137
+ .gt("skill_points", user.skill_points);
138
+
139
+ return NextResponse.json({
140
+ user: {
141
+ ...user,
142
+ position: (playersWithMorePoints || 0) + 1,
143
+ is_online: isOnline,
144
+ },
145
+ });
146
+ }
@@ -1,180 +1,180 @@
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
- export const Schema = {
7
- input: z.strictObject({}),
8
- output: z.object({
9
- profiles: z.number(),
10
- beatmaps: z.number(),
11
- scores: z.number(),
12
- onlineUsers: z.number(),
13
- countChart: z.array(
14
- z.object({
15
- type: z.string(),
16
- value: z.number(),
17
- })
18
- ),
19
- lastBeatmaps: z.array(
20
- z.object({
21
- id: z.number().nullable().optional(),
22
- nominations: z.array(z.number()).nullable().optional(),
23
- playcount: z.number().nullable().optional(),
24
- created_at: z.string().nullable().optional(),
25
- difficulty: z.number().nullable().optional(),
26
- noteCount: z.number().nullable().optional(),
27
- length: z.number().nullable().optional(),
28
- title: z.string().nullable().optional(),
29
- ranked: z.boolean().nullable().optional(),
30
- beatmapFile: z.string().nullable().optional(),
31
- image: z.string().nullable().optional(),
32
- starRating: z.number().nullable().optional(),
33
- owner: z.number().nullable().optional(),
34
- ownerUsername: z.string().nullable().optional(),
35
- ownerAvatar: z.string().nullable().optional(),
36
- status: z.string().nullable().optional(),
37
- })
38
- ),
39
- topUsers: z.array(
40
- z.object({
41
- username: z.string(),
42
- id: z.number(),
43
- avatar_url: z.string(),
44
- skill_points: z.number(),
45
- })
46
- ),
47
- lastComments: z.array(
48
- z.object({
49
- owner: z.number(),
50
- content: z.string(),
51
- username: z.string(),
52
- beatmapTitle: z.string(),
53
- beatmapPage: z.number(),
54
- })
55
- ),
56
- }),
57
- };
58
-
59
- export async function POST(request: Request) {
60
- return protectedApi({
61
- request,
62
- schema: Schema,
63
- authorization: () => {},
64
- activity: handler,
65
- });
66
- }
67
-
68
- export async function handler(data: (typeof Schema)["input"]["_type"]) {
69
- const countProfilesQuery = await supabase
70
- .from("profiles")
71
- .select("*", { count: "exact", head: true });
72
-
73
- const countBeatmapsQuery = await supabase
74
- .from("beatmaps")
75
- .select("*", { count: "exact", head: true });
76
-
77
- let { data: beatmapPage, error: errorlast } = await supabase
78
- .from("beatmapPages")
79
- .select(
80
- `
81
- *,
82
- beatmaps (
83
- created_at,
84
- playcount,
85
- length,
86
- ranked,
87
- beatmapFile,
88
- image,
89
- starRating,
90
- difficulty,
91
- noteCount,
92
- title
93
- ),
94
- profiles (
95
- username,
96
- avatar_url
97
- )
98
- `
99
- )
100
- .eq("status", "RANKED")
101
- .order("ranked_at", { ascending: false })
102
- .limit(4);
103
-
104
- let { data: topUsers } = await supabase
105
- .from("profiles")
106
- .select("*")
107
- .neq("ban", "excluded")
108
- .order("skill_points", { ascending: false })
109
- .limit(3);
110
-
111
- let { data: comments } = await supabase
112
- .from("beatmapPageComments")
113
- .select(
114
- `
115
- *,
116
- beatmapPages!inner(
117
- *
118
- ),
119
- profiles!inner(
120
- username
121
- )`
122
- )
123
- .order("created_at", { ascending: false })
124
- .limit(5);
125
-
126
- const countScoresQuery = await supabase
127
- .from("scores")
128
- .select("id", { count: "exact", head: true });
129
-
130
- // 30 minutes activity
131
- const countOnline = await supabase
132
- .from("profileActivities")
133
- .select("*", { count: "exact", head: true })
134
- .gt("last_activity", Date.now() - 1800000);
135
-
136
- const countChart = await supabase
137
- .from("chartedValues")
138
- .select("value")
139
- .eq("type", "online_players")
140
- .gt("created_at", new Date(Date.now() - 86400000).toISOString());
141
-
142
- return NextResponse.json({
143
- beatmaps: countBeatmapsQuery.count,
144
- profiles: countProfilesQuery.count,
145
- scores: countScoresQuery.count,
146
- onlineUsers: countOnline.count,
147
- countChart: countChart.data,
148
- lastBeatmaps: beatmapPage?.map((e) => ({
149
- playcount: e.beatmaps?.playcount,
150
- created_at: e.created_at,
151
- difficulty: e.beatmaps?.difficulty,
152
- noteCount: e.beatmaps?.noteCount,
153
- length: e.beatmaps?.length,
154
- title: e.beatmaps?.title,
155
- ranked: e.beatmaps?.ranked,
156
- beatmapFile: e.beatmaps?.beatmapFile,
157
- image: e.beatmaps?.image,
158
- starRating: e.beatmaps?.starRating,
159
- owner: e.owner,
160
- ownerUsername: e.profiles?.username,
161
- ownerAvatar: e.profiles?.avatar_url,
162
- id: e.id,
163
- status: e.status,
164
- nominations: e.nominations as number[],
165
- })),
166
- topUsers: topUsers?.map((e) => ({
167
- username: e.username,
168
- id: e.id,
169
- avatar_url: e.avatar_url,
170
- skill_points: e.skill_points,
171
- })),
172
- lastComments: comments?.map((e) => ({
173
- owner: e.owner,
174
- content: e.content,
175
- username: e.profiles.username,
176
- beatmapTitle: e.beatmapPages.title,
177
- beatmapPage: e.beatmapPages.id,
178
- })),
179
- });
180
- }
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
+ export const Schema = {
7
+ input: z.strictObject({}),
8
+ output: z.object({
9
+ profiles: z.number(),
10
+ beatmaps: z.number(),
11
+ scores: z.number(),
12
+ onlineUsers: z.number(),
13
+ countChart: z.array(
14
+ z.object({
15
+ type: z.string(),
16
+ value: z.number(),
17
+ })
18
+ ),
19
+ lastBeatmaps: z.array(
20
+ z.object({
21
+ id: z.number().nullable().optional(),
22
+ nominations: z.array(z.number()).nullable().optional(),
23
+ playcount: z.number().nullable().optional(),
24
+ created_at: z.string().nullable().optional(),
25
+ difficulty: z.number().nullable().optional(),
26
+ noteCount: z.number().nullable().optional(),
27
+ length: z.number().nullable().optional(),
28
+ title: z.string().nullable().optional(),
29
+ ranked: z.boolean().nullable().optional(),
30
+ beatmapFile: z.string().nullable().optional(),
31
+ image: z.string().nullable().optional(),
32
+ starRating: z.number().nullable().optional(),
33
+ owner: z.number().nullable().optional(),
34
+ ownerUsername: z.string().nullable().optional(),
35
+ ownerAvatar: z.string().nullable().optional(),
36
+ status: z.string().nullable().optional(),
37
+ })
38
+ ),
39
+ topUsers: z.array(
40
+ z.object({
41
+ username: z.string(),
42
+ id: z.number(),
43
+ avatar_url: z.string(),
44
+ skill_points: z.number(),
45
+ })
46
+ ),
47
+ lastComments: z.array(
48
+ z.object({
49
+ owner: z.number(),
50
+ content: z.string(),
51
+ username: z.string(),
52
+ beatmapTitle: z.string(),
53
+ beatmapPage: z.number(),
54
+ })
55
+ ),
56
+ }),
57
+ };
58
+
59
+ export async function POST(request: Request) {
60
+ return protectedApi({
61
+ request,
62
+ schema: Schema,
63
+ authorization: () => {},
64
+ activity: handler,
65
+ });
66
+ }
67
+
68
+ export async function handler(data: (typeof Schema)["input"]["_type"]) {
69
+ const countProfilesQuery = await supabase
70
+ .from("profiles")
71
+ .select("*", { count: "exact", head: true });
72
+
73
+ const countBeatmapsQuery = await supabase
74
+ .from("beatmaps")
75
+ .select("*", { count: "exact", head: true });
76
+
77
+ let { data: beatmapPage, error: errorlast } = await supabase
78
+ .from("beatmapPages")
79
+ .select(
80
+ `
81
+ *,
82
+ beatmaps (
83
+ created_at,
84
+ playcount,
85
+ length,
86
+ ranked,
87
+ beatmapFile,
88
+ image,
89
+ starRating,
90
+ difficulty,
91
+ noteCount,
92
+ title
93
+ ),
94
+ profiles (
95
+ username,
96
+ avatar_url
97
+ )
98
+ `
99
+ )
100
+ .eq("status", "RANKED")
101
+ .order("ranked_at", { ascending: false })
102
+ .limit(4);
103
+
104
+ let { data: topUsers } = await supabase
105
+ .from("profiles")
106
+ .select("*")
107
+ .neq("ban", "excluded")
108
+ .order("skill_points", { ascending: false })
109
+ .limit(3);
110
+
111
+ let { data: comments } = await supabase
112
+ .from("beatmapPageComments")
113
+ .select(
114
+ `
115
+ *,
116
+ beatmapPages!inner(
117
+ *
118
+ ),
119
+ profiles!inner(
120
+ username
121
+ )`
122
+ )
123
+ .order("created_at", { ascending: false })
124
+ .limit(5);
125
+
126
+ const countScoresQuery = await supabase
127
+ .from("scores")
128
+ .select("id", { count: "exact", head: true });
129
+
130
+ // 30 minutes activity
131
+ const countOnline = await supabase
132
+ .from("profileActivities")
133
+ .select("*", { count: "exact", head: true })
134
+ .gt("last_activity", Date.now() - 1800000);
135
+
136
+ const countChart = await supabase
137
+ .from("chartedValues")
138
+ .select("value")
139
+ .eq("type", "online_players")
140
+ .gt("created_at", new Date(Date.now() - 86400000).toISOString());
141
+
142
+ return NextResponse.json({
143
+ beatmaps: countBeatmapsQuery.count,
144
+ profiles: countProfilesQuery.count,
145
+ scores: countScoresQuery.count,
146
+ onlineUsers: countOnline.count,
147
+ countChart: countChart.data,
148
+ lastBeatmaps: beatmapPage?.map((e) => ({
149
+ playcount: e.beatmaps?.playcount,
150
+ created_at: e.created_at,
151
+ difficulty: e.beatmaps?.difficulty,
152
+ noteCount: e.beatmaps?.noteCount,
153
+ length: e.beatmaps?.length,
154
+ title: e.beatmaps?.title,
155
+ ranked: e.beatmaps?.ranked,
156
+ beatmapFile: e.beatmaps?.beatmapFile,
157
+ image: e.beatmaps?.image,
158
+ starRating: e.beatmaps?.starRating,
159
+ owner: e.owner,
160
+ ownerUsername: e.profiles?.username,
161
+ ownerAvatar: e.profiles?.avatar_url,
162
+ id: e.id,
163
+ status: e.status,
164
+ nominations: e.nominations as number[],
165
+ })),
166
+ topUsers: topUsers?.map((e) => ({
167
+ username: e.username,
168
+ id: e.id,
169
+ avatar_url: e.avatar_url,
170
+ skill_points: e.skill_points,
171
+ })),
172
+ lastComments: comments?.map((e) => ({
173
+ owner: e.owner,
174
+ content: e.content,
175
+ username: e.profiles.username,
176
+ beatmapTitle: e.beatmapPages.title,
177
+ beatmapPage: e.beatmapPages.id,
178
+ })),
179
+ });
180
+ }