add prettier formatter

js-formatter
Afonso 2024-02-12 18:04:56 +01:00
parent e3f931d4f5
commit 38bddf1763
21 changed files with 1141 additions and 1018 deletions

5
.gitignore vendored
View File

@ -75,4 +75,7 @@ gradle-app.setting
### Gradle Patch ###
# Java heap dump
*.hprof
*.hprof
### Docker ###
/db/*

8
.prettierrc 100644
View File

@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"bracketSpacing": false,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -11,6 +11,6 @@ TicketUnlock=true
# Skip the warning screen and logo shown after the POST sequence
SkipWarningScreen=true
# Single player: Show 1P only, at the center of the screen
SinglePlayer=true
SinglePlayer=false
# !!EXPERIMENTAL!! Skip from the card-scanning screen directly to music selection screen
SkipToMusicSelection=false
SkipToMusicSelection=true

View File

@ -1,6 +1,6 @@
# AquaNet
This is the codebase for the new frontend of AquaDX.
This is the codebase for the new frontend of AquaDX.
This project is also heavily WIP, so more details will be added later on.
## Development
@ -19,4 +19,3 @@ Finally, run:
yarn install
yarn dev
```

View File

@ -1,35 +1,35 @@
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import {writable} from 'svelte/store'
export default writable(0)
```

View File

@ -6,15 +6,36 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AquaNet</title>
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png">
<link rel="manifest" href="/assets/icons/site.webmanifest">
<link rel="mask-icon" href="/assets/icons/safari-pinned-tab.svg" color="#b3c6ff">
<link rel="shortcut icon" href="/assets/icons/favicon.ico">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-config" content="/assets/icons/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
<link
rel="apple-touch-icon"
sizes="180x180"
href="/assets/icons/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/icons/favicon-16x16.png"
/>
<link rel="manifest" href="/assets/icons/site.webmanifest" />
<link
rel="mask-icon"
href="/assets/icons/safari-pinned-tab.svg"
color="#b3c6ff"
/>
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-config"
content="/assets/icons/browserconfig.xml"
/>
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="app"></div>

View File

@ -7,13 +7,16 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-check --tsconfig ./tsconfig.json",
"format": "prettier --write ."
},
"devDependencies": {
"@iconify/svelte": "^3.1.6",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@tsconfig/svelte": "^5.0.2",
"chartjs-adapter-moment": "^1.0.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.1.2",
"sass": "^1.70.0",
"svelte": "^4.2.10",
"svelte-check": "^3.6.4",

View File

@ -1,19 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/assets/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
"name": "",
"short_name": "",
"icons": [
{
"src": "/assets/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -1,11 +1,11 @@
<script lang="ts">
import { Router, Route } from "svelte-routing";
import Home from "./pages/Home.svelte";
import MaimaiRating from "./pages/MaimaiRating.svelte";
import UserHome from "./pages/UserHome.svelte";
import Icon from '@iconify/svelte';
import {Router, Route} from 'svelte-routing'
import Home from './pages/Home.svelte'
import MaimaiRating from './pages/MaimaiRating.svelte'
import UserHome from './pages/UserHome.svelte'
import Icon from '@iconify/svelte'
export let url = "";
export let url = ''
</script>
<nav>
@ -53,4 +53,4 @@
@media (max-width: $w-mobile)
justify-content: center
</style>
</style>

View File

@ -1,5 +1,5 @@
export interface TrendEntry {
date: string
rating: number
plays: number
}
export interface TrendEntry {
date: string
rating: number
plays: number
}

View File

@ -1,51 +1,54 @@
import {aqua_host, data_host} from "./config";
import type {TrendEntry} from "./generalTypes";
import type {MaimaiUserSummaryEntry} from "./maimaiTypes";
const multTable = [
[100.5, 22.4, "SSSp"],
[100, 21.6, "SSS"],
[99.5, 21.1, "SSp"],
[99, 20.8, "SS"],
[98, 20.3, "Sp"],
[97, 20, "S"],
[94, 16.8, "AAA"],
[90, 15.2, "AA"],
[80, 13.6, "A"]
]
export function getMult(achievement: number) {
achievement /= 10000
for (let i = 0; i < multTable.length; i++) {
if (achievement >= (multTable[i][0] as number)) return multTable[i]
}
return [0, 0, 0]
}
export async function getMaimai(endpoint: string, params: any) {
return await fetch(`${aqua_host}/Maimai2Servlet/${endpoint}`, {
method: "POST",
body: JSON.stringify(params)
}).then(res => res.json())
}
export async function getMaimaiAllMusic(): Promise<{ [key: string]: any }> {
return fetch(`${data_host}/maimai/meta/00/all-music.json`).then(it => it.json())
}
export async function getMaimaiApi(endpoint: string, params: any) {
let url = new URL(`${aqua_host}/api/game/maimai2new/${endpoint}`)
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]))
return await fetch(url).then(res => res.json())
}
export async function getMaimaiTrend(userId: number): Promise<TrendEntry[]> {
return await getMaimaiApi("trend", {userId})
}
export async function getMaimaiUser(userId: number): Promise<MaimaiUserSummaryEntry> {
return await getMaimaiApi("user-summary", {userId})
}
import {aqua_host, data_host} from './config'
import type {TrendEntry} from './generalTypes'
import type {MaimaiUserSummaryEntry} from './maimaiTypes'
const multTable = [
[100.5, 22.4, 'SSSp'],
[100, 21.6, 'SSS'],
[99.5, 21.1, 'SSp'],
[99, 20.8, 'SS'],
[98, 20.3, 'Sp'],
[97, 20, 'S'],
[94, 16.8, 'AAA'],
[90, 15.2, 'AA'],
[80, 13.6, 'A'],
]
export function getMult(achievement: number) {
achievement /= 10000
for (let i = 0; i < multTable.length; i++) {
if (achievement >= (multTable[i][0] as number)) return multTable[i]
}
return [0, 0, 0]
}
export async function getMaimai(endpoint: string, params: any) {
return await fetch(`${aqua_host}/Maimai2Servlet/${endpoint}`, {
method: 'POST',
body: JSON.stringify(params),
}).then((res) => res.json())
}
export async function getMaimaiAllMusic(): Promise<{[key: string]: any}> {
return fetch(`${data_host}/maimai/meta/00/all-music.json`).then((it) =>
it.json()
)
}
export async function getMaimaiApi(endpoint: string, params: any) {
let url = new URL(`${aqua_host}/api/game/maimai2new/${endpoint}`)
Object.keys(params).forEach((key) =>
url.searchParams.append(key, params[key])
)
return await fetch(url).then((res) => res.json())
}
export async function getMaimaiTrend(userId: number): Promise<TrendEntry[]> {
return await getMaimaiApi('trend', {userId})
}
export async function getMaimaiUser(
userId: number
): Promise<MaimaiUserSummaryEntry> {
return await getMaimaiApi('user-summary', {userId})
}

View File

@ -1,115 +1,115 @@
export interface Rating {
musicId: number
level: number
achievement: number
}
export interface ParsedRating extends Rating {
music: MaimaiMusic,
calc: number,
rank: string
}
export interface MaimaiMusic {
name: string,
composer: string,
bpm: number,
ver: number,
note: {
lv: number
designer: string
lv_id: number
notes: number
}
}
export interface MaimaiUserSummaryEntry {
name: string
iconId: number
serverRank: number
accuracy: number
rating: number
ratingHighest: number
ranks: { name: string, count: number }[]
maxCombo: number
fullCombo: number
allPerfect: number
totalDxScore: number
plays: number
totalPlayTime: number
joined: string
lastSeen: string
lastVersion: string
best35: string
best15: string
recent: MaimaiUserPlaylog[]
}
export interface MaimaiUserPlaylog {
id: number;
musicId: number;
level: number;
trackNo: number;
vsRank: number;
achievement: number;
deluxscore: number;
scoreRank: number;
maxCombo: number;
totalCombo: number;
maxSync: number;
totalSync: number;
tapCriticalPerfect: number;
tapPerfect: number;
tapGreat: number;
tapGood: number;
tapMiss: number;
holdCriticalPerfect: number;
holdPerfect: number;
holdGreat: number;
holdGood: number;
holdMiss: number;
slideCriticalPerfect: number;
slidePerfect: number;
slideGreat: number;
slideGood: number;
slideMiss: number;
touchCriticalPerfect: number;
touchPerfect: number;
touchGreat: number;
touchGood: number;
touchMiss: number;
breakCriticalPerfect: number;
breakPerfect: number;
breakGreat: number;
breakGood: number;
breakMiss: number;
isTap: boolean;
isHold: boolean;
isSlide: boolean;
isTouch: boolean;
isBreak: boolean;
isCriticalDisp: boolean;
isFastLateDisp: boolean;
fastCount: number;
lateCount: number;
isAchieveNewRecord: boolean;
isDeluxscoreNewRecord: boolean;
comboStatus: number;
syncStatus: number;
isClear: boolean;
beforeRating: number;
afterRating: number;
beforeGrade: number;
afterGrade: number;
afterGradeRank: number;
beforeDeluxRating: number;
afterDeluxRating: number;
isPlayTutorial: boolean;
isEventMode: boolean;
isFreedomMode: boolean;
playMode: number;
isNewFree: boolean;
trialPlayAchievement: number;
extNum1: number;
extNum2: number;
}
export interface Rating {
musicId: number
level: number
achievement: number
}
export interface ParsedRating extends Rating {
music: MaimaiMusic
calc: number
rank: string
}
export interface MaimaiMusic {
name: string
composer: string
bpm: number
ver: number
note: {
lv: number
designer: string
lv_id: number
notes: number
}
}
export interface MaimaiUserSummaryEntry {
name: string
iconId: number
serverRank: number
accuracy: number
rating: number
ratingHighest: number
ranks: {name: string; count: number}[]
maxCombo: number
fullCombo: number
allPerfect: number
totalDxScore: number
plays: number
totalPlayTime: number
joined: string
lastSeen: string
lastVersion: string
best35: string
best15: string
recent: MaimaiUserPlaylog[]
}
export interface MaimaiUserPlaylog {
id: number
musicId: number
level: number
trackNo: number
vsRank: number
achievement: number
deluxscore: number
scoreRank: number
maxCombo: number
totalCombo: number
maxSync: number
totalSync: number
tapCriticalPerfect: number
tapPerfect: number
tapGreat: number
tapGood: number
tapMiss: number
holdCriticalPerfect: number
holdPerfect: number
holdGreat: number
holdGood: number
holdMiss: number
slideCriticalPerfect: number
slidePerfect: number
slideGreat: number
slideGood: number
slideMiss: number
touchCriticalPerfect: number
touchPerfect: number
touchGreat: number
touchGood: number
touchMiss: number
breakCriticalPerfect: number
breakPerfect: number
breakGreat: number
breakGood: number
breakMiss: number
isTap: boolean
isHold: boolean
isSlide: boolean
isTouch: boolean
isBreak: boolean
isCriticalDisp: boolean
isFastLateDisp: boolean
fastCount: number
lateCount: number
isAchieveNewRecord: boolean
isDeluxscoreNewRecord: boolean
comboStatus: number
syncStatus: number
isClear: boolean
beforeRating: number
afterRating: number
beforeGrade: number
afterGrade: number
afterGradeRank: number
beforeDeluxRating: number
afterDeluxRating: number
isPlayTutorial: boolean
isEventMode: boolean
isFreedomMode: boolean
playMode: number
isNewFree: boolean
trialPlayAchievement: number
extNum1: number
extNum2: number
}

View File

@ -1,101 +1,116 @@
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale, TimeScale, type ChartOptions, type LineOptions,
} from 'chart.js';
import moment from "moment/moment";
// @ts-ignore
import CalHeatmap from "cal-heatmap";
// @ts-ignore
import CalTooltip from 'cal-heatmap/plugins/Tooltip';
import type {Line} from "svelte-chartjs";
export function title(t: string) {
document.title = `AquaNet - ${t}`
}
export function registerChart() {
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
TimeScale
);
}
export function renderCal(el: HTMLElement, d: {date: any, value: any}[]) {
const cal = new CalHeatmap();
return cal.paint({
itemSelector: el,
domain: {
type: 'month',
label: { text: 'MMM', textAlign: 'start', position: 'top' },
},
subDomain: {
type: 'ghDay',
radius: 2, width: 11, height: 11, gutter: 4
},
range: 12,
data: {source: d, x: 'date', y: 'value'},
scale: {
color: {
type: 'linear',
range: ['#14432a', '#4dd05a'],
domain: [0, d.reduce((a, b) => Math.max(a, b.value), 0)]
},
},
date: {start: moment().subtract(1, 'year').add(1, 'month').toDate()},
theme: "dark",
}, [
[CalTooltip, {text: (_: Date, v: number, d: any) =>
`${v ?? "No"} songs played on ${d.format('MMMM D, YYYY')}`}]
]);
}
export const CHARTJS_OPT: ChartOptions<"line"> = {
responsive: true,
maintainAspectRatio: false,
// TODO: Show point on hover
elements: {
point: {
radius: 0
}
},
scales: {
xAxis: {
type: 'time',
display: false
},
y: {
display: false,
}
},
plugins: {
legend: {
display: false
},
tooltip: {
mode: "index",
intersect: false
}
},
}
/**
* Usage: clazz({a: false, b: true}) -> "b"
*
* @param obj HashMap<string, boolean>
*/
export function clazz(obj: { [key: string]: boolean }) {
return Object.keys(obj).filter(k => obj[k]).join(" ")
}
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
TimeScale,
type ChartOptions,
type LineOptions,
} from 'chart.js'
import moment from 'moment/moment'
// @ts-ignore
import CalHeatmap from 'cal-heatmap'
// @ts-ignore
import CalTooltip from 'cal-heatmap/plugins/Tooltip'
import type {Line} from 'svelte-chartjs'
export function title(t: string) {
document.title = `AquaNet - ${t}`
}
export function registerChart() {
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
TimeScale
)
}
export function renderCal(el: HTMLElement, d: {date: any; value: any}[]) {
const cal = new CalHeatmap()
return cal.paint(
{
itemSelector: el,
domain: {
type: 'month',
label: {text: 'MMM', textAlign: 'start', position: 'top'},
},
subDomain: {
type: 'ghDay',
radius: 2,
width: 11,
height: 11,
gutter: 4,
},
range: 12,
data: {source: d, x: 'date', y: 'value'},
scale: {
color: {
type: 'linear',
range: ['#14432a', '#4dd05a'],
domain: [0, d.reduce((a, b) => Math.max(a, b.value), 0)],
},
},
date: {start: moment().subtract(1, 'year').add(1, 'month').toDate()},
theme: 'dark',
},
[
[
CalTooltip,
{
text: (_: Date, v: number, d: any) =>
`${v ?? 'No'} songs played on ${d.format('MMMM D, YYYY')}`,
},
],
]
)
}
export const CHARTJS_OPT: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
// TODO: Show point on hover
elements: {
point: {
radius: 0,
},
},
scales: {
xAxis: {
type: 'time',
display: false,
},
y: {
display: false,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
},
},
}
/**
* Usage: clazz({a: false, b: true}) -> "b"
*
* @param obj HashMap<string, boolean>
*/
export function clazz(obj: {[key: string]: boolean}) {
return Object.keys(obj)
.filter((k) => obj[k])
.join(' ')
}

