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 (