From 4c388349c2b4c5dd77746ffb2f583195cd791815 Mon Sep 17 00:00:00 2001 From: Devin Lin Date: Wed, 26 Jul 2023 18:53:10 -0400 Subject: [PATCH] Add favourites feature --- .../favourites/add-track/route.ts | 77 ++++++++++++++++ .../favourites/remove-track/route.ts | 85 ++++++++++++++++++ .../[accountUuid]/favourites/route.ts | 78 ++++++++++++++++ .../playlist/[playlistId]/add-track/route.ts | 11 +-- website/app/collection/favourites/page.tsx | 90 +++++++++++++++++++ website/components/apiclient.tsx | 23 +++++ website/components/appsidebar.tsx | 11 ++- website/components/trackmenu.tsx | 37 +++++++- website/util/constants.ts | 1 + 9 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 website/app/api/collection/[accountUuid]/favourites/add-track/route.ts create mode 100644 website/app/api/collection/[accountUuid]/favourites/remove-track/route.ts create mode 100644 website/app/api/collection/[accountUuid]/favourites/route.ts create mode 100644 website/app/collection/favourites/page.tsx create mode 100644 website/util/constants.ts diff --git a/website/app/api/collection/[accountUuid]/favourites/add-track/route.ts b/website/app/api/collection/[accountUuid]/favourites/add-track/route.ts new file mode 100644 index 0000000..b75923f --- /dev/null +++ b/website/app/api/collection/[accountUuid]/favourites/add-track/route.ts @@ -0,0 +1,77 @@ +import { checkAuthenticated } from "@/util/api"; +import { FAVOURITES_PLAYLIST_NAME } from "@/util/constants"; +import { getDB } from "@/util/db"; +import { NextResponse } from "next/server"; + +// POST /collection/[accountUuid]/favourites/add-track +// Add a track to the favourites +// Request body: +// { trackId } + +export async function POST(request: Request, { params }: { params: { accountUuid: string } }) { + const { trackId } = await request.json(); + + // check authorization + const tokenUuid = await checkAuthenticated(); + if (tokenUuid === null || tokenUuid !== params.accountUuid) { + return NextResponse.json({ error: "not authorized" }, { status: 401 }); + } + + const conn = await getDB(); + + try { + + let playlistId = 0; + + // check if playlist exists + const playlistRes = await conn.query(` + SELECT id FROM playlist WHERE name = $1 AND account_uuid = $2 + `, [FAVOURITES_PLAYLIST_NAME, tokenUuid]); + + if (playlistRes.rowCount === 0) { + + // add favourites playlist if it doesn't exist + const insertQuery = await conn.query(` + INSERT INTO playlist (name, account_uuid) VALUES ($1, $2) RETURNING * + `, [FAVOURITES_PLAYLIST_NAME, tokenUuid]); + + playlistId = insertQuery.rows[0].id; + + } else { + playlistId = playlistRes.rows[0].id; + } + + // check if track exists + const trackRes = await conn.query(` + SELECT id FROM track WHERE id = $1 AND account_uuid = $2 + `, [trackId, tokenUuid]); + + if (trackRes.rowCount === 0) { + await conn.end(); + return NextResponse.json({ error: 'track not found' }, { status: 404 }); + } + + // check how many tracks there are + const trackCountRes = await conn.query(` + SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = $1 + `, [playlistId]); + + const trackCount = trackCountRes.rows[0].count; + + // insert the track association + await conn.query(` + INSERT INTO playlist_tracks(account_uuid, playlist_id, track_id, position, added_on) + VALUES ($1, $2, $3, $4, $5) + `, + [tokenUuid, playlistId, trackId, trackCount, new Date().toISOString()] + ); + + await conn.end(); + + return NextResponse.json({ status: 'success' }, { status: 200 }); + } catch (error) { + await conn.end(); + console.error("Error updating playlist:", error); + return NextResponse.json({ error: 'an error occurred while updating the playlist' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/website/app/api/collection/[accountUuid]/favourites/remove-track/route.ts b/website/app/api/collection/[accountUuid]/favourites/remove-track/route.ts new file mode 100644 index 0000000..9929505 --- /dev/null +++ b/website/app/api/collection/[accountUuid]/favourites/remove-track/route.ts @@ -0,0 +1,85 @@ +import { checkAuthenticated } from "@/util/api"; +import { FAVOURITES_PLAYLIST_NAME } from "@/util/constants"; +import { getDB } from "@/util/db"; +import { DBPlaylistTrack } from "@/util/models/playlist"; +import { NextResponse } from "next/server"; + +// POST /collection/[accountUuid]/favourites/remove-track +// Removes a track from the favourites +// Request body: +// { trackId } + +export async function POST(request: Request, { params }: { params: { accountUuid: string } }) { + let { trackId } = await request.json(); + + // check authorization + const tokenUuid = await checkAuthenticated(); + if (tokenUuid === null || tokenUuid !== params.accountUuid) { + return NextResponse.json({ error: "not authorized" }, { status: 401 }); + } + + const conn = await getDB(); + + try { + // check if playlist exists + const playlistRes = await conn.query(` + SELECT id FROM playlist WHERE name = $1 AND account_uuid = $2 + `, [FAVOURITES_PLAYLIST_NAME, tokenUuid]); + + if (playlistRes.rowCount === 0) { + await conn.end(); + return NextResponse.json({ error: "favourites not found or not authorized to update" }, { status: 404 }); + } + + const playlistId = playlistRes.rows[0].id; + + // check how many tracks there are + const tracksRes = await conn.query(` + SELECT * FROM playlist_tracks WHERE playlist_id = $1 + `, [playlistId]); + + await conn.query(`BEGIN`); + + // delete favourites entry + const deleteRes = await conn.query(` + DELETE FROM playlist_tracks + WHERE playlist_id = $1 AND track_id = $2 + RETURNING * + `, [playlistId, trackId]); + + // if the track wasn't found + if (deleteRes.rowCount === 0) { + await conn.query(`COMMIT`); + await conn.end(); + return NextResponse.json({ error: 'track not found in playlist' }, { status: 404 }); + } + + // add the position it was deleted at + const position = deleteRes.rows[0].position; + + const playlistTracks = tracksRes.rows as DBPlaylistTrack[]; + + // update other entries (all position spots after) + for (const playlistTrack of playlistTracks) { + if (playlistTrack.position > position) { + + await conn.query(` + UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3 AND track_id = $4 + `, [playlistTrack.position - 1, playlistId, playlistTrack.position, playlistTrack.track_id]); + + } + } + + await conn.query(`COMMIT`); + await conn.end(); + + return NextResponse.json({ status: 'success' }, { status: 200 }); + } catch (error) { + + await conn.query(`ROLLBACK`); + + await conn.end(); + console.error("Error updating playlist:", error); + return NextResponse.json({ error: 'an error occurred while updating the playlist' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/website/app/api/collection/[accountUuid]/favourites/route.ts b/website/app/api/collection/[accountUuid]/favourites/route.ts new file mode 100644 index 0000000..403342b --- /dev/null +++ b/website/app/api/collection/[accountUuid]/favourites/route.ts @@ -0,0 +1,78 @@ +import { checkAuthenticated } from "@/util/api"; +import { FAVOURITES_PLAYLIST_NAME } from "@/util/constants"; +import { getDB } from "@/util/db"; +import { getAPIAlbum } from "@/util/models/album"; +import { getAPIArtist } from "@/util/models/artist"; +import { getAPIGenre } from "@/util/models/genre"; +import { DBPlaylist, getAPIPlaylist } from "@/util/models/playlist"; +import { getAPITrack } from "@/util/models/track"; +import { NextResponse } from "next/server"; + +// GET /api/collection/favourites +// get a user's favourites track list + +export async function GET(request: Request, { params }: { params: { accountUuid: string } }) { + const accountUuid = params.accountUuid; + + // check authorization + const tokenUuid = await checkAuthenticated(); + if (tokenUuid === null || tokenUuid !== accountUuid) { + return NextResponse.json({ error: "not authorized" }, { status: 401 }); + } + + const conn = await getDB(); + + const playlistRes = await conn.query(` + SELECT * FROM playlist WHERE account_uuid = $1 AND name = $2 LIMIT 1 + `, [accountUuid, FAVOURITES_PLAYLIST_NAME]); + + if (playlistRes.rowCount < 1) { + await conn.end(); + return NextResponse.json([], { status: 200 }); + } + + const dbPlaylist = playlistRes.rows[0] as DBPlaylist; + + if (tokenUuid !== dbPlaylist.account_uuid) { + await conn.end(); + return NextResponse.json({ error: "not authorized" }, { status: 401 }); + } + + const trackRes = await conn.query(` + SELECT + t.*, + playlist_tracks.position as playlist_position, + JSON_AGG(artist.*) as artists, + JSON_AGG(album.*) as albums, + JSON_AGG(genre.*) as genres, + JSON_AGG(playlist.*) as playlists + FROM playlist_tracks + INNER JOIN track as t ON t.id = playlist_tracks.track_id + LEFT OUTER JOIN track_to_artist ON t.id = track_to_artist.track_id + LEFT OUTER JOIN artist ON track_to_artist.artist_id = artist.id + LEFT OUTER JOIN track_to_album ON t.id = track_to_album.album_id + LEFT OUTER JOIN album ON track_to_album.album_id = album.id + LEFT OUTER JOIN track_to_genre ON t.id = track_to_genre.track_id + LEFT OUTER JOIN genre ON track_to_genre.genre_id = genre.id + FULL OUTER JOIN playlist_tracks AS pt2 ON t.id = pt2.track_id + FULL OUTER JOIN playlist ON pt2.playlist_id = playlist.id + WHERE playlist_tracks.playlist_id = $1 + GROUP BY t.id, playlist_tracks.position + ORDER BY playlist_tracks.position ASC + `, [dbPlaylist.id]); + + await conn.end(); + + const tracks = trackRes.rows.map(track => { + const apiTrack: any = getAPITrack(track); + apiTrack.albums = track.albums.filter((album: any) => album).map((album: any) => getAPIAlbum(album)); + apiTrack.artists = track.artists.filter((artist: any) => artist).map((artist: any) => getAPIArtist(artist)); + apiTrack.genres = track.genres.filter((genre: any) => genre).map((genre: any) => getAPIGenre(genre)); + apiTrack.playlists = track.playlists.filter((playlist: any) => playlist).map((playlist: any) => getAPIPlaylist(playlist)); + apiTrack.playlist_position = track.playlist_position; + + return apiTrack; + }); + + return NextResponse.json(tracks, { status: 200 }); +} diff --git a/website/app/api/playlist/[playlistId]/add-track/route.ts b/website/app/api/playlist/[playlistId]/add-track/route.ts index 933b148..60b6fe1 100644 --- a/website/app/api/playlist/[playlistId]/add-track/route.ts +++ b/website/app/api/playlist/[playlistId]/add-track/route.ts @@ -48,16 +48,9 @@ export async function POST(request: Request, { params }: { params: { playlistId: const trackCount = trackCountRes.rows[0].count; // insert the track association - await conn.query( - ` + await conn.query(` INSERT INTO playlist_tracks(account_uuid, playlist_id, track_id, position, added_on) - VALUES ( - $1, - $2, - $3, - $4, - $5 - ) + VALUES ($1, $2, $3, $4, $5) `, [tokenUuid, playlistId, trackId, trackCount, new Date().toISOString()] ); diff --git a/website/app/collection/favourites/page.tsx b/website/app/collection/favourites/page.tsx new file mode 100644 index 0000000..c330e77 --- /dev/null +++ b/website/app/collection/favourites/page.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useEffect, useState } from "react"; +import { apiGetCollectionFavourites, apiGetCollectionTracks, apiGetCollectionTracksSearch } from "@/components/apiclient"; +import { useAppStateContext } from "@/components/appstateprovider"; +import { APITrack } from "@/util/models/track"; +import {Grid, TextField, Typography, InputAdornment, CssBaseline} from "@mui/material"; +import { Search } from "@mui/icons-material"; +import { useRouter } from "next/navigation"; +import { useLoginStateContext } from "@/components/loginstateprovider"; +import AlertComponent, { AlertEntry } from "@/components/alerts"; +import TrackTable from "@/components/trackTable"; +import { ThemeProvider } from '@mui/material/styles'; +import { lightTheme, darkTheme } from '@/components/themes'; +import {styled} from '@mui/system'; + +const StyledGrid = styled(Grid)(({ theme }) => ({ + height: 1, + backgroundColor: theme.palette.mode === 'dark' ? 'rgb(5, 10, 25)' : 'rgb(255, 245, 245)', +})); + +export default function CollectionPage() { + const appState = useAppStateContext(); + const theme = appState.theme; + const loginState = useLoginStateContext(); + const router = useRouter(); + + const color = theme === 'dark' ? 'white' : 'rgb(50, 50, 50)'; + + const [tracks, setTracks] = useState([] as APITrack[]); + const [alerts, setAlerts] = useState([] as AlertEntry[]); + + const loadTracks = () => { + apiGetCollectionFavourites(loginState.loggedInUserUuid) + .then((res) => { + setTracks(res.data as APITrack[]); + }) + .catch(err => { + setAlerts([...alerts, { severity: "error", message: "Error fetching tracks, see console for details." }]); + console.error(err); + }); + }; + + useEffect(() => { + // wait for credentials to be loaded + if (!loginState.loggedInStateValid) { + return; + } + + // if not logged in, go to login page + if (!loginState.isLoggedIn) { + router.push('/login'); + return; + } + + // load tracks + loadTracks(); + }, [loginState]); + + if (!loginState.loggedInStateValid) { + return <>; + } + + const handleTrackClick = (track: APITrack) => { + appState.changeQueue(tracks, tracks.indexOf(track)); + appState.playCurrentTrack(); + }; + + return ( + + + + + + + Favourites + + + {/* Weird. paddingBottom works but not marginBottom. */} + + + + + + ); +} diff --git a/website/components/apiclient.tsx b/website/components/apiclient.tsx index 7b8a13b..819b91c 100644 --- a/website/components/apiclient.tsx +++ b/website/components/apiclient.tsx @@ -167,3 +167,26 @@ export function apiGetGenreTracks(genreId: string) { url: `/api/genre/${genreId}/tracks` }); } + +export function apiGetCollectionFavourites(accountUuid: string) { + return axios({ + method: 'get', + url: `/api/collection/${accountUuid}/favourites` + }); +} + +export function apiPostCollectionAddFavouritesTrack(accountUuid: string, trackId: number) { + return axios({ + method: 'post', + url: `/api/collection/${accountUuid}/favourites/add-track`, + data: { trackId } + }); +} + +export function apiPostCollectionRemoveFavouritesTrack(accountUuid: string, trackId: number) { + return axios({ + method: 'post', + url: `/api/collection/${accountUuid}/favourites/remove-track`, + data: { trackId } + }); +} \ No newline at end of file diff --git a/website/components/appsidebar.tsx b/website/components/appsidebar.tsx index ae0bb89..834718d 100644 --- a/website/components/appsidebar.tsx +++ b/website/components/appsidebar.tsx @@ -1,6 +1,6 @@ 'use client' -import { AlbumOutlined, FolderOutlined, Margin, MusicNoteOutlined, PersonOutlineOutlined, QueueMusicOutlined, UploadFileOutlined, WhatshotOutlined } from "@mui/icons-material"; +import { AlbumOutlined, FavoriteBorderOutlined, FolderOutlined, MusicNoteOutlined, PersonOutlineOutlined, QueueMusicOutlined, UploadFileOutlined, WhatshotOutlined } from "@mui/icons-material"; import { Divider, ListItemButton, ListItemIcon, ListItemText } from "@mui/material"; import { usePathname, useRouter } from "next/navigation"; import { useAppStateContext } from "./appstateprovider"; @@ -86,6 +86,15 @@ export default function AppSidebar() { + router.push(`/collection/favourites`)} + > + + + + @@ -145,6 +154,26 @@ export default function TrackMenu(props: { track: APITrack, anchorEl: any, reque ); + const handleFavouriteToggle = () => { + if (isFavourited) { + apiPostCollectionRemoveFavouritesTrack(loginState.loggedInUserUuid, track.id) + .then(() => { + requestReload(); + }) + .catch(err => { + console.error(err); + }); + } else { + apiPostCollectionAddFavouritesTrack(loginState.loggedInUserUuid, track.id) + .then(() => { + requestReload(); + }) + .catch(err => { + console.error(err); + }) + } + } + return ( - + - { isFavourite ? : } + { isFavourited ? : } Favourite diff --git a/website/util/constants.ts b/website/util/constants.ts new file mode 100644 index 0000000..aee5ec2 --- /dev/null +++ b/website/util/constants.ts @@ -0,0 +1 @@ +export const FAVOURITES_PLAYLIST_NAME = 'INTERNAL_FAVOURITES_PLAYLIST'; \ No newline at end of file