diff --git a/DB/game.sql b/DB/game.sql new file mode 100644 index 0000000..ec34e92 --- /dev/null +++ b/DB/game.sql @@ -0,0 +1,243 @@ +DO +$$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'game_status') THEN + CREATE TYPE game_status AS ENUM ('playing', 'finished'); + END IF; + END +$$; + + +DO +$$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'game_song_input') THEN + CREATE TYPE game_song_input AS + ( + spotify_song_id TEXT, + wrong_option_1 TEXT, + wrong_option_2 TEXT, + wrong_option_3 TEXT + ); + END IF; + END +$$; + +-- Games +CREATE TABLE IF NOT EXISTS games +( + game_id SERIAL PRIMARY KEY, + playlist_id TEXT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE, + status game_status NOT NULL DEFAULT 'playing', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Players of each game and their scores +CREATE TABLE IF NOT EXISTS game_players +( + game_id INTEGER REFERENCES games (game_id) ON DELETE CASCADE, + user_id UUID REFERENCES users (id), + score INTEGER NOT NULL DEFAULT 0, + is_creator BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (game_id, user_id) +); +-- Index for ensuring only one creator per game or none +CREATE UNIQUE INDEX one_creator_per_game ON game_players (game_id) WHERE is_creator = true; +-- Index for finding players in a game +CREATE INDEX idx_game_players_player_id ON game_players (user_id); +-- Index for finding high scores by user +CREATE INDEX idx_game_players_score ON game_players (user_id, score DESC); + +-- Songs +-- mostly for reference after the game is over since we always fetch songs from Spotify +CREATE TABLE IF NOT EXISTS songs +( + spotify_song_id TEXT PRIMARY KEY, + song_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + preview_url TEXT +); + +-- Songs in each game +CREATE TABLE IF NOT EXISTS game_rounds +( + game_id INTEGER REFERENCES games (game_id) ON DELETE CASCADE, + song_order INTEGER NOT NULL, + song_id TEXT NOT NULL, + wrong_option_1 TEXT NOT NULL, + wrong_option_2 TEXT NOT NULL, + wrong_option_3 TEXT NOT NULL, + PRIMARY KEY (game_id, song_order), + + CONSTRAINT fk_spotify_song_id FOREIGN KEY (song_id) REFERENCES songs (spotify_song_id), + CONSTRAINT fk_wrong_option_1 FOREIGN KEY (wrong_option_1) REFERENCES songs (spotify_song_id), + CONSTRAINT fk_wrong_option_2 FOREIGN KEY (wrong_option_2) REFERENCES songs (spotify_song_id), + CONSTRAINT fk_wrong_option_3 FOREIGN KEY (wrong_option_3) REFERENCES songs (spotify_song_id) +); + +-- Statistics for each song of each game for each user +CREATE TABLE IF NOT EXISTS game_player_song_stats +( + game_id INTEGER REFERENCES games (game_id) ON DELETE CASCADE, + user_id UUID REFERENCES users (id), + song_order INTEGER NOT NULL, + time_to_guess INTEGER, + correct_guess BOOLEAN NOT NULL DEFAULT false, + guessed_song_id TEXT, + PRIMARY KEY (game_id, user_id, song_order), + FOREIGN KEY (game_id, song_order) REFERENCES game_rounds (game_id, song_order) +); + +CREATE INDEX idx_game_player_song_stats_game_user ON game_player_song_stats (game_id, user_id); + +--- Functions + +-- Initialize a game with a playlist and players +CREATE OR REPLACE FUNCTION init_game( + playlist_id_input TEXT, + player_ids UUID[], + song_data game_song_input[] +) + RETURNS TABLE + ( + game_id INTEGER, + created_at TIMESTAMP WITH TIME ZONE + ) +AS +$$ +DECLARE + new_game_id INTEGER; + new_created_at TIMESTAMP WITH TIME ZONE; + player_id UUID; +BEGIN + -- Create game + INSERT INTO games (playlist_id, status) + VALUES (playlist_id_input, 'playing') + RETURNING games.game_id, games.created_at INTO new_game_id, new_created_at; + + -- Add all players from array + FOREACH player_id IN ARRAY player_ids + LOOP + INSERT INTO game_players (game_id, user_id, score) + VALUES (new_game_id, player_id, 0); + END LOOP; + + UPDATE game_players + SET is_creator = true + WHERE game_players.game_id = new_game_id + AND game_players.user_id = player_ids[1]; + + -- Insert songs (assuming the songs already exist in the songs table) + INSERT INTO game_rounds (game_id, + song_order, + song_id, + wrong_option_1, + wrong_option_2, + wrong_option_3) + SELECT new_game_id, + row_number() OVER (ORDER BY song.spotify_song_id), -- Added ORDER BY + song.spotify_song_id, + song.wrong_option_1, + song.wrong_option_2, + song.wrong_option_3 + FROM unnest(song_data) song; + + RETURN QUERY + SELECT new_game_id AS game_id, new_created_at AS created_at; + +END; +$$ LANGUAGE plpgsql; + +-- Get all games for a user +CREATE OR REPLACE FUNCTION get_player_games(p_user_id UUID) + RETURNS TABLE + ( + -- Game basic info + game_id INTEGER, + status game_status, + playlist_id TEXT, + playlist_cover TEXT, + playlist_name TEXT, + created_at TIMESTAMPTZ, + creator_id UUID, + -- Opponent info (renamed from player_ to opponent_) + opponent_user_id UUID, + opponent_avatar_url TEXT, + opponent_username TEXT, + opponent_spotify_id TEXT, + opponent_spotify_visibility BOOLEAN, + opponent_daily_streak INTEGER, + opponent_daily_streak_updated_at TIMESTAMPTZ, + -- Round info + round_number INTEGER, + correct_song_id TEXT, + correct_song_name TEXT, + correct_song_artist TEXT, + correct_song_preview_url TEXT, + wrong_song_1_id TEXT, + wrong_song_1_name TEXT, + wrong_song_1_artist TEXT, + wrong_song_1_preview_url TEXT, + wrong_song_2_id TEXT, + wrong_song_2_name TEXT, + wrong_song_2_artist TEXT, + wrong_song_2_preview_url TEXT, + wrong_song_3_id TEXT, + wrong_song_3_name TEXT, + wrong_song_3_artist TEXT, + wrong_song_3_preview_url TEXT + ) +AS +$$ +BEGIN + RETURN QUERY + SELECT g.game_id, + g.status, + g.playlist_id, + p.cover, + p.name, + g.created_at, + gp_creator.user_id, + + -- Opponent information + u.id, + u.avatar_url, + u.username, + u.spotify_id, + u.spotify_visibility, + u.daily_streak, + u.daily_streak_updated_at, + -- Round information + gr.song_order, + cs.spotify_song_id, + cs.song_name, + cs.artist_name, + cs.preview_url, + w1.spotify_song_id, + w1.song_name, + w1.artist_name, + w1.preview_url, + w2.spotify_song_id, + w2.song_name, + w2.artist_name, + w2.preview_url, + w3.spotify_song_id, + w3.song_name, + w3.artist_name, + w3.preview_url + FROM games g + -- Join to get current user's games + JOIN game_players gp_current ON g.game_id = gp_current.game_id AND gp_current.user_id = p_user_id + -- Join to get opponent's info + JOIN game_players gp_opponent ON g.game_id = gp_opponent.game_id AND gp_opponent.user_id != p_user_id + LEFT JOIN game_players gp_creator ON g.game_id = gp_creator.game_id AND gp_creator.is_creator = true + JOIN users u ON gp_opponent.user_id = u.id + JOIN game_rounds gr ON g.game_id = gr.game_id + JOIN songs cs ON gr.song_id = cs.spotify_song_id + JOIN songs w1 ON gr.wrong_option_1 = w1.spotify_song_id + JOIN songs w2 ON gr.wrong_option_2 = w2.spotify_song_id + JOIN songs w3 ON gr.wrong_option_3 = w3.spotify_song_id + JOIN playlists p ON g.playlist_id = p.id + ORDER BY g.created_at DESC, gr.song_order ASC; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/DB/playlists.sql b/DB/playlists.sql index 9c39e60..031f240 100644 --- a/DB/playlists.sql +++ b/DB/playlists.sql @@ -12,6 +12,6 @@ CREATE UNIQUE INDEX enabled_playlist_unique_name ON playlists (name) WHERE enabl create table categories ( name text not null, - "playlistId" text not null references playlists, + "playlistId" text not null references playlists (id) on delete cascade, primary key (name, "playlistId") ); diff --git a/components/UsersView.vue b/components/UsersView.vue index d2ab1e3..2e268fe 100644 --- a/components/UsersView.vue +++ b/components/UsersView.vue @@ -42,6 +42,8 @@ function isUserInformation(user: GetFriendsResponse | GetUserResponse): user is // Map users conditionally depending on their type const mappedUsers: Array = computed(() => { + if(!props.users || props.users.length < 1) return []; + return props.users.map(user => { if (isGetFriendsResponse(user)) { return user.user; @@ -75,7 +77,7 @@ const mappedUsers: Array = computed(() => {
diff --git a/composables/useGames.ts b/composables/useGames.ts new file mode 100644 index 0000000..9339569 --- /dev/null +++ b/composables/useGames.ts @@ -0,0 +1,29 @@ +import type {GetGameResponse} from "@/types/api/game"; + +export const useGame = () => { + const games = useState('games', () => null) + const loading = useState('gameLoading', () => false) + const error = useState('gameError', () => null) + + const fetchGames = async () => { + loading.value = true + try { + games.value = await getGames() + } catch (e) { + error.value = (e as Error).message + } finally { + loading.value = false + } + } + + return { + games, + loading, + error, + fetchGames + } +} + +async function getGames() { + return await $fetch('http://localhost:3000/api/v1/games/') +} \ No newline at end of file diff --git a/layouts/Game/GameSelectLayout.vue b/layouts/Game/GameSelectLayout.vue new file mode 100644 index 0000000..e51b080 --- /dev/null +++ b/layouts/Game/GameSelectLayout.vue @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/layouts/HeaderFooterView.vue b/layouts/HeaderFooterView.vue index 233ff0e..e064306 100644 --- a/layouts/HeaderFooterView.vue +++ b/layouts/HeaderFooterView.vue @@ -8,7 +8,7 @@
-
+
diff --git a/package-lock.json b/package-lock.json index 4a8d988..f874545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@nuxt/image": "^1.8.1", "@nuxtjs/supabase": "v1.2.2", "@nuxtjs/tailwindcss": "^6.12.1", + "jsonpath": "^1.1.1", "nuxt": "^3.13.0", "vue": "latest", "vue-router": "latest", @@ -20,6 +21,7 @@ "devDependencies": { "@iconify-json/mdi": "^1.2.1", "@nuxt/eslint": "^0.5.7", + "@types/jsonpath": "^0.2.4", "@types/node": "^22.8.5", "eslint": "^9.11.1", "typedoc": "^0.26.10" @@ -3903,6 +3905,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/jsonpath": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/jsonpath/-/jsonpath-0.2.4.tgz", + "integrity": "sha512-K3hxB8Blw0qgW6ExKgMbXQv2UPZBoE2GqLpVY+yr7nMD2Pq86lsuIzyAaiQ7eMqFL5B6di6pxSkogLJEyEHoGA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -6299,7 +6308,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "devOptional": true, "license": "MIT" }, "node_modules/deepmerge": { @@ -6721,6 +6729,110 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "9.11.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", @@ -7238,6 +7350,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", + "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -7287,7 +7411,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -7405,7 +7528,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-npm-meta": { @@ -8690,6 +8812,17 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpath": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", + "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", + "license": "MIT", + "dependencies": { + "esprima": "1.2.2", + "static-eval": "2.0.2", + "underscore": "1.12.1" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -13063,6 +13196,15 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/static-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", + "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "license": "MIT", + "dependencies": { + "escodegen": "^1.8.1" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -13945,6 +14087,12 @@ "unplugin": "^1.3.1" } }, + "node_modules/underscore": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", + "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", + "license": "MIT" + }, "node_modules/undici": { "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", @@ -15474,7 +15622,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index d71c4d5..6374c74 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@nuxt/image": "^1.8.1", "@nuxtjs/supabase": "v1.2.2", "@nuxtjs/tailwindcss": "^6.12.1", + "jsonpath": "^1.1.1", "nuxt": "^3.13.0", "vue": "latest", "vue-router": "latest", @@ -25,6 +26,7 @@ "devDependencies": { "@iconify-json/mdi": "^1.2.1", "@nuxt/eslint": "^0.5.7", + "@types/jsonpath": "^0.2.4", "@types/node": "^22.8.5", "eslint": "^9.11.1", "typedoc": "^0.26.10" diff --git a/pages/index.vue b/pages/index.vue index b84dbc6..149ff46 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,10 +1,16 @@