Added UserBox page

pull/42/head
alexay7 2024-06-05 18:57:48 +02:00
parent c5d81afdf6
commit f4bb1101bf
7 changed files with 1134 additions and 203 deletions

View File

@ -133,6 +133,17 @@ input
transition: $transition
box-sizing: border-box
select
border-radius: $border-radius
border: 1px solid transparent
padding: 0.6em 1.2em
font-size: 1em
font-weight: 500
font-family: inherit
background-color: $ov-lighter
transition: $transition
box-sizing: border-box
input[type="checkbox"]
width: 1.2em
height: 1.2em

View File

@ -0,0 +1,674 @@
<!-- Svelte 4.2.11 -->
<script lang="ts">
import {
UserBoxItemKind,
type AquaNetUser,
type ChangeUserBoxReq,
} from "../libs/generalTypes";
import { USER, USERBOX } from "../libs/sdk";
import { t, ts } from "../libs/i18n";
import { DATA_HOST, HAS_USERBOX_ASSETS } from "../libs/config";
import { FADE_IN, FADE_OUT } from "../libs/config";
import { fade, slide } from "svelte/transition";
import StatusOverlays from "./StatusOverlays.svelte";
import Icon from "@iconify/svelte";
let user: AquaNetUser;
let aimeId = "";
let loading = true;
let error = "";
let submitting = "";
let changed: string[] = [];
let tab = 0;
const tabs = ["chusan", "ongeki", "maimai"];
// Things that can be changed in the userbox
const userBoxFields = [
{
key: "frame",
label: t("userbox.frame"),
userBoxKey: "frameId",
kind: UserBoxItemKind.frame,
},
{
key: "nameplate",
label: t("userbox.nameplate"),
userBoxKey: "nameplateId",
kind: UserBoxItemKind.nameplate,
},
{
key: "trophy",
label: t("userbox.trophy"),
userBoxKey: "trophyId",
kind: UserBoxItemKind.trophy,
},
{
key: "mapicon",
label: t("userbox.mapicon"),
userBoxKey: "mapIconId",
kind: UserBoxItemKind.mapicon,
},
{
key: "voice",
label: t("userbox.voice"),
userBoxKey: "voiceId",
kind: UserBoxItemKind.sysvoice,
},
{
key: "wear",
label: t("userbox.wear"),
userBoxKey: "avatarWear",
kind: UserBoxItemKind.avatar,
},
{
key: "head",
label: t("userbox.head"),
userBoxKey: "avatarHead",
kind: UserBoxItemKind.avatar,
},
{
key: "face",
label: t("userbox.face"),
userBoxKey: "avatarHead",
kind: UserBoxItemKind.avatar,
},
{
key: "skin",
label: t("userbox.skin"),
userBoxKey: "avatarSkin",
kind: UserBoxItemKind.avatar,
},
{
key: "item",
label: t("userbox.item"),
userBoxKey: "avatarItem",
kind: UserBoxItemKind.avatar,
},
{
key: "front",
label: t("userbox.front"),
userBoxKey: "avatarFront",
kind: UserBoxItemKind.avatar,
},
{
key: "back",
label: t("userbox.back"),
userBoxKey: "avatarBack",
kind: UserBoxItemKind.avatar,
},
] as const;
// The different types available (avatar is a special case, as it has multiple categories)
const userBoxItems = userBoxFields
.map((f) => f.kind)
.filter((v, i, a) => a.indexOf(v) === i);
// Available options for the current user on each field
let availableOptions = {
nameplate: [],
frame: [],
trophy: [],
mapicon: [],
voice: [],
wear: [],
head: [],
face: [],
skin: [],
item: [],
front: [],
back: [],
} as Record<string, { id: number; label: string }[]>;
// Current values of the userbox
let values = {
nameplate: undefined,
frame: undefined,
trophy: undefined,
mapicon: undefined,
voice: undefined,
wear: undefined,
head: undefined,
face: undefined,
skin: undefined,
item: undefined,
front: undefined,
back: undefined,
} as Record<string, number | undefined>;
function submit(body: ChangeUserBoxReq, field: string) {
if (submitting) return;
submitting = body.kind;
USERBOX.setUserBox(body)
.then(() => {
changed = changed.filter((c) => c !== field);
})
.catch((e) => {
error = e.message;
submitting = "";
})
.finally(() => {
submitting = "";
});
}
async function fetchData(card: string) {
const userId = await USERBOX.getAimeId(card);
if (!userId) return;
aimeId = userId.luid;
const currentValues = await USERBOX.getProfile(userId.luid).catch((e) => {
loading = false;
error = t("userbox.error.noprofile")
return;
});
if(!currentValues) return;
values = {
nameplate: currentValues.nameplateId,
frame: currentValues.frameId,
trophy: currentValues.trophyId,
mapicon: currentValues.mapIconId,
voice: currentValues.voiceId,
wear: currentValues.avatarWear,
head: currentValues.avatarHead,
face: currentValues.avatarFace,
skin: currentValues.avatarSkin,
item: currentValues.avatarItem,
front: currentValues.avatarFront,
back: currentValues.avatarBack,
};
const itemLabels = await USERBOX.getItemLabels().catch((e) => {
loading = false;
error = t("userbox.error.nodata")
return
});
if(!itemLabels) return;
await Promise.all(
userBoxItems.map(async (kind) => {
// Populate info about the items
return USERBOX.getUnlockedItems(userId.luid, kind).then((items) => {
switch (kind) {
case UserBoxItemKind.nameplate:
// Add the item id and the label to the available options
availableOptions.nameplate = items.map((i) => {
return {
id: i.itemId,
label: itemLabels.nameplate[i.itemId],
};
});
break;
case UserBoxItemKind.frame:
availableOptions.frame = items.map((i) => {
return {
id: i.itemId,
label: itemLabels.frame[i.itemId],
};
});
break;
case UserBoxItemKind.trophy:
availableOptions.trophy = items.map((i) => {
return {
id: i.itemId,
label: itemLabels.trophy[i.itemId],
};
});
break;
case UserBoxItemKind.mapicon:
availableOptions.mapicon = items.map((i) => {
return {
id: i.itemId,
label: itemLabels.mapicon[i.itemId],
};
});
break;
case UserBoxItemKind.sysvoice:
availableOptions.voice = items.map((i) => {
return {
id: i.itemId,
label: itemLabels.sysvoice[i.itemId],
};
});
break;
case UserBoxItemKind.avatar:
// Depending of the second number of the item id, we can know the kind of item
items.forEach((i) => {
const kind = i.itemId.toString().split("")[1];
switch (kind) {
case "1":
availableOptions.wear.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
case "2":
availableOptions.head.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
case "3":
availableOptions.face.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
case "4":
availableOptions.skin.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
case "5":
availableOptions.item.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
case "6":
availableOptions.front.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
case "7":
availableOptions.back.push({
id: i.itemId,
label: itemLabels.avatar[i.itemId],
});
break;
}
});
break;
}
});
}),
);
loading = false;
}
function generateBodyFromKind(
kind:
| "frame"
| "nameplate"
| "trophy"
| "mapicon"
| "voice"
| "wear"
| "head"
| "face"
| "skin"
| "item"
| "front"
| "back",
value: number,
aimeId: string,
): ChangeUserBoxReq {
switch (kind) {
case "frame":
return { kind: "frame", frameId: value, aimeId };
case "nameplate":
return { kind: "plate", nameplateId: value, aimeId };
case "trophy":
return { kind: "trophy", trophyId: value, aimeId };
case "mapicon":
return { kind: "mapicon", mapiconid: value, aimeId };
case "voice":
return { kind: "sysvoice", voiceId: value, aimeId };
case "wear":
return { kind: "avatar", accId: value, category: 1, aimeId };
case "head":
return { kind: "avatar", accId: value, category: 2, aimeId };
case "face":
return { kind: "avatar", accId: value, category: 3, aimeId };
case "skin":
return { kind: "avatar", accId: value, category: 4, aimeId };
case "item":
return { kind: "avatar", accId: value, category: 5, aimeId };
case "front":
return { kind: "avatar", accId: value, category: 6, aimeId };
case "back":
return { kind: "avatar", accId: value, category: 7, aimeId };
}
}
USER.me().then((u) => {
if (u) {
user = u;
const card = user.cards.length > 0 ? user.cards[0].luid : "";
if (card) {
fetchData(card);
} else {
loading = false;
}
}
});
</script>
{#if !loading && !error}
<div class="outer-container">
<nav>
{#each tabs as tabName, i}
<div
transition:slide={{ axis: "x" }}
class:active={tab === i}
on:click={() => {
tab = i;
// Set url params
window.history.pushState({}, "", `/settings?tab=${tab}`);
}}
on:keydown={(e) => e.key === "Enter" && (tab = i)}
role="button"
tabindex="0"
>
{ts(`userbox.tabs.${tabName}`)}
</div>
{/each}
</nav>
{#if tab === 0}
<div class="container" out:fade={FADE_OUT} in:fade={FADE_IN}>
<div class="fields">
{#each userBoxFields as { key, label, kind }, i (key)}
<div class="field">
<label for={key}>{label}</label>
<div>
<select
bind:value={values[key]}
id={key}
on:change={() => {
changed = [...changed, key];
}}
>
{#each availableOptions[key] as option (option)}
<option value={option.id}
>{option.label ||
`${key} ${option.id.toString()}`}</option
>
{/each}
</select>
{#if changed.includes(key)}
<button
transition:slide={{ axis: "x" }}
on:click={() => {
const newValue = values[key];
if (newValue === undefined) return;
submit(generateBodyFromKind(key, newValue, aimeId), key);
}}
>
{#if submitting === key}
<Icon icon="line-md:loading-twotone-loop" />
{:else}
{t("settings.profile.save")}
{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
{#if HAS_USERBOX_ASSETS}
<div class="preview">
<h2>{t("userbox.preview.ui")}</h2>
<!-- Frame -->
{#if values.frame}
<img
src={`${DATA_HOST}/d/chu3/frame/${values.frame}.png`}
alt="Preview"
/>
{/if}
<div class="secondrow">
<!-- Map Icon -->
{#if values.mapicon}
<div class="mapicon">
<img
src={`${DATA_HOST}/d/chu3/mapicon/${values.mapicon}.png`}
alt="Preview"
/>
</div>
{/if}
<!-- System voice -->
{#if values.voice}
<div>
<img
src={`${DATA_HOST}/d/chu3/systemVoice/${values.voice}.png`}
alt="Preview"
/>
</div>
{/if}
</div>
<h2>{t("userbox.preview.nameplate")}</h2>
<!-- Nameplate -->
{#if values.nameplate}
<div class="nameplate">
<img
src={`${DATA_HOST}/d/chu3/nameplate/${values.nameplate}.png`}
alt="Preview"
/>
<p class="trophy">
{availableOptions.trophy.find((x) => x.id === values.trophy)
?.label}
</p>
<div class="username">
<p>
{user.displayName}
</p>
</div>
</div>
{/if}
<h2>{t("userbox.preview.avatar")}</h2>
<div class="avatar">
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.wear}.png`}
alt="Preview"
/>
</div>
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.head}.png`}
alt="Preview"
/>
</div>
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.face}.png`}
alt="Preview"
/>
</div>
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.skin}.png`}
alt="Preview"
/>
</div>
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.item}.png`}
alt="Preview"
/>
</div>
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.front}.png`}
alt="Preview"
/>
</div>
<div>
<img
src={`${DATA_HOST}/d/chu3/avatarAccessory/${values.back}.png`}
alt="Preview"
/>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div>
<p>WIP</p>
</div>
{/if}
</div>
{/if}
<StatusOverlays {error} {loading} />
<style lang="sass">
@import "../vars"
.outer-container
display: flex
flex-direction: column
gap: 1rem
nav
display: flex
gap: 1rem
div
padding: 0.5rem 1rem
border-radius: 0.4rem
cursor: pointer
transition: background-color 0.2s
font-weight: 500
&.active
color: $c-main
img
width: 100%
height: auto
.container
display: flex
flex-direction: row
gap: 3rem
@media (max-width: $w-max)
flex-direction: column
.preview
display: flex
flex-direction: column
gap: 1rem
width: 50%
@media (max-width: $w-max)
width: 100%
.secondrow
display: flex
gap: 1rem
justify-content: space-between
div
width: 40%
flex-grow:0
.avatar
display: grid
grid-template-columns: repeat(3, 1fr)
grid-template-rows: repeat(3, 1fr)
gap: 1rem
div
border: 1px solid white
aspect-ratio:1
overflow: hidden
display: flex
align-items: center
justify-content: center
border-radius: 0.4rem
img
width: auto
height: 100%
.nameplate
position: relative
width: 400px
> .trophy
position: absolute
top: 10px
left: 150px
width: 220px
background-color: #fff
color: black
border-radius: 0.4rem
text-align: center
font-weight:500
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5)
font-size: .8rem
> .username
position: absolute
top: 50px
left: 150px
width: 220px
height: 50px
background-color: #fff
color: black
border-radius: 0.2rem
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5)
display: flex
> p
margin: .2rem .5rem
font-size: 1.2rem
.mapicon
height: 100px
display: flex
justify-content: center
img
width: auto
height: 100%
.fields
display: flex
flex-direction: column
gap: 12px
width: 100%
flex-grow: 0
label
display: flex
flex-direction: column
.field
display: flex
flex-direction: column
label
max-width: max-content
> div:not(.bool)
display: flex
align-items: center
gap: 1rem
margin-top: 0.5rem
> select
flex: 1
</style>

View File

@ -11,5 +11,7 @@ export const DISCORD_INVITE = 'https://discord.gg/FNgveqFF7s'
// UI
export const FADE_OUT = { duration: 200 }
export const FADE_IN = { delay: 400 }
export const DEFAULT_PFP = "/assets/imgs/no_profile.png"
export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
// USERBOX_ASSETS
export const HAS_USERBOX_ASSETS = true

View File

@ -118,5 +118,68 @@ export type AllMusic = { [key: string]: MusicMeta }
export interface GameOption {
key: string
value: any
type: "Boolean"
type: 'Boolean'
}
export interface UserBox {
userName:string,
level:number,
exp:string,
point:number,
totalPoint:number,
playerRating:number,
highestRating:number,
nameplateId:number,
frameId:number,
characterId:number,
trophyId:number,
totalMapNum:number,
totalHiScore: number,
totalBasicHighScore:number,
totalAdvancedHighScore:number,
totalExpertHighScore:number,
totalMasterHighScore:number,
totalUltimaHighScore:number,
friendCount:number,
firstPlayDate:Date,
lastPlayDate:Date,
courseClass:number,
overPowerPoint:number,
overPowerRate:number,
mapIconId:number,
voiceId:number,
avatarWear: number,
avatarHead: number,
avatarFace: number,
avatarSkin: number,
avatarItem: number,
avatarFront: number,
avatarBack: number,
}
// Assign a number to each kind of user box item with an enum
export enum UserBoxItemKind {
nameplate = 1,
frame = 2,
trophy = 3,
mapicon = 8,
sysvoice = 9,
avatar = 11,
}
// Define type only with the keys
export type UserBoxItemKindStr = keyof typeof UserBoxItemKind;
type ChangePlateReq = {kind:'plate', nameplateId:number}
type ChangeFrameReq = {kind:'frame', frameId:number}
type ChangeTrophyReq = {kind:'trophy',trophyId:number}
type ChangeMapIconReq = {kind:'mapicon',mapiconid:number}
type ChangeVoiceReq = {kind:'sysvoice',voiceId:number}
type ChangeAvatarReq = {
kind:'avatar',
accId:number,
category:number
}
export type ChangeUserBoxReq = {aimeId:string} & (ChangePlateReq | ChangeFrameReq | ChangeTrophyReq | ChangeMapIconReq | ChangeVoiceReq | ChangeAvatarReq);

View File

@ -17,13 +17,13 @@ export const EN_REF_USER = {
'UserHome.Version': 'Last Version',
'UserHome.RecentScores': 'Recent Scores',
'UserHome.NoData': 'No data in the past ${days} days',
'UserHome.UnknownSong': "(unknown song)",
'UserHome.UnknownSong': '(unknown song)',
'UserHome.Settings': 'Settings',
'UserHome.NoValidGame': "The user hasn't played any game yet.",
'UserHome.ShowRanksDetails': "Click to show details",
'UserHome.NoValidGame': 'The user hasn\'t played any game yet.',
'UserHome.ShowRanksDetails': 'Click to show details',
'UserHome.RankDetail.Title': 'Achievement Details',
'UserHome.RankDetail.Level': "Level",
'UserHome.B50': "B50",
'UserHome.RankDetail.Level': 'Level',
'UserHome.B50': 'B50',
}
export const EN_REF_Welcome = {
@ -57,11 +57,11 @@ export const EN_REF_LEADERBOARD = {
}
export const EN_REF_GENERAL = {
'game.mai2': "Mai",
'game.chu3': "Chuni",
'game.ongeki': "Ongeki",
'game.wacca': "Wacca",
'status.error': "Error",
'game.mai2': 'Mai',
'game.chu3': 'Chuni',
'game.ongeki': 'Ongeki',
'game.wacca': 'Wacca',
'status.error': 'Error',
'status.error.hint': 'Something went wrong, please try again later or ',
'status.error.hint.link': 'join our discord for support.',
'status.detail': 'Detail: ${detail}',
@ -82,39 +82,40 @@ export const EN_REF_HOME = {
'home.join-discord-description': 'Join our Discord server to chat with other players and get help.',
'home.setup': 'Setup Connection',
'home.setup-description': 'If you own a cab or arcade setup, begin setting up the connection.',
'home.linkcard.cards': "Your Cards",
'home.linkcard.description': "Here are the cards you have linked to your account",
'home.linkcard.account-card': "Account Card",
'home.linkcard.registered': "Registered",
'home.linkcard.lastused': "Last used",
'home.linkcard.enter-info': "Please enter the following information",
'home.linkcard.access-code': "The 20-digit access code on the back of your card. (If it doesn't work, please try scanning your card in game and enter the access code shown on screen)",
'home.linkcard.enter-sn1': "Download the NFC Tools app on your phone",
'home.linkcard.enter-sn2': "and scan your card. Then, enter the Serial Number.",
'home.linkcard.link': "Link",
'home.linkcard.data-conflict': "Data Conflict",
'home.linkcard.name': "Name",
'home.linkcard.rating': "Rating",
'home.linkcard.last-login': "Last Login",
'home.linkcard.linked-own': "This card is already linked to your account",
'home.linkcard.linked-another': "This card is already linked to another account",
'home.linkcard.notfound': "Card not found",
'home.linkcard.unlink': "Unlink Card",
'home.linkcard.unlink-notice': "Are you sure you want to unlink this card?",
'home.setup.welcome': "Welcome! If you own an arcade cabinet or game setup, please follow the instructions below to set up the connection with AquaDX.",
'home.setup.blockquote': "We assume that you already have the required files and can run the game (e.g. ROM and segatools) that come with the cabinet or game setup. If not, please contact the seller of your device for the required files, as we will not provide them for copyright reasons.",
'home.setup.get': "Get started",
'home.setup.edit': "Please edit your segatools.ini file and modify the following lines",
'home.setup.test': "Then, after you restart the game, you should be able to connect to AquaDX. Please verify that the network tests are all GOOD in the test menu.",
'home.setup.ask': "If you have any questions, please ask in our",
'home.setup.support': "server",
'home.setup.keychip-tips': "This is your unique keychip, do not share it with anyone",
'home.linkcard.cards': 'Your Cards',
'home.linkcard.description': 'Here are the cards you have linked to your account',
'home.linkcard.account-card': 'Account Card',
'home.linkcard.registered': 'Registered',
'home.linkcard.lastused': 'Last used',
'home.linkcard.enter-info': 'Please enter the following information',
'home.linkcard.access-code': 'The 20-digit access code on the back of your card. (If it doesn\'t work, please try scanning your card in game and enter the access code shown on screen)',
'home.linkcard.enter-sn1': 'Download the NFC Tools app on your phone',
'home.linkcard.enter-sn2': 'and scan your card. Then, enter the Serial Number.',
'home.linkcard.link': 'Link',
'home.linkcard.data-conflict': 'Data Conflict',
'home.linkcard.name': 'Name',
'home.linkcard.rating': 'Rating',
'home.linkcard.last-login': 'Last Login',
'home.linkcard.linked-own': 'This card is already linked to your account',
'home.linkcard.linked-another': 'This card is already linked to another account',
'home.linkcard.notfound': 'Card not found',
'home.linkcard.unlink': 'Unlink Card',
'home.linkcard.unlink-notice': 'Are you sure you want to unlink this card?',
'home.setup.welcome': 'Welcome! If you own an arcade cabinet or game setup, please follow the instructions below to set up the connection with AquaDX.',
'home.setup.blockquote': 'We assume that you already have the required files and can run the game (e.g. ROM and segatools) that come with the cabinet or game setup. If not, please contact the seller of your device for the required files, as we will not provide them for copyright reasons.',
'home.setup.get': 'Get started',
'home.setup.edit': 'Please edit your segatools.ini file and modify the following lines',
'home.setup.test': 'Then, after you restart the game, you should be able to connect to AquaDX. Please verify that the network tests are all GOOD in the test menu.',
'home.setup.ask': 'If you have any questions, please ask in our',
'home.setup.support': 'server',
'home.setup.keychip-tips': 'This is your unique keychip, do not share it with anyone',
}
export const EN_REF_SETTINGS = {
'settings.title': 'Settings',
'settings.tabs.profile': 'Profile',
'settings.tabs.game': 'Game',
'settings.tabs.userbox': 'Userbox',
'settings.fields.unlockMusic.name': 'Unlock All Music',
'settings.fields.unlockMusic.desc': 'Unlock all music and master difficulty in game.',
'settings.fields.unlockChara.name': 'Unlock All Characters',
@ -140,7 +141,30 @@ export const EN_REF_SETTINGS = {
'settings.profile.unchanged': 'Unchanged',
}
export const EN_REF_USERBOX = {
'userbox.tabs.chusan':'Chuni',
'userbox.tabs.maimai':'Mai (WIP)',
'userbox.tabs.ongeki':'Ongeki (WIP)',
'userbox.nameplate': 'Nameplate',
'userbox.frame': 'Frame',
'userbox.trophy': 'Trophy (Title)',
'userbox.mapicon': 'Map Icon',
'userbox.voice':'System Voice',
'userbox.wear':'Avatar Wear',
'userbox.head':'Avatar Head',
'userbox.face':'Avatar Face',
'userbox.skin':'Avatar Skin',
'userbox.item':'Avatar Item',
'userbox.front':'Avatar Front',
'userbox.back':'Avatar Back',
'userbox.preview.avatar':'Avatar Preview',
'userbox.preview.nameplate':'Nameplate Preview',
'userbox.preview.ui':'Interface Preview',
'userbox.error.noprofile':'No profile was found for this game',
'userbox.error.nodata':'No data was found for this game',
}
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS }
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS, ...EN_REF_USERBOX }
export type LocalizedMessages = typeof EN_REF

View File

@ -1,162 +1,315 @@
import { AQUA_HOST, DATA_HOST } from "./config";
import type {
AllMusic,
Card,
CardSummary,
GenericGameSummary,
GenericRanking,
TrendEntry,
AquaNetUser, GameOption
} from "./generalTypes";
import type { GameName } from "./scoring";
interface RequestInitWithParams extends RequestInit {
params?: { [index: string]: string }
localCache?: boolean
}
/**
* Modify a fetch url
*
* @param input Fetch url input
* @param callback Callback for modification
*/
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
let u = new URL((input instanceof Request) ? input.url : input);
const result = callback(u)
if (result) u = result
if (input instanceof Request) {
// @ts-ignore
return { url: u, ...input }
}
return u
}
/**
* Fetch with url parameters
*/
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
return fetch(reconstructUrl(input, u => {
u.search = new URLSearchParams(init?.params ?? {}).toString()
}), init)
}
let cache: { [index: string]: any } = {}
export async function post(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
}
let res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
}
/**
* aqua.net.UserRegistrar
*
* @param user
*/
async function register(user: { username: string, email: string, password: string, turnstile: string }) {
return await post('/api/v2/user/register', user)
}
async function login(user: { email: string, password: string, turnstile: string }) {
const data = await post('/api/v2/user/login', user)
// Put token into local storage
localStorage.setItem('token', data.token)
}
const isLoggedIn = () => !!localStorage.getItem('token')
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
export const USER = {
register,
login,
confirmEmail: (token: string) =>
post('/api/v2/user/confirm-email', { token }),
me: (): Promise<AquaNetUser> => {
ensureLoggedIn()
return post('/api/v2/user/me', {})
},
keychip: (): Promise<string> =>
post('/api/v2/user/keychip', {}).then(it => it.keychip),
setting: (key: string, value: string) =>
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
uploadPfp: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return post('/api/v2/user/upload-pfp', { }, { method: 'POST', body: formData })
},
isLoggedIn,
ensureLoggedIn,
}
export const CARD = {
summary: (cardId: string): Promise<{card: Card, summary: CardSummary}> =>
post('/api/v2/card/summary', { cardId }),
link: (props: { cardId: string, migrate: string }) =>
post('/api/v2/card/link', props),
unlink: (cardId: string) =>
post('/api/v2/card/unlink', { cardId }),
userGames: (username: string): Promise<CardSummary> =>
post('/api/v2/card/user-games', { username }),
}
export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }),
ranking: (game: GameName): Promise<GenericRanking[]> =>
post(`/api/v2/game/${game}/ranking`, { }),
}
export const DATA = {
allMusic: (game: GameName): Promise<AllMusic> =>
fetch(`${DATA_HOST}/d/${game}/00/all-music.json`).then(it => it.json())
}
export const SETTING = {
get: (): Promise<GameOption[]> =>
post('/api/v2/settings/get', {}),
set: (key: string, value: any) =>
post('/api/v2/settings/set', { key, value: `${value}` }),
}
import { AQUA_HOST, DATA_HOST } from './config'
import type {
AllMusic,
Card,
CardSummary,
GenericGameSummary,
GenericRanking,
TrendEntry,
AquaNetUser, GameOption,
UserBox,
ChangeUserBoxReq,
UserBoxItemKind
} from './generalTypes'
import type { GameName } from './scoring'
interface RequestInitWithParams extends RequestInit {
params?: { [index: string]: string }
localCache?: boolean
}
/**
* Modify a fetch url
*
* @param input Fetch url input
* @param callback Callback for modification
*/
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
let u = new URL((input instanceof Request) ? input.url : input)
const result = callback(u)
if (result) u = result
if (input instanceof Request) {
// @ts-ignore
return { url: u, ...input }
}
return u
}
/**
* Fetch with url parameters
*/
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
return fetch(reconstructUrl(input, u => {
u.search = new URLSearchParams(init?.params ?? {}).toString()
}), init)
}
const cache: { [index: string]: any } = {}
export async function post(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
}
export async function get(endpoint: string, params:any,init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(init)]
if (cached) return cached
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'GET',
params,
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(init)] = ret
return ret
}
export async function put(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
// Add token if exists
const token = localStorage.getItem('token')
if (token && !('token' in params)) params = { ...(params ?? {}), token }
if (init?.localCache) {
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
if (cached) return cached
}
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'PUT',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
const ret = res.json()
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
return ret
}
export async function realPost(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
const res = await fetchWithParams(AQUA_HOST + endpoint, {
method: 'POST',
body: JSON.stringify(params),
headers:{
'Content-Type':'application/json',
...init?.headers
},
...init
}).catch(e => {
console.error(e)
throw new Error('Network error')
})
if (!res.ok) {
const text = await res.text()
console.error(`${res.status}: ${text}`)
// If 400 invalid token is caught, should invalidate the token and redirect to signin
if (text === 'Invalid token') {
localStorage.removeItem('token')
window.location.href = '/'
}
// Try to parse as json
let json
try {
json = JSON.parse(text)
} catch (e) {
throw new Error(text)
}
if (json.error) throw new Error(json.error)
}
return res.json()
}
/**
* aqua.net.UserRegistrar
*
* @param user
*/
async function register(user: { username: string, email: string, password: string, turnstile: string }) {
return await post('/api/v2/user/register', user)
}
async function login(user: { email: string, password: string, turnstile: string }) {
const data = await post('/api/v2/user/login', user)
// Put token into local storage
localStorage.setItem('token', data.token)
}
const isLoggedIn = () => !!localStorage.getItem('token')
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
export const USER = {
register,
login,
confirmEmail: (token: string) =>
post('/api/v2/user/confirm-email', { token }),
me: (): Promise<AquaNetUser> => {
ensureLoggedIn()
return post('/api/v2/user/me', {})
},
keychip: (): Promise<string> =>
post('/api/v2/user/keychip', {}).then(it => it.keychip),
setting: (key: string, value: string) =>
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
uploadPfp: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return post('/api/v2/user/upload-pfp', { }, { method: 'POST', body: formData })
},
isLoggedIn,
ensureLoggedIn,
}
export const USERBOX = {
getAimeId:(cardId:string):Promise<{luid:string}|null> =>realPost('/api/sega/aime/getByAccessCode',{ accessCode:cardId }),
getProfile:(aimeId:string):Promise<UserBox> =>get('/api/game/chuni/v2/profile',{ aimeId }),
getUnlockedItems:(aimeId:string, itemId: UserBoxItemKind):Promise<{itemKind:number, itemId:number,stock:number,isValid:boolean}[]> =>
get(`/api/game/chuni/v2/item/${itemId}`,{ aimeId }),
getItemLabels:() => {
const kinds = [ 'nameplate', 'frame', 'trophy', 'mapicon', 'sysvoice', 'avatar' ]
return Promise.all(kinds.map(it =>
get(`/api/game/chuni/v2/data/${it}`,{}).then((res:{id:number,name:string}[]) =>
// Use the id as the key
res.reduce((acc, cur) => ({ ...acc, [cur.id]: cur.name }), {}) as { [index: number]: string }
))).then(([ nameplate, frame, trophy, mapicon, sysvoice, avatar ]) => ({
nameplate, frame, trophy, mapicon, sysvoice, avatar
}))
},
setUserBox:({ kind,...body }:ChangeUserBoxReq) =>
put(`/api/game/chuni/v2/profile/${kind}`, body),
}
export const CARD = {
summary: (cardId: string): Promise<{card: Card, summary: CardSummary}> =>
post('/api/v2/card/summary', { cardId }),
link: (props: { cardId: string, migrate: string }) =>
post('/api/v2/card/link', props),
unlink: (cardId: string) =>
post('/api/v2/card/unlink', { cardId }),
userGames: (username: string): Promise<CardSummary> =>
post('/api/v2/card/user-games', { username }),
}
export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }),
ranking: (game: GameName): Promise<GenericRanking[]> =>
post(`/api/v2/game/${game}/ranking`, { }),
}
export const DATA = {
allMusic: (game: GameName): Promise<AllMusic> =>
fetch(`${DATA_HOST}/d/${game}/00/all-music.json`).then(it => it.json())
}
export const SETTING = {
get: (): Promise<GameOption[]> =>
post('/api/v2/settings/get', {}),
set: (key: string, value: any) =>
post('/api/v2/settings/set', { key, value: `${value}` }),
}

View File

@ -9,6 +9,7 @@
import { pfp } from "../../libs/ui";
import { t, ts } from "../../libs/i18n";
import { FADE_IN, FADE_OUT } from "../../libs/config";
import UserBox from "../../components/UserBox.svelte";
USER.ensureLoggedIn()
@ -16,7 +17,7 @@
let error: string;
let submitting = ""
let tab = 0
const tabs = [ 'profile', 'game' ]
const tabs = [ 'profile', 'game', 'userbox']
const profileFields = [
[ 'displayName', t('settings.profile.name') ],
@ -145,6 +146,9 @@
</div>
{/each}
</div>
{:else if tab === 2}
<!-- Tab 2: Userbox settings -->
<UserBox />
{/if}
<StatusOverlays {error} loading={!me || !!submitting} />