Some checks failed
Test and deploy staging / staging (push) Failing after 43s
Run the sveltekit-3 migration and finish its manual tasks:
- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path
Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
import { and, desc, eq } from 'drizzle-orm';
|
||
import { fail } from '@sveltejs/kit';
|
||
import { db } from '#lib/server/db/index.js';
|
||
import { osuEntries } from '#lib/server/db/schema/index.js';
|
||
import { allowedOsuUrl, boundedInteger, validDate } from '#lib/game-tracker/journal.js';
|
||
import type { Actions, PageServerLoad } from './$types';
|
||
|
||
export const load: PageServerLoad = async ({ locals }) => ({
|
||
entries: locals.user
|
||
? await db
|
||
.select()
|
||
.from(osuEntries)
|
||
.where(eq(osuEntries.userId, locals.user.id))
|
||
.orderBy(desc(osuEntries.playedOn), desc(osuEntries.id))
|
||
: [],
|
||
seo: {
|
||
title: 'osu! training · Game Tracker',
|
||
description:
|
||
'A personal journal for memorable scores, beatmap goals and speed, stamina, aim and reading practice.'
|
||
}
|
||
});
|
||
export const actions = {
|
||
save: async ({ request, locals }) => {
|
||
if (!locals.user) return fail(401, { message: 'Sign in to save training.' });
|
||
const data = await request.formData();
|
||
const t = (k: string) => String(data.get(k) ?? '').trim();
|
||
const id = t('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
|
||
const kind = t('kind');
|
||
const mode = t('mode');
|
||
const goal = t('goal') || 'pass';
|
||
const skill = t('skill') || 'stamina';
|
||
if (
|
||
(t('id') && !id) ||
|
||
!['score', 'goal', 'training'].includes(kind) ||
|
||
!['osu', 'taiko', 'fruits', 'mania'].includes(mode) ||
|
||
!['pass', 'fc', 'accuracy', 'rank', 'custom'].includes(goal) ||
|
||
!['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'].includes(
|
||
skill
|
||
) ||
|
||
!t('title') ||
|
||
t('title').length > 250 ||
|
||
t('notes').length > 5000 ||
|
||
t('mods').length > 80 ||
|
||
!validDate(t('playedOn'))
|
||
)
|
||
return fail(400, { message: 'Check the title, date and training details.' });
|
||
if (!allowedOsuUrl(t('beatmapUrl'), 'beatmap') || !allowedOsuUrl(t('scoreUrl'), 'score'))
|
||
return fail(400, { message: 'Use an https://osu.ppy.sh beatmap or score link.' });
|
||
const optionalInt = (key: string, min: number, max: number) =>
|
||
t(key) ? boundedInteger(data.get(key), min, max) : null;
|
||
const bpm = optionalInt('bpm', 1, 2000),
|
||
duration = optionalInt('duration', 1, 86400),
|
||
combo = optionalInt('combo', 0, 1000000),
|
||
misses = optionalInt('misses', 0, 1000000);
|
||
const decimal = (key: string, min: number, max: number) =>
|
||
!t(key) ||
|
||
(/^\d+(\.\d{1,4})?$/.test(t(key)) && Number(t(key)) >= min && Number(t(key)) <= max);
|
||
if (
|
||
(t('bpm') && bpm === null) ||
|
||
(t('duration') && duration === null) ||
|
||
(t('combo') && combo === null) ||
|
||
(t('misses') && misses === null) ||
|
||
!decimal('accuracy', 0, 100) ||
|
||
!decimal('pp', 0, 100000) ||
|
||
!t('rate') ||
|
||
!decimal('rate', 0.1, 4) ||
|
||
(kind === 'training' && (!bpm || !duration))
|
||
)
|
||
return fail(400, {
|
||
message:
|
||
'Training needs BPM and duration. Check numeric fields, accuracy (0–100) and rate (0.1–4).'
|
||
});
|
||
const values = {
|
||
userId: locals.user.id,
|
||
kind: kind as 'score' | 'goal' | 'training',
|
||
mode,
|
||
goal,
|
||
skill,
|
||
title: t('title'),
|
||
notes: t('notes'),
|
||
bpm,
|
||
duration,
|
||
combo,
|
||
misses,
|
||
accuracy: t('accuracy') || null,
|
||
pp: t('pp') || null,
|
||
rate: t('rate'),
|
||
mods: t('mods') || 'NM',
|
||
beatmapUrl: t('beatmapUrl') || null,
|
||
scoreUrl: t('scoreUrl') || null,
|
||
playedOn: t('playedOn'),
|
||
achieved: data.get('achieved') === 'on'
|
||
};
|
||
if (id) {
|
||
const updated = await db
|
||
.update(osuEntries)
|
||
.set(values)
|
||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||
.returning({ id: osuEntries.id });
|
||
if (!updated.length) return fail(404, { message: 'Entry not found.' });
|
||
} else await db.insert(osuEntries).values(values);
|
||
return { saved: true };
|
||
},
|
||
toggleGoal: async ({ request, locals }) => {
|
||
if (!locals.user) return fail(401, { message: 'Sign in to update goals.' });
|
||
const data = await request.formData();
|
||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||
if (!id) return fail(400, { message: 'Invalid goal.' });
|
||
const updated = await db
|
||
.update(osuEntries)
|
||
.set({ achieved: data.get('achieved') === 'on' })
|
||
.where(
|
||
and(
|
||
eq(osuEntries.id, id),
|
||
eq(osuEntries.userId, locals.user.id),
|
||
eq(osuEntries.kind, 'goal')
|
||
)
|
||
)
|
||
.returning({ id: osuEntries.id });
|
||
return updated.length ? { saved: true } : fail(404, { message: 'Goal not found.' });
|
||
},
|
||
delete: async ({ request, locals }) => {
|
||
if (!locals.user) return fail(401, { message: 'Sign in to remove entries.' });
|
||
const data = await request.formData();
|
||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||
if (!id) return fail(400, { message: 'Invalid entry.' });
|
||
const removed = await db
|
||
.delete(osuEntries)
|
||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||
.returning({ id: osuEntries.id });
|
||
return removed.length ? { saved: true } : fail(404, { message: 'Entry not found.' });
|
||
}
|
||
} satisfies Actions;
|