Skip to content

Commit 4ffde0a

Browse files
committed
feat: lichess game importer
Closes #13
1 parent 49b7222 commit 4ffde0a

File tree

9 files changed

+434
-3
lines changed

9 files changed

+434
-3
lines changed

components/ChessGameResultRow.vue

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
<td v-else-if="link == null">
6262
<span class="text-grey">No Link</span>
6363
</td>
64+
65+
<slot />
6466
</tr>
6567
</template>
6668
<script lang="ts">

layouts/default.vue

+2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
<nav-dropdown name="Games" fa-icon="fas fa-chess">
1010
<nav-dropdown-item name="New Game" fa-icon="fas fa-plus" href="/game/new" />
11+
<v-divider />
1112
<nav-dropdown-item name="My Games" fa-icon="fas fa-chess" href="/games" />
13+
<nav-dropdown-item name="Game Importer" fa-icon="fas fa-download" href="/games/import" />
1214
</nav-dropdown>
1315

1416
<v-spacer />

pages/game/[id].vue

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
</v-dialog>
3737
</div>
3838

39+
<p v-if="game.platform == 'lichess'">
40+
This game was imported from Lichess! <page-link text="Click to view it" :href="`https://lichess.org/${game.platform_id}`" />.
41+
</p>
42+
3943
<p v-if="game?.tournament_info">
4044
This game was from a tournament!
4145
<PageLink :href="`/uscf/tournament/${game?.tournament_info.eventId}`">

