Compare commits

..

9 Commits

24 changed files with 266 additions and 119 deletions

View File

@ -5,6 +5,7 @@
<excludeFolder url="file://$MODULE_DIR$/.tmp" /> <excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" /> <excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" /> <excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/scripts/venv" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

7
.idea/misc.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (actaeon)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (actaeon)" project-jdk-type="Python SDK" />
</project>

View File

@ -4,6 +4,9 @@ An ARTEMiS frontend.
# **WARNING: Back up your database** # **WARNING: Back up your database**
I am not responsible for misconfigurations that lead to database issues. **You should back up your database before making any changes to it.** I am not responsible for misconfigurations that lead to database issues. **You should back up your database before making any changes to it.**
### **Important**: your default database encoding must be unicode
You can change the encoding by running `ALTER DATABASE aime CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;`
## Creating Database Tables ## Creating Database Tables
Before running the database importer script, you must create the tables in your database. This can be done by running the server once with `AUTOMIGRATE` enabled ([see below](#runtime-variables)), or by running `npm run migrate` (make sure you have run `npm i` first and have the [`DATABASE_URL`](#runtime-variables) environment variable set). Before running the database importer script, you must create the tables in your database. This can be done by running the server once with `AUTOMIGRATE` enabled ([see below](#runtime-variables)), or by running `npm run migrate` (make sure you have run `npm i` first and have the [`DATABASE_URL`](#runtime-variables) environment variable set).

View File

@ -8,7 +8,9 @@ const dbmigrate = DBMigrate.getInstance(true);
if (process.argv[2] === 'up') if (process.argv[2] === 'up')
dbmigrate.up(); dbmigrate.up();
else if (process.argv[2] == 'down') else if (process.argv[2] === 'down')
dbmigrate.down(); dbmigrate.down();
else if (process.argv[2] === 'reset')
dbmigrate.reset();
else else
console.error('Unknown action', argv[2]); console.error('Unknown action', argv[2]);

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240413060445-create-chuni-static-music-ext-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240413060445-create-chuni-static-music-ext-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1 @@
DROP TABLE actaeon_chuni_static_music_ext;

View File

@ -0,0 +1,14 @@
CREATE TABLE actaeon_chuni_static_music_ext (
songId INT NOT NULL,
chartId INT NOT NULL,
chartDesigner VARCHAR(255),
tapJudgeCount INT NOT NULL,
holdJudgeCount INT NOT NULL,
slideJudgeCount INT NOT NULL,
airJudgeCount INT NOT NULL,
flickJudgeCount INT NOT NULL,
allJudgeCount INT NOT NULL,
PRIMARY KEY (songId, chartId)
);

View File

@ -12,6 +12,7 @@
"migrate:up": "dotenvx run -f .env.local -- node db-migrate.cjs up", "migrate:up": "dotenvx run -f .env.local -- node db-migrate.cjs up",
"migrate": "npm run migrate:up", "migrate": "npm run migrate:up",
"migrate:down": "dotenvx run -f .env.local -- node db-migrate.cjs down", "migrate:down": "dotenvx run -f .env.local -- node db-migrate.cjs down",
"migrate:reset": "dotenvx run -f .env.local -- node db-migrate.cjs reset",
"migrate:create": "dotenvx run -f .env.local -- db-migrate create", "migrate:create": "dotenvx run -f .env.local -- db-migrate create",
"db:export": "dotenvx run -f .env.local -- kysely-codegen --exclude-pattern cozynet* --exclude-pattern alembic_version --exclude-pattern actaeon_migrations --out-file src/types/db.d.ts && node process-db.js" "db:export": "dotenvx run -f .env.local -- kysely-codegen --exclude-pattern cozynet* --exclude-pattern alembic_version --exclude-pattern actaeon_migrations --out-file src/types/db.d.ts && node process-db.js"
}, },

View File

@ -52,6 +52,9 @@ Usage: `py db-importer.py [options] <game> [game_options]`
The database importer requires the `DATABASE_URL` environment variable to be set, which can be set in an `.env` file in the format `DATABASE_URL=mysql://user:pass@host:port/db_name` (see the `--env` option) The database importer requires the `DATABASE_URL` environment variable to be set, which can be set in an `.env` file in the format `DATABASE_URL=mysql://user:pass@host:port/db_name` (see the `--env` option)
### **Important**: your default database encoding must be unicode
You can change the encoding by running `ALTER DATABASE aime CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;`
# **WARNING: Back up your database** # **WARNING: Back up your database**
I am not responsible for misconfigurations that lead to database issues. **You should back up your database before making any changes to it.** I am not responsible for misconfigurations that lead to database issues. **You should back up your database before making any changes to it.**

View File

@ -63,11 +63,36 @@ class Chuni(Importer):
self.get_xml('trophy', 'Trophy', ('./name/id', int), './name/str', ('./rareType', int), './explainText') self.get_xml('trophy', 'Trophy', ('./name/id', int), './name/str', ('./rareType', int), './explainText')
) )
def import_charts(self):
inserts = []
for file in chain(self.data_dir.glob(f'A000/music/*/*.c2s'),
self.opt_dir.glob(f'*/music/*/*.c2s')):
print(file)
data = {}
song, chart = map(int, file.stem.split('_'))
if song >= 8000: chart = 5
with open(file, 'r', encoding='utf8') as f:
for line in f.readlines():
parts = line.strip().split('\t')
if len(parts) == 2:
data[parts[0]] = parts[1]
inserts.append((song, chart, data['CREATOR'], data['T_JUDGE_TAP'], data['T_JUDGE_HLD'], data['T_JUDGE_SLD'],
data['T_JUDGE_AIR'], data['T_JUDGE_FLK'], data['T_JUDGE_ALL']))
fields = ['songId', 'chartId', 'chartDesigner', 'tapJudgeCount', 'holdJudgeCount', 'slideJudgeCount',
'airJudgeCount', 'flickJudgeCount', 'allJudgeCount']
self.cur.executemany(
f'''INSERT INTO actaeon_chuni_static_music_ext({','.join(fields)})
VALUES ({','.join(['%s'] * len(fields))})
ON DUPLICATE KEY UPDATE {','.join(f"{f}={f}" for f in fields)}''',
inserts
)
def do_import(self): def do_import(self):
self.import_map_icon() self.import_map_icon()
self.import_name_plate() self.import_name_plate()
self.import_system_voice() self.import_system_voice()
self.import_trophies() self.import_trophies()
self.import_charts()
@staticmethod @staticmethod
def register(parser): def register(parser):

