Merge branch 'v1-dev' into pr/119

pull/119/head
Azalea 2025-03-01 00:33:30 -05:00
commit 338819416f
13 changed files with 210 additions and 40 deletions

Binary file not shown.

Binary file not shown.

View File

@ -7,8 +7,10 @@
import { USER } from "./libs/sdk"; import { USER } from "./libs/sdk";
import type { AquaNetUser } from "./libs/generalTypes"; import type { AquaNetUser } from "./libs/generalTypes";
import Settings from "./pages/User/Settings.svelte"; import Settings from "./pages/User/Settings.svelte";
import { pfp } from "./libs/ui"
import MaiPhoto from "./pages/MaiPhoto.svelte"; import MaiPhoto from "./pages/MaiPhoto.svelte";
import { pfp, tooltip } from "./libs/ui"
import { ANNOUNCEMENT } from "./libs/config";
import { t } from "./libs/i18n";
console.log(`%c console.log(`%c
┏━┓ ┳━┓━┓┏━ ┏━┓ ┳━┓━┓┏━
@ -37,13 +39,18 @@
<span>AquaNet</span> <span>AquaNet</span>
</a> </a>
{/if} {/if}
<a href="/home">home</a> {#if ANNOUNCEMENT}
<div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")} <div class="announcement">
role="button" tabindex="0">maps</div> <strong>{t('navigation.notice')}</strong>: {ANNOUNCEMENT}
<a href="/ranking">rankings</a> </div>
{/if}
<a href="/home">{t('navigation.home').toLowerCase()}</a>
<!-- <div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
role="button" tabindex="0">{t('navigation.maps').toLowerCase()}</div> -->
<a href="/ranking">{t('navigation.rankings').toLowerCase()}</a>
<a href="/pictures">pictures</a> <a href="/pictures">pictures</a>
{#if me} {#if me}
<a href="/u/{me.username}"> <a href="/u/{me.username}" use:tooltip={t('navigation.profile')}>
<img alt="profile" class="pfp" use:pfp={me}/> <img alt="profile" class="pfp" use:pfp={me}/>
</a> </a>
{/if} {/if}
@ -81,6 +88,22 @@
border-radius: vars.$border-radius border-radius: vars.$border-radius
object-fit: cover object-fit: cover
.announcement
position: absolute
left: 50%
transform: translate(-50%, 0)
top: 0
width: 50%
height: 100%
display: flex
justify-content: center
align-content: center
z-index: -1
background: linear-gradient(90deg, #6f0f0f00 0%, vars.$c-shadow 50%, #6f0f0f00 100%)
font-size: 1.125em
text-decoration: none !important
color: inherit !important
.pfp .pfp
width: 2rem width: 2rem
height: 2rem height: 2rem

View File

@ -134,7 +134,7 @@ button.icon
.error .error
color: vars.$c-error color: vars.$c-error
input input, textarea
border-radius: vars.$border-radius border-radius: vars.$border-radius
border: 1px solid transparent border: 1px solid transparent
padding: 0.6em 1.2em padding: 0.6em 1.2em
@ -144,6 +144,10 @@ input
background-color: vars.$ov-lighter background-color: vars.$ov-lighter
transition: vars.$transition transition: vars.$transition
box-sizing: border-box box-sizing: border-box
resize: none
textarea
height: 5em
// Dropdown // Dropdown
select select
@ -314,6 +318,9 @@ main.content
max-width: 400px max-width: 400px
.aqua-tooltip
z-index: 900
.no-margin .no-margin
margin: 0 margin: 0

View File

@ -60,7 +60,7 @@
.tooltip .tooltip
position: absolute position: absolute
z-index: 1000 z-index: 900
background: white background: white
padding: 10px 16px padding: 10px 16px
border-radius: vars.$border-radius border-radius: vars.$border-radius

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { DDS } from "../../../libs/userbox/dds" import { DDS, type RGB } from "../../../libs/userbox/dds"
import { ddsDB } from "../../../libs/userbox/userbox" import { ddsDB } from "../../../libs/userbox/userbox"
const DDSreader = new DDS(ddsDB); const DDSreader = new DDS(ddsDB);
@ -15,6 +15,26 @@
let ratingToString = (rating: number) => { let ratingToString = (rating: number) => {
return rating.toFixed(2) return rating.toFixed(2)
} }
interface RatingRange {
min: number,
offset: number,
color?: RGB
};
// https://en.wikipedia.org/wiki/Chunithm#Rating
const ratingColors: RatingRange[] = ([
{min: 0.00, offset: 4, color: {r: 0, g: 191, b: 64}},
{min: 4.00, offset: 4, color: {r: 255, g: 111, b: 0}},
{min: 7.00, offset: 4, color: {r: 255, g: 64, b: 64}},
{min: 10.00, offset: 4, color: {r: 147, g: 38, b: 255}},
{min: 12.00, offset: 3},
{min: 13.25, offset: 2},
{min: 14.50, offset: 1},
{min: 15.25, offset: 0},
{min: 16.00, offset: 5}
]).filter(f => f.min <= chuniRating);
const ratingDigitOrder = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "."]
const ratingColorData = (ratingColors[ratingColors.length - 1] ?? ratingColors[0]);
</script> </script>
{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`, `nameplate:00000001`) then nameplateURL} {#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`, `nameplate:00000001`) then nameplateURL}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
@ -41,17 +61,36 @@
{chuniName} {chuniName}
</span> </span>
</div> </div>
<div class="chuni-user-rating"> <div class={`chuni-user-rating color-${ratingColorData.color}`}>
RATING
<span class="chuni-user-rating-number"> {#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_Common_01_v11.dds", 485, 5 + (28 * ratingColorData.offset), 62, 15, undefined, ratingColorData.color) then url}
{ratingToString(chuniRating)} {#if url}
</span> <img src={url} alt="Rating">
<span class="chuni-user-rating-number">
{#each ratingToString(chuniRating).split("") as digit}
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_Common_01_v11.dds", 552 + (24 * (ratingDigitOrder.indexOf(digit) ?? 0)), 1 + (28 * ratingColorData.offset), 16, 20, undefined, ratingColorData.color) then url}
<img src={url} alt="Rating Digit">
{/await}
{/each}
</span>
{:else}
RATING
<span class="chuni-user-rating-number">
{ratingToString(chuniRating)}
</span>
{/if}
{/await}
</div> </div>
</div> </div>
</div> </div>
{/await} {/await}
<style lang="sass"> <style lang="sass">
@use "../../../vars" @use "../../../vars"
@font-face
font-family: "Gothic A1"
src: url("/assets/fonts/GothicA1.woff2")
.chuni-nameplate .chuni-nameplate
width: 576px width: 576px
height: 228px height: 228px
@ -78,7 +117,7 @@
top: 40px top: 40px
font-size: 1.15em font-size: 1.15em
font-family: sans-serif font-family: "Gothic A1", sans-serif
font-weight: bold font-weight: bold
overflow-x: hidden overflow-x: hidden
@ -123,7 +162,7 @@
display: flex display: flex
align-items: center align-items: center
color: black color: black
font-family: sans-serif font-family: "Gothic A1", sans-serif
font-weight: bold font-weight: bold
.chuni-user-name .chuni-user-name
@ -144,7 +183,7 @@
flex: 1 0 35% flex: 1 0 35%
font-size: 0.875em font-size: 0.875em
text-shadow: #333 1px 1px, #333 1px -1px, #333 -1px 1px, #333 -1px -1px text-shadow: #333 1px 1px, #333 1px -1px, #333 -1px 1px, #333 -1px -1px
color: #ddf color: #fff
.chuni-user-rating-number .chuni-user-rating-number
font-size: 1.5em font-size: 1.5em

View File

@ -16,6 +16,8 @@ export const FADE_OUT = { duration: 200 }
export const FADE_IN = { delay: 400 } export const FADE_IN = { delay: 400 }
export const DEFAULT_PFP = '/assets/imgs/no_profile.png' export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
export const ANNOUNCEMENT = '' // If set, will add an announcement to the top bar. Keep it short.
// Documentation for Userbox mode can be found in `docs/aquabox-url-mode.md` // Documentation for Userbox mode can be found in `docs/aquabox-url-mode.md`
// Please note that if this is set, it must be manually unset by users in Chuni Settings -> Update Userbox -> Switch to URL mode -> (empty value) -> Enter key // Please note that if this is set, it must be manually unset by users in Chuni Settings -> Update Userbox -> Switch to URL mode -> (empty value) -> Enter key
export const USERBOX_DEFAULT_URL = "" export const USERBOX_DEFAULT_URL = ""

View File

@ -72,6 +72,11 @@ export const EN_REF_GENERAL = {
'action.refresh': 'Refresh', 'action.refresh': 'Refresh',
'action.cancel': 'Cancel', 'action.cancel': 'Cancel',
'action.confirm': 'Confirm', 'action.confirm': 'Confirm',
'navigation.profile': 'Profile',
'navigation.maps': 'Maps',
'navigation.home': 'Home',
'navigation.rankings': 'Rankings',
'navigation.notice': 'Notice'
} }
export const EN_REF_HOME = { export const EN_REF_HOME = {

View File

@ -67,7 +67,7 @@ const multTable = {
[ 60.0, 0, 'B' ], [ 60.0, 0, 'B' ],
[ 1.0, 0, 'C' ], [ 1.0, 0, 'C' ],
[ 0.0, 0, 'D' ] [ 0.0, 0, 'D' ]
] ],
} }
export function getMult(achievement: number, game: GameName) { export function getMult(achievement: number, game: GameName) {

View File

@ -56,6 +56,12 @@ void main() {
gl_FragColor = texture2D(uTexture, vTextureCoord); gl_FragColor = texture2D(uTexture, vTextureCoord);
}` }`
export interface RGB {
r: number,
g: number,
b: number
}
export class DDS { export class DDS {
constructor(db: IDBDatabase | undefined) { constructor(db: IDBDatabase | undefined) {
this.cache = new DDSCache(db); this.cache = new DDSCache(db);
@ -241,13 +247,27 @@ export class DDS {
* @param s Scale factor * @param s Scale factor
* @returns An object URL which correlates to a Blob * @returns An object URL which correlates to a Blob
*/ */
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise<string> { async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number, color?: RGB): Promise<string> {
if (!await this.loadFile(path)) if (!await this.loadFile(path))
return ""; return "";
this.canvas2D.width = w * (s ?? 1); this.canvas2D.width = w * (s ?? 1);
this.canvas2D.height = h * (s ?? 1); this.canvas2D.height = h * (s ?? 1);
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1)); this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
if (color) {
let colorData = this.ctx.getImageData(0, 0, this.canvas2D.width, this.canvas2D.height);
for (let i = 0; colorData.data.length > i; i++)
switch (i % 4) {
case 0:
colorData.data[i] *= (color.r / 255); break;
case 1:
colorData.data[i] *= (color.g / 255); break;
case 2:
colorData.data[i] *= (color.b / 255); break;
}
this.ctx.putImageData(colorData, 0, 0);
}
/* We don't want to cache this, it's a spritesheet piece. */ /* We don't want to cache this, it's a spritesheet piece. */
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])); return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
}; };

View File

@ -85,6 +85,8 @@ const DIRECTORY_PATHS = ([
([ ([
"CHU_UI_Common_Avatar_body_00.dds", "CHU_UI_Common_Avatar_body_00.dds",
"CHU_UI_Common_Avatar_face_00.dds", "CHU_UI_Common_Avatar_face_00.dds",
"CHU_UI_Common_01_v11.dds",
"CHU_UI_TeamEmblem_01_v14.dds",
"CHU_UI_title_rank_00_v10.dds" "CHU_UI_title_rank_00_v10.dds"
]).includes(name), ]).includes(name),
id: (name: string) => name id: (name: string) => name

View File

@ -28,8 +28,9 @@
[ 'displayName', t('settings.profile.name') ], [ 'displayName', t('settings.profile.name') ],
[ 'username', t('settings.profile.username') ], [ 'username', t('settings.profile.username') ],
[ 'password', t('settings.profile.password') ], [ 'password', t('settings.profile.password') ],
/* Neither of these did anything of importance
[ 'country', t('settings.profile.country') ], [ 'country', t('settings.profile.country') ],
[ 'profileLocation', t('settings.profile.location') ], [ 'profileLocation', t('settings.profile.location') ],*/
[ 'profileBio', t('settings.profile.bio') ], [ 'profileBio', t('settings.profile.bio') ],
] as const ] as const
@ -165,9 +166,14 @@
<div class="field"> <div class="field">
<label for={field}>{name}</label> <label for={field}>{name}</label>
<div> <div>
<input id={field} type="text" use:passwordAction={field === 'password'} {#if field == "profileBio"}
bind:value={me[field]} on:input={() => changed = [...changed, field]} <textarea id={field} bind:value={me[field]} on:input={() => changed = [...changed, field]} maxlength=255 placeholder={t('settings.profile.unset')}></textarea>
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/> {:else}
<input id={field} type="text" use:passwordAction={field === 'password'}
bind:value={me[field]} on:input={() => changed = [...changed, field]}
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
{/if}
{#if changed.includes(field) && me[field]} {#if changed.includes(field) && me[field]}
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, me[field])}> <button transition:slide={{axis: 'x'}} on:click={() => submit(field, me[field])}>
{#if submitting === field} {#if submitting === field}
@ -256,7 +262,7 @@
gap: 1rem gap: 1rem
margin-top: 0.5rem margin-top: 0.5rem
> input > input, > textarea
flex: 1 flex: 1
img img
@ -266,6 +272,8 @@
object-fit: cover object-fit: cover
aspect-ratio: 1 aspect-ratio: 1
.cropper-container .cropper-container
position: relative position: relative
width: 400px width: 400px

View File

@ -30,14 +30,14 @@
registerChart() registerChart()
export let username: string; export let username: string;
export let game: GameName = "mai2" export let game: GameName | "auto" = "auto"
let calElement: HTMLElement let calElement: HTMLElement
let error: string; let error: string;
let me: AquaNetUser let me: AquaNetUser
title(`User ${username}`) title(`User ${username}`)
const rounding = useLocalStorage("rounding", true); const rounding = useLocalStorage("rounding", true);
const titleText = GAME_TITLE[game] const titleText = game != "auto" ? GAME_TITLE[game] : "?"
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {} interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
@ -57,6 +57,19 @@
USER.isLoggedIn() && USER.me().then(u => me = u) USER.isLoggedIn() && USER.me().then(u => me = u)
CARD.userGames(username).then(games => { CARD.userGames(username).then(games => {
if (game == "auto") {
let targetGames = Object.entries(games)
.map(d => {
if (d[1])
d[1].lastLogin = d[1].lastLogin ? new Date(d[1].lastLogin) : new Date(0);
return d;
}).sort((a,b) => {
return b[1]?.lastLogin - a[1]?.lastLogin;
});
if (targetGames[0])
window.location.href = `/u/${username}/${targetGames[0][0]}`
return;
}
if (!games[game]) { if (!games[game]) {
// Find a valid game // Find a valid game
const valid = Object.entries(games).filter(([g, valid]) => valid) const valid = Object.entries(games).filter(([g, valid]) => valid)
@ -105,10 +118,11 @@
}).catch((e) => { error = e.message; console.error(e) } ); }).catch((e) => { error = e.message; console.error(e) } );
} }
if (Object.keys(GAME_TITLE).includes(game)) init() if (Object.keys(GAME_TITLE).includes(game) || game == "auto") init()
else error = t("UserHome.InvalidGame", {game}) else error = t("UserHome.InvalidGame", {game})
const setRival = (isAdd: boolean) => { const setRival = (isAdd: boolean) => {
if (game == "auto") return;
isLoading = true isLoading = true
GAME.setRival(game, username, isAdd).then(() => { GAME.setRival(game, username, isAdd).then(() => {
d!.user.rival = isAdd d!.user.rival = isAdd
@ -122,9 +136,22 @@
<img use:pfp={d.user.aquaUser} alt="" class="pfp" on:error={pfpNotFound}> <img use:pfp={d.user.aquaUser} alt="" class="pfp" on:error={pfpNotFound}>
<div class="name-box"> <div class="name-box">
<div class="name-left"> <div class="name-left">
<h2>{d.user.name}</h2>
{#if d.user.aquaUser} {#if d.user.aquaUser}
{#if d.user.aquaUser.displayName}
<h2>{d.user.aquaUser?.displayName}</h2>
{:else}
<h2>{d.user.name}</h2>
{/if}
<div class="game-name">
{#if d.user.aquaUser.displayName}
{d.user.name}
{/if}
(@{d.user.aquaUser.username})
</div>
<div class="country">{countryCodeToEmoji(d.user.aquaUser?.country)}</div> <div class="country">{countryCodeToEmoji(d.user.aquaUser?.country)}</div>
{:else}
<h2>{d.user.name}</h2>
{/if} {/if}
</div> </div>
{#if typeof d.user.rival === 'boolean' && game === 'mai2'} {#if typeof d.user.rival === 'boolean' && game === 'mai2'}
@ -133,19 +160,31 @@
{d.user.rival ? t("UserHome.RemoveRival") : t("UserHome.AddRival")} {d.user.rival ? t("UserHome.RemoveRival") : t("UserHome.AddRival")}
</span> </span>
{/if} {/if}
{#if me && me.username === username}
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href="/settings">
<Icon icon="eos-icons:rotating-gear"/>
</a>
{/if}
</div> </div>
<nav> <nav>
{#each d.validGames as [g, name]} {#each d.validGames as [g, name]}
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a> <a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
{/each} {/each}
{#if me && me.username === username}
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href="/settings">
<Icon icon="eos-icons:rotating-gear"/>
</a>
{/if}
</nav> </nav>
</div> </div>
{#if d.user.aquaUser?.profileBio}
<div class="activity-info">
<div class="info-bottom profile-bio-container">
<div class="profile-bio">
<span>{t("settings.profile.bio")}</span>
<span class="profile-bio-text">{d.user.aquaUser?.profileBio}</span>
</div>
</div>
</div>
{/if}
<ChuniUserboxDisplay {game} {username} bind:error={error} /> <ChuniUserboxDisplay {game} {username} bind:error={error} />
<div> <div>
@ -272,12 +311,14 @@
</div> </div>
</div> </div>
<RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} {game}/> <!-- I don't like doing this but it may be preferable to gaslighting the types -->
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} {game}/>
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} {game}/> <RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} game={game != "auto" ? game : "mai2"}/>
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} game={game != "auto" ? game : "mai2"}/>
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} game={game != "auto" ? game : "mai2"}/>
<!-- <RatingComposition title="Hot 10" comp={d.user.ratingComposition.hot10} {allMusics} {game}/> --> <!-- <RatingComposition title="Hot 10" comp={d.user.ratingComposition.hot10} {allMusics} {game}/> -->
<!-- <RatingComposition title="N10" comp={d.user.ratingComposition.next10} {allMusics} {game}/> --> <!-- <RatingComposition title="N10" comp={d.user.ratingComposition.next10} {allMusics} {game}/> -->
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/> <RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} game={game != "auto" ? game : "mai2"} top={10}/>
<div class="recent"> <div class="recent">
<h2>{t('UserHome.RecentScores')}</h2> <h2>{t('UserHome.RecentScores')}</h2>
@ -298,12 +339,12 @@
{r.notes?.[r.level === 10 ? 0 : r.level]?.lv?.toFixed(1) ?? r.worldsEndTag ?? '-'} {r.notes?.[r.level === 10 ? 0 : r.level]?.lv?.toFixed(1) ?? r.worldsEndTag ?? '-'}
</span> </span>
</span> </span>
<span class={`rank-${getMult(r.achievement, game)[2].toString()[0]}`}> <span class={`rank-${getMult(r.achievement, game != "auto" ? game : "mai2")[2].toString()[0]}`}>
<span class="rank-text">{("" + getMult(r.achievement, game)[2]).replace("p", "+")}</span> <span class="rank-text">{("" + getMult(r.achievement, game != "auto" ? game : "mai2")[2]).replace("p", "+")}</span>
<span class="rank-num" use:tooltip={(r.achievement / 10000).toFixed(4)}> <span class="rank-num" use:tooltip={(r.achievement / 10000).toFixed(4)}>
{ {
rounding.value ? rounding.value ?
roundFloor(r.achievement, game, 1) : roundFloor(r.achievement, game != "auto" ? game : "mai2", 1) :
(r.achievement / 10000).toFixed(4) (r.achievement / 10000).toFixed(4)
}% }%
</span> </span>
@ -357,6 +398,9 @@
display: flex display: flex
align-items: center align-items: center
position: relative
z-index: 20
.name-box .name-box
flex: 1 flex: 1
display: flex display: flex
@ -367,6 +411,16 @@
.name-left .name-left
display: flex display: flex
gap: 1em gap: 1em
position: relative
.game-name
position: absolute
left: 0.5em
bottom: 0
transform: translate(0, 75%)
opacity: 50%
white-space: nowrap
max-width: 50%
.pfp .pfp
width: 100px width: 100px
@ -403,6 +457,16 @@
.info-bottom .info-bottom
width: max-content width: max-content
&.profile-bio-container,
&.profile-bio-container div
width: 100%
.profile-bio-text
white-space: pre
max-height: 10em
overflow-y: auto
flex: 1
.info-top > div > span:last-child .info-top > div > span:last-child
font-size: 1.5rem font-size: 1.5rem