Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
NUXT_PUBLIC_APP_NAME="osu!dash"
NUXT_PUBLIC_OSU_API_BASE="https://osu.ppy.sh/api/v2"
NUXT_PUBLIC_OSU_TARGET_USER="Yourusername"
NUXT_OSU_CLIENT_ID="your_client_id"
NUXT_OSU_CLIENT_SECRET="your_client_secret"
OSU_REPLAY_PATH="/path/to/your/osu/exports"
NUXT_ENABLE_REPLAY_WATCHER="false"
NUXT_ENABLE_REPLAY_WATCHER="false"
NUXT_ALLOWED_HOSTS="osu.korpseluv.com"

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value 'osu.korpseluv.com' in the .env.example file should match the intention of being an example. Consider using a placeholder like 'your-tunnel-domain.com' or 'example.com' instead of what appears to be a specific/personal domain.

Suggested change
NUXT_ALLOWED_HOSTS="osu.korpseluv.com"
NUXT_ALLOWED_HOSTS="your-tunnel-domain.com"

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Why is this change needed?
## Testing

- [ ] `npm run build`
- [ ] `npm run typecheck`
- [ ] Manual check (screenshots optional)

## Security / privacy
Expand Down
101 changes: 83 additions & 18 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import countryCodeList from 'flagpack-core/countryCodeList.json'
import BeatmapCover from '../components/BeatmapCover.vue'
import HitErrorBar from '../components/HitErrorBar.vue'
Expand Down Expand Up @@ -81,7 +81,7 @@ const runtimeConfig = useRuntimeConfig()
const appVersion = computed(() => String((runtimeConfig.public as any)?.gitHash || 'unknown'))

