From 32828dd20d2559bc88a9ee4954cfc5784c200f8f Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 15 Sep 2026 12:25:40 +0200 Subject: [PATCH 1/6] Added api calls for getting preview url, lyrics, albums by artist, tracks by artist, and tracks on album --- webdev-prosjekt1/src/api/itunes.ts | 29 ++++++++++++ webdev-prosjekt1/src/api/lrclib.ts | 23 ++++++++++ webdev-prosjekt1/src/api/spotify.ts | 69 ++++++++++++++++++++++++++++- webdev-prosjekt1/src/api/types.ts | 30 ++++++++++++- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 webdev-prosjekt1/src/api/itunes.ts create mode 100644 webdev-prosjekt1/src/api/lrclib.ts diff --git a/webdev-prosjekt1/src/api/itunes.ts b/webdev-prosjekt1/src/api/itunes.ts new file mode 100644 index 0000000..a38a280 --- /dev/null +++ b/webdev-prosjekt1/src/api/itunes.ts @@ -0,0 +1,29 @@ +import type { Track, ITunesSearchResponse } from "./types"; + +export async function fetchPreview(track: Track): Promise { + const params = new URLSearchParams({ + term: `${track.name} ${track.artists[0].name}`, + media: "music", + entity: "song", + country: "NO", + limit: "5" + }); + + const response = await fetch( + `https://itunes.apple.com/search?${params}` + ); + + if (!response.ok) { + throw new Error(`iTunes API error: ${response.status}`); + } + + const json: ITunesSearchResponse = await response.json(); + + const match = json.results.find( + (result) => + result.trackName.toLowerCase() === track.name.toLowerCase() && + result.artistName.toLowerCase() === track.artists[0].name.toLowerCase() + ); + + return match?.previewUrl ?? json.results[0]?.previewUrl ?? null; +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/api/lrclib.ts b/webdev-prosjekt1/src/api/lrclib.ts new file mode 100644 index 0000000..84498fc --- /dev/null +++ b/webdev-prosjekt1/src/api/lrclib.ts @@ -0,0 +1,23 @@ +import type { Lyrics } from "./types"; + +export async function getLyrics(artist: string, track: string, album: string, durationMs: number): Promise { + const params = new URLSearchParams({ + artist_name: artist, + track_name: track, + album_name: album, + duration: Math.round(durationMs / 1000).toString() + }); + + const response = await fetch(`https://lrclib.net/api/get?${params}`, { + method: "Get", + headers: { + "Lrclib-Client": "MusicWizard/1.0" + } + }); + + if (!response.ok) { + return null; + } + + return await response.json(); +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/api/spotify.ts b/webdev-prosjekt1/src/api/spotify.ts index 580b7ee..8e8e8e0 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 { SearchResult, Track, Album } from "./types"; const BASE_URL = "https://api.spotify.com/v1" @@ -95,7 +95,7 @@ export async function search(query: string): Promise { export async function fetchTrack(id: string): Promise { const token = await getValidToken(); - const response = await fetch(`${BASE_URL}/tracks/${id}`, { + const response = await fetch(`${BASE_URL}/tracks/${encodeURIComponent(id)}`, { method: "GET", headers: { "Authorization": `Bearer ${token}` @@ -125,3 +125,68 @@ export async function fetchTracks(ids: string[]): Promise { return tracks; } + + +export async function fetchTracksOnAlbum(id: string): Promise { + const token = await getValidToken(); + + const response = await fetch(`${BASE_URL}/albums/${encodeURIComponent(id)}/tracks/`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Spotify api error: ${response.status}`); + } + + const json = await response.json(); + const tracks: Track[] = json.items; + + return tracks; +} + +export async function fetchTracksByArtist(name: string): Promise { + const token = await getValidToken(); + + const response = await fetch(`${BASE_URL}/search?q=${encodeURIComponent(`artist:${name}`)}&type=track&market=NO&limit=10`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Spotify api error: ${response.status}`); + } + + const json = await response.json(); + const tracks: Track[] = json.tracks.items; + + return tracks; +} + +export async function fetchAlbumsByArtistId(id: string): Promise { + const token = await getValidToken(); + + const response = await fetch(`${BASE_URL}/artists/${id}/albums?include_groups=album&market=NO&limit=10`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Spotify response body:", error); + + throw new Error( + `Spotify API error: ${response.status} ${response.statusText}` + ); + } + + const json = await response.json(); + + return json.items; +} diff --git a/webdev-prosjekt1/src/api/types.ts b/webdev-prosjekt1/src/api/types.ts index 816cb1c..e1dfc52 100644 --- a/webdev-prosjekt1/src/api/types.ts +++ b/webdev-prosjekt1/src/api/types.ts @@ -63,4 +63,32 @@ export type SearchResult = { artists: Artist[], tracks: Track[], albums: Album[] -} \ No newline at end of file +} + + +export type Lyrics = { + id: number; + name: string; + trackName: string; + artistName: string; + albumName: string; + duration: number; + instrumental: boolean; + plainLyrics: string | null; + syncedLyrics: string | null; +}; + + +export type ITunesTrack = { + trackId: number; + trackName: string; + artistName: string; + collectionName: string; + previewUrl?: string; + trackTimeMillis: number; +}; + +export type ITunesSearchResponse = { + resultCount: number; + results: ITunesTrack[]; +}; \ No newline at end of file From 2607990e3640935b72ef0ed51a56dcec14e03c6d Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 15 Sep 2026 13:22:06 +0200 Subject: [PATCH 2/6] Changed TrackDetailPage to show actual lyrics. --- webdev-prosjekt1/src/App.tsx | 5 ++-- webdev-prosjekt1/src/api/spotify.ts | 1 - ...SongDetailPage.css => TrackDetailPage.css} | 4 +-- ...SongDetailPage.tsx => TrackDetailPage.tsx} | 27 +++++++++++++------ 4 files changed, 24 insertions(+), 13 deletions(-) rename webdev-prosjekt1/src/components/{SongDetailPage.css => TrackDetailPage.css} (98%) rename webdev-prosjekt1/src/components/{SongDetailPage.tsx => TrackDetailPage.tsx} (59%) diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 3c969a0..68e431d 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -4,7 +4,7 @@ import './App.css'; import './variables.css'; import { Button } from './components/Button'; import { Searchbar } from './components/Searchbar'; -import { SongDetailPage } from './components/SongDetailPage'; +import { TrackDetailPage } from './components/TrackDetailPage'; import { AlbumDetailPage } from './components/AlbumDetailPage'; import { ArtistDetailPage } from './components/ArtistDetailPage'; import { Header } from './components/Header'; @@ -119,11 +119,12 @@ function App() { queryKey: ['search', query], queryFn: () => search(query), enabled: query.trim().length > 0, + staleTime: Infinity, }); if (isAlbumDetailRoute) return ; if (isArtistDetailRoute) return ; - if (isSongDetailRoute) return ; + if (isSongDetailRoute) return ; return (
diff --git a/webdev-prosjekt1/src/api/spotify.ts b/webdev-prosjekt1/src/api/spotify.ts index a2dbf43..21cf5f1 100644 --- a/webdev-prosjekt1/src/api/spotify.ts +++ b/webdev-prosjekt1/src/api/spotify.ts @@ -87,7 +87,6 @@ export async function search(query: string): Promise { albums: json.albums.items, } const searchResult: SearchResult = newSearchResult; - console.log(searchResult); return searchResult; } diff --git a/webdev-prosjekt1/src/components/SongDetailPage.css b/webdev-prosjekt1/src/components/TrackDetailPage.css similarity index 98% rename from webdev-prosjekt1/src/components/SongDetailPage.css rename to webdev-prosjekt1/src/components/TrackDetailPage.css index 1900baa..13fd69a 100644 --- a/webdev-prosjekt1/src/components/SongDetailPage.css +++ b/webdev-prosjekt1/src/components/TrackDetailPage.css @@ -118,11 +118,11 @@ .lyrics-copy { color: var(--text_secondary); font-size: 1.05rem; - line-height: 1.9; + line-height: 1.65; } .lyrics-copy p { - margin: 0 0 28px; + margin: 0 0 8px; } @media (max-width: 900px) { diff --git a/webdev-prosjekt1/src/components/SongDetailPage.tsx b/webdev-prosjekt1/src/components/TrackDetailPage.tsx similarity index 59% rename from webdev-prosjekt1/src/components/SongDetailPage.tsx rename to webdev-prosjekt1/src/components/TrackDetailPage.tsx index 24d0c65..a0fbbc6 100644 --- a/webdev-prosjekt1/src/components/SongDetailPage.tsx +++ b/webdev-prosjekt1/src/components/TrackDetailPage.tsx @@ -1,9 +1,11 @@ -import './SongDetailPage.css'; +import './TrackDetailPage.css'; import '../variables.css'; -import { Header } from './Header'; +import { useQuery } from '@tanstack/react-query'; +import { getLyrics } from '../api/lrclib'; import type { Track } from '../api/types'; +import { Header } from './Header'; -type SongDetailPageProps = { +type TrackDetailPageProps = { song: Track; }; @@ -17,9 +19,16 @@ const formatDuration = (durationMs: number) => { return `${minutes}:${seconds}`; }; -export const SongDetailPage = ({ song }: SongDetailPageProps) => { +export const TrackDetailPage = ({ song }: TrackDetailPageProps) => { + const firstArtistName = song.artists[0].name; const artistNames = song.artists.map((artist) => artist.name).join(', '); const albumImage = song.album.images[0]?.url; + const { data: lyrics, isLoading: isLyricsLoading, isError: isLyricsError } = useQuery({ + queryKey: ['lyrics', song.id], + queryFn: () => getLyrics(firstArtistName, song.name, song.album.name, song.duration_ms), + enabled: Boolean(firstArtistName && song.name && song.album.name), + staleTime: Infinity, + }); return (
@@ -52,10 +61,12 @@ export const SongDetailPage = ({ song }: SongDetailPageProps) => {
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer vitae sem vel lorem tincidunt luctus. Curabitur feugiat, justo sed gravida consequat, nisl erat posuere nibh, vitae consequat lorem urna non erat.

-

Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Aenean at arcu sed lorem aliquet porttitor. Donec feugiat, magna in volutpat posuere, justo erat consequat sapien, at luctus erat nunc vitae libero.

-

Praesent commodo, nulla at facilisis dictum, lectus massa tempus erat, sed porttitor nibh justo non ipsum. Suspendisse potenti. Mauris congue, arcu sed posuere interdum, nisl turpis finibus lectus, sed imperdiet lorem lacus sit amet augue.

-

Nam volutpat augue quis lorem commodo, a consequat magna pretium. Sed at neque vel justo blandit consequat. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

+ {isLyricsLoading &&

Loading lyrics...

} + {isLyricsError &&

Could not load lyrics for this track.

} + {!isLyricsLoading && !isLyricsError && !lyrics &&

Lyrics are not available for this track.

} + {lyrics?.plainLyrics && lyrics.plainLyrics.split('\n').map((line, index) => ( +

{line || '\u00a0'}

+ ))}
From 10946f204950c48f203059a418d6650d2ee3f228 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 15 Sep 2026 15:32:32 +0200 Subject: [PATCH 3/6] Added Spotify artist albums, songs, album navigation, and fixed lyrics rendering. --- webdev-prosjekt1/src/App.tsx | 7 +- webdev-prosjekt1/src/api/lrclib.ts | 2 +- webdev-prosjekt1/src/api/spotify.ts | 21 ------ webdev-prosjekt1/src/api/types.ts | 3 + .../src/components/AlbumDetailPage.tsx | 1 + .../src/components/ArtistDetailPage.css | 67 ++++++++++++++++--- .../src/components/ArtistDetailPage.tsx | 56 ++++++++++++++-- .../src/components/TrackDetailPage.css | 22 ++++++ .../src/components/TrackDetailPage.tsx | 22 ++++-- 9 files changed, 155 insertions(+), 46 deletions(-) diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 68e431d..1f12d93 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -47,6 +47,7 @@ function App() { href: 'https://api.spotify.com/v1/artists/artist-id', uri: 'spotify:artist:artist-id', images: [dummyAlbumImage], + external_ids: { spotify: 'artist-id' }, }; const dummySong: Track = { id: 'song-id', @@ -119,12 +120,12 @@ function App() { queryKey: ['search', query], queryFn: () => search(query), enabled: query.trim().length > 0, - staleTime: Infinity, + staleTime: 1000 * 60 * 60 }); if (isAlbumDetailRoute) return ; - if (isArtistDetailRoute) return ; - if (isSongDetailRoute) return ; + if (isArtistDetailRoute) return ; + if (isSongDetailRoute) return ; return (
diff --git a/webdev-prosjekt1/src/api/lrclib.ts b/webdev-prosjekt1/src/api/lrclib.ts index 84498fc..38999fd 100644 --- a/webdev-prosjekt1/src/api/lrclib.ts +++ b/webdev-prosjekt1/src/api/lrclib.ts @@ -19,5 +19,5 @@ export async function getLyrics(artist: string, track: string, album: string, du return null; } - return await response.json(); + return await response.json() as Lyrics; } \ No newline at end of file diff --git a/webdev-prosjekt1/src/api/spotify.ts b/webdev-prosjekt1/src/api/spotify.ts index 21cf5f1..3367b86 100644 --- a/webdev-prosjekt1/src/api/spotify.ts +++ b/webdev-prosjekt1/src/api/spotify.ts @@ -143,27 +143,6 @@ export async function fetchTracks(ids: string[]): Promise { return tracks; } - -export async function fetchTracksOnAlbum(id: string): Promise { - const token = await getValidToken(); - - const response = await fetch(`${BASE_URL}/albums/${encodeURIComponent(id)}/tracks/`, { - method: "GET", - headers: { - "Authorization": `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Spotify api error: ${response.status}`); - } - - const json = await response.json(); - const tracks: Track[] = json.items; - - return tracks; -} - export async function fetchTracksByArtist(name: string): Promise { const token = await getValidToken(); diff --git a/webdev-prosjekt1/src/api/types.ts b/webdev-prosjekt1/src/api/types.ts index a819b6d..efb4ad7 100644 --- a/webdev-prosjekt1/src/api/types.ts +++ b/webdev-prosjekt1/src/api/types.ts @@ -14,6 +14,9 @@ export type Artist = { href: string; uri: string; images: Image[]; + external_ids: { + spotify: string; + }; } diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx index 76c5f91..f8e4617 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx @@ -17,6 +17,7 @@ export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) = const { data: tracks, isLoading, isError } = useQuery({ queryKey: ['album-tracks', album.id], queryFn: () => fetchAlbumTracks(album.id), + staleTime: 1000 * 60 * 60 }); return ( diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.css b/webdev-prosjekt1/src/components/ArtistDetailPage.css index 3a2081e..2c60f9b 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.css +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.css @@ -77,16 +77,20 @@ overflow-wrap: break-word; } -.artist-detail-page .detail-subtitle { - margin: 22px 0 0; - font-size: clamp(1.05rem, 2vw, 1.35rem); - color: var(--text_secondary); +.artist-detail-page .spotify-link { + display: inline-flex; + align-items: center; + gap: 9px; + margin-top: 24px; + color: var(--primary_color); + font-weight: 700; + text-decoration: none; } -.artist-detail-page .detail-meta { - margin: 12px 0 0; - color: var(--text_muted); - text-transform: capitalize; +.artist-detail-page .spotify-link:hover, +.artist-detail-page .spotify-link:focus-visible { + color: var(--text_primary); + text-decoration: underline; } .artist-detail-page .detail-divider { @@ -112,12 +116,53 @@ .artist-content p { max-width: 660px; - margin: 28px 0 0; color: var(--text_secondary); font-size: 1.05rem; line-height: 1.9; } +.artist-overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 36px; + margin-top: 38px; +} + +.artist-overview-group { + min-width: 0; +} + +.artist-overview-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.artist-overview-heading h3 { + margin: 0; + font-size: 1.4rem; +} + +.artist-overview-heading span { + color: var(--primary_color); + font-size: 0.85rem; +} + +.artist-card-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.artist-status { + margin: 0; + font-size: 0.95rem; +} + @media (max-width: 900px) { .artist-detail-page .detail-content { grid-template-columns: 1fr; @@ -139,4 +184,8 @@ margin-top: 30px; padding: 42px 32px 0; } + + .artist-overview-grid { + grid-template-columns: 1fr; + } } diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx index bae8536..832598d 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx @@ -1,14 +1,30 @@ import './ArtistDetailPage.css'; import '../variables.css'; +import { useQuery } from '@tanstack/react-query'; import { Header } from './Header'; -import type { Artist } from '../api/types'; +import { AlbumCard } from './AlbumCard'; +import { TrackCard } from './TrackCard'; +import { fetchAlbumsByArtistId, fetchTracksByArtist } from '../api/spotify'; +import type { Album, Artist, Track } from '../api/types'; type ArtistDetailPageProps = { artist: Artist; + onTrackClick: (track: Track) => void; + onAlbumClick: (album: Album) => void; }; -export const ArtistDetailPage = ({ artist }: ArtistDetailPageProps) => { +export const ArtistDetailPage = ({ artist, onTrackClick, onAlbumClick }: ArtistDetailPageProps) => { const artistImage = artist.images[0]?.url; + const { data: tracks, isLoading: isLoadingTracks, isError: isTracksError } = useQuery({ + queryKey: ['artist-tracks', artist.name], + queryFn: () => fetchTracksByArtist(artist.name), + staleTime: 1000 * 60 * 60 + }); + const { data: albums, isLoading: isLoadingAlbums, isError: isAlbumsError } = useQuery({ + queryKey: ['artist-albums', artist.id], + queryFn: () => fetchAlbumsByArtistId(String(artist.id)), + staleTime: 1000 * 60 * 60 + }); return (
@@ -26,8 +42,9 @@ export const ArtistDetailPage = ({ artist }: ArtistDetailPageProps) => {

Artist

{artist.name}

-

Artist profile

-

{artist.type} · Music Wiz profile

+ + Open in Spotify +
@@ -37,7 +54,36 @@ export const ArtistDetailPage = ({ artist }: ArtistDetailPageProps) => {

Artist overview

-

Albums, songs and more from this artist will appear here when the artist data is connected.

+ +
+
+
+

Tracks

+ {tracks?.length ?? 0} +
+ {isLoadingTracks &&

Loading songs...

} + {isTracksError &&

Could not load songs for this artist.

} + {tracks && tracks.length > 0 && ( +
    + {tracks.map((track) => onTrackClick(track)} />)} +
+ )} +
+ +
+
+

Albums

+ {albums?.length ?? 0} +
+ {isLoadingAlbums &&

Loading albums...

} + {isAlbumsError &&

Could not load albums for this artist.

} + {albums && albums.length > 0 && ( +
    + {albums.map((album) => onAlbumClick(album)} />)} +
+ )} +
+
); diff --git a/webdev-prosjekt1/src/components/TrackDetailPage.css b/webdev-prosjekt1/src/components/TrackDetailPage.css index 13fd69a..6e271f7 100644 --- a/webdev-prosjekt1/src/components/TrackDetailPage.css +++ b/webdev-prosjekt1/src/components/TrackDetailPage.css @@ -85,6 +85,28 @@ color: var(--text_muted); } +.album-detail-link { + display: inline; + padding: 0; + border: 0; + border-radius: 0; + color: inherit; + font: inherit; + text-decoration: none; + background: transparent; + cursor: pointer; + vertical-align: baseline; +} + +.album-detail-link:hover, +.album-detail-link:focus-visible { + color: var(--primary_color); + background: transparent; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + .detail-divider { width: 100%; height: 1px; diff --git a/webdev-prosjekt1/src/components/TrackDetailPage.tsx b/webdev-prosjekt1/src/components/TrackDetailPage.tsx index a0fbbc6..043129e 100644 --- a/webdev-prosjekt1/src/components/TrackDetailPage.tsx +++ b/webdev-prosjekt1/src/components/TrackDetailPage.tsx @@ -2,11 +2,12 @@ import './TrackDetailPage.css'; import '../variables.css'; import { useQuery } from '@tanstack/react-query'; import { getLyrics } from '../api/lrclib'; -import type { Track } from '../api/types'; +import type { Album, Track } from '../api/types'; import { Header } from './Header'; type TrackDetailPageProps = { song: Track; + onAlbumClick: (album: Album) => void; }; export type DetailSelectHandler = (song: Track) => void; @@ -19,7 +20,7 @@ const formatDuration = (durationMs: number) => { return `${minutes}:${seconds}`; }; -export const TrackDetailPage = ({ song }: TrackDetailPageProps) => { +export const TrackDetailPage = ({ song, onAlbumClick }: TrackDetailPageProps) => { const firstArtistName = song.artists[0].name; const artistNames = song.artists.map((artist) => artist.name).join(', '); const albumImage = song.album.images[0]?.url; @@ -27,7 +28,7 @@ export const TrackDetailPage = ({ song }: TrackDetailPageProps) => { queryKey: ['lyrics', song.id], queryFn: () => getLyrics(firstArtistName, song.name, song.album.name, song.duration_ms), enabled: Boolean(firstArtistName && song.name && song.album.name), - staleTime: Infinity, + staleTime: Infinity }); return ( @@ -44,11 +45,15 @@ export const TrackDetailPage = ({ song }: TrackDetailPageProps) => {
-

Song

+

Track

{song.name}

{artistNames}

- {song.album.name} · {song.album.release_date} · {formatDuration(song.duration_ms)} + + + {song.album.release_date} · {formatDuration(song.duration_ms)}


@@ -64,9 +69,12 @@ export const TrackDetailPage = ({ song }: TrackDetailPageProps) => { {isLyricsLoading &&

Loading lyrics...

} {isLyricsError &&

Could not load lyrics for this track.

} {!isLyricsLoading && !isLyricsError && !lyrics &&

Lyrics are not available for this track.

} - {lyrics?.plainLyrics && lyrics.plainLyrics.split('\n').map((line, index) => ( + {/* {lyrics?.plainLyrics && lyrics.plainLyrics.split('\n').map((line, index) => (

{line || '\u00a0'}

- ))} + ))} */} + { +

{lyrics?.plainLyrics}

+ }
From 6cd7a53427e3364f75965e41177aaebd7d5a6e86 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 15 Sep 2026 15:36:03 +0200 Subject: [PATCH 4/6] Renamed song-detail route to be track-detail. --- webdev-prosjekt1/src/App.tsx | 6 +++--- webdev-prosjekt1/src/components/TrackDetailPage.css | 2 +- webdev-prosjekt1/src/components/TrackDetailPage.tsx | 9 +++------ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 1f12d93..aa2ac76 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -32,7 +32,7 @@ function App() { 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 = currentPath === '/song-detail' || currentPath === '/song-detail/'; + const isSongDetailRoute = currentPath === '/track-detail' || currentPath === '/track-detail/'; const isAlbumDetailRoute = currentPath === '/album-detail' || currentPath === '/album-detail/'; const isArtistDetailRoute = currentPath === '/artist-detail' || currentPath === '/artist-detail/'; const dummyAlbumImage = { @@ -98,7 +98,7 @@ function App() { const handleTrackClick = (track: Track) => { setSelectedTrack(track); localStorage.setItem('music-wiz-selected-track', JSON.stringify(track)); - navigateTo('/song-detail'); + navigateTo('/track-detail'); }; const handleAlbumClick = (album: Album) => { setSelectedAlbum(album); @@ -109,7 +109,7 @@ function App() { const fullTrack = await fetchTrack(track.id); setSelectedTrack(fullTrack); localStorage.setItem('music-wiz-selected-track', JSON.stringify(fullTrack)); - navigateTo('/song-detail'); + navigateTo('/track-detail'); }; const handleArtistClick = (artist: Artist) => { setSelectedArtist(artist); diff --git a/webdev-prosjekt1/src/components/TrackDetailPage.css b/webdev-prosjekt1/src/components/TrackDetailPage.css index 6e271f7..57cd487 100644 --- a/webdev-prosjekt1/src/components/TrackDetailPage.css +++ b/webdev-prosjekt1/src/components/TrackDetailPage.css @@ -1,4 +1,4 @@ -.song-detail-page { +.track-detail-page { min-height: 100vh; padding-bottom: 80px; color: var(--text_primary); diff --git a/webdev-prosjekt1/src/components/TrackDetailPage.tsx b/webdev-prosjekt1/src/components/TrackDetailPage.tsx index 043129e..6336bf3 100644 --- a/webdev-prosjekt1/src/components/TrackDetailPage.tsx +++ b/webdev-prosjekt1/src/components/TrackDetailPage.tsx @@ -32,7 +32,7 @@ export const TrackDetailPage = ({ song, onAlbumClick }: TrackDetailPageProps) => }); return ( -
+
@@ -69,12 +69,9 @@ export const TrackDetailPage = ({ song, onAlbumClick }: TrackDetailPageProps) => {isLyricsLoading &&

Loading lyrics...

} {isLyricsError &&

Could not load lyrics for this track.

} {!isLyricsLoading && !isLyricsError && !lyrics &&

Lyrics are not available for this track.

} - {/* {lyrics?.plainLyrics && lyrics.plainLyrics.split('\n').map((line, index) => ( + {lyrics?.plainLyrics && lyrics.plainLyrics.split('\n').map((line, index) => (

{line || '\u00a0'}

- ))} */} - { -

{lyrics?.plainLyrics}

- } + ))}
From f28a899bfec5a265cf59a37bef6b077e6f5359a6 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 15 Sep 2026 16:38:51 +0200 Subject: [PATCH 5/6] Added preview of track, and other minor ui changes. --- webdev-prosjekt1/src/App.tsx | 6 +- webdev-prosjekt1/src/api/itunes.ts | 6 +- webdev-prosjekt1/src/api/spotify.ts | 2 + webdev-prosjekt1/src/api/types.ts | 6 + .../src/components/AlbumDetailPage.css | 4 + .../src/components/AlbumDetailPage.tsx | 6 +- .../src/components/ArtistDetailPage.css | 1 - .../src/components/ArtistDetailPage.tsx | 2 +- .../src/components/PreviewPlayer.tsx | 111 ++++++++++++++++++ .../src/components/SpotifyLink.css | 17 +++ webdev-prosjekt1/src/components/TrackCard.css | 4 + webdev-prosjekt1/src/components/TrackCard.tsx | 5 +- .../src/components/TrackDetailPage.css | 50 ++++++++ .../src/components/TrackDetailPage.tsx | 2 + 14 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 webdev-prosjekt1/src/components/PreviewPlayer.tsx create mode 100644 webdev-prosjekt1/src/components/SpotifyLink.css diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index aa2ac76..e975a2a 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -49,7 +49,7 @@ function App() { images: [dummyAlbumImage], external_ids: { spotify: 'artist-id' }, }; - const dummySong: Track = { + const dummyTrack: Track = { id: 'song-id', name: 'Song Name', type: 'track', @@ -59,6 +59,7 @@ function App() { id: 'album-id', name: 'Album Name', album_type: 'album', + href: 'https://api.spotify.com/v1/albums/album-id', artists: [dummyArtist], images: [dummyAlbumImage], release_date: '2026', @@ -77,6 +78,7 @@ function App() { id: 'album-id', name: 'Album Name', album_type: 'album', + href: 'https://api.spotify.com/v1/albums/album-id', artists: [dummyArtist], images: [dummyAlbumImage], release_date: '2026', @@ -125,7 +127,7 @@ function App() { if (isAlbumDetailRoute) return ; if (isArtistDetailRoute) return ; - if (isSongDetailRoute) return ; + if (isSongDetailRoute) return ; return (
diff --git a/webdev-prosjekt1/src/api/itunes.ts b/webdev-prosjekt1/src/api/itunes.ts index a38a280..1607d4f 100644 --- a/webdev-prosjekt1/src/api/itunes.ts +++ b/webdev-prosjekt1/src/api/itunes.ts @@ -1,6 +1,8 @@ -import type { Track, ITunesSearchResponse } from "./types"; +import type { ITunesSearchResponse, PreviewTrack } from "./types"; -export async function fetchPreview(track: Track): Promise { + + +export async function fetchPreview(track: PreviewTrack): Promise { const params = new URLSearchParams({ term: `${track.name} ${track.artists[0].name}`, media: "music", diff --git a/webdev-prosjekt1/src/api/spotify.ts b/webdev-prosjekt1/src/api/spotify.ts index 3367b86..11c3efd 100644 --- a/webdev-prosjekt1/src/api/spotify.ts +++ b/webdev-prosjekt1/src/api/spotify.ts @@ -88,6 +88,8 @@ export async function search(query: string): Promise { } const searchResult: SearchResult = newSearchResult; + console.log(json); + return searchResult; } diff --git a/webdev-prosjekt1/src/api/types.ts b/webdev-prosjekt1/src/api/types.ts index efb4ad7..bc2d9b6 100644 --- a/webdev-prosjekt1/src/api/types.ts +++ b/webdev-prosjekt1/src/api/types.ts @@ -58,6 +58,7 @@ export type Album = { id: string; name: string; album_type: "album" | "single" | "compilation"; + href: string; artists: { id: number; name: string; @@ -105,4 +106,9 @@ export type ITunesTrack = { export type ITunesSearchResponse = { resultCount: number; results: ITunesTrack[]; +}; + +export type PreviewTrack = { + name: string; + artists: { name: string }[]; }; \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.css b/webdev-prosjekt1/src/components/AlbumDetailPage.css index 6467406..edfa465 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.css +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.css @@ -94,6 +94,10 @@ background: var(--border_gold_subtle); } +.album-detail-page .preview-player { + margin-top: 24px; +} + .album-tracks { max-width: 1000px; margin: 30px auto 0; diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx index f8e4617..726c4e5 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx @@ -1,10 +1,12 @@ import './AlbumDetailPage.css'; import '../variables.css'; +import { useState } from 'react'; import { Header } from './Header'; import { useQuery } from '@tanstack/react-query'; import { fetchAlbumTracks } from '../api/spotify'; import type { Album, AlbumTrack } from '../api/types'; import { TrackCard } from './TrackCard'; +import { PreviewPlayer } from './PreviewPlayer'; type AlbumDetailPageProps = { album: Album; @@ -12,6 +14,7 @@ type AlbumDetailPageProps = { }; export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) => { + const [playingTrackId, setPlayingTrackId] = useState(null); const artistNames = album.artists.map((artist) => artist.name).join(', '); const albumImage = album.images[0]?.url; const { data: tracks, isLoading, isError } = useQuery({ @@ -40,6 +43,7 @@ export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) =

{album.album_type} · {album.release_date} · {album.total_tracks} tracks

+
@@ -54,7 +58,7 @@ export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) = {tracks && (
    {tracks.map((track) => ( - onTrackClick(track)} /> + onTrackClick(track)} /> ))}
)} diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.css b/webdev-prosjekt1/src/components/ArtistDetailPage.css index 2c60f9b..87dc33a 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.css +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.css @@ -90,7 +90,6 @@ .artist-detail-page .spotify-link:hover, .artist-detail-page .spotify-link:focus-visible { color: var(--text_primary); - text-decoration: underline; } .artist-detail-page .detail-divider { diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx index 832598d..cc380ce 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx @@ -43,7 +43,7 @@ export const ArtistDetailPage = ({ artist, onTrackClick, onAlbumClick }: ArtistD

Artist

{artist.name}

- Open in Spotify + Open in Spotify
diff --git a/webdev-prosjekt1/src/components/PreviewPlayer.tsx b/webdev-prosjekt1/src/components/PreviewPlayer.tsx new file mode 100644 index 0000000..64284c5 --- /dev/null +++ b/webdev-prosjekt1/src/components/PreviewPlayer.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react'; +import { fetchPreview } from '../api/itunes'; +import './SpotifyLink.css'; + +type PreviewTrack = { + id: string; + name: string; + artists: { name: string }[]; +}; + +type PreviewPlayerProps = { + tracks: PreviewTrack[]; + href?: string; + onTrackChange?: (trackId: string | null) => void; +}; + +export const PreviewPlayer = ({ tracks, href, onTrackChange }: PreviewPlayerProps) => { + const audioRef = useRef(null); + const [currentIndex, setCurrentIndex] = useState(0); + const [previewUrls, setPreviewUrls] = useState>({}); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const playTrack = async (index: number) => { + setIsLoading(true); + try { + for (let trackIndex = index; trackIndex < tracks.length; trackIndex += 1) { + const track = tracks[trackIndex]; + let previewUrl = previewUrls[track.id]; + if (previewUrl === undefined) { + try { + previewUrl = await fetchPreview(track); + } catch { + previewUrl = null; + } + setPreviewUrls((urls) => ({ ...urls, [track.id]: previewUrl })); + } + + if (!previewUrl) continue; + + const audio = audioRef.current; + if (!audio) return; + setCurrentIndex(trackIndex); + audio.src = previewUrl; + await audio.play(); + setIsPlaying(true); + onTrackChange?.(track.id); + return; + } + } catch { + setIsPlaying(false); + onTrackChange?.(null); + } finally { + setIsLoading(false); + } + }; + + const handlePlayToggle = async () => { + const audio = audioRef.current; + if (!audio || tracks.length === 0) return; + + if (isPlaying) { + audio.pause(); + setIsPlaying(false); + onTrackChange?.(null); + return; + } + + if (audio.src && audio.paused === true && audio.currentTime > 0) { + await audio.play(); + setIsPlaying(true); + onTrackChange?.(tracks[currentIndex].id); + return; + } + + await playTrack(currentIndex); + }; + + const handleEnded = () => { + setIsPlaying(false); + onTrackChange?.(null); + if (currentIndex + 1 < tracks.length) { + void playTrack(currentIndex + 1); + return; + } + + setCurrentIndex(0); + if (audioRef.current) audioRef.current.currentTime = 0; + }; + + return ( +
+ + {href && ( + + Open in Spotify + + )} +
+ ); +}; \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/SpotifyLink.css b/webdev-prosjekt1/src/components/SpotifyLink.css new file mode 100644 index 0000000..7e8b75e --- /dev/null +++ b/webdev-prosjekt1/src/components/SpotifyLink.css @@ -0,0 +1,17 @@ +.spotify-link { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0 16px; + border: 1px solid var(--primary_color); + border-radius: 999px; + color: var(--primary_color); + font-weight: 700; + text-decoration: none; +} + +.spotify-link:hover, +.spotify-link:focus-visible { + color: var(--text_primary); + background: var(--primary_color); +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/TrackCard.css b/webdev-prosjekt1/src/components/TrackCard.css index 74c9d26..a208453 100644 --- a/webdev-prosjekt1/src/components/TrackCard.css +++ b/webdev-prosjekt1/src/components/TrackCard.css @@ -17,6 +17,10 @@ outline-offset: 3px; } +.search-result-card.is-active .card-header { + color: var(--primary_color); +} + .card-image { aspect-ratio: 1 / 1; width: 100%; diff --git a/webdev-prosjekt1/src/components/TrackCard.tsx b/webdev-prosjekt1/src/components/TrackCard.tsx index d7a6d3c..56a1a09 100644 --- a/webdev-prosjekt1/src/components/TrackCard.tsx +++ b/webdev-prosjekt1/src/components/TrackCard.tsx @@ -5,6 +5,7 @@ import musicwizimage from '../assets/musicwiz.png'; type TrackCardProps = { track: Track | AlbumTrack; album?: Album; + isActive?: boolean; onClick: () => void; } @@ -15,13 +16,13 @@ const formatDuration = (durationMs: number) => { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } -export const TrackCard = ({ track, album, onClick }: TrackCardProps) => { +export const TrackCard = ({ track, album, isActive = false, 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(); }}>
    diff --git a/webdev-prosjekt1/src/components/TrackDetailPage.css b/webdev-prosjekt1/src/components/TrackDetailPage.css index 57cd487..8f5fc30 100644 --- a/webdev-prosjekt1/src/components/TrackDetailPage.css +++ b/webdev-prosjekt1/src/components/TrackDetailPage.css @@ -115,6 +115,56 @@ background: var(--border_gold_subtle); } +.preview-player { + display: flex; + align-items: center; + gap: 14px; + margin-top: 24px; +} + +.preview-player-button { + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + box-sizing: border-box; + border: 1px solid var(--primary_color); + border-radius: 999px; + color: var(--text_primary); + font: inherit; + font-weight: 700; + background: var(--primary_color); + cursor: pointer; +} + +.preview-player-button:hover:not(:disabled), +.preview-player-button:focus-visible:not(:disabled) { + filter: brightness(1.12); +} + +.preview-player-button:disabled { + opacity: 0.6; + cursor: wait; +} + +.preview-play-icon { + display: block; + width: 0; + height: 0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 12px solid currentColor; +} + +.preview-pause-icon { + display: block; + width: 12px; + height: 16px; + border-right: 4px solid currentColor; + border-left: 4px solid currentColor; +} + .detail-coming-soon { margin: 0; color: var(--text_subtle); diff --git a/webdev-prosjekt1/src/components/TrackDetailPage.tsx b/webdev-prosjekt1/src/components/TrackDetailPage.tsx index 6336bf3..0bdd1dc 100644 --- a/webdev-prosjekt1/src/components/TrackDetailPage.tsx +++ b/webdev-prosjekt1/src/components/TrackDetailPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { getLyrics } from '../api/lrclib'; import type { Album, Track } from '../api/types'; import { Header } from './Header'; +import { PreviewPlayer } from './PreviewPlayer'; type TrackDetailPageProps = { song: Track; @@ -55,6 +56,7 @@ export const TrackDetailPage = ({ song, onAlbumClick }: TrackDetailPageProps) => {song.album.release_date} · {formatDuration(song.duration_ms)}

    +
    From b81ff655dfc768b6f9d13d1f66fc542e2e959b42 Mon Sep 17 00:00:00 2001 From: Kristoffer Fredrik Dahle Melen Date: Tue, 15 Sep 2026 16:47:30 +0200 Subject: [PATCH 6/6] Added a clear button in the searchbar --- webdev-prosjekt1/src/components/Searchbar.css | 22 +++++++++++++++++++ webdev-prosjekt1/src/components/Searchbar.tsx | 10 +++++++++ 2 files changed, 32 insertions(+) diff --git a/webdev-prosjekt1/src/components/Searchbar.css b/webdev-prosjekt1/src/components/Searchbar.css index 70cf389..53cda69 100644 --- a/webdev-prosjekt1/src/components/Searchbar.css +++ b/webdev-prosjekt1/src/components/Searchbar.css @@ -41,6 +41,28 @@ color: var(--text_subtle); } +.search-clear-button { + display: grid; + place-items: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--text_muted); + font-size: 1.4rem; + line-height: 1; + background: transparent; + cursor: pointer; +} + +.search-clear-button:hover, +.search-clear-button:focus-visible { + color: var(--text_primary); + background: var(--surface_background); +} + .searchbar:focus-within { border-color: var(--primary_color); } diff --git a/webdev-prosjekt1/src/components/Searchbar.tsx b/webdev-prosjekt1/src/components/Searchbar.tsx index 56b14d3..d96903b 100644 --- a/webdev-prosjekt1/src/components/Searchbar.tsx +++ b/webdev-prosjekt1/src/components/Searchbar.tsx @@ -22,10 +22,20 @@ export const Searchbar = ({ onSearch, initialQuery = '' }: SearchbarProps) => { onSearch(query.trim()); }; + const handleClear = () => { + setQuery(''); + onSearch(''); + }; + return (
    setQuery(event.target.value)}/> + {query && ( + + )}
    ) }