diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index c11b91c..3c969a0 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import './App.css'; import './variables.css'; @@ -11,17 +11,30 @@ import { Header } from './components/Header'; import { TrackCard } from './components/TrackCard'; import { AlbumCard } from './components/AlbumCard'; import { ArtistCard } from './components/ArtistCard'; -import type { Album, Artist, Track } from './api/types'; -import { search } from './api/spotify'; +import type { Album, AlbumTrack, Artist, Track } from './api/types'; +import { fetchTrack, search } from './api/spotify'; import filterIcon from './assets/filter.png'; import musicWizImage from './assets/musicwiz.png'; +const readStoredItem = (key: string): T | null => { + try { + const storedItem = localStorage.getItem(key); + return storedItem ? JSON.parse(storedItem) as T : null; + } catch { + return null; + } +}; + function App() { const [query, setQuery] = useState(''); + const [currentPath, setCurrentPath] = useState(window.location.pathname); + const [selectedTrack, setSelectedTrack] = useState(() => readStoredItem('music-wiz-selected-track')); + const [selectedAlbum, setSelectedAlbum] = useState(() => readStoredItem('music-wiz-selected-album')); + const [selectedArtist, setSelectedArtist] = useState(() => readStoredItem('music-wiz-selected-artist')); const hasSearched = query.trim().length > 0; - const isSongDetailRoute = window.location.pathname === '/song-detail' || window.location.pathname === '/song-detail/'; - const isAlbumDetailRoute = window.location.pathname === '/album-detail' || window.location.pathname === '/album-detail/'; - const isArtistDetailRoute = window.location.pathname === '/artist-detail' || window.location.pathname === '/artist-detail/'; + const isSongDetailRoute = currentPath === '/song-detail' || currentPath === '/song-detail/'; + const isAlbumDetailRoute = currentPath === '/album-detail' || currentPath === '/album-detail/'; + const isArtistDetailRoute = currentPath === '/artist-detail' || currentPath === '/artist-detail/'; const dummyAlbumImage = { url: musicWizImage, height: 640, @@ -70,16 +83,47 @@ function App() { total_tracks: 10, uri: 'spotify:album:album-id', }; + useEffect(() => { + const handlePopState = () => setCurrentPath(window.location.pathname); + window.addEventListener('popstate', handlePopState); + + return () => window.removeEventListener('popstate', handlePopState); + }, []); + const navigateTo = (path: string) => { + window.history.pushState({}, '', path); + setCurrentPath(path); + }; const handleSearch = (nextQuery: string) => setQuery(nextQuery); + const handleTrackClick = (track: Track) => { + setSelectedTrack(track); + localStorage.setItem('music-wiz-selected-track', JSON.stringify(track)); + navigateTo('/song-detail'); + }; + const handleAlbumClick = (album: Album) => { + setSelectedAlbum(album); + localStorage.setItem('music-wiz-selected-album', JSON.stringify(album)); + navigateTo('/album-detail'); + }; + const handleAlbumTrackClick = async (track: AlbumTrack) => { + const fullTrack = await fetchTrack(track.id); + setSelectedTrack(fullTrack); + localStorage.setItem('music-wiz-selected-track', JSON.stringify(fullTrack)); + navigateTo('/song-detail'); + }; + const handleArtistClick = (artist: Artist) => { + setSelectedArtist(artist); + localStorage.setItem('music-wiz-selected-artist', JSON.stringify(artist)); + navigateTo('/artist-detail'); + }; const { data: results, isLoading, isFetching, isError, error } = useQuery({ queryKey: ['search', query], queryFn: () => search(query), enabled: query.trim().length > 0, }); - if (isAlbumDetailRoute) return ; - if (isArtistDetailRoute) return ; - if (isSongDetailRoute) return ; + if (isAlbumDetailRoute) return ; + if (isArtistDetailRoute) return ; + if (isSongDetailRoute) return ; return (
@@ -92,15 +136,15 @@ function App() {

Tracks

-
    {results?.tracks.map((track) => )}
+
    {results?.tracks.map((track) => handleTrackClick(track)} />)}

Albums

-
    {results?.albums.map((album) => )}
+
    {results?.albums.map((album) => handleAlbumClick(album)} />)}

Artists

-
    {results?.artists.map((artist) => )}
+
    {results?.artists.map((artist) => handleArtistClick(artist)} />)}
) : ( diff --git a/webdev-prosjekt1/src/api/spotify.ts b/webdev-prosjekt1/src/api/spotify.ts index 580b7ee..73c07c7 100644 --- a/webdev-prosjekt1/src/api/spotify.ts +++ b/webdev-prosjekt1/src/api/spotify.ts @@ -1,4 +1,4 @@ -import type { SearchResult, Track } from "./types"; +import type { AlbumTrack, SearchResult, Track } from "./types"; const BASE_URL = "https://api.spotify.com/v1" @@ -112,6 +112,24 @@ export async function fetchTrack(id: string): Promise { return track; } +export async function fetchAlbumTracks(id: string): Promise { + const token = await getValidToken(); + + const response = await fetch(`${BASE_URL}/albums/${id}/tracks?market=NO&limit=50`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Spotify api error: ${response.status}`); + } + + const json = await response.json(); + return json.items; +} + export async function fetchTracks(ids: string[]): Promise { let tracks: Track[] = []; diff --git a/webdev-prosjekt1/src/api/types.ts b/webdev-prosjekt1/src/api/types.ts index 816cb1c..5a53178 100644 --- a/webdev-prosjekt1/src/api/types.ts +++ b/webdev-prosjekt1/src/api/types.ts @@ -40,6 +40,17 @@ export type Track = { uri: string; } +export type AlbumTrack = { + id: string; + name: string; + type: string; + href: string; + artists: Track['artists']; + duration_ms: number; + track_number: number; + uri: string; +} + export type Album = { id: string; name: string; diff --git a/webdev-prosjekt1/src/components/AlbumCard.tsx b/webdev-prosjekt1/src/components/AlbumCard.tsx index 84d442d..d2fa81c 100644 --- a/webdev-prosjekt1/src/components/AlbumCard.tsx +++ b/webdev-prosjekt1/src/components/AlbumCard.tsx @@ -4,15 +4,18 @@ import musicwizimage from '../assets/musicwiz.png'; type AlbumCardProps = { album: Album; + onClick: () => void; } -export const AlbumCard = ({ album }: AlbumCardProps) => { +export const AlbumCard = ({ album, onClick }: AlbumCardProps) => { const image = album.images[0]?.url ?? musicwizimage; const artistNames = album.artists.map((artist) => artist.name).join(', '); const year = album.release_date.slice(0, 4); return ( -
  • +
  • { + if (event.key === 'Enter' || event.key === ' ') onClick(); + }}>
    diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.css b/webdev-prosjekt1/src/components/AlbumDetailPage.css index 20799ce..76dd114 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.css +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.css @@ -115,6 +115,15 @@ line-height: 1.9; } +.album-track-list { + display: grid; + gap: 10px; + max-width: 760px; + margin: 28px 0 0; + padding: 0; + list-style: none; +} + @media (max-width: 900px) { .album-detail-page .detail-content { grid-template-columns: 1fr; diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx index 3cdc455..76c5f91 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx @@ -1,15 +1,23 @@ import './AlbumDetailPage.css'; import '../variables.css'; import { Header } from './Header'; -import type { Album } from '../api/types'; +import { useQuery } from '@tanstack/react-query'; +import { fetchAlbumTracks } from '../api/spotify'; +import type { Album, AlbumTrack } from '../api/types'; +import { TrackCard } from './TrackCard'; type AlbumDetailPageProps = { album: Album; + onTrackClick: (track: AlbumTrack) => void; }; -export const AlbumDetailPage = ({ album }: AlbumDetailPageProps) => { +export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) => { const artistNames = album.artists.map((artist) => artist.name).join(', '); const albumImage = album.images[0]?.url; + const { data: tracks, isLoading, isError } = useQuery({ + queryKey: ['album-tracks', album.id], + queryFn: () => fetchAlbumTracks(album.id), + }); return (
    @@ -40,7 +48,15 @@ export const AlbumDetailPage = ({ album }: AlbumDetailPageProps) => {

    Track listing

    -

    The tracks from this album will appear here when the album data is connected.

    + {isLoading &&

    Loading tracks...

    } + {isError &&

    Could not load tracks for this album.

    } + {tracks && ( +
      + {tracks.map((track) => ( + onTrackClick(track)} /> + ))} +
    + )}
    ); diff --git a/webdev-prosjekt1/src/components/ArtistCard.tsx b/webdev-prosjekt1/src/components/ArtistCard.tsx index a2a5463..101ed9f 100644 --- a/webdev-prosjekt1/src/components/ArtistCard.tsx +++ b/webdev-prosjekt1/src/components/ArtistCard.tsx @@ -5,13 +5,16 @@ import musicwizimage from '../assets/musicwiz.png'; type ArtistCardProps = { artist: Artist; + onClick: () => void; } -export const ArtistCard = ({ artist }: ArtistCardProps) => { +export const ArtistCard = ({ artist, onClick }: ArtistCardProps) => { const image = artist.images[0]?.url ?? musicwizimage; return ( -
  • +
  • { + if (event.key === 'Enter' || event.key === ' ') onClick(); + }}>
    diff --git a/webdev-prosjekt1/src/components/TrackCard.css b/webdev-prosjekt1/src/components/TrackCard.css index 78f1c74..9e6f94e 100644 --- a/webdev-prosjekt1/src/components/TrackCard.css +++ b/webdev-prosjekt1/src/components/TrackCard.css @@ -7,6 +7,12 @@ border-radius: 0.5rem; background-color: rgb(43, 43, 43); + cursor: pointer; +} + +.search-result-card:focus-visible { + outline: 2px solid var(--primary_color); + outline-offset: 3px; } .card-image { diff --git a/webdev-prosjekt1/src/components/TrackCard.tsx b/webdev-prosjekt1/src/components/TrackCard.tsx index 3c3ea05..7cf0f22 100644 --- a/webdev-prosjekt1/src/components/TrackCard.tsx +++ b/webdev-prosjekt1/src/components/TrackCard.tsx @@ -1,9 +1,11 @@ import './TrackCard.css'; -import type { Track } from '../api/types'; +import type { Album, AlbumTrack, Track } from '../api/types'; import musicwizimage from '../assets/musicwiz.png'; type TrackCardProps = { - track: Track; + track: Track | AlbumTrack; + album?: Album; + onClick: () => void; } const formatDuration = (durationMs: number) => { @@ -13,12 +15,15 @@ const formatDuration = (durationMs: number) => { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } -export const TrackCard = ({ track }: TrackCardProps) => { - const image = track.album.images[0]?.url ?? musicwizimage; +export const TrackCard = ({ track, album, onClick }: TrackCardProps) => { + const trackAlbum = 'album' in track ? track.album : undefined; + const image = trackAlbum?.images[0]?.url ?? album?.images[0]?.url ?? musicwizimage; const artistNames = track.artists.map((artist) => artist.name).join(', '); return ( -
  • +
  • { + if (event.key === 'Enter' || event.key === ' ') onClick(); + }}>