diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 4806562..510a04d 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'; @@ -12,7 +12,7 @@ import { TrackCard } from './components/TrackCard'; import { AlbumCard } from './components/AlbumCard'; import { ArtistCard } from './components/ArtistCard'; import { FilterPopup, EMPTY_FILTERS } from './components/FilterPopup'; -import type { Album, AlbumTrack, Artist, SearchResult, StoredSearch, Track } from './api/types'; +import type { Album, AlbumTrack, Artist, Track } from './api/types'; import { fetchTrack, fetchTracks, search, TOP_10_IDS, BOTTOM_10_IDS } from './api/spotify'; import filterIcon from './assets/filter.png'; import musicWizImage from './assets/musicwiz.png'; @@ -27,8 +27,7 @@ const readStoredItem = (key: string): T | null => { }; function App() { - const storedSearch = readStoredItem('music-wiz-search'); - const [query, setQuery] = useState(storedSearch?.query ?? ''); + const [query, setQuery] = useState(''); const [isFilterOpen, setIsFilterOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const [currentPath, setCurrentPath] = useState(window.location.pathname); @@ -36,7 +35,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 = { @@ -51,8 +50,9 @@ 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 = { + const dummyTrack: Track = { id: 'song-id', name: 'Song Name', type: 'track', @@ -62,6 +62,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', @@ -80,6 +81,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', @@ -106,7 +108,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); @@ -117,7 +119,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); @@ -128,7 +130,7 @@ function App() { queryKey: ['search', effectiveQuery], queryFn: () => search(effectiveQuery), enabled: query.trim().length > 0, - initialData: storedSearch?.effectiveQuery === effectiveQuery ? storedSearch.results : undefined, + staleTime: 1000 * 60 * 60, }); const { data: topTracks = [] } = useQuery({ @@ -143,17 +145,6 @@ function App() { staleTime: Infinity, }); - useEffect(() => { - if (!results || !query.trim()) return; - - const searchToStore: StoredSearch = { - query, - effectiveQuery, - results: results as SearchResult, - }; - localStorage.setItem('music-wiz-search', JSON.stringify(searchToStore)); - }, [effectiveQuery, query, results]); - const artistGenresById = new Map(); results?.artists.forEach((artist) => { if (artist.genres) { @@ -206,8 +197,8 @@ function App() { ) ?? []; if (isAlbumDetailRoute) return ; - if (isArtistDetailRoute) return ; - if (isSongDetailRoute) return ; + if (isArtistDetailRoute) return ; + if (isSongDetailRoute) return ; return (
diff --git a/webdev-prosjekt1/src/api/itunes.ts b/webdev-prosjekt1/src/api/itunes.ts new file mode 100644 index 0000000..1607d4f --- /dev/null +++ b/webdev-prosjekt1/src/api/itunes.ts @@ -0,0 +1,31 @@ +import type { ITunesSearchResponse, PreviewTrack } from "./types"; + + + +export async function fetchPreview(track: PreviewTrack): 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..38999fd --- /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() 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 d77ae82..52b84e9 100644 --- a/webdev-prosjekt1/src/api/spotify.ts +++ b/webdev-prosjekt1/src/api/spotify.ts @@ -1,4 +1,4 @@ -import type { AlbumTrack, SearchResult, Track } from "./types"; +import type { AlbumTrack, SearchResult, Track, Album } from "./types"; const BASE_URL = "https://api.spotify.com/v1" @@ -87,7 +87,8 @@ export async function search(query: string): Promise { albums: json.albums.items, } const searchResult: SearchResult = newSearchResult; - console.log(searchResult); + + console.log(json); return searchResult; } @@ -95,7 +96,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}` @@ -143,3 +144,47 @@ export async function fetchTracks(ids: string[]): Promise { 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 4e88747..8b7af6b 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; + }; genres?: string[]; } @@ -56,6 +59,7 @@ export type Album = { id: string; name: string; album_type: "album" | "single" | "compilation"; + href: string; artists: { id: number; name: string; @@ -77,8 +81,35 @@ export type SearchResult = { albums: Album[] } -export type StoredSearch = { - query: string; - effectiveQuery: string; - results: SearchResult; -} \ 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[]; +}; + +export type PreviewTrack = { + name: string; + artists: { name: string }[]; +}; 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 76c5f91..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,11 +14,13 @@ 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({ queryKey: ['album-tracks', album.id], queryFn: () => fetchAlbumTracks(album.id), + staleTime: 1000 * 60 * 60 }); return ( @@ -39,6 +43,7 @@ export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) =

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

+
@@ -53,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 3a2081e..87dc33a 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.css +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.css @@ -77,16 +77,19 @@ 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); } .artist-detail-page .detail-divider { @@ -112,12 +115,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 +183,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..cc380ce 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/Header.tsx b/webdev-prosjekt1/src/components/Header.tsx index 94c363d..79b2532 100644 --- a/webdev-prosjekt1/src/components/Header.tsx +++ b/webdev-prosjekt1/src/components/Header.tsx @@ -7,14 +7,9 @@ type HeaderProps = { }; export const Header = ({ onHome }: HeaderProps) => { - const handleHomeClick = () => { - localStorage.removeItem('music-wiz-search'); - onHome?.(); - }; - return (
- +
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 ( +
+ ); +}; \ No newline at end of file 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 && ( + + )}
) } diff --git a/webdev-prosjekt1/src/components/SongDetailPage.tsx b/webdev-prosjekt1/src/components/SongDetailPage.tsx deleted file mode 100644 index 24d0c65..0000000 --- a/webdev-prosjekt1/src/components/SongDetailPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import './SongDetailPage.css'; -import '../variables.css'; -import { Header } from './Header'; -import type { Track } from '../api/types'; - -type SongDetailPageProps = { - song: Track; -}; - -export type DetailSelectHandler = (song: Track) => void; - -const formatDuration = (durationMs: number) => { - const totalSeconds = Math.floor(durationMs / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = String(totalSeconds % 60).padStart(2, '0'); - - return `${minutes}:${seconds}`; -}; - -export const SongDetailPage = ({ song }: SongDetailPageProps) => { - const artistNames = song.artists.map((artist) => artist.name).join(', '); - const albumImage = song.album.images[0]?.url; - - return ( -
-
- -
-
- {albumImage ? ( - {`Album - ) : ( - Album cover image - )} -
- -
-