pages/games/import.vue

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<template>
2+
<main>
3+
<h1>Game Importer</h1>
4+
5+
<h2>Select a Platform</h2>
6+
<p>
7+
Click a platform to load games from and import as desired.
8+
If you want to link an account, go to <page-link text="your integration settings" href="/settings/integrations" />.
9+
</p>
10+
<div>
11+
<!-- on hold until chess.com lets me use their oauth :( -->
12+
<!-- <v-btn v-if="integrations?.chesscom" class="mr-3" text="Chess.com" color="green" /> -->
13+
<v-btn v-if="integrations?.lichess" text="Lichess" color="grey" :loading="lichessFetching" @click="fetchLichessGames" />
14+
</div>
15+
16+
<h2>Games</h2>
17+
<v-table theme="dark">
18+
<thead>
19+
<tr>
20+
<th>Players</th>
21+
<th>Result</th>
22+
<th>Date</th>
23+
<th>Options</th>
24+
</tr>
25+
</thead>
26+
<tbody>
27+
<chess-game-result-row v-for="game in lichessGames" :key="game.id" :date="game.date"
28+
:white-player="game.whitePlayer" :black-player="game.blackPlayer"
29+
:clean-result="game.cleanResult" :friendly-result="game.friendlyResult">
30+
<td>
31+
<span v-if="game.imported?.status === false">
32+
<v-btn color="green" :loading="loading[game.id]" @click="importGame(game, 'lichess')">
33+
Import&nbsp;<i class="fa-solid fa-download" />
34+
</v-btn>
35+
</span>
36+
37+
<PageLink v-if="game.imported?.status === true" :href="`/game/${game.imported.id}`">
38+
<v-btn color="blue">
39+
View&nbsp;<i class="fa-solid fa-external-link" />
40+
</v-btn>
41+
</PageLink>
42+
</td>
43+
</chess-game-result-row>
44+
</tbody>
45+
</v-table>
46+
</main>
47+
</template>
48+
49+
<script lang="ts">
50+
import { defineComponent } from 'vue'
51+
import type { Database } from '~/types/supabase'
52+
import type { CleanedGame } from '~/utils/games'
53+
import type { UserImportGameResponse, UserIntegrationsResponse, UserLichessGames } from '~/types/requests'
54+
55+
type weirdMergedCleanedGame = CleanedGame & {imported?: {status: boolean, id?: string}}
56+
57+
export default defineComponent({
58+
name: 'import',
59+
60+
async setup() {
61+
const client = useSupabaseClient<Database>()
62+
const user = useSupabaseUser().value
63+
const id = user?.id
64+
65+
const inteData = await useFetch<UserIntegrationsResponse>(`/api/users/${id}/integrations`)
66+
67+
const integrations = inteData.data.value?.integrations
68+
69+
return {
70+
client, user, integrations
71+
}
72+
},
73+
74+
data() {
75+
return {
76+
// Your data properties here
77+
lichessFetching: false,
78+
lichessGames: [] as weirdMergedCleanedGame[],
79+
loading: {} as Record<string, boolean>
80+
}
81+
},
82+
83+
methods: {
84+
// Your methods here
85+
async fetchLichessGames() {
86+
this.lichessFetching = true
87+
await $fetch<UserLichessGames>('/api/users/me/games/lichess').then((res) => {
88+
if (res.error) {
89+
throw showError({ statusCode: 500, statusMessage: res.error })
90+
}
91+
92+
if (res.data) {
93+
this.lichessGames = res.data
94+
}
95+
})
96+
this.lichessFetching = false
97+
},
98+
99+
async importGame(game: weirdMergedCleanedGame, platform: string) {
100+
this.loading[game.id] = true
101+
await $fetch<UserImportGameResponse>('/api/users/me/games/import', {
102+
method: 'POST',
103+
headers: {
104+
'Content-Type': 'application/json'
105+
},
106+
body: {
107+
platformId: game.id,
108+
platform,
109+
platformUsername: this.integrations?.lichess?.data.id
110+
}
111+
}).then((res) => {
112+
if (res.error) {
113+
throw showError({ statusCode: 500, statusMessage: res.error })
114+
}
115+
116+
if (res.success) {
117+
this.lichessGames.find(g => g.id === game.id)!.imported = { status: true, id: res.id }
118+
}
119+
})
120+
this.loading[game.id] = false
121+
}
122+
}
123+
})
124+
</script>

pages/games.vue renamed to pages/games/index.vue

+9-3
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,26 @@
2525
<script lang="ts">
2626
import { defineComponent } from 'vue'
2727
import type { CleanedGame } from '~/utils/games'
28+
import type { GetGameResponse } from '~/types/requests'
2829
2930
export default defineComponent({
30-
name: 'games',
31+
name: 'index',
3132
3233
async setup() {
3334
const user = useSupabaseUser().value
3435
if (!user) {
3536
throw showError({ statusCode: 500, statusMessage: 'Sign in??' })
3637
}
3738
39+
useSeoMeta({
40+
title: 'My Games',
41+
description: 'View your games'
42+
})
43+
3844
let games: CleanedGame[] = []
39-
await $fetch<{success: boolean, message?: string, games?: CleanedGame[]}>('/api/games/:id'.replace(':id', user.id)).then((res) => {
45+
await $fetch<GetGameResponse>('/api/games/:id'.replace(':id', user.id)).then((res) => {
4046
if (!res.success) {
41-
throw showError({ statusCode: 500, statusMessage: res.message ?? 'Unknown error' })
47+
throw showError({ statusCode: 500, statusMessage: res.error ?? 'Unknown error' })
4248
}
4349
4450
games = res.games ?? []

server/api/users/me/games/import.ts

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { SupabaseClient } from '@supabase/supabase-js'
2+
import { serverSupabaseClient, serverSupabaseServiceRole, serverSupabaseUser } from '#supabase/server'
3+
import { Game } from '~/types/lichess'
4+
import { Database, TableIntegrations } from '~/types/supabase'
5+
import { failureResponse } from '~/types/requests'
6+
7+
export default defineEventHandler(async (event) => {
8+
const client = await serverSupabaseClient<Database>(event)
9+
const user = await serverSupabaseUser(event)
10+
const body = await readBody(event)
11+
if (!user) {
12+
return failureResponse('User not found.')
13+
}
14+
15+
const serviceClient = serverSupabaseServiceRole<Database>(event)
16+
17+
const { platformId, platform, platformUsername } = body
18+
19+
// check if game import exists
20+
const { data: game } = await client.from('games').select('*').eq('platform_id', platformId).eq('platform', platform).eq('user_id', user.id).single()
21+
22+
// if it does, return it
23+
if (game) {
24+
return {
25+
success: true,
26+
id: game.id
27+
}
28+
}
29+
30+
// Otherwise, create a new integration
31+
// const pgn =
32+
try {
33+
const { white, black, pgn } = await gatherData(serviceClient, platform, platformId, platformUsername)
34+
35+
const { data: game, error: createError } = await client.from('games').insert([{
36+
user_id: user.id,
37+
platform_id: platformId,
38+
platform,
39+
pgn,
40+
white_player: white,
41+
black_player: black
42+
}]).select()
43+
44+
if (createError) {
45+
return failureResponse(createError.message)
46+
}
47+
48+
return {
49+
success: true,
50+
id: game[0].id
51+
}
52+
} catch (error) {
53+
return failureResponse((error as Error).message)
54+
}
55+
})
56+
57+
async function findPlayersByIntegration(serviceClient: SupabaseClient<Database>, integration: string, ids: string[]) {
58+
const { data, error } = await serviceClient.from('integrations').select('*').eq('platform', integration).in('data ->> id', ids)
59+
if (error) {
60+
throw error
61+
}
62+
63+
return data
64+
}
65+
66+
async function gatherData(serviceClient: SupabaseClient<Database>, platform: string, platformId: string, platformUsername: string): Promise<{ white: string | null; black: string | null; pgn: string }> {
67+
if (platform === 'lichess') {
68+
const game = await retrieveLichessGame(platformId)
69+
const { pgn } = game
70+
const { white, black } = game.players
71+
72+
const ids = [white.user.id, black.user.id]
73+
const players: TableIntegrations[] = (await findPlayersByIntegration(serviceClient, 'lichess', ids))
74+
75+
return {
76+
white: players.find(player => player.data.id === white.user.id)?.user_id || null,
77+
black: players.find(player => player.data.id === black.user.id)?.user_id || null,
78+
pgn
79+
}
80+
}
81+
82+
throw new Error('Invalid platform.')
83+
}
84+
85+
async function retrieveLichessGame(gameId: string): Promise<Game> {
86+
return await $fetch<Game>(`https://lichess.org/game/export/${gameId}?pgnInJson=true&opening=true`, {
87+
headers: {
88+
Accept: 'application/json'
89+
}
90+
})
91+
}

0 commit comments

Comments
 (0)