View File

@ -4,4 +4,4 @@ import App from './App.svelte'
// @ts-ignore
const app = new App({target: document.getElementById('app')})
export default app
export default app

View File

@ -1,83 +1,83 @@
<main id="home" class="no-margin">
<h1>AquaNet</h1>
<div class="btn-group">
<button>Login</button>
<button>Sign Up</button>
</div>
<div class="light-pollution">
<div class="l1"></div>
<div class="l2"></div>
<div class="l3"></div>
</div>
</main>
<style lang="sass">
@import "../vars"
#home
color: $c-main
position: relative
width: 100%
height: 100%
padding-left: 100px
overflow: hidden
box-sizing: border-box
display: flex
flex-direction: column
justify-content: center
margin-top: -$nav-height
> h1
font-family: Quicksand, $font
user-select: none
// Gap between text characters
letter-spacing: 0.2em
margin-top: 0
opacity: 0.9
.btn-group
display: flex
gap: 8px
.light-pollution
pointer-events: none
opacity: 0.6
> div
position: absolute
z-index: -1
.l1
left: -560px
top: 90px
height: 1130px
width: 1500px
$color: rgb(158, 110, 230)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba($color, 0) 100%)
.l2
left: -200px
top: 560px
height: 1200px
width: 1500px
$color: rgb(92, 195, 250)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba($color, 0) 100%)
.l3
left: -600px
opacity: 0.7
top: -630px
width: 1500px
height: 1000px
$color: rgb(230, 110, 156)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba($color, 0) 100%)
@media (max-width: 500px)
align-items: center
padding-left: 0
</style>
<main id="home" class="no-margin">
<h1>AquaNet</h1>
<div class="btn-group">
<button>Login</button>
<button>Sign Up</button>
</div>
<div class="light-pollution">
<div class="l1"></div>
<div class="l2"></div>
<div class="l3"></div>
</div>
</main>
<style lang="sass">
@import "../vars"
#home
color: $c-main
position: relative
width: 100%
height: 100%
padding-left: 100px
overflow: hidden
box-sizing: border-box
display: flex
flex-direction: column
justify-content: center
margin-top: -$nav-height
> h1
font-family: Quicksand, $font
user-select: none
// Gap between text characters
letter-spacing: 0.2em
margin-top: 0
opacity: 0.9
.btn-group
display: flex
gap: 8px
.light-pollution
pointer-events: none
opacity: 0.6
> div
position: absolute
z-index: -1
.l1
left: -560px
top: 90px
height: 1130px
width: 1500px
$color: rgb(158, 110, 230)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba($color, 0) 100%)
.l2
left: -200px
top: 560px
height: 1200px
width: 1500px
$color: rgb(92, 195, 250)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba($color, 0) 100%)
.l3
left: -600px
opacity: 0.7
top: -630px
width: 1500px
height: 1000px
$color: rgb(230, 110, 156)
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba($color, 0) 100%)
@media (max-width: 500px)
align-items: center
padding-left: 0
</style>

