Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 56 additions & 12 deletions webdev-prosjekt1/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = <T,>(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<Track | null>(() => readStoredItem<Track>('music-wiz-selected-track'));
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(() => readStoredItem<Album>('music-wiz-selected-album'));
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(() => readStoredItem<Artist>('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,
Expand Down Expand Up @@ -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 <AlbumDetailPage album={dummyAlbum} />;
if (isArtistDetailRoute) return <ArtistDetailPage artist={dummyArtist} />;
if (isSongDetailRoute) return <SongDetailPage song={dummySong} />;
if (isAlbumDetailRoute) return <AlbumDetailPage album={selectedAlbum ?? dummyAlbum} onTrackClick={handleAlbumTrackClick} />;
if (isArtistDetailRoute) return <ArtistDetailPage artist={selectedArtist ?? dummyArtist} />;
if (isSongDetailRoute) return <SongDetailPage song={selectedTrack ?? dummySong} />;

return (
<main className='app-shell'>
Expand All @@ -92,15 +136,15 @@ function App() {
<section className='results-section'>
<div className='results-group'>
<h2 className='results-heading'>Tracks</h2>
<ul className='card-list'>{results?.tracks.map((track) => <TrackCard key={track.id} track={track} />)}</ul>
<ul className='card-list'>{results?.tracks.map((track) => <TrackCard key={track.id} track={track} onClick={() => handleTrackClick(track)} />)}</ul>
</div>
<div className='results-group'>
<h2 className='results-heading'>Albums</h2>
<ul className='card-list'>{results?.albums.map((album) => <AlbumCard key={album.id} album={album} />)}</ul>
<ul className='card-list'>{results?.albums.map((album) => <AlbumCard key={album.id} album={album} onClick={() => handleAlbumClick(album)} />)}</ul>
</div>
<div className='results-group'>
<h2 className='results-heading'>Artists</h2>
<ul className='card-list'>{results?.artists.map((artist) => <ArtistCard key={artist.id} artist={artist} />)}</ul>
<ul className='card-list'>{results?.artists.map((artist) => <ArtistCard key={artist.id} artist={artist} onClick={() => handleArtistClick(artist)} />)}</ul>
</div>
</section>
) : (
Expand Down
20 changes: 19 additions & 1 deletion webdev-prosjekt1/src/api/spotify.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -112,6 +112,24 @@ export async function fetchTrack(id: string): Promise<Track> {
return track;
}

export async function fetchAlbumTracks(id: string): Promise<AlbumTrack[]> {
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<Track[]> {
let tracks: Track[] = [];
Expand Down
11 changes: 11 additions & 0 deletions webdev-prosjekt1/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions webdev-prosjekt1/src/components/AlbumCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<li className="search-result-card">
<li className="search-result-card" role='button' tabIndex={0} onClick={onClick} onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onClick();
}}>
<div className='card-image-wrapper'>
<img src={image} alt="" className='card-image'/>
</div>
Expand Down
9 changes: 9 additions & 0 deletions webdev-prosjekt1/src/components/AlbumDetailPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 19 additions & 3 deletions webdev-prosjekt1/src/components/AlbumDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className='album-detail-page'>
Expand Down Expand Up @@ -40,7 +48,15 @@ export const AlbumDetailPage = ({ album }: AlbumDetailPageProps) => {
<header>
<h2 id='album-tracks-title'>Track listing</h2>
</header>
<p>The tracks from this album will appear here when the album data is connected.</p>
{isLoading && <p>Loading tracks...</p>}
{isError && <p>Could not load tracks for this album.</p>}
{tracks && (
<ul className='album-track-list'>
{tracks.map((track) => (
<TrackCard key={track.id} track={track} album={album} onClick={() => onTrackClick(track)} />
))}
</ul>
)}
</section>
</main>
);
Expand Down
7 changes: 5 additions & 2 deletions webdev-prosjekt1/src/components/ArtistCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<li className="search-result-card artist-card">
<li className="search-result-card artist-card" role='button' tabIndex={0} onClick={onClick} onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onClick();
}}>
<div className='card-image-wrapper artist-image-wrapper'>
<img src={image} alt="" className='card-image'/>
</div>
Expand Down
6 changes: 6 additions & 0 deletions webdev-prosjekt1/src/components/TrackCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 10 additions & 5 deletions webdev-prosjekt1/src/components/TrackCard.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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 (
<li className="search-result-card">
<li className="search-result-card" role='button' tabIndex={0} onClick={onClick} onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onClick();
}}>
<div className='card-image-wrapper'>
<img src={image} alt="" className='card-image'/>
</div>
Expand Down