View File

@ -22,11 +22,14 @@ export const getMusic = async (musicId?: number) => {
.on('favorite.favKind', '=', 1) .on('favorite.favKind', '=', 1)
.on('favorite.user', '=', user?.id!) .on('favorite.user', '=', user?.id!)
) )
.innerJoin('actaeon_chuni_static_music_ext as musicExt', join =>
join.onRef('music.songId', '=', 'musicExt.songId')
.onRef('music.chartId', '=', 'musicExt.chartId'))
.select(({ fn }) => [...CHUNI_MUSIC_PROPERTIES, .select(({ fn }) => [...CHUNI_MUSIC_PROPERTIES,
'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess', 'score.scoreRank', 'score.scoreMax', 'score.isFullCombo', 'score.isAllJustice', 'score.isSuccess', 'score.scoreRank', 'score.scoreMax',
'score.maxComboCount', 'score.maxComboCount',
fn<boolean>('NOT ISNULL', ['favorite.favId']).as('favorite'), fn<boolean>('NOT ISNULL', ['favorite.favId']).as('favorite'),
chuniRating()]) chuniRating()] as const)
.where(({ selectFrom, eb, and, or }) => and([ .where(({ selectFrom, eb, and, or }) => and([
eb('music.version', '=', selectFrom('chuni_static_music') eb('music.version', '=', selectFrom('chuni_static_music')
.select(({ fn }) => fn.max('version').as('latest'))), .select(({ fn }) => fn.max('version').as('latest'))),

View File

@ -36,6 +36,9 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
.innerJoin('chuni_static_music as music', join => join .innerJoin('chuni_static_music as music', join => join
.onRef('music.songId', '=', 'playlog.musicId') .onRef('music.songId', '=', 'playlog.musicId')
.onRef('music.chartId', '=', 'playlog.level')) .onRef('music.chartId', '=', 'playlog.level'))
.innerJoin('actaeon_chuni_static_music_ext as musicExt', join => join
.onRef('music.songId', '=', 'musicExt.songId')
.onRef('music.chartId', '=', 'musicExt.chartId'))
.where(({ and, eb, selectFrom }) => and([ .where(({ and, eb, selectFrom }) => and([
eb('playlog.user', '=', user.id), eb('playlog.user', '=', user.id),
eb('music.version', '=', selectFrom('chuni_static_music') eb('music.version', '=', selectFrom('chuni_static_music')
@ -45,14 +48,14 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
({ ref }) => ['playlog.id', 'playlog.sortNumber', 'playlog.playDate', 'playlog.userPlayDate', 'playlog.track', ({ ref }) => ['playlog.id', 'playlog.sortNumber', 'playlog.playDate', 'playlog.userPlayDate', 'playlog.track',
'playlog.score', 'playlog.rank', 'playlog.maxCombo', 'playlog.maxChain', 'playlog.rateTap', 'playlog.score', 'playlog.rank', 'playlog.maxCombo', 'playlog.maxChain', 'playlog.rateTap',
'playlog.rateHold', 'playlog.rateSlide', 'playlog.rateAir', 'playlog.rateFlick', 'playlog.judgeGuilty', 'playlog.rateHold', 'playlog.rateSlide', 'playlog.rateAir', 'playlog.rateFlick', 'playlog.judgeGuilty',
'playlog.judgeAttack', 'playlog.judgeCritical', 'playlog.judgeHeaven', 'playlog.playerRating', 'playlog.judgeAttack', 'playlog.judgeCritical', 'playlog.judgeHeaven', 'playlog.playerRating', 'playlog.judgeJustice',
'playlog.isNewRecord', 'playlog.isFullCombo', 'playlog.fullChainKind', 'playlog.isAllJustice', 'playlog.isNewRecord', 'playlog.isFullCombo', 'playlog.fullChainKind', 'playlog.isAllJustice',
'playlog.playKind', 'playlog.isClear', 'playlog.placeName', 'playlog.playKind', 'playlog.isClear', 'playlog.placeName',
...CHUNI_MUSIC_PROPERTIES, ...CHUNI_MUSIC_PROPERTIES,
chuniRating(ref('playlog.score')), chuniRating(ref('playlog.score')),
sql<number>`(playlog.playerRating - (LEAD(playlog.playerRating) OVER (ORDER BY id DESC)))` sql<number>`(playlog.playerRating - (LEAD(playlog.playerRating) OVER (ORDER BY id DESC)))`
.as('playerRatingChange') .as('playerRatingChange')
]) ] as const)
.orderBy('playlog.id desc') .orderBy('playlog.id desc')
) )
.selectFrom('p') .selectFrom('p')

View File

@ -93,6 +93,9 @@ export async function getUserRating(user: UserPayload) {
))`.as('score'), join => join.onTrue()) ))`.as('score'), join => join.onTrue())
.innerJoin('chuni_static_music as music', join => join.onRef('score.musicId', '=', 'music.songId') .innerJoin('chuni_static_music as music', join => join.onRef('score.musicId', '=', 'music.songId')
.onRef('score.level', '=', 'music.chartId')) .onRef('score.level', '=', 'music.chartId'))
.innerJoin('actaeon_chuni_static_music_ext as musicExt', join => join
.onRef('music.songId', '=', 'musicExt.songId')
.onRef('music.chartId', '=', 'musicExt.chartId'))
.select(({ lit }) => [...CHUNI_MUSIC_PROPERTIES, chuniRating(sql.raw(`CAST(score.scoreMax AS INT)`)), .select(({ lit }) => [...CHUNI_MUSIC_PROPERTIES, chuniRating(sql.raw(`CAST(score.scoreMax AS INT)`)),
sql<string>`CAST(score.scoreMax AS INT)`.as('scoreMax'), sql<string>`CAST(score.scoreMax AS INT)`.as('scoreMax'),
lit<number>(1).as('pastIndex') lit<number>(1).as('pastIndex')
@ -104,8 +107,10 @@ export async function getUserRating(user: UserPayload) {
const top = await db.selectFrom('chuni_score_best as score') const top = await db.selectFrom('chuni_score_best as score')
.innerJoin('chuni_static_music as music', join => join .innerJoin('chuni_static_music as music', join => join
.onRef('music.songId', '=', 'score.musicId') .onRef('music.songId', '=', 'score.musicId')
.onRef('music.chartId', '=', 'score.level') .onRef('music.chartId', '=', 'score.level'))
) .innerJoin('actaeon_chuni_static_music_ext as musicExt', join => join
.onRef('music.songId', '=', 'musicExt.songId')
.onRef('music.chartId', '=', 'musicExt.chartId'))
.where(({ eb, and, selectFrom }) => and([ .where(({ eb, and, selectFrom }) => and([
eb('user', '=', user.id), eb('user', '=', user.id),
eb('score.level', '!=', 5), eb('score.level', '!=', 5),

View File

@ -36,7 +36,7 @@ export default async function ChuniDashboard() {
</div> </div>
<div className="text-lg font-semibold px-4 pt-4 border-t border-gray-500 md:hidden">Recent Plays</div> <div className="text-lg font-semibold px-4 pt-4 border-t border-gray-500 md:hidden">Recent Plays</div>
<div className="my-4 w-full flex-grow grid gap-2 grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 5xl:grid-cols-6 6xl:grid-cols-8"> <div className="my-4 w-full flex-grow grid gap-2 grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 5xl:grid-cols-6 6xl:grid-cols-8">
{playlog.data.map((entry, i) => <ChuniPlaylogCard className="w-full h-48" {playlog.data.map((entry, i) => <ChuniPlaylogCard className="w-full h-52"
badgeClass="h-5 lg:h-[1.125rem] xl:h-6 2xl:h-[1.125rem] 4xl:h-6 5xl:h-[1.125rem]" badgeClass="h-5 lg:h-[1.125rem] xl:h-6 2xl:h-[1.125rem] 4xl:h-6 5xl:h-[1.125rem]"
playlog={entry} key={i} />)} playlog={entry} key={i} />)}
</div> </div>

View File

@ -19,7 +19,8 @@ type ChuniMusicPlaylogProps = {
export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => { export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => {
type Music = (typeof music)[number]; type Music = (typeof music)[number];
type Playlog = (typeof playlog)['data'][number]; type Playlog = (typeof playlog)['data'][number];
const defaultExpanded: Record<string, Set<string>> = {}
const [selected, setSelected] = useState(new Set<string>());
const difficulties: (Music & { playlog: Playlog[] })[] = []; const difficulties: (Music & { playlog: Playlog[] })[] = [];
music.forEach(m => { music.forEach(m => {
@ -27,65 +28,69 @@ export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) =>
}); });
playlog.data.forEach(play => { playlog.data.forEach(play => {
defaultExpanded[play.chartId!] = new Set();
difficulties[play.chartId!].playlog.push(play); difficulties[play.chartId!].playlog.push(play);
}); });
const [expanded, setExpanded] = useState(defaultExpanded);
const badgeClass = 'h-6 sm:h-8'; const badgeClass = 'h-6 sm:h-8';
return (<div className="flex flex-col w-full px-2 sm:px-0"> return (<div className="flex flex-col w-full px-1 sm:px-0">
{difficulties.map((data, i) => { <Accordion selectionMode="multiple" selectedKeys={selected}>
const rank = CHUNI_SCORE_RANKS[data.scoreRank!]; {difficulties.map((data, i) => {
const badges = [ const rank = CHUNI_SCORE_RANKS[data.scoreRank!];
!!data.scoreRank && <ChuniScoreBadge variant={getVariantFromRank(data.scoreRank)} className={`${badgeClass} tracking-[0.05cqw]`} key="1"> const badges = [
{rank.endsWith('+') ? <> !!data.scoreRank && <ChuniScoreBadge variant={getVariantFromRank(data.scoreRank)} className={`${badgeClass} tracking-[0.05cqw]`} key="1">
{rank.slice(0, -1)} {rank.endsWith('+') ? <>
<div className="inline-block translate-y-[-15cqh]">+</div> {rank.slice(0, -1)}
</> : rank} <div className="inline-block translate-y-[-15cqh]">+</div>
</ChuniScoreBadge>, </> : rank}
data.isSuccess ? <ChuniLampSuccessBadge key="2" className={badgeClass} success={data.isSuccess} /> : null, </ChuniScoreBadge>,
<ChuniLampComboBadge key="3" className={badgeClass} {...data} /> data.isSuccess ? <ChuniLampSuccessBadge key="2" className={badgeClass} success={data.isSuccess} /> : null,
].filter(x => x); (data.isFullCombo || data.isAllJustice) && <ChuniLampComboBadge key="3" className={badgeClass} {...data} />
].filter(x => x);
const toggleExpanded = () => expanded[i] && setExpanded(e =>
({ ...e,
[i]: e[i].size ? new Set() : new Set(['1'])
}));
return (<div key={i} className="mb-2 border-b pb-2 border-gray-500 flex flex-row flex-wrap"> // <div key={i} className="mb-2 border-b pb-2 border-gray-500 flex flex-row flex-wrap items-center">
<div className={`flex items-center gap-2 flex-wrap w-full lg:w-auto lg:flex-grow ${data.playlog.length ? 'cursor-pointer' : ''}`} onClick={toggleExpanded}> return (<AccordionItem key={i.toString()} classNames={{ trigger: 'py-0 my-2' }} title={<div className="flex flex-row flex-wrap items-center gap-y-1.5"
<div className="flex items-center"> onClick={() => {
<div className="w-14 mr-2 p-0.5 bg-black"> const key = i.toString();
<ChuniLevelBadge className="w-full" music={data} /> setSelected(s => s.has(key) ? new Set([...s].filter(k => k !== key)) : new Set([...s, key]))
}}>
<div className={`flex items-center gap-2 flex-wrap lg:flex-grow ${data.playlog.length ? 'cursor-pointer w-full lg:w-auto' : 'flex-grow'}`}>
<div className="flex items-center">
<div className="w-14 mr-2 p-0.5 bg-black">
<ChuniLevelBadge className="w-full" music={data} />
</div>
<div className="text-xl font-semibold">{CHUNI_DIFFICULTIES[i]}</div>
</div> </div>
<div className="text-xl font-semibold">{CHUNI_DIFFICULTIES[i]}</div> {!data.playlog.length && <div className="text-right italic text-gray-500 flex-grow">No Play History</div>}
{data.rating ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
{data.scoreMax ? <div className="ml-2 text-center flex-grow sm:flex-grow-0 max-sm:text-sm">
<span className="font-semibold">High Score: </span>{data.scoreMax.toLocaleString()}
</div> : null}
{data.maxComboCount ? <div className="ml-2 text-center flex-grow sm:flex-grow-0 max-sm:text-sm">
<span className="font-semibold">Max Combo: </span>{data.maxComboCount.toLocaleString()}
</div> : null}
</div> </div>
{!data.playlog.length && <div className="text-right italic text-gray-500 flex-grow">No Play History</div>} {badges.length ? <div className={`flex-grow items-center lg:flex-grow-0 ml-auto mr-auto sm:ml-0 lg:ml-auto lg:mr-0 flex gap-0.5 flex-wrap justify-center sm:justify-start ${data.playlog.length ? 'cursor-pointer' : ''}`}>
{data.rating ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null} {badges}
{data.scoreMax ? <div className="ml-2 text-center flex-grow sm:flex-grow-0">
<span className="font-semibold">High Score: </span>{data.scoreMax.toLocaleString()}
</div> : null} </div> : null}
{data.maxComboCount ? <div className="ml-2 text-center flex-grow sm:flex-grow-0"> </div>}>
<span className="font-semibold">Max Combo: </span>{data.maxComboCount.toLocaleString()} <div className="flex flex-wrap gap-x-4 gap-y-2 mb-3 justify-center sm:justify-end max-sm:text-xs">
</div> : null} <span className="mr-auto max-sm:w-full text-center"><span className="font-semibold">Chart designer:</span> {data.chartDesigner}</span>
</div> {!!data.tapJudgeCount && <span><span className="font-semibold">Tap:</span> {data.tapJudgeCount}</span>}
{badges.length ? <div className={`flex-grow lg:flex-grow-0 ml-auto mr-auto sm:ml-0 lg:ml-auto lg:mr-0 mt-2 flex gap-0.5 flex-wrap justify-center sm:justify-start ${data.playlog.length ? 'cursor-pointer' : ''}`} onClick={toggleExpanded}> {!!data.flickJudgeCount && <span><span className="font-semibold">Flick:</span> {data.flickJudgeCount}</span>}
{badges} {!!data.holdJudgeCount && <span><span className="font-semibold">Hold:</span> {data.holdJudgeCount}</span>}
</div> : null} {!!data.slideJudgeCount && <span><span className="font-semibold">Slide:</span> {data.slideJudgeCount}</span>}
{data.playlog.length ? <Accordion selectedKeys={expanded[i]} onSelectionChange={k => setExpanded(e => ({ ...e, [i]: k as any }))}> {!!data.airJudgeCount && <span><span className="font-semibold">Air:</span> {data.airJudgeCount}</span>}
<AccordionItem key="1" title="Play History"> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 4xl:grid-cols-4 5xl:grid-cols-5 6xl:grid-cols-6 gap-2"> <div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 4xl:grid-cols-4 5xl:grid-cols-5 6xl:grid-cols-6 gap-2">
{data.playlog.map(p => <ChuniPlaylogCard key={p.id} {data.playlog.map(p => <ChuniPlaylogCard key={p.id}
showDetails showDetails
badgeClass="h-5 sm:h-6 md:h-5 lg:h-[1.125rem] 3xl:h-5" badgeClass="h-5 sm:h-6 md:h-5 lg:h-[1.125rem] 3xl:h-5 md:-mt-1"
playlog={p} className="h-64 md:h-52" />)} playlog={p} className="h-64 md:h-52" />)}
</div> </div>
</AccordionItem> </AccordionItem>);
</Accordion> : null })}
} </Accordion>
</div>)
})}
</div>); </div>);
}; };

View File

@ -52,8 +52,11 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
const [userDropdownOpen, setUserDropdownOpen] = useState(false); const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [menuTranslate, setMenuTranslate] = useState<number | null>(null); const [menuTranslate, setMenuTranslate] = useState<number | null>(null);
const [notificationsTranslate, setNotificationsTranslate] = useState<number | null>(null); const [notificationsTranslate, setNotificationsTranslate] = useState<number | null>(null);
const [navbarShowing, setNavbarShowing] = useState(true);
const reloaded = useReloaded(); const reloaded = useReloaded();
const menusOpened = useRef(false); const menusOpened = useRef(false);
const scrollOffset = useRef(0);
const lastScroll = useRef<number | null>(null);
const path = pathname === '/' ? (user?.homepage ?? '/dashboard') : pathname; const path = pathname === '/' ? (user?.homepage ?? '/dashboard') : pathname;
@ -71,8 +74,23 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
router.replace('', { scroll: false }); router.replace('', { scroll: false });
}, [router, reloaded]); }, [router, reloaded]);
useWindowListener('scroll', () => {
if (lastScroll.current === null) lastScroll.current = window.scrollY;
scrollOffset.current += window.scrollY - lastScroll.current;
lastScroll.current = window.scrollY;
if (scrollOffset.current < -100 || window.scrollY < 35)
setNavbarShowing(true);
else if (scrollOffset.current > 100)
setNavbarShowing(false);
});
useWindowListener('scrollend', () => {
scrollOffset.current = 0;
});
const { ref } = useSwipeable({ const { ref } = useSwipeable({
touchEventOptions: { passive: false }, touchEventOptions: { passive: true },
onSwiped: e => { onSwiped: e => {
const speedX = Math.abs(e.vxvy[0]); const speedX = Math.abs(e.vxvy[0]);
const speedY = Math.abs(e.vxvy[1]); const speedY = Math.abs(e.vxvy[1]);
@ -90,6 +108,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
setMenuTranslate(null); setMenuTranslate(null);
setNotificationsTranslate(null); setNotificationsTranslate(null);
if (!isMenuOpen && !isNotificationsOpen)
document.body.classList.remove('touch-none', 'overflow-hidden'); document.body.classList.remove('touch-none', 'overflow-hidden');
}, },
onSwipeStart: e => { onSwipeStart: e => {
@ -109,21 +128,17 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
if ((isMenuOpen && e.dir === 'Left') || (allMenusClosed && e.dir === 'Right' && xPercent <= 0.6)) { if ((isMenuOpen && e.dir === 'Left') || (allMenusClosed && e.dir === 'Right' && xPercent <= 0.6)) {
setMenuTranslate(e.deltaX); setMenuTranslate(e.deltaX);
e.event.preventDefault();
document.body.classList.add('touch-none', 'overflow-hidden'); document.body.classList.add('touch-none', 'overflow-hidden');
} else if ((isNotificationsOpen && e.dir === 'Right' || (allMenusClosed && e.dir === 'Left' && xPercent >= 0.4))) { } else if ((isNotificationsOpen && e.dir === 'Right' || (allMenusClosed && e.dir === 'Left' && xPercent >= 0.4))) {
setNotificationsTranslate(e.deltaX); setNotificationsTranslate(e.deltaX);
e.event.preventDefault();
document.body.classList.add('touch-none', 'overflow-hidden'); document.body.classList.add('touch-none', 'overflow-hidden');
} }
}, },
onSwiping: e => { onSwiping: e => {
if (menuTranslate !== null) { if (menuTranslate !== null) {
setMenuTranslate(e.deltaX); setMenuTranslate(e.deltaX);
e.event.preventDefault();
} else if (notificationsTranslate !== null) { } else if (notificationsTranslate !== null) {
setNotificationsTranslate(e.deltaX); setNotificationsTranslate(e.deltaX);
e.event.preventDefault();
} }
} }
}) as { ref: RefCallback<Document>; }; }) as { ref: RefCallback<Document>; };
@ -133,6 +148,13 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
return () => ref({} as any); return () => ref({} as any);
}, [ref]); }, [ref]);
useEffect(() => {
if (isMenuOpen || isNotificationsOpen)
document.body.classList.add('touch-none', 'overflow-hidden');
else
document.body.classList.remove('touch-none', 'overflow-hidden');
}, [isMenuOpen, isNotificationsOpen])
useEffect(() => { useEffect(() => {
if (user) if (user)
getFriendRequests().then(setFriendRequests); getFriendRequests().then(setFriendRequests);
@ -236,7 +258,8 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
}, [friendRequests]); }, [friendRequests]);
const topNavbar = useMemo(() => { const topNavbar = useMemo(() => {
return (<Navbar className="w-full fixed" classNames={{ wrapper: 'max-w-full p-0' }} shouldHideOnScroll={breakpoint === undefined} height="5.5rem"> return (<Navbar className={`w-lvw fixed transition ${!navbarShowing && breakpoint === undefined ? '-translate-y-full' : ''}`}
classNames={{ wrapper: 'max-w-full p-0' }} height="5.5rem">
<div className="flex h-header px-6 items-center flex-shrink-0 w-full z-[48]"> <div className="flex h-header px-6 items-center flex-shrink-0 w-full z-[48]">
<Button className="text-2xl font-bold cursor-pointer flex items-center m-0 ps-1.5 pe-2 mr-6" variant="light" <Button className="text-2xl font-bold cursor-pointer flex items-center m-0 ps-1.5 pe-2 mr-6" variant="light"
onClick={() => setMenuOpen(true)}> onClick={() => setMenuOpen(true)}>
@ -323,7 +346,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
} }
</div> </div>
</Navbar>); </Navbar>);
}, [breakpoint, routeGroup, friendRequests, userDropdownOpen, isNotificationsOpen, user, notifications]); }, [breakpoint, routeGroup, friendRequests, userDropdownOpen, isNotificationsOpen, user, notifications, navbarShowing]);
const leftSidebar = useMemo(() => { const leftSidebar = useMemo(() => {
return (<div className={`fixed inset-0 w-full h-full z-[49] ${isMenuOpen ? '' : 'pointer-events-none'}`}> return (<div className={`fixed inset-0 w-full h-full z-[49] ${isMenuOpen ? '' : 'pointer-events-none'}`}>
@ -472,10 +495,10 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
{ leftSidebar } { leftSidebar }
{ rightSidebar } { rightSidebar }
{/* begin top navbar */} {/* begin top navbar */}
<div className="flex flex-col flex-grow"> <div className="flex flex-col flex-grow h-full">
{topNavbar} {topNavbar}
<div className={`sm:px-5 flex-grow pt-fixed flex flex-col`}> <div className={`sm:px-5 flex-grow pt-fixed flex flex-col h-full`}>
{children} {children}
</div> </div>
</div> </div>

View File

@ -16,6 +16,10 @@ $scrollbar-size: 13px;
// dont theme scrollbars on mobile // dont theme scrollbars on mobile
@media (hover: hover) { @media (hover: hover) {
html {
scrollbar-gutter: stable;
}
@supports not selector(::-webkit-scrollbar) { @supports not selector(::-webkit-scrollbar) {
* { * {
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg); scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg);

View File

@ -1,29 +1,4 @@
export const ChuniPenguinIcon = ({ className }: { className?: string; }) => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px" export const ChuniPenguinIcon = ({ className }: { className?: string; }) => <svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 575.97 1066.56" enable-background="new 0 0 575.97 1066.56" xmlSpace="preserve" fill="currentColor" className={className}> viewBox="0 0 576 1066.6" fill="currentColor" className={className}>
<path d="M221.68,0c26.2,53.56,47.17,108.69,50.4,170.46c12.03-22.99,25.9-43.3,35.81-65.84c0.55,0.09,1.11,0.18,1.66,0.28 <path d="M222 0c26 54 47 109 50 171 12-23 26-44 36-66 9 44-9 97-30 137 70 15 195 0 198 115-13 38-42 73-47 117 70-84 233-143 81 8-24 26-64 42-83 71 5 64 16 133-17 191 12 86-27 195-118 220-11 14 14 41 23 55 7 21 68 21 55 41-11 7-23 7-35 6-61 2-64-65-77-104-36-6-71-17-104-31C60 891-14 795 1 689c2-34 11-67 22-100 5-12 24 15 33 19 3 4 6 1 6-3 0-42 10-84 35-120-83-152-92-396 38-85 21-54 27-119 86-145-27-48-61-106-70-164 23 14 39 34 54 57-8-50-4-103 17-148z" />
c0.39,4.67,1.15,9.34,1.11,14c-0.27,36.51-8.89,71.22-23.11,104.65c-2.73,6.42-6.06,12.61-9.31,18.79
c-1.49,2.84-2.04,4.27,2.24,4.4c33.79,1.05,67.46,4.01,101.1,7.11c40.28,3.7,78.14,35.91,88.71,74.85
c2.54,9.34,4.83,18.73,5.25,28.42c0.23,5.27,1.53,11.24-5.11,14.24c-0.61,0.28-0.95,1.33-1.25,2.1
c-3.98,10.33-9.62,19.78-15.81,28.87c-6.47,9.5-10.78,19.85-13.62,30.92c-3.42,13.36-6.97,26.7-10.59,40.53
c2.16-0.73,2.81-2.31,3.87-3.43c34.4-36.21,73.61-65.43,120.82-82.99c4.54-1.69,9.22-2.89,14.14-2.74
c5.97,0.19,8.93,3.61,7.7,9.49c-0.6,2.9-1.8,5.72-3.08,8.41c-14.9,31.17-36.12,57.28-62.57,79.48
c-25.66,21.54-51.57,42.74-79.05,61.94c-3.19,2.23-3.79,5.27-4.02,8.64c-0.49,7.38,1,14.6,1.95,21.85
c1.73,13.23,2.04,26.56,2.69,39.85c0.81,16.63,1.37,33.31,1.18,49.95c-0.29,25.61-5.24,50.02-21.36,70.95
c-2.08,2.7-1.63,5.67-1.3,8.62c3.51,31.74,1.61,63.21-5.37,94.31c-6.59,29.32-22.61,53.87-40.63,77.22
c-13.54,17.55-30.65,30.92-50.2,41.25c-6.99,3.69-14.4,6.31-22.21,7.25c-4.29,0.52-4.01,2.26-3.29,5.55
c4.14,18.97,14.64,34.33,26.47,49.24c10.09,12.72,23.64,19.05,38.27,24.13c5.35,1.86,10.79,3.54,15.91,5.91
c6.25,2.9,6.31,7.24,0.59,11.17c-10.79,7.4-23.01,7.22-35.19,6.21c-9.12-0.76-18.07-2.79-26.9-5.35
c-8.22-2.39-14.37-7.21-19.56-13.89c-13.28-17.09-19.18-37.09-23.62-57.76c-1.58-7.37-2.06-14.84-2.35-22.32
c-0.12-3.18-1.43-4.08-4.42-4.63c-35.69-6.53-70.33-16.92-103.64-31.11c-29.5-12.57-55.91-30.67-79.54-52.14
c-33.9-30.81-57.32-68.72-69.26-113.09c-6.75-25.06-5.48-50.85-3.95-76.51c2.07-34.54,10.82-67.64,21.4-100.36
c1.76-5.45,1.94-5.55,6.47-2.07c9.12,7,18.19,14.07,27.29,21.09c1.15,0.89,2.23,2.54,3.81,1.92c1.94-0.76,1.26-2.87,1.34-4.44
c0.95-18.47,2.63-36.86,6.14-55.04c4.61-23.86,16.43-44.56,28.94-64.89c2.49-4.04,2.43-6.65-0.1-10.61
c-28.64-44.79-47.6-93.27-55.22-146.06c-2.81-19.45-4.95-38.96-6.55-58.54c-0.42-5.16-0.64-10.34,1.28-15.31
c2.25-5.84,6.39-7.36,11.95-4.51c2.27,1.17,3.99,2.97,5.83,4.67c19.95,18.47,34.4,41.04,47.76,64.38
c5.63,9.83,10.85,19.89,16.15,29.9c7.61,14.37,13.01,29.55,16.18,45.51c0.28,1.43,0.72,2.83,1.3,5.09
c3.4-7.96,6.01-15.28,8.25-22.75c5.88-19.62,12.46-39.03,18.53-58.59c5.95-19.17,17.46-33.74,33.1-45.71
c8.37-6.41,15.5-14.49,25.78-18.2c2.35-0.85,1.84-2.24,0.6-3.84c-35.87-46.59-55.6-100.48-70.69-156.46
c-0.22-0.8-0.38-1.62-0.48-2.44c-0.03-0.28,0.2-0.59,0.53-1.46c22.8,14.47,39.21,34.72,54.35,57.82
c-1.89-15.06-3.3-28.96-3.98-42.97c-0.96-19.68,1.65-38.87,5.8-57.94c3.5-16.11,7.73-32.04,13.71-47.45
C221.02,0,221.35,0,221.68,0z"/>
</svg>; </svg>;

View File

@ -32,6 +32,14 @@ const getChangeColor = (val: number) => {
}; };
export const ChuniPlaylogCard = ({ playlog, className, badgeClass, showDetails }: ChuniPlaylogCardProps) => { export const ChuniPlaylogCard = ({ playlog, className, badgeClass, showDetails }: ChuniPlaylogCardProps) => {
const rate = showDetails ? <>
{!!playlog.tapJudgeCount && <div>Tap: {(playlog.rateTap! / 100).toFixed(2)}%</div>}
{!!playlog.flickJudgeCount && <div>Flick: {(playlog.rateFlick! / 100).toFixed(2)}%</div>}
{!!playlog.holdJudgeCount && <div>Hold: {(playlog.rateHold! / 100).toFixed(2)}%</div>}
{!!playlog.slideJudgeCount && <div>Slide: {(playlog.rateSlide! / 100).toFixed(2)}%</div>}
{!!playlog.airJudgeCount && <div>Air: {(playlog.rateAir! / 100).toFixed(2)}%</div>}
</> : null;
return (<TickerHoverProvider>{setHover => <div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} return (<TickerHoverProvider>{setHover => <div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
className={`rounded-md bg-content1 relative flex flex-col p-2 pt-1 border border-black/25 ${className ?? ''}`}> className={`rounded-md bg-content1 relative flex flex-col p-2 pt-1 border border-black/25 ${className ?? ''}`}>
<div className="flex"> <div className="flex">
@ -68,11 +76,8 @@ export const ChuniPlaylogCard = ({ playlog, className, badgeClass, showDetails }
{showDetails && <div className="hidden md:flex flex-col text-sm gap-1 items-end text-nowrap ml-1"> {showDetails && <div className="hidden md:flex flex-col text-sm gap-1 items-end text-nowrap ml-1">
<div className="text-xs my-1">{formatJst(playlog.userPlayDate!)}</div> <div className="text-xs my-1">{formatJst(playlog.userPlayDate!)}</div>
<div>Tap: {(playlog.rateTap! / 100).toFixed(2)}%</div> {rate}
<div>Flick: {(playlog.rateFlick! / 100).toFixed(2)}%</div> <div style={{ marginBottom: `${(+!playlog.tapJudgeCount + +!playlog.flickJudgeCount + +!playlog.holdJudgeCount + +!playlog.slideJudgeCount + +!playlog.airJudgeCount) * 1.25}rem` }} />
<div>Hold: {(playlog.rateHold! / 100).toFixed(2)}%</div>
<div>Slide: {(playlog.rateSlide! / 100).toFixed(2)}%</div>
<div>Air: {(playlog.rateAir! / 100).toFixed(2)}%</div>
</div>} </div>}
</div> </div>
<div <div
@ -84,20 +89,17 @@ export const ChuniPlaylogCard = ({ playlog, className, badgeClass, showDetails }
<ChuniLampComboBadge {...playlog} /> <ChuniLampComboBadge {...playlog} />
{!!playlog.isNewRecord && <ChuniScoreBadge variant="gold" fontSize="sm">NEW RECORD</ChuniScoreBadge>} {!!playlog.isNewRecord && <ChuniScoreBadge variant="gold" fontSize="sm">NEW RECORD</ChuniScoreBadge>}
</div> </div>
<div className="flex flex-wrap text-xs justify-around drop-shadow-sm gap-1"> <div className="flex flex-wrap text-xs justify-around drop-shadow gap-1">
<div className="text-chuni-justice-critical">Justice Critical: {playlog.judgeHeaven}</div> <div className="text-yellow-500 dark:text-yellow-400">J. Heaven: {playlog.judgeHeaven}</div>
<div className="text-chuni-justice">Justice: {playlog.judgeCritical}</div> <div className="text-yellow-600 dark:text-yellow-500">J. Critical: {playlog.judgeCritical}</div>
<div className="text-chuni-attack">Attack: {playlog.judgeAttack}</div> <div className="text-orange-500">Justice: {playlog.judgeJustice}</div>
<div className="text-chuni-miss">Miss: {playlog.judgeGuilty}</div> <div className="text-emerald-600">Attack: {playlog.judgeAttack}</div>
<div className="text-gray-400">Miss: {playlog.judgeGuilty}</div>
</div> </div>
{showDetails && <> {showDetails && <>
<Divider className="md:hidden my-2"/> <Divider className="md:hidden my-2"/>
<div className="flex flex-wrap text-xs justify-around md:hidden gap-1 mb-1"> <div className="flex flex-wrap text-xs justify-around md:hidden gap-1 mb-1">
<div>Tap: {(playlog.rateTap! / 100).toFixed(2)}%</div> {rate}
<div>Flick: {(playlog.rateFlick! / 100).toFixed(2)}%</div>
<div>Hold: {(playlog.rateHold! / 100).toFixed(2)}%</div>
<div>Slide: {(playlog.rateSlide! / 100).toFixed(2)}%</div>
<div>Air: {(playlog.rateAir! / 100).toFixed(2)}%</div>
</div> </div>
</>} </>}
</div>} </div>}

View File

@ -492,7 +492,6 @@ const payloadValid = (payload: any, filterers: Filterers<any, any>) => {
for (const filterer of filterers) { for (const filterer of filterers) {
if (!(filterer.name in payload)) continue; if (!(filterer.name in payload)) continue;
const data = payload[filterer.name]; const data = payload[filterer.name];
console.log(filterer.name, data)
if (filterer.type === 'select' && !(data instanceof Set)) if (filterer.type === 'select' && !(data instanceof Set))
return false; return false;
if (filterer.type === 'slider' && !Array.isArray(data)) if (filterer.type === 'slider' && !Array.isArray(data))

View File

@ -1,4 +1,4 @@
import { ReactNode, useRef } from 'react'; import { ReactNode, useEffect, useRef } from 'react';
import { useWindowVirtualizer } from '@tanstack/react-virtual'; import { useWindowVirtualizer } from '@tanstack/react-virtual';
import { useResizeObserver } from 'usehooks-ts'; import { useResizeObserver } from 'usehooks-ts';
@ -26,6 +26,10 @@ export const WindowScrollerGrid = <D extends any>({ rowSize, colSize, items, chi
overscan: 5, overscan: 5,
scrollingDelay: 0 scrollingDelay: 0
}); });
useEffect(() => {
virtualizer.measure();
}, [rowSize, colSize, virtualizer]);
return (<div ref={listRef} className={width <= 0 ? `invisible` : ''}> return (<div ref={listRef} className={width <= 0 ? `invisible` : ''}>
{width > 0 && <div className="w-full relative" style={{ {width > 0 && <div className="w-full relative" style={{

View File

@ -6,6 +6,13 @@ export const CHUNI_MUSIC_PROPERTIES = ['music.songId',
'music.worldsEndTag', 'music.worldsEndTag',
'music.genre', 'music.genre',
'music.version', 'music.version',
'music.level' 'music.level',
'musicExt.chartDesigner',
'musicExt.tapJudgeCount',
'musicExt.holdJudgeCount',
'musicExt.slideJudgeCount',
'musicExt.airJudgeCount',
'musicExt.flickJudgeCount',
'musicExt.allJudgeCount'
// sql<string>`CAST(music.level AS DECIMAL(3, 1))`.as('level') // sql<string>`CAST(music.level AS DECIMAL(3, 1))`.as('level')
] as const; ] as const;

13
src/types/db.d.ts vendored
View File

@ -21,6 +21,18 @@ export interface ActaeonChuniStaticMapIcon {
sortName: string | null; sortName: string | null;
} }
export interface ActaeonChuniStaticMusicExt {
airJudgeCount: number;
allJudgeCount: number;
chartDesigner: string | null;
chartId: number;
flickJudgeCount: number;
holdJudgeCount: number;
slideJudgeCount: number;
songId: number;
tapJudgeCount: number;
}
export interface ActaeonChuniStaticNamePlate { export interface ActaeonChuniStaticNamePlate {
id: number; id: number;
imagePath: string | null; imagePath: string | null;
@ -3343,6 +3355,7 @@ export interface DB {
actaeon_arcade_ext: ActaeonArcadeExt; actaeon_arcade_ext: ActaeonArcadeExt;
actaeon_arcade_join_keys: ActaeonArcadeJoinKeys; actaeon_arcade_join_keys: ActaeonArcadeJoinKeys;
actaeon_chuni_static_map_icon: ActaeonChuniStaticMapIcon; actaeon_chuni_static_map_icon: ActaeonChuniStaticMapIcon;
actaeon_chuni_static_music_ext: ActaeonChuniStaticMusicExt;
actaeon_chuni_static_name_plate: ActaeonChuniStaticNamePlate; actaeon_chuni_static_name_plate: ActaeonChuniStaticNamePlate;
actaeon_chuni_static_system_voice: ActaeonChuniStaticSystemVoice; actaeon_chuni_static_system_voice: ActaeonChuniStaticSystemVoice;
actaeon_chuni_static_trophies: ActaeonChuniStaticTrophies; actaeon_chuni_static_trophies: ActaeonChuniStaticTrophies;

View File

@ -18,12 +18,6 @@ const config = {
'4xl': '1920px', '4xl': '1920px',
'5xl': '2560px', '5xl': '2560px',
'6xl': '3440px' '6xl': '3440px'
},
colors: {
'chuni-justice-critical': '#dfb920',
'chuni-justice': '#db7814',
'chuni-attack': '#61a873',
'chuni-miss': '#adadad'
} }
} }
} }