View File

@ -1,199 +1,219 @@
<script lang="ts">
import {data_host} from "../libs/config";
import {getMaimaiAllMusic, getMaimai, getMult} from "../libs/maimai";
import type {ParsedRating, Rating} from "../libs/maimaiTypes";
export let userId: any
userId = +userId
if (!userId) console.error("No user ID provided")
Promise.all([
getMaimai("GetUserRatingApi", {userId}),
getMaimaiAllMusic().then(it => it.json())
]).then(([rating, music]) => {
data = rating
musicInfo = music
if (!data || !musicInfo) {
console.error("Failed to fetch data")
return
}
parsedRatings = {
old: parseRating(data.userRating.ratingList),
new: parseRating(data.userRating.newRatingList)
}
})
function parseRating(arr: Rating[]) {
return arr.map(x => {
const music = musicInfo[x.musicId]
if (!music) {
console.error(`Music not found: ${x.musicId}`)
return null
}
music.note = music.notes[x.level]
const mult = getMult(x.achievement)
return {...x,
music: music,
calc: (mult[1] as number) * music.note.lv,
rank: mult[2]
}
}).filter(x => x != null) as ParsedRating[]
}
let parsedRatings: {
old: ParsedRating[],
new: ParsedRating[]
} | null = null
let data: {
userRating: {
rating: number,
ratingList: Rating[],
newRatingList: Rating[]
}
} | null = null
let musicInfo: any = null
</script>
<main>
<!-- Display all parsed ratings -->
{#if parsedRatings}
{#each [{title: "Old", data: parsedRatings.old}, {title: "New", data: parsedRatings.new}] as section}
<h2>{section.title}</h2>
<div class="rating-cards">
{#each section.data as rating}
<div class="level-{rating.level}">
<img class="cover" src={`${data_host}/maimai/assetbundle/jacket_s/00${rating.musicId.toString().padStart(6, '0').substring(2)}.png`} alt="">
<div class="detail">
<span class="name">{rating.music.name}</span>
<span class="rating">
<span>{(rating.achievement / 10000).toFixed(2)}%</span>
<img class="rank" src={`${data_host}/maimai/sprites/rankimage/UI_GAM_Rank_${rating.rank}.png`} alt="">
</span>
<span>{rating.calc.toFixed(1)}</span>
</div>
<img class="ver" src={`${data_host}/maimai/sprites/tab/title/UI_CMN_TabTitle_MaimaiTitle_Ver${rating.music.ver.toString().substring(0, 3)}.png`} alt="">
<div class="lv">{rating.music.note.lv}</div>
</div>
{/each}
</div>
{/each}
{/if}
</main>
<style lang="sass">
.rating-cards
display: grid
gap: 2rem
width: 100%
// Fill as many columns as possible
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
// Center the cards
justify-items: center
align-items: center
// Style each card
> div
$border-radius: 20px
width: 200px
height: 200px
border-radius: $border-radius
display: flex
position: relative
// Difficulty border
border: 5px solid var(--lv-color, #60aaff)
&.level-1
--lv-color: #aaff60
&.level-2
--lv-color: #f25353
&.level-3
--lv-color: #e881ff
img
object-fit: cover
pointer-events: none
img.cover
width: 100%
height: 100%
border-radius: calc($border-radius - 3px)
img.ver
position: absolute
top: -20px
left: -30px
height: 50px
// Information
.detail
position: absolute
bottom: 0
left: 0
right: 0
padding: 10px
background: rgba(0, 0, 0, 0.5)
border-radius: 0 0 calc($border-radius - 3px) calc($border-radius - 3px)
// Blur
backdrop-filter: blur(3px)
display: flex
flex-direction: column
text-align: left
> span
// Disable text wrapping, max 2 lines
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.name
font-size: 1.2em
font-weight: bold
.rating
display: flex
img
height: 1.5em
.lv
position: absolute
bottom: 0
right: 0
padding: 5px 10px
background: var(--lv-color)
// Top left border radius
border-radius: 10px 0
font-size: 1.3em
&:before
content: "Lv"
font-size: 0.8em
// Mobile
@media (max-width: 500px)
margin-left: -1rem
margin-right: -1rem
width: calc(100% + 2rem)
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr))
font-size: 0.8em
> div
width: 150px
height: 150px
img.ver
height: 45px
left: -20px
</style>
<script lang="ts">
import {data_host} from '../libs/config'
import {getMaimaiAllMusic, getMaimai, getMult} from '../libs/maimai'
import type {ParsedRating, Rating} from '../libs/maimaiTypes'
export let userId: any
userId = +userId
if (!userId) console.error('No user ID provided')
Promise.all([
getMaimai('GetUserRatingApi', {userId}),
getMaimaiAllMusic(),
]).then(([rating, music]) => {
data = rating
musicInfo = music
if (!data || !musicInfo) {
console.error('Failed to fetch data')
return
}
parsedRatings = {
old: parseRating(data.userRating.ratingList),
new: parseRating(data.userRating.newRatingList),
}
})
function parseRating(arr: Rating[]) {
return arr
.map((x) => {
const music = musicInfo[x.musicId]
if (!music) {
console.error(`Music not found: ${x.musicId}`)
return null
}
music.note = music.notes[x.level]
const mult = getMult(x.achievement)
return {
...x,
music: music,
calc: (mult[1] as number) * music.note.lv,
rank: mult[2],
}
})
.filter((x) => x != null) as ParsedRating[]
}
let parsedRatings: {
old: ParsedRating[]
new: ParsedRating[]
} | null = null
let data: {
userRating: {
rating: number
ratingList: Rating[]
newRatingList: Rating[]
}
} | null = null
let musicInfo: any = null
</script>
<main>
<!-- Display all parsed ratings -->
{#if parsedRatings}
{#each [{title: 'Old', data: parsedRatings.old}, {title: 'New', data: parsedRatings.new}] as section}
<h2>{section.title}</h2>
<div class="rating-cards">
{#each section.data as rating}
<div class="level-{rating.level}">
<img
class="cover"
src={`${data_host}/maimai/assetbundle/jacket_s/00${rating.musicId
.toString()
.padStart(6, '0')
.substring(2)}.png`}
alt=""
/>
<div class="detail">
<span class="name">{rating.music.name}</span>
<span class="rating">
<span>{(rating.achievement / 10000).toFixed(2)}%</span>
<img
class="rank"
src={`${data_host}/maimai/sprites/rankimage/UI_GAM_Rank_${rating.rank}.png`}
alt=""
/>
</span>
<span>{rating.calc.toFixed(1)}</span>
</div>
<img
class="ver"
src={`${data_host}/maimai/sprites/tab/title/UI_CMN_TabTitle_MaimaiTitle_Ver${rating.music.ver
.toString()
.substring(0, 3)}.png`}
alt=""
/>
<div class="lv">{rating.music.note.lv}</div>
</div>
{/each}
</div>
{/each}
{/if}
</main>
<style lang="sass">
.rating-cards
display: grid
gap: 2rem
width: 100%
// Fill as many columns as possible
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
// Center the cards
justify-items: center
align-items: center
// Style each card
> div
$border-radius: 20px
width: 200px
height: 200px
border-radius: $border-radius
display: flex
position: relative
// Difficulty border
border: 5px solid var(--lv-color, #60aaff)
&.level-1
--lv-color: #aaff60
&.level-2
--lv-color: #f25353
&.level-3
--lv-color: #e881ff
img
object-fit: cover
pointer-events: none
img.cover
width: 100%
height: 100%
border-radius: calc($border-radius - 3px)
img.ver
position: absolute
top: -20px
left: -30px
height: 50px
// Information
.detail
position: absolute
bottom: 0
left: 0
right: 0
padding: 10px
background: rgba(0, 0, 0, 0.5)
border-radius: 0 0 calc($border-radius - 3px) calc($border-radius - 3px)
// Blur
backdrop-filter: blur(3px)
display: flex
flex-direction: column
text-align: left
> span
// Disable text wrapping, max 2 lines
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.name
font-size: 1.2em
font-weight: bold
.rating
display: flex
img
height: 1.5em
.lv
position: absolute
bottom: 0
right: 0
padding: 5px 10px
background: var(--lv-color)
// Top left border radius
border-radius: 10px 0
font-size: 1.3em
&:before
content: "Lv"
font-size: 0.8em
// Mobile
@media (max-width: 500px)
margin-left: -1rem
margin-right: -1rem
width: calc(100% + 2rem)
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr))
font-size: 0.8em
> div
width: 150px
height: 150px
img.ver
height: 45px
left: -20px
</style>

View File

@ -1,385 +1,426 @@
<script lang="ts">
import {CHARTJS_OPT, clazz, registerChart, renderCal, title} from "../libs/ui";
import {getMaimaiAllMusic, getMaimaiTrend, getMaimaiUser, getMult} from "../libs/maimai";
import type {MaimaiMusic, MaimaiUserPlaylog, MaimaiUserSummaryEntry} from "../libs/maimaiTypes";
import type {TrendEntry} from "../libs/generalTypes";
import {data_host} from "../libs/config";
import 'cal-heatmap/cal-heatmap.css';
import { Line } from 'svelte-chartjs';
import moment from "moment";
import 'chartjs-adapter-moment';
registerChart()
export let userId: any;
userId = +userId
let calElement: HTMLElement
title(`User ${userId}`)
interface MusicAndPlay extends MaimaiMusic, MaimaiUserPlaylog {}
let d: {
user: MaimaiUserSummaryEntry,
trend: TrendEntry[]
recent: MusicAndPlay[]
} | null = null
Promise.all([
getMaimaiUser(userId),
getMaimaiTrend(userId),
getMaimaiAllMusic()
]).then(([user, trend, music]) => {
console.log(user)
console.log(trend)
console.log(music)
d = {user, trend, recent: user.recent.map(it => {return {...music[it.musicId], ...it}})}
localStorage.setItem("tmp-user-details", JSON.stringify(d))
renderCal(calElement, trend.map(it => {return {date: it.date, value: it.plays}}))
})
</script>
<main id="user-home">
{#if d !== null}
<div class="user-pfp">
<img src={`${data_host}/maimai/assetbundle/icon/${d.user.iconId.toString().padStart(6, "0")}.png`} alt="" class="pfp">
<h2>{d.user.name}</h2>
</div>
<div>
<h2>Rating Statistics</h2>
<div class="scoring-info">
<div class="chart">
<div class="info-top">
<div class="rating">
<span>DX Rating</span>
<span>{d.user.rating.toLocaleString()}</span>
</div>
<div class="rank">
<span>Server Rank</span>
<span>#{d.user.serverRank.toLocaleString()}</span>
</div>
</div>
<div class="trend">
<!-- ChartJS cannot be fully responsive unless there is a parent div that's independent from its size and helps it determine its size -->
<div class="chartjs-box-reference">
<Line data={{
datasets: [
{
label: 'Rating',
data: d.trend.map(it => {return {x: Date.parse(it.date), y: it.rating}}),
borderColor: '#646cff',
tension: 0.1,
// TODO: Set X axis span to 3 months
}
]
}} options={CHARTJS_OPT} />
</div>
</div>
<div class="info-bottom">
{#each d.user.ranks as r}
<div>
<span>{r.name}</span>
<span>{r.count}</span>
</div>
{/each}
</div>
</div>
<div class="other-info">
<div class="accuracy">
<span>Accuracy</span>
<span>{(d.user.accuracy / 10000).toFixed(2)}%</span>
</div>
<div class="max-combo">
<span>Max Combo</span>
<span>{d.user.maxCombo}</span>
</div>
<div class="full-combo">
<span>Full Combo</span>
<span>{d.user.fullCombo}</span>
</div>
<div class="all-perfect">
<span>All Perfect</span>
<span>{d.user.allPerfect}</span>
</div>
<div class="total-dx-score">
<span>DX Score</span>
<span>{d.user.totalDxScore.toLocaleString()}</span>
</div>
</div>
</div>
</div>
<div>
<h2>Play Activity</h2>
<div class="activity-info">
<div id="cal-heatmap" bind:this={calElement} />
<div class="info-bottom">
<div class="plays">
<span>Plays</span>
<span>{d.user.plays}</span>
</div>
<div class="time">
<span>Play Time</span>
<span>{(d.user.totalPlayTime / 60 / 60).toFixed(1)} hr</span>
</div>
<div class="first-play">
<span>First Seen</span>
<span>{moment(d.user.joined).format("YYYY-MM-DD")}</span>
</div>
<div class="last-play">
<span>Last Seen</span>
<span>{moment(d.user.lastSeen).format("YYYY-MM-DD")}</span>
</div>
<div class="last-version">
<span>Last Version</span>
<span>{d.user.lastVersion}</span>
</div>
</div>
</div>
</div>
<div class="recent">
<h2>Recent Scores</h2>
<div class="scores">
{#each d.recent as r, i}
<div class={clazz({alt: i % 2 === 0})}>
<img src={`${data_host}/maimai/assetbundle/jacket_s/00${r.musicId.toString().padStart(6, '0').substring(2)}.png`} alt="">
<div class="info">
<span class="name">{r.name}</span>
<div>
<span class={"rank-" + ("" + getMult(r.achievement)[2])[0]}>
<span class="rank-text">{("" + getMult(r.achievement)[2]).replace("p", "+")}</span>
<span class="rank-num">{(r.achievement / 10000).toFixed(2)}%</span>
</span>
<span class={"dx-change " + clazz({increased: r.afterDeluxRating - r.beforeDeluxRating > 0})}>
{r.afterDeluxRating - r.beforeDeluxRating}
</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<p>Loading...</p>
{/if}
</main>
<style lang="sass">
@import "../vars"
$gap: 20px
#user-home
display: flex
flex-direction: column
gap: $gap
margin: 100px auto 0
padding: 0 32px 32px
min-height: 100%
max-width: $w-max
background-color: rgba(black, 0.2)
border-radius: 16px 16px 0 0
@media (max-width: #{$w-max + (64px) * 2})
margin: 100px 32px 0
padding: 0 32px 16px
@media (max-width: $w-mobile)
margin: 100px 0 0
padding: 0 32px 16px
.user-pfp
display: flex
align-items: flex-end
gap: $gap
margin-top: -40px
h2
font-size: 2rem
margin: 0
.pfp
width: 100px
height: 100px
border-radius: 5px
object-fit: cover
.info-bottom, .info-top, .other-info
display: flex
gap: $gap
> div
display: flex
flex-direction: column
> span:first-child
font-weight: bold
font-size: 0.8rem
// character spacing
letter-spacing: 0.1em
color: $c-main
.info-top > div > span:last-child
font-size: 1.5rem
.scoring-info
display: flex
gap: $gap
max-height: 250px
.chart
flex: 0 1 790px
display: flex
flex-direction: column
.other-info
flex: 1 0 100px
flex-direction: column
gap: 0
justify-content: space-between
.trend
height: 300px
width: 100%
max-width: 790px
position: relative
> .chartjs-box-reference
position: absolute
inset: 0
@media (max-width: $w-mobile)
flex-direction: column
max-height: unset
.chart
flex: 0
.trend
max-height: 130px
.other-info
> div
flex-direction: row
justify-content: space-between
.info-bottom
justify-content: space-between
.activity-info
display: flex
flex-direction: column
gap: $gap
#cal-heatmap
overflow-x: auto
@media (max-width: $w-mobile)
#cal-heatmap
width: 100%
.info-bottom
flex-direction: column
gap: 0
> div
flex-direction: row
justify-content: space-between
// Recent Scores section
.recent
.scores
display: flex
flex-direction: column
flex-wrap: wrap
gap: $gap
> div.alt
background-color: rgba(white, 0.03)
border-radius: 10px
// Image and song info
> div
display: flex
align-items: center
gap: $gap
padding-right: 16px
max-width: 100%
box-sizing: border-box
img
width: 50px
height: 50px
border-radius: 10px
object-fit: cover
// Song info and score
> div
flex: 1
display: flex
justify-content: space-between
// Limit song name to one line
overflow: hidden
.name
overflow: hidden
overflow-wrap: anywhere
white-space: nowrap
text-overflow: ellipsis
@media (max-width: $w-mobile)
flex-direction: column
gap: 0
span
text-align: left
.rank-S
// Gold green gradient on text
background: linear-gradient(90deg, #ffee94, #ffb798, #ffa3e5, #ebff94)
-webkit-background-clip: text
color: transparent
.rank-A
color: #ff8a8a
.rank-B
color: #6ba6ff
span
display: inline-block
text-align: right
// Vertical table-like alignment
span.rank-text
min-width: 30px
span.rank-num
min-width: 60px
span.dx-change
min-width: 30px
span.increased
&:before
content: "+"
color: $c-good
</style>
<script lang="ts">
import {CHARTJS_OPT, clazz, registerChart, renderCal, title} from '../libs/ui'
import {
getMaimaiAllMusic,
getMaimaiTrend,
getMaimaiUser,
getMult,
} from '../libs/maimai'
import type {
MaimaiMusic,
MaimaiUserPlaylog,
MaimaiUserSummaryEntry,
} from '../libs/maimaiTypes'
import type {TrendEntry} from '../libs/generalTypes'
import {data_host} from '../libs/config'
import 'cal-heatmap/cal-heatmap.css'
import {Line} from 'svelte-chartjs'
import moment from 'moment'
import 'chartjs-adapter-moment'
registerChart()
export let userId: any
userId = +userId
let calElement: HTMLElement
title(`User ${userId}`)
interface MusicAndPlay extends MaimaiMusic, MaimaiUserPlaylog {}
let d: {
user: MaimaiUserSummaryEntry
trend: TrendEntry[]
recent: MusicAndPlay[]
} | null = null
Promise.all([
getMaimaiUser(userId),
getMaimaiTrend(userId),
getMaimaiAllMusic(),
]).then(([user, trend, music]) => {
console.log(user)
console.log(trend)
console.log(music)
d = {
user,
trend,
recent: user.recent.map((it) => {
return {...music[it.musicId], ...it}
}),
}
localStorage.setItem('tmp-user-details', JSON.stringify(d))
renderCal(
calElement,
trend.map((it) => {
return {date: it.date, value: it.plays}
})
)
})
</script>
<main id="user-home">
{#if d !== null}
<div class="user-pfp">
<img
src={`${data_host}/maimai/assetbundle/icon/${d.user.iconId.toString().padStart(6, '0')}.png`}
alt=""
class="pfp"
/>
<h2>{d.user.name}</h2>
</div>
<div>
<h2>Rating Statistics</h2>
<div class="scoring-info">
<div class="chart">
<div class="info-top">
<div class="rating">
<span>DX Rating</span>
<span>{d.user.rating.toLocaleString()}</span>
</div>
<div class="rank">
<span>Server Rank</span>
<span>#{d.user.serverRank.toLocaleString()}</span>
</div>
</div>
<div class="trend">
<!-- ChartJS cannot be fully responsive unless there is a parent div that's independent from its size and helps it determine its size -->
<div class="chartjs-box-reference">
<Line
data={{
datasets: [
{
label: 'Rating',
data: d.trend.map((it) => {
return {x: Date.parse(it.date), y: it.rating}
}),
borderColor: '#646cff',
tension: 0.1,
// TODO: Set X axis span to 3 months
},
],
}}
options={CHARTJS_OPT}
/>
</div>
</div>
<div class="info-bottom">
{#each d.user.ranks as r}
<div>
<span>{r.name}</span>
<span>{r.count}</span>
</div>
{/each}
</div>
</div>
<div class="other-info">
<div class="accuracy">
<span>Accuracy</span>
<span>{(d.user.accuracy / 10000).toFixed(2)}%</span>
</div>
<div class="max-combo">
<span>Max Combo</span>
<span>{d.user.maxCombo}</span>
</div>
<div class="full-combo">
<span>Full Combo</span>
<span>{d.user.fullCombo}</span>
</div>
<div class="all-perfect">
<span>All Perfect</span>
<span>{d.user.allPerfect}</span>
</div>
<div class="total-dx-score">
<span>DX Score</span>
<span>{d.user.totalDxScore.toLocaleString()}</span>
</div>
</div>
</div>
</div>
<div>
<h2>Play Activity</h2>
<div class="activity-info">
<div id="cal-heatmap" bind:this={calElement} />
<div class="info-bottom">
<div class="plays">
<span>Plays</span>
<span>{d.user.plays}</span>
</div>
<div class="time">
<span>Play Time</span>
<span>{(d.user.totalPlayTime / 60 / 60).toFixed(1)} hr</span>
</div>
<div class="first-play">
<span>First Seen</span>
<span>{moment(d.user.joined).format('YYYY-MM-DD')}</span>
</div>
<div class="last-play">
<span>Last Seen</span>
<span>{moment(d.user.lastSeen).format('YYYY-MM-DD')}</span>
</div>
<div class="last-version">
<span>Last Version</span>
<span>{d.user.lastVersion}</span>
</div>
</div>
</div>
</div>
<div class="recent">
<h2>Recent Scores</h2>
<div class="scores">
{#each d.recent as r, i}
<div class={clazz({alt: i % 2 === 0})}>
<img
src={`${data_host}/maimai/assetbundle/jacket_s/00${r.musicId.toString().padStart(6, '0').substring(2)}.png`}
alt=""
/>
<div class="info">
<span class="name">{r.name}</span>
<div>
<span class={'rank-' + ('' + getMult(r.achievement)[2])[0]}>
<span class="rank-text"
>{('' + getMult(r.achievement)[2]).replace('p', '+')}</span
>
<span class="rank-num"
>{(r.achievement / 10000).toFixed(2)}%</span
>
</span>
<span
class={'dx-change ' +
clazz({
increased: r.afterDeluxRating - r.beforeDeluxRating > 0,
})}
>
{r.afterDeluxRating - r.beforeDeluxRating}
</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<p>Loading...</p>
{/if}
</main>
<style lang="sass">
@import "../vars"
$gap: 20px
#user-home
display: flex
flex-direction: column
gap: $gap
margin: 100px auto 0
padding: 0 32px 32px
min-height: 100%
max-width: $w-max
background-color: rgba(black, 0.2)
border-radius: 16px 16px 0 0
@media (max-width: #{$w-max + (64px) * 2})
margin: 100px 32px 0
padding: 0 32px 16px
@media (max-width: $w-mobile)
margin: 100px 0 0
padding: 0 32px 16px
.user-pfp
display: flex
align-items: flex-end
gap: $gap
margin-top: -40px
h2
font-size: 2rem
margin: 0
.pfp
width: 100px
height: 100px
border-radius: 5px
object-fit: cover
.info-bottom, .info-top, .other-info
display: flex
gap: $gap
> div
display: flex
flex-direction: column
> span:first-child
font-weight: bold
font-size: 0.8rem
// character spacing
letter-spacing: 0.1em
color: $c-main
.info-top > div > span:last-child
font-size: 1.5rem
.scoring-info
display: flex
gap: $gap
max-height: 250px
.chart
flex: 0 1 790px
display: flex
flex-direction: column
.other-info
flex: 1 0 100px
flex-direction: column
gap: 0
justify-content: space-between
.trend
height: 300px
width: 100%
max-width: 790px
position: relative
> .chartjs-box-reference
position: absolute
inset: 0
@media (max-width: $w-mobile)
flex-direction: column
max-height: unset
.chart
flex: 0
.trend
max-height: 130px
.other-info
> div
flex-direction: row
justify-content: space-between
.info-bottom
justify-content: space-between
.activity-info
display: flex
flex-direction: column
gap: $gap
#cal-heatmap
overflow-x: auto
@media (max-width: $w-mobile)
#cal-heatmap
width: 100%
.info-bottom
flex-direction: column
gap: 0
> div
flex-direction: row
justify-content: space-between
// Recent Scores section
.recent
.scores
display: flex
flex-direction: column
flex-wrap: wrap
gap: $gap
> div.alt
background-color: rgba(white, 0.03)
border-radius: 10px
// Image and song info
> div
display: flex
align-items: center
gap: $gap
padding-right: 16px
max-width: 100%
box-sizing: border-box
img
width: 50px
height: 50px
border-radius: 10px
object-fit: cover
// Song info and score
> div
flex: 1
display: flex
justify-content: space-between
// Limit song name to one line
overflow: hidden
.name
overflow: hidden
overflow-wrap: anywhere
white-space: nowrap
text-overflow: ellipsis
@media (max-width: $w-mobile)
flex-direction: column
gap: 0
span
text-align: left
.rank-S
// Gold green gradient on text
background: linear-gradient(90deg, #ffee94, #ffb798, #ffa3e5, #ebff94)
-webkit-background-clip: text
color: transparent
.rank-A
color: #ff8a8a
.rank-B
color: #6ba6ff
span
display: inline-block
text-align: right
// Vertical table-like alignment
span.rank-text
min-width: 30px
span.rank-num
min-width: 60px
span.dx-change
min-width: 30px
span.increased
&:before
content: "+"
color: $c-good
</style>

View File

@ -1,4 +1,4 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import {vitePreprocess} from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess

View File

@ -16,5 +16,5 @@
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
"references": [{"path": "./tsconfig.node.json"}]
}

View File

@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import {defineConfig} from 'vite'
import {svelte} from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({

View File

@ -1047,6 +1047,16 @@ postcss@^8.4.35:
picocolors "^1.0.0"
source-map-js "^1.0.2"
prettier-plugin-svelte@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz#2e050eb56dbb467a42c45ad6ce18bb277d28ffa0"
integrity sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==
prettier@^3.2.5:
version "3.2.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"