diff --git a/webdev-prosjekt1/src/App.css b/webdev-prosjekt1/src/App.css index a27a2e4..7d977c5 100644 --- a/webdev-prosjekt1/src/App.css +++ b/webdev-prosjekt1/src/App.css @@ -17,63 +17,6 @@ body { overflow: hidden; } -.main-header { - max-width: 1240px; - margin: 0 auto; - padding: 18px 48px 14px; - border-bottom: 2px solid rgba(212, 175, 55, 0.22); -} - -.app-title { - color: var(--primary_color); - text-decoration: none; - display: inline-flex; - align-items: stretch; - position: relative; - padding-left: 70px; -} - -.app-title-text-wrapper { - display: flex; - flex-direction: column; -} - -.app-logo-wrapper { - position: absolute; - left: 0; - top: 0; - - height: 100%; - margin: var(--margin); -} - -.app-logo { - display: block; - - height: 100%; - width: auto; - - object-fit: contain; -} - -.app-title-text { - font-size: 48px; - line-height: 0.95; - margin: var(--margin); -} - -.header-note { - font-size: 13px; - margin: 4px 0 0; - - font-weight: 400; - letter-spacing: 0.08em; - - text-transform: uppercase; - white-space: nowrap; - text-align: center; -} - .search-section { display: flex; gap: 10px; @@ -190,9 +133,12 @@ body { align-items: start; } -@media (max-width: 900px) { - .results-section { - grid-template-columns: 1fr; +@media (max-width: 700px) { + .search-controls { + width: calc(100% - 48px); + } + + .hero-section { width: calc(100% - 48px); } } diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index e1c10f6..c11b91c 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -1,102 +1,119 @@ import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import './App.css'; import './variables.css'; import { Button } from './components/Button'; import { Searchbar } from './components/Searchbar'; +import { SongDetailPage } from './components/SongDetailPage'; +import { AlbumDetailPage } from './components/AlbumDetailPage'; +import { ArtistDetailPage } from './components/ArtistDetailPage'; +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 filterIcon from './assets/filter.png'; import musicWizImage from './assets/musicwiz.png'; -import logoImage from './assets/music-wiz-logo.png'; -import { useQuery } from '@tanstack/react-query'; -import { search } from './api/spotify'; function App() { const [query, setQuery] = useState(''); const hasSearched = query.trim().length > 0; - - const handleSearch = (nextQuery: string) => { - setQuery(nextQuery); + 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 dummyAlbumImage = { + url: musicWizImage, + height: 640, + width: 640, }; - - const { - data: results, - isLoading, - isFetching, - isError, - error, - } = useQuery({ + const dummyArtist: Artist = { + id: 1, + name: 'Artist Name', + type: 'artist', + href: 'https://api.spotify.com/v1/artists/artist-id', + uri: 'spotify:artist:artist-id', + images: [dummyAlbumImage], + }; + const dummySong: Track = { + id: 'song-id', + name: 'Song Name', + type: 'track', + href: 'https://api.spotify.com/v1/tracks/song-id', + artists: [dummyArtist], + album: { + id: 'album-id', + name: 'Album Name', + album_type: 'album', + artists: [dummyArtist], + images: [dummyAlbumImage], + release_date: '2026', + release_date_precision: 'year', + total_tracks: 10, + uri: 'spotify:album:album-id', + }, + duration_ms: 214000, + explicit: false, + track_number: 1, + external_ids: { isrc: 'DUMMY-ISRC' }, + is_playable: true, + uri: 'spotify:track:song-id', + }; + const dummyAlbum: Album = { + id: 'album-id', + name: 'Album Name', + album_type: 'album', + artists: [dummyArtist], + images: [dummyAlbumImage], + release_date: '2026', + release_date_precision: 'year', + total_tracks: 10, + uri: 'spotify:album:album-id', + }; + const handleSearch = (nextQuery: string) => setQuery(nextQuery); + const { data: results, isLoading, isFetching, isError, error } = useQuery({ queryKey: ['search', query], queryFn: () => search(query), enabled: query.trim().length > 0, }); - console.log(results); + if (isAlbumDetailRoute) return ; + if (isArtistDetailRoute) return ; + if (isSongDetailRoute) return ; return ( - <> -
-
- -
- -
- -

MUSIC WIZ

-

Your personal music magician

-
-
-
- -
- -
+ {hasSearched ? ( +
+
+

Tracks

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

Albums

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

Artists

+
    {results?.artists.map((artist) => )}
+
- - {hasSearched ? ( -
-
-

Tracks

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

Albums

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

Artists

-
    - {results?.artists.map((artist) => ( - - ))} -
-
-
- ) : ( -
-
-
- A musical wizard -
-
- )} -
- + ) : ( +
+
+
+ A musical wizard +
+
+ )} + {(isLoading || isFetching) &&

Loading...

} + {isError &&

Search failed: {error instanceof Error ? error.message : 'Unknown error'}

} + ); } diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.css b/webdev-prosjekt1/src/components/AlbumDetailPage.css new file mode 100644 index 0000000..20799ce --- /dev/null +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.css @@ -0,0 +1,140 @@ +.album-detail-page { + min-height: 100vh; + padding-bottom: 80px; + color: #f8f0df; + background: radial-gradient(circle at 26% 44%, #43331e 0, #1c1814 31%, #11100f 70%); +} + +.album-detail-page .detail-content { + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + align-items: center; + gap: clamp(28px, 5vw, 72px); + max-width: 1100px; + margin: 0 auto; + padding: 92px 48px 40px; +} + +.album-detail-page .detail-art { + display: grid; + place-items: center; + position: relative; + width: 100%; + margin: 0; + aspect-ratio: 1; + border: 1px solid rgba(212, 175, 55, 0.42); + background: rgba(17, 16, 15, 0.72); + box-shadow: 18px 22px 0 rgba(212, 175, 55, 0.08), 0 24px 50px rgba(0, 0, 0, 0.36); +} + +.album-detail-page .detail-art::after { + content: ''; + position: absolute; + inset: 13px; + border: 1px solid rgba(212, 175, 55, 0.2); +} + +.album-detail-page .detail-art img { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; +} + +.album-detail-page .detail-art-placeholder { + color: var(--primary_color); + font-size: clamp(1.8rem, 4vw, 3.2rem); + font-weight: 700; + font-family: Georgia, serif; + text-align: center; +} + +.album-detail-page .detail-copy { + max-width: 560px; + min-width: 0; +} + +.album-detail-page .detail-eyebrow { + margin: 0 0 12px; + color: var(--primary_color); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.album-detail-page .detail-copy h1 { + margin: 0; + font-size: clamp(2.6rem, 6vw, 5.4rem); + line-height: 0.98; + font-weight: 700; + word-break: normal; + hyphens: none; + overflow-wrap: break-word; +} + +.album-detail-page .detail-subtitle { + margin: 22px 0 0; + font-size: clamp(1.05rem, 2vw, 1.35rem); + color: #d9cdb8; +} + +.album-detail-page .detail-meta { + margin: 12px 0 0; + color: #a79a86; + text-transform: capitalize; +} + +.album-detail-page .detail-divider { + width: 100%; + height: 1px; + margin: 36px 0 18px; + border: 0; + background: rgba(212, 175, 55, 0.32); +} + +.album-tracks { + max-width: 1000px; + margin: 30px auto 0; + padding: 56px 48px 0; + border-top: 1px solid rgba(212, 175, 55, 0.32); +} + +.album-tracks h2 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; +} + +.album-tracks p { + max-width: 660px; + margin: 28px 0 0; + color: #d9cdb8; + font-size: 1.05rem; + line-height: 1.9; +} + +@media (max-width: 900px) { + .album-detail-page .detail-content { + grid-template-columns: 1fr; + gap: 54px; + padding: 56px 32px 32px; + } + + .album-detail-page .detail-art { + max-width: 340px; + margin: 0 auto; + } + + .album-detail-page .detail-copy { + min-width: 0; + max-width: 560px; + margin: 0 auto; + } + + .album-tracks { + margin-top: 30px; + padding: 42px 32px 0; + } +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx new file mode 100644 index 0000000..3cdc455 --- /dev/null +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx @@ -0,0 +1,47 @@ +import './AlbumDetailPage.css'; +import '../variables.css'; +import { Header } from './Header'; +import type { Album } from '../api/types'; + +type AlbumDetailPageProps = { + album: Album; +}; + +export const AlbumDetailPage = ({ album }: AlbumDetailPageProps) => { + const artistNames = album.artists.map((artist) => artist.name).join(', '); + const albumImage = album.images[0]?.url; + + return ( +
+
+ +
+
+ {albumImage ? ( + {`Album + ) : ( + Album cover image + )} +
+ +
+

Album

+

{album.name}

+

{artistNames || 'Artist Name'}

+

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

+ +
+
+
+ +
+
+

Track listing

+
+

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

+
+
+ ); +}; \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.css b/webdev-prosjekt1/src/components/ArtistDetailPage.css new file mode 100644 index 0000000..1bc8deb --- /dev/null +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.css @@ -0,0 +1,142 @@ +.artist-detail-page { + min-height: 100vh; + padding-bottom: 80px; + color: #f8f0df; + background: radial-gradient(circle at 26% 44%, #43331e 0, #1c1814 31%, #11100f 70%); +} + +.artist-detail-page .detail-content { + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + align-items: center; + gap: clamp(28px, 5vw, 72px); + max-width: 1100px; + margin: 0 auto; + padding: 92px 48px 40px; +} + +.artist-detail-page .detail-art { + display: grid; + place-items: center; + position: relative; + width: 100%; + margin: 0; + aspect-ratio: 1; + border: 1px solid rgba(212, 175, 55, 0.42); + border-radius: 50%; + overflow: hidden; + background: rgba(17, 16, 15, 0.72); + box-shadow: 18px 22px 0 rgba(212, 175, 55, 0.08), 0 24px 50px rgba(0, 0, 0, 0.36); +} + +.artist-detail-page .detail-art::after { + content: ''; + position: absolute; + inset: 13px; + border: 1px solid rgba(212, 175, 55, 0.2); + border-radius: 50%; +} + +.artist-detail-page .detail-art img { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; +} + +.artist-detail-page .detail-art-placeholder { + color: var(--primary_color); + font-size: clamp(1.8rem, 4vw, 3.2rem); + font-weight: 700; + font-family: Georgia, serif; + text-align: center; +} + +.artist-detail-page .detail-copy { + max-width: 560px; + min-width: 0; +} + +.artist-detail-page .detail-eyebrow { + margin: 0 0 12px; + color: var(--primary_color); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.artist-detail-page .detail-copy h1 { + margin: 0; + font-size: clamp(2.6rem, 6vw, 5.4rem); + line-height: 0.98; + font-weight: 700; + word-break: normal; + hyphens: none; + overflow-wrap: break-word; +} + +.artist-detail-page .detail-subtitle { + margin: 22px 0 0; + font-size: clamp(1.05rem, 2vw, 1.35rem); + color: #d9cdb8; +} + +.artist-detail-page .detail-meta { + margin: 12px 0 0; + color: #a79a86; + text-transform: capitalize; +} + +.artist-detail-page .detail-divider { + width: 100%; + height: 1px; + margin: 36px 0 18px; + border: 0; + background: rgba(212, 175, 55, 0.32); +} + +.artist-content { + max-width: 1000px; + margin: 30px auto 0; + padding: 56px 48px 0; + border-top: 1px solid rgba(212, 175, 55, 0.32); +} + +.artist-content h2 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; +} + +.artist-content p { + max-width: 660px; + margin: 28px 0 0; + color: #d9cdb8; + font-size: 1.05rem; + line-height: 1.9; +} + +@media (max-width: 900px) { + .artist-detail-page .detail-content { + grid-template-columns: 1fr; + gap: 54px; + padding: 56px 32px 32px; + } + + .artist-detail-page .detail-art { + max-width: 340px; + margin: 0 auto; + } + + .artist-detail-page .detail-copy { + max-width: 560px; + margin: 0 auto; + } + + .artist-content { + margin-top: 30px; + padding: 42px 32px 0; + } +} diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx new file mode 100644 index 0000000..bae8536 --- /dev/null +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx @@ -0,0 +1,44 @@ +import './ArtistDetailPage.css'; +import '../variables.css'; +import { Header } from './Header'; +import type { Artist } from '../api/types'; + +type ArtistDetailPageProps = { + artist: Artist; +}; + +export const ArtistDetailPage = ({ artist }: ArtistDetailPageProps) => { + const artistImage = artist.images[0]?.url; + + return ( +
+
+ +
+
+ {artistImage ? ( + {`Portrait + ) : ( + Artist image + )} +
+ +
+

Artist

+

{artist.name}

+

Artist profile

+

{artist.type} · Music Wiz profile

+ +
+
+
+ +
+
+

Artist overview

+
+

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

+
+
+ ); +}; diff --git a/webdev-prosjekt1/src/components/Header.css b/webdev-prosjekt1/src/components/Header.css new file mode 100644 index 0000000..f19f58f --- /dev/null +++ b/webdev-prosjekt1/src/components/Header.css @@ -0,0 +1,61 @@ +.main-header { + max-width: 1240px; + margin: 0 auto; + padding: 18px 48px 14px; + border-bottom: 2px solid rgba(212, 175, 55, 0.22); +} + +.app-title { + color: var(--primary_color); + text-decoration: none; + display: inline-flex; + align-items: stretch; + position: relative; + padding-left: 70px; +} + +.app-title-text-wrapper { + display: flex; + flex-direction: column; +} + +.app-logo-wrapper { + position: absolute; + left: 0; + top: 0; + height: 100%; + margin: var(--margin); +} + +.app-logo { + display: block; + height: 100%; + width: auto; + object-fit: contain; +} + +.app-title-text { + font-size: 48px; + line-height: 0.95; + margin: var(--margin); +} + +.header-note { + font-size: 13px; + margin: 4px 0 0; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + text-align: center; +} + +@media (max-width: 700px) { + .main-header { + padding: 16px 24px 12px; + } + + .app-title-text { + font-size: 40px; + } +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/Header.tsx b/webdev-prosjekt1/src/components/Header.tsx new file mode 100644 index 0000000..faa2d1a --- /dev/null +++ b/webdev-prosjekt1/src/components/Header.tsx @@ -0,0 +1,19 @@ +import './Header.css'; +import '../variables.css'; +import logoImage from '../assets/music-wiz-logo.png'; + +export const Header = () => { + return ( +
+ +
+ +
+ +

MUSIC WIZ

+

Your personal music magician

+
+
+
+ ); +}; \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/Searchbar.tsx b/webdev-prosjekt1/src/components/Searchbar.tsx index d69561b..2c2240a 100644 --- a/webdev-prosjekt1/src/components/Searchbar.tsx +++ b/webdev-prosjekt1/src/components/Searchbar.tsx @@ -1,6 +1,6 @@ import './Searchbar.css'; import '../variables.css'; -import { useState } from 'react'; +import { useState, type SubmitEvent } from 'react'; type SearchbarProps = { onSearch: (query: string) => void; @@ -9,7 +9,7 @@ type SearchbarProps = { export const Searchbar = ({ onSearch }: SearchbarProps) => { const [query, setQuery] = useState(''); - const handleSubmit = (event: React.SubmitEvent) => { + const handleSubmit = (event: SubmitEvent) => { event.preventDefault(); if (!query.trim()) return; diff --git a/webdev-prosjekt1/src/components/SongDetailPage.css b/webdev-prosjekt1/src/components/SongDetailPage.css new file mode 100644 index 0000000..a5ba47d --- /dev/null +++ b/webdev-prosjekt1/src/components/SongDetailPage.css @@ -0,0 +1,152 @@ +.song-detail-page { + min-height: 100vh; + padding-bottom: 80px; + color: #f8f0df; + background: radial-gradient(circle at 26% 44%, #43331e 0, #1c1814 31%, #11100f 70%); +} + +.detail-content { + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + align-items: center; + gap: clamp(28px, 5vw, 72px); + max-width: 1100px; + margin: 0 auto; + padding: 92px 48px 40px; +} + +.detail-art { + display: grid; + place-items: center; + position: relative; + width: 100%; + margin: 0; + aspect-ratio: 1; + border: 1px solid rgba(212, 175, 55, 0.42); + background: rgba(17, 16, 15, 0.72); + box-shadow: 18px 22px 0 rgba(212, 175, 55, 0.08), 0 24px 50px rgba(0, 0, 0, 0.36); +} + +.detail-art::after { + content: ''; + position: absolute; + inset: 13px; + border: 1px solid rgba(212, 175, 55, 0.2); +} + +.detail-art img { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-art-placeholder { + color: var(--primary_color); + font-size: clamp(1.8rem, 4vw, 3.2rem); + font-weight: 700; + font-family: Georgia, serif; + text-align: center; +} + +.detail-copy { + max-width: 560px; + min-width: 0; +} + +.detail-eyebrow { + margin: 0 0 12px; + color: var(--primary_color); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.detail-copy h1 { + margin: 0; + font-size: clamp(2.6rem, 6vw, 5.4rem); + line-height: 0.98; + font-weight: 700; + word-break: normal; + hyphens: none; + overflow-wrap: break-word; +} + +.detail-subtitle { + margin: 22px 0 0; + font-size: clamp(1.05rem, 2vw, 1.35rem); + color: #d9cdb8; +} + +.detail-meta { + margin: 12px 0 0; + color: #a79a86; +} + +.detail-divider { + width: 100%; + height: 1px; + margin: 36px 0 18px; + border: 0; + background: rgba(212, 175, 55, 0.32); +} + +.detail-coming-soon { + margin: 0; + color: #847b6c; + font-size: 0.95rem; +} + +.lyrics-section { + display: grid; + grid-template-columns: minmax(180px, 260px) minmax(0, 660px); + gap: clamp(36px, 9vw, 130px); + max-width: 1000px; + margin: 30px auto 0; + padding: 56px 48px 0; + border-top: 1px solid rgba(212, 175, 55, 0.32); +} + +.lyrics-heading h2 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; +} + +.lyrics-copy { + color: #d9cdb8; + font-size: 1.05rem; + line-height: 1.9; +} + +.lyrics-copy p { + margin: 0 0 28px; +} + +@media (max-width: 900px) { + .detail-content { + grid-template-columns: 1fr; + gap: 54px; + padding: 56px 32px 32px; + } + + .detail-art { + max-width: 340px; + margin: 0 auto; + } + + .detail-copy { + min-width: 0; + max-width: 560px; + margin: 0 auto; + } + + .lyrics-section { + grid-template-columns: 1fr; + gap: 28px; + margin-top: 30px; + padding: 42px 32px 0; + } +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/SongDetailPage.tsx b/webdev-prosjekt1/src/components/SongDetailPage.tsx new file mode 100644 index 0000000..24d0c65 --- /dev/null +++ b/webdev-prosjekt1/src/components/SongDetailPage.tsx @@ -0,0 +1,63 @@ +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