const fallbackUser: UserPayload = {
username: 'chlokun',
username: (runtimeConfig.public as any)?.osuTargetUser || 'chlokun',
avatar: 'https://a.ppy.sh/6829235?1680000000.jpeg',
rank: 852341,
pp: 1450,
Expand Down Expand Up @@ -319,7 +319,7 @@ const modBadgeClass = (mod: string) => {

const rankHistory = computed(() => user.value.rankHistory || [])

const chartRef = ref<HTMLElement | null>(null)
const chartRef = ref<SVGSVGElement | null>(null)
const graphHover = ref<{
index: number
xPercent: number
Expand Down Expand Up @@ -375,6 +375,42 @@ const tabs = [

const activeTab = ref<'overview' | 'top' | 'history' | 'deep'>('overview')

// Map URL hash to a tab id. Supports '#deep-stats' and '#deep'.
const mapHashToTab = (hash: string | null) => {
if (!hash) return null
const h = hash.startsWith('#') ? hash.slice(1) : hash
if (h === 'deep' || h === 'deep-stats') return 'deep'
if (h === 'overview') return 'overview'
if (h === 'top') return 'top'
if (h === 'history') return 'history'
return null
}

const setTabFromHash = () => {
const t = mapHashToTab(typeof window !== 'undefined' ? window.location.hash : null)
if (t) activeTab.value = t
}

onMounted(() => {
// initialize from the fragment when the component mounts
setTabFromHash()
window.addEventListener('hashchange', setTabFromHash)
})

onBeforeUnmount(() => {
Comment on lines +394 to +400

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tab navigation logic uses window.location.hash directly without checking if window is defined on line 397, but the check is present on line 390. While line 390 guards setTabFromHash's initial read, line 397 is inside onMounted which only runs client-side, so the additional check on line 390 may be redundant. Consider removing the typeof window check on line 390 for consistency, or document why it's needed.

Suggested change
onMounted(() => {
// initialize from the fragment when the component mounts
setTabFromHash()
window.addEventListener('hashchange', setTabFromHash)
})
onBeforeUnmount(() => {
onMounted(() => {
if (typeof window === 'undefined') return
// initialize from the fragment when the component mounts
setTabFromHash()
window.addEventListener('hashchange', setTabFromHash)
})
onBeforeUnmount(() => {
if (typeof window === 'undefined') return

Copilot uses AI. Check for mistakes.
window.removeEventListener('hashchange', setTabFromHash)
})

// keep the URL fragment in sync with the active tab
watch(activeTab, (val) => {
const frag = val === 'deep' ? 'deep-stats' : val
try {
history.replaceState(null, '', `#${frag}`)
} catch (e) {
if (typeof window !== 'undefined') window.location.hash = `#${frag}`
}
})
Comment on lines +405 to +412

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The watch callback for activeTab changes the URL without checking if we're in a browser environment. While the watch will only run client-side in practice, the explicit window check on line 410 suggests defensive programming, but it's inconsistent with line 408 which doesn't check before calling history.replaceState. Consider adding a guard at the start of the watch callback to ensure window/history are available.

Copilot uses AI. Check for mistakes.

const deepScores = computed(() => {
const pool = [
...filteredRecent.value,
Expand Down Expand Up @@ -608,10 +644,38 @@ const onGraphMove = (event: MouseEvent) => {
const firstPoint = meta.points[0]
if (!firstPoint) return

const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
const targetX = xRatio * meta.width
// Map client coordinates into SVG coordinate space for accurate alignment.
let targetX = 0
const svg = chartRef.value
if (svg && typeof svg.getScreenCTM === 'function') {
try {
const pt = (svg.createSVGPoint ? svg.createSVGPoint() : (new DOMPoint() as any)) as any

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback for DOMPoint when createSVGPoint is unavailable casts to 'any', which bypasses type safety. Consider defining a proper interface or type for the point object to maintain type safety, ensuring it has x, y properties and a matrixTransform method.

Copilot uses AI. Check for mistakes.
pt.x = event.clientX
pt.y = event.clientY
const ctm = svg.getScreenCTM()
if (ctm) {
const inv = ctm.inverse()
const transformed = pt.matrixTransform(inv)
targetX = transformed.x
} else {
// fallback to bounding rect ratio
const rect = svg.getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
} catch (e) {
const rect = svg.getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
} else {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
Comment on lines +647 to +678

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are four levels of fallback logic for computing targetX (lines 650-678), with repeated code blocks computing the same rect-based calculation. Consider extracting the fallback calculation into a helper function to reduce duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

let nearest = firstPoint
let minDist = Math.abs(firstPoint.x - targetX)
Expand Down Expand Up @@ -763,24 +827,21 @@ const onGraphLeave = () => {
</div>

<div class="flex flex-row flex-wrap gap-2">
<div
<div

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation appears to have changed from the original alignment. The opening div tag now has inconsistent indentation (extra spaces) compared to the surrounding code, which may indicate a formatting issue.

Copilot uses AI. Check for mistakes.
v-for="badge in gradeBadges"
:key="badge.label"
class="silky-in rounded-full px-3 py-1 text-xs font-slim transition-all duration-500 ease-out"
:class="[
badge.tone === 'badge-ss' ? 'bg-white text-black border border-white' : '',
badge.tone === 'badge-s' ? 'bg-white text-black border border-white' : '',
badge.tone === 'badge-a' ? 'border border-white/50 text-white' : '',
badge.tone === 'badge-s' ? '' : '',
badge.tone === 'badge-ss' ? '' : '',
:class="[
badge.tone === 'badge-ss' ? 'bg-white/6 text-white ring-2 ring-amber-200/20 shadow-[0_0_10px_rgba(255,255,255,0.06)]' : '',
badge.tone === 'badge-s' ? 'bg-white/6 text-white ring-2 ring-white/10 shadow-[0_0_8px_rgba(255,255,255,0.04)]' : '',
badge.label === 'F' ? 'line-through opacity-60 border border-white/20 text-white' : '',
!['badge-ss','badge-s','badge-a'].includes(badge.tone) && badge.label !== 'F' ? 'border border-white/30 text-white' : ''
!['badge-ss','badge-s'].includes(badge.tone) && badge.label !== 'F' ? 'border border-white/30 text-white' : ''

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 838 checks if badge.tone is NOT in the array ['badge-ss','badge-s'], but line 835-836 define styles for 'badge-ss' and 'badge-s'. The removed line 837 previously had a check for 'badge-a', which is now missing from line 838. If 'badge-a' is still a valid tone value, it will now incorrectly fall through to the default border styling instead of having its own specific styling.

Suggested change
!['badge-ss','badge-s'].includes(badge.tone) && badge.label !== 'F' ? 'border border-white/30 text-white' : ''
!['badge-ss','badge-s','badge-a'].includes(badge.tone) && badge.label !== 'F' ? 'border border-white/30 text-white' : ''

Copilot uses AI. Check for mistakes.
]"
>
<span class="inline-flex items-baseline gap-1 leading-none">
<span class="text-sm font-semibold">{{ badge.label }}</span>
<span aria-hidden="true">·</span>
<span class="text-sm font-normal">{{ badge.value.toLocaleString() }}</span>
<span class="text-sm font-light text-zinc-300">{{ badge.value.toLocaleString() }}</span>
</span>
</div>
</div>
Expand Down Expand Up @@ -1086,7 +1147,7 @@ const onGraphLeave = () => {
:key="ev.id"
class="relative mb-5 last:mb-0"
>
<span class="absolute -left-1.5 mt-0.5 h-3 w-3 rounded-full border border-white bg-black" />
<span class="absolute -left-4 top-1/2 -translate-y-1/2 h-3 w-3 rounded-full border border-white bg-black" />

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeline dot positioning changed from '-left-1.5 mt-0.5' to '-left-4 top-1/2 -translate-y-1/2'. This change shifts the dot 16px to the left (from -6px to -16px) and centers it vertically. Verify that there is sufficient left padding or margin in the parent container to accommodate the additional -10px shift, otherwise the dot may be clipped or overflow outside the visible area.

Copilot uses AI. Check for mistakes.
<div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2 backdrop-blur">
<p class="text-sm text-white">{{ ev.text }}</p>
<p class="text-xs text-zinc-500">{{ ev.created_at ? new Date(ev.created_at).toISOString().slice(0, 10) : '' }}</p>
Expand All @@ -1110,7 +1171,11 @@ const onGraphLeave = () => {
<div
v-for="score in deepScores"
:key="(score as any).beatmap?.checksum || (score as any).beatmap_md5 || score.id"
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/40 p-4 backdrop-blur transition-all duration-500 ease-out hover:-translate-y-px hover:border-white/30"
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/40 p-4 backdrop-blur transition-all duration-500 ease-out hover:-translate-y-px hover:border-white/30 cursor-pointer"
role="button"
tabindex="0"
@click="score.deep_stats && openDetail(score)"
@keyup.enter="score.deep_stats && openDetail(score)"
Comment on lines +1174 to +1178

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional rendering logic 'score.deep_stats && openDetail(score)' on both click and keyup handlers will silently do nothing if deep_stats is falsy. For better accessibility and user feedback, consider disabling the interactive elements (removing role, tabindex, cursor-pointer) when deep_stats is not available, or providing visual feedback that the item is not clickable.

Copilot uses AI. Check for mistakes.
>
<div class="absolute inset-0 opacity-20">
<BeatmapCover
Expand Down
12 changes: 10 additions & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export default defineNuxtConfig({
{ name: 'viewport', content: 'width=device-width, initial-scale=1, user-scalable=no' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/icon.png' },
{ rel: 'apple-touch-icon', href: '/icon.png' }
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', href: '/favicon.ico' }
]
}
},
Expand All @@ -78,6 +78,14 @@ export default defineNuxtConfig({
},
vite: {
plugins: [tsconfigPaths()],
server: {
// allow hosts for previewing the dev server through a tunnel
// Can be set via env: `NUXT_ALLOWED_HOSTS` or `VITE_ALLOWED_HOSTS` (comma-separated).

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions "VITE_ALLOWED_HOSTS" as an option, but based on the environment variable naming convention used in the project (NUXT_*), using VITE_ALLOWED_HOSTS might not work as expected. Consider clarifying in the comment whether VITE_ALLOWED_HOSTS is actually supported, or if only NUXT_ALLOWED_HOSTS should be documented.

Copilot uses AI. Check for mistakes.
allowedHosts: (process.env.NUXT_ALLOWED_HOSTS || process.env.VITE_ALLOWED_HOSTS || 'osu.korpseluv.com')

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded default value 'osu.korpseluv.com' should not be in the production configuration. This appears to be a personal or development-specific hostname. The default should either be empty, use 'localhost', or be documented that it must be overridden via environment variables. Having a specific external domain as the default could cause security issues or unexpected behavior for other developers.

Suggested change
allowedHosts: (process.env.NUXT_ALLOWED_HOSTS || process.env.VITE_ALLOWED_HOSTS || 'osu.korpseluv.com')
allowedHosts: (process.env.NUXT_ALLOWED_HOSTS || process.env.VITE_ALLOWED_HOSTS || 'localhost')

Copilot uses AI. Check for mistakes.
.split(',')
.map((s) => s.trim())
.filter(Boolean)
},
resolve: {
alias: {
'~': fileURLToPath(new URL('.', import.meta.url)),
Expand Down