Song

-

{song.name}

-

{artistNames}

-

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

- -
-
-
- -
-
-

Lyrics

-
- -
-

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.

-
-
-
- ); -}; \ 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/SongDetailPage.css b/webdev-prosjekt1/src/components/TrackDetailPage.css similarity index 64% rename from webdev-prosjekt1/src/components/SongDetailPage.css rename to webdev-prosjekt1/src/components/TrackDetailPage.css index 1900baa..8f5fc30 100644 --- a/webdev-prosjekt1/src/components/SongDetailPage.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); @@ -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; @@ -93,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); @@ -118,11 +190,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/TrackDetailPage.tsx b/webdev-prosjekt1/src/components/TrackDetailPage.tsx new file mode 100644 index 0000000..0bdd1dc --- /dev/null +++ b/webdev-prosjekt1/src/components/TrackDetailPage.tsx @@ -0,0 +1,81 @@ +import './TrackDetailPage.css'; +import '../variables.css'; +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; + onAlbumClick: (album: Album) => void; +}; + +export type DetailSelectHandler = (song: Track) => void; + +const formatDuration = (durationMs: number) => { + const totalSeconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + + return `${minutes}:${seconds}`; +}; + +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; + 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 ( +
    +
    + +
    +
    + {albumImage ? ( + {`Album + ) : ( + Album cover image + )} +
    + +
    +

    Track

    +

    {song.name}

    +

    {artistNames}

    +

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

    + + +
    +
    +
    + +
    +
    +

    Lyrics

    +
    + +
    + {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'}

    + ))} +
    +
    +
    + ); +}; \ No newline at end of file