diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c26fcf0..e0cc7c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4328,6 +4328,11 @@ } } }, + "date-fns": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.12.0.tgz", + "integrity": "sha512-qJgn99xxKnFgB1qL4jpxU7Q2t0LOn1p8KMIveef3UZD7kqjT3tpFNNdXJelEHhE+rUgffriXriw/sOSU+cS1Hw==" + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0c715c..0cd6401 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@types/react-dom": "^16.9.7", "@types/react-router-dom": "^5.1.5", "autoprefixer": "^9.7.6", + "date-fns": "^2.12.0", "npm-run-all": "^4.1.5", "postcss-cli": "^7.1.1", "prettier": "^2.0.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6906aed..ad9d84f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { LoginFailure } from "./components/LoginFailure"; import { NavBar } from "./components/NavBar"; import { useAuth } from "./hooks/use-auth"; import "./tailwind/generated.css"; +import { RecentListens } from "./components/RecentListens"; export function App() { const { isLoaded } = useAuth(); @@ -20,6 +21,7 @@ export function App() { + ); diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index a9a1fc4..ad77d54 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,4 +1,7 @@ -import { User } from "./user"; +import { Listen } from "./entities/listen"; +import { Pagination } from "./entities/pagination"; +import { PaginationOptions } from "./entities/pagination-options"; +import { User } from "./entities/user"; export class UnauthenticatedError extends Error {} @@ -38,3 +41,29 @@ export const getUsersMe = async (): Promise => { const user: User = await res.json(); return user; }; + +export const getRecentListens = async ( + options: PaginationOptions = { page: 1, limit: 10 } +): Promise> => { + const { page, limit } = options; + + const res = await fetch(`/api/v1/listens?page=${page}&limit=${limit}`, { + headers: getDefaultHeaders(), + }); + + switch (res.status) { + case 200: { + break; + } + case 401: { + throw new UnauthenticatedError(`No token or token expired`); + } + default: { + throw new Error(`Unable to getRecentListens: ${res.status}`); + } + } + + const listens: Pagination = await res.json(); + console.log("getRecentListens", { listens }); + return listens; +}; diff --git a/frontend/src/api/entities/listen.ts b/frontend/src/api/entities/listen.ts new file mode 100644 index 0000000..4c402b4 --- /dev/null +++ b/frontend/src/api/entities/listen.ts @@ -0,0 +1,32 @@ +export interface Listen { + id: string; + playedAt: string; + track: Track; +} + +interface Track { + id: string; + name: string; + album: Album; + artists: Artist[]; + spotify?: SpotifyInfo; +} + +interface Album { + id: string; + name: string; + spotify?: SpotifyInfo; +} + +interface Artist { + id: string; + name: string; + spotify?: SpotifyInfo; +} + +interface SpotifyInfo { + id: string; + uri: string; + type: string; + href: string; +} diff --git a/frontend/src/api/entities/pagination-options.ts b/frontend/src/api/entities/pagination-options.ts new file mode 100644 index 0000000..bca850b --- /dev/null +++ b/frontend/src/api/entities/pagination-options.ts @@ -0,0 +1,4 @@ +export interface PaginationOptions { + limit: number; + page: number; +} diff --git a/frontend/src/api/entities/pagination.ts b/frontend/src/api/entities/pagination.ts new file mode 100644 index 0000000..faf3715 --- /dev/null +++ b/frontend/src/api/entities/pagination.ts @@ -0,0 +1,33 @@ +export interface Pagination { + /** + * a list of items to be returned + */ + items: PaginationObject[]; + /** + * associated meta information (e.g., counts) + */ + meta: PaginationMeta; +} + +export interface PaginationMeta { + /** + * the amount of items on this specific page + */ + itemCount: number; + /** + * the total amount of items + */ + totalItems: number; + /** + * the amount of items that were requested per page + */ + itemsPerPage: number; + /** + * the total amount of pages in this paginator + */ + totalPages: number; + /** + * the current page this paginator "points" to + */ + currentPage: number; +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/entities/user.ts similarity index 100% rename from frontend/src/api/user.ts rename to frontend/src/api/entities/user.ts diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 24e019f..d542d1e 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Link } from "react-router-dom"; -import { User } from "../api/user"; +import { User } from "../api/entities/user"; import { useAuth } from "../hooks/use-auth"; import { SpotifyLogo } from "../icons/Spotify"; @@ -12,12 +12,17 @@ export const NavBar: React.FC = () => {
Listory
-
+
{user && ( - - Home - + <> + + Home + + + Your Listens + + )}
@@ -38,10 +43,14 @@ export const NavBar: React.FC = () => { const NavUserInfo: React.FC<{ user: User }> = ({ user }) => { return ( -
+
{user.displayName} {user.photo && ( - + Profile picture of logged in user )}
); diff --git a/frontend/src/components/RecentListens.tsx b/frontend/src/components/RecentListens.tsx new file mode 100644 index 0000000..5246350 --- /dev/null +++ b/frontend/src/components/RecentListens.tsx @@ -0,0 +1,93 @@ +import React, { useState, useEffect } from "react"; +import { useAuth } from "../hooks/use-auth"; +import { Listen } from "../api/entities/listen"; +import { getRecentListens } from "../api/api"; +import { Redirect } from "react-router-dom"; +import { formatDistanceToNow } from "date-fns"; + +const LISTENS_PER_PAGE = 15; + +export const RecentListens: React.FC = () => { + const { user } = useAuth(); + + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [listens, setListens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + (async () => { + setIsLoading(true); + + try { + const listensFromApi = await getRecentListens({ + page, + limit: LISTENS_PER_PAGE, + }); + + if (totalPages !== listensFromApi.meta.totalPages) { + setTotalPages(listensFromApi.meta.totalPages); + } + setListens(listensFromApi.items); + } catch (err) { + console.error("Error while fetching recent listens:", err); + } finally { + setIsLoading(false); + } + })(); + }, [user, page, totalPages]); + + if (!user) { + return ; + } + + if (isLoading) { + return ( +
+ Loading Listens +
+ ); + } + + return ( +
+

Recent listens

+
+ {listens.length === 0 && ( +
+

Could not find any listens!

+
+ )} + {listens.map((listen) => ( + + ))} +
+
+

Page: {page}

+ {page !== 1 && ( +

+ +

+ )} + {page !== totalPages && ( +

+ +

+ )} +
+
+ ); +}; + +const ListenItem: React.FC<{ listen: Listen }> = ({ listen }) => { + const trackName = listen.track.name; + const artists = listen.track.artists.map((artist) => artist.name).join(", "); + const timeAgo = formatDistanceToNow(new Date(listen.playedAt), { + addSuffix: true, + }); + return ( +
+ {trackName} - {artists} - {timeAgo} +
+ ); +}; diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx index 8d926d7..8499c37 100644 --- a/frontend/src/hooks/use-auth.tsx +++ b/frontend/src/hooks/use-auth.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { getUsersMe, UnauthenticatedError } from "../api/api"; -import { User } from "../api/user"; +import { User } from "../api/entities/user"; const authContext = createContext( (undefined as any) as AuthContext diff --git a/frontend/src/logo.svg b/frontend/src/logo.svg deleted file mode 100644 index 6b60c10..0000000 --- a/frontend/src/logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - -