diff --git a/webdev-prosjekt1/README.md b/webdev-prosjekt1/README.md index 653fa86..a6b4d60 100644 --- a/webdev-prosjekt1/README.md +++ b/webdev-prosjekt1/README.md @@ -20,9 +20,9 @@ If you are developing a production application, we recommend updating the config ```js export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... @@ -37,42 +37,40 @@ export default defineConfig([ ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) - +]); ``` You can also install [eslint-plugin-react-x](https://npmx.dev/package/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://npmx.dev/package/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +import reactX from "eslint-plugin-react-x"; +import reactDom from "eslint-plugin-react-dom"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... // Enable lint rules for React - reactX.configs['recommended-typescript'], + reactX.configs["recommended-typescript"], // Enable lint rules for React DOM reactDom.configs.recommended, ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) - +]); ``` diff --git a/webdev-prosjekt1/eslint.config.js b/webdev-prosjekt1/eslint.config.js index ef614d2..da6f026 100644 --- a/webdev-prosjekt1/eslint.config.js +++ b/webdev-prosjekt1/eslint.config.js @@ -1,14 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -19,4 +19,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/webdev-prosjekt1/package-lock.json b/webdev-prosjekt1/package-lock.json index 5792b1d..1b0ca1d 100644 --- a/webdev-prosjekt1/package-lock.json +++ b/webdev-prosjekt1/package-lock.json @@ -34,6 +34,7 @@ "eslint-plugin-react-refresh": "^0.5.4", "globals": "^17.11.0", "jsdom": "^27.1.0", + "prettier": "^3.9.7", "typescript": "~6.0.2", "typescript-eslint": "^8.67.0", "vite": "^8.3.0", @@ -3308,6 +3309,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.7.tgz", + "integrity": "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/webdev-prosjekt1/package.json b/webdev-prosjekt1/package.json index d41ca35..47b74d5 100644 --- a/webdev-prosjekt1/package.json +++ b/webdev-prosjekt1/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest" @@ -38,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.5.4", "globals": "^17.11.0", "jsdom": "^27.1.0", + "prettier": "^3.9.7", "typescript": "~6.0.2", "typescript-eslint": "^8.67.0", "vite": "^8.3.0", diff --git a/webdev-prosjekt1/src/App.css b/webdev-prosjekt1/src/App.css index 26a2f74..30a113d 100644 --- a/webdev-prosjekt1/src/App.css +++ b/webdev-prosjekt1/src/App.css @@ -1,250 +1,249 @@ * { - box-sizing: border-box; + box-sizing: border-box; } body { - margin: var(--margin); - min-width: 320px; - font-family: var(--font_family); + margin: var(--margin); + min-width: 320px; + font-family: var(--font_family); } .app-shell { - min-height: 100vh; - background: var(--primary_background_color); - overflow-x: hidden; + min-height: 100vh; + background: var(--primary_background_color); + overflow-x: hidden; } .search-section { - display: flex; - gap: 10px; + display: flex; + gap: 10px; - width: 100%; - max-width: 1020px; + width: 100%; + max-width: 1020px; - margin: 14px auto 0; - padding: 0 24px; + margin: 14px auto 0; + padding: 0 24px; } @media (max-width: 700px) { - .main-header { - padding: 16px 24px 12px; - } + .main-header { + padding: 16px 24px 12px; + } - .app-title-text { - font-size: 40px; - } + .app-title-text { + font-size: 40px; + } - .search-section { - max-width: 100%; - padding: 0 12px; - } - - .hero-section { - width: calc(100% - 48px); - } + .search-section { + max-width: 100%; + padding: 0 12px; + } + .hero-section { + width: calc(100% - 48px); + } } .hero-section { - max-width: 1400px; - width: calc(100% - 96px); + max-width: 1400px; + width: calc(100% - 96px); - margin: 0 auto; - padding: 18px 0 50px; + margin: 0 auto; + padding: 18px 0 50px; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 48px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 48px; } .top-tracks-section { - display: flex; - gap: 24px; + display: flex; + gap: 24px; - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .top-tracks-group { - display: flex; - flex-direction: column; - gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .top-tracks-group .search-result-card { - position: relative; + position: relative; } .top-tracks-group .card-list { - counter-reset: track-rank; + counter-reset: track-rank; } .top-tracks-group .search-result-card::before { - counter-increment: track-rank; - content: counter(track-rank); + counter-increment: track-rank; + content: counter(track-rank); - position: absolute; - top: 4px; - left: 4px; - z-index: 2; + position: absolute; + top: 4px; + left: 4px; + z-index: 2; - display: grid; - place-items: center; + display: grid; + place-items: center; - width: 20px; - height: 20px; + width: 20px; + height: 20px; - border-radius: 50%; + border-radius: 50%; - background: var(--primary_color); - color: var(--primary_background_color); - box-shadow: 0 1px 4px var(--shadow_black); + background: var(--primary_color); + color: var(--primary_background_color); + box-shadow: 0 1px 4px var(--shadow_black); - font-size: 0.7rem; - font-weight: 700; - line-height: 1; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; } @media (max-width: 1100px) { - .hero-section { - flex-direction: column; - } + .hero-section { + flex-direction: column; + } - .top-tracks-section { - width: 100%; - } + .top-tracks-section { + width: 100%; + } - .hero-art { - position: relative; - top: 0; - align-self: center; - } + .hero-art { + position: relative; + top: 0; + align-self: center; + } } @media (max-width: 700px) { - .top-tracks-section { - flex-direction: column; - } + .top-tracks-section { + flex-direction: column; + } } .hero-art { - position: sticky; - top: 24px; + position: sticky; + top: 24px; - margin: 24px 0 0; + margin: 24px 0 0; - width: 520px; - min-height: 460px; + width: 520px; + min-height: 460px; - display: grid; - place-items: center; + display: grid; + place-items: center; - flex-shrink: 0; + flex-shrink: 0; } .hero-art img { - position: relative; - z-index: 1; + position: relative; + z-index: 1; - width: 480px; - max-width: calc(100% - 40px); - height: auto; + width: 480px; + max-width: calc(100% - 40px); + height: auto; - object-fit: contain; + object-fit: contain; - filter: drop-shadow(0 20px 25px var(--shadow_hero)); + filter: drop-shadow(0 20px 25px var(--shadow_hero)); } .music-wiz-glow { - position: absolute; - top: 50%; - left: 50%; - width: min(125%, 640px); - aspect-ratio: 1; - border-radius: 50%; - transform: translate(-50%, -50%); - background: var(--hero_glow); - filter: blur(50px); - pointer-events: none; + position: absolute; + top: 50%; + left: 50%; + width: min(125%, 640px); + aspect-ratio: 1; + border-radius: 50%; + transform: translate(-50%, -50%); + background: var(--hero_glow); + filter: blur(50px); + pointer-events: none; } /* Kept after the desktop artwork rules so the homepage matches favorites on phones. */ @media (max-width: 700px) { - .hero-art { - width: 320px; - min-height: 380px; - } + .hero-art { + width: 320px; + min-height: 380px; + } - .hero-art img { - width: 290px; - max-width: calc(100% - 30px); - } + .hero-art img { + width: 290px; + max-width: calc(100% - 30px); + } - .hero-art .music-wiz-glow { - width: 380px; - max-width: 125%; - } + .hero-art .music-wiz-glow { + width: 380px; + max-width: 125%; + } } .card-list { - width: 100%; - list-style: none; - padding: 0; - margin: 0; + width: 100%; + list-style: none; + padding: 0; + margin: 0; - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .results-section { - max-width: 1400px; - width: calc(100% - 96px); + max-width: 1400px; + width: calc(100% - 96px); - margin: 20px auto 0; - padding: 0 0 60px; + margin: 20px auto 0; + padding: 0 0 60px; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 24px; - align-items: start; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + align-items: start; } @media (max-width: 1100px) { - .results-section { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .results-section { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 700px) { - .search-controls { - width: calc(100% - 48px); - } + .search-controls { + width: calc(100% - 48px); + } - .hero-section { - width: calc(100% - 48px); - } + .hero-section { + width: calc(100% - 48px); + } - .results-section { - width: calc(100% - 48px); - grid-template-columns: minmax(0, 1fr); - gap: 32px; - } + .results-section { + width: calc(100% - 48px); + grid-template-columns: minmax(0, 1fr); + gap: 32px; + } } .results-group { - display: flex; - flex-direction: column; - gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; } .results-heading { - margin: 0; - color: var(--primary_color); - font-size: 1.4rem; - letter-spacing: 0.04em; + margin: 0; + color: var(--primary_color); + font-size: 1.4rem; + letter-spacing: 0.04em; } diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 92976d9..ebf6015 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -1,301 +1,420 @@ -import { useEffect, 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 { TrackDetailPage } from './components/TrackDetailPage'; -import { AlbumDetailPage } from './components/AlbumDetailPage'; -import { ArtistDetailPage } from './components/ArtistDetailPage'; -import { FavoritesPage } from './components/FavoritesPage'; -import { Header } from './components/Header'; -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, Track } from './api/types'; -import { fetchTrack, fetchTracks, search, TOP_10_IDS, BOTTOM_10_IDS } from './api/spotify'; -import musicWizImage from './assets/musicwiz.png'; -import { FunnelSimple } from '@phosphor-icons/react'; +import { useEffect, 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 { TrackDetailPage } from "./components/TrackDetailPage"; +import { AlbumDetailPage } from "./components/AlbumDetailPage"; +import { ArtistDetailPage } from "./components/ArtistDetailPage"; +import { FavoritesPage } from "./components/FavoritesPage"; +import { Header } from "./components/Header"; +import { TrackCard } from "./components/TrackCard"; +import { AlbumCard } from "./components/AlbumCard"; +import { ArtistCard } from "./components/ArtistCard"; +import { FilterPopup } from "./components/FilterPopup"; +import { EMPTY_FILTERS } from "./filterOptions"; +import type { Album, AlbumTrack, Artist, Track } from "./api/types"; +import { + fetchTrack, + fetchTracks, + search, + TOP_10_IDS, + BOTTOM_10_IDS, +} from "./api/spotify"; +import musicWizImage from "./assets/musicwiz.png"; +import { FunnelSimple } from "@phosphor-icons/react"; const readStoredItem = (key: string): T | null => { - try { - const storedItem = localStorage.getItem(key); - return storedItem ? JSON.parse(storedItem) as T : null; - } catch { - return null; - } + try { + const storedItem = localStorage.getItem(key); + return storedItem ? (JSON.parse(storedItem) as T) : null; + } catch { + return null; + } }; const readSessionItem = (key: string): T | null => { - try { - const storedItem = sessionStorage.getItem(key); - return storedItem ? JSON.parse(storedItem) as T : null; - } catch { - return null; - } + try { + const storedItem = sessionStorage.getItem(key); + return storedItem ? (JSON.parse(storedItem) as T) : null; + } catch { + return null; + } }; -const SEARCH_QUERY_STORAGE_KEY = 'music-wiz-search-query'; -const FILTERS_STORAGE_KEY = 'music-wiz-filters'; -const BASE_PATH = '/project1'; +const SEARCH_QUERY_STORAGE_KEY = "music-wiz-search-query"; +const FILTERS_STORAGE_KEY = "music-wiz-filters"; +const BASE_PATH = "/project1"; const getRoutePath = () => { - const pathname = window.location.pathname; - return pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) || '/' : pathname; + const pathname = window.location.pathname; + return pathname.startsWith(BASE_PATH) + ? pathname.slice(BASE_PATH.length) || "/" + : pathname; }; function App() { - const [query, setQuery] = useState(() => readSessionItem(SEARCH_QUERY_STORAGE_KEY) ?? ''); - const [isFilterOpen, setIsFilterOpen] = useState(false); - const [filters, setFilters] = useState(() => readSessionItem(FILTERS_STORAGE_KEY) ?? EMPTY_FILTERS); - const [currentPath, setCurrentPath] = useState(getRoutePath); - const [selectedTrack, setSelectedTrack] = useState(() => readStoredItem('music-wiz-selected-track')); - const [selectedAlbum, setSelectedAlbum] = useState(() => readStoredItem('music-wiz-selected-album')); - const [selectedArtist, setSelectedArtist] = useState(() => readStoredItem('music-wiz-selected-artist')); - const hasSearched = query.trim().length > 0; - const isSongDetailRoute = currentPath === '/track-detail' || currentPath === '/track-detail/'; - const isAlbumDetailRoute = currentPath === '/album-detail' || currentPath === '/album-detail/'; - const isArtistDetailRoute = currentPath === '/artist-detail' || currentPath === '/artist-detail/'; - const isFavoritesRoute = currentPath === '/favorites' || currentPath === '/favorites/'; - const dummyAlbumImage = { - url: musicWizImage, - height: 640, - width: 640, - }; - 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], - external_ids: { spotify: 'artist-id' }, - }; - const dummyTrack: 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', - href: 'https://api.spotify.com/v1/albums/album-id', - 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', - href: 'https://api.spotify.com/v1/albums/album-id', - artists: [dummyArtist], - images: [dummyAlbumImage], - release_date: '2026', - release_date_precision: 'year', - total_tracks: 10, - uri: 'spotify:album:album-id', - }; - useEffect(() => { - const handlePopState = () => setCurrentPath(getRoutePath()); - window.addEventListener('popstate', handlePopState); + const [query, setQuery] = useState( + () => readSessionItem(SEARCH_QUERY_STORAGE_KEY) ?? "", + ); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [filters, setFilters] = useState( + () => + readSessionItem(FILTERS_STORAGE_KEY) ?? + EMPTY_FILTERS, + ); + const [currentPath, setCurrentPath] = useState(getRoutePath); + const [selectedTrack, setSelectedTrack] = useState(() => + readStoredItem("music-wiz-selected-track"), + ); + const [selectedAlbum, setSelectedAlbum] = useState(() => + readStoredItem("music-wiz-selected-album"), + ); + const [selectedArtist, setSelectedArtist] = useState(() => + readStoredItem("music-wiz-selected-artist"), + ); + const hasSearched = query.trim().length > 0; + const isSongDetailRoute = + currentPath === "/track-detail" || currentPath === "/track-detail/"; + const isAlbumDetailRoute = + currentPath === "/album-detail" || currentPath === "/album-detail/"; + const isArtistDetailRoute = + currentPath === "/artist-detail" || currentPath === "/artist-detail/"; + const isFavoritesRoute = + currentPath === "/favorites" || currentPath === "/favorites/"; + const dummyAlbumImage = { + url: musicWizImage, + height: 640, + width: 640, + }; + 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], + external_ids: { spotify: "artist-id" }, + }; + const dummyTrack: 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", + href: "https://api.spotify.com/v1/albums/album-id", + 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", + href: "https://api.spotify.com/v1/albums/album-id", + artists: [dummyArtist], + images: [dummyAlbumImage], + release_date: "2026", + release_date_precision: "year", + total_tracks: 10, + uri: "spotify:album:album-id", + }; + useEffect(() => { + const handlePopState = () => setCurrentPath(getRoutePath()); + window.addEventListener("popstate", handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, []); - useEffect(() => { - sessionStorage.setItem(SEARCH_QUERY_STORAGE_KEY, JSON.stringify(query)); - }, [query]); - useEffect(() => { - sessionStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters)); - }, [filters]); - const navigateTo = (path: string) => { - window.history.pushState({}, '', `${BASE_PATH}${path}`); - setCurrentPath(path); - }; - const handleSearch = (nextQuery: string) => setQuery(nextQuery); - const handleHome = () => setQuery(''); - // Spotify's artist "genres" field is unreliable (often empty), so ask Spotify - // to do the genre filtering itself via the "genre:" search field filter. - const genreFilterClause = filters.genres.length === 1 ? ` genre:"${filters.genres[0]}"` : ''; - const effectiveQuery = `${query}${filters.onlyNew ? ' tag:new' : ''}${genreFilterClause}`; - const handleTrackClick = (track: Track) => { - setSelectedTrack(track); - localStorage.setItem('music-wiz-selected-track', JSON.stringify(track)); - navigateTo('/track-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('/track-detail'); - }; - const handleArtistClick = (artist: Artist) => { - setSelectedArtist(artist); - localStorage.setItem('music-wiz-selected-artist', JSON.stringify(artist)); - navigateTo('/artist-detail'); - }; - const handleFavoriteTrackClick = (track: Track | AlbumTrack) => { - if ('album' in track) { - handleTrackClick(track); - } else { - handleAlbumTrackClick(track); - } - }; - const { data: results, isLoading, isFetching, isError, error } = useQuery({ - queryKey: ['search', effectiveQuery], - queryFn: () => search(effectiveQuery), - enabled: query.trim().length > 0, - staleTime: 1000 * 60 * 60, - gcTime: 1000 * 60 * 60, - }); + return () => window.removeEventListener("popstate", handlePopState); + }, []); + useEffect(() => { + sessionStorage.setItem(SEARCH_QUERY_STORAGE_KEY, JSON.stringify(query)); + }, [query]); + useEffect(() => { + sessionStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters)); + }, [filters]); + const navigateTo = (path: string) => { + window.history.pushState({}, "", `${BASE_PATH}${path}`); + setCurrentPath(path); + }; + const handleSearch = (nextQuery: string) => setQuery(nextQuery); + const handleHome = () => setQuery(""); + // Spotify's artist "genres" field is unreliable (often empty), so ask Spotify + // to do the genre filtering itself via the "genre:" search field filter. + const genreFilterClause = + filters.genres.length === 1 ? ` genre:"${filters.genres[0]}"` : ""; + const effectiveQuery = `${query}${filters.onlyNew ? " tag:new" : ""}${genreFilterClause}`; + const handleTrackClick = (track: Track) => { + setSelectedTrack(track); + localStorage.setItem("music-wiz-selected-track", JSON.stringify(track)); + navigateTo("/track-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("/track-detail"); + }; + const handleArtistClick = (artist: Artist) => { + setSelectedArtist(artist); + localStorage.setItem("music-wiz-selected-artist", JSON.stringify(artist)); + navigateTo("/artist-detail"); + }; + const handleFavoriteTrackClick = (track: Track | AlbumTrack) => { + if ("album" in track) { + handleTrackClick(track); + } else { + handleAlbumTrackClick(track); + } + }; + const { + data: results, + isLoading, + isFetching, + isError, + error, + } = useQuery({ + queryKey: ["search", effectiveQuery], + queryFn: () => search(effectiveQuery), + enabled: query.trim().length > 0, + staleTime: 1000 * 60 * 60, + gcTime: 1000 * 60 * 60, + }); - const { data: topTracks = [] } = useQuery({ - queryKey: ['top-10-tracks'], - queryFn: () => fetchTracks(TOP_10_IDS), - staleTime: Infinity, - gcTime: Infinity, - }); + const { data: topTracks = [] } = useQuery({ + queryKey: ["top-10-tracks"], + queryFn: () => fetchTracks(TOP_10_IDS), + staleTime: Infinity, + gcTime: Infinity, + }); - const { data: bottomTracks = [] } = useQuery({ - queryKey: ['bottom-10-tracks'], - queryFn: () => fetchTracks(BOTTOM_10_IDS), - staleTime: Infinity, - gcTime: Infinity, - }); + const { data: bottomTracks = [] } = useQuery({ + queryKey: ["bottom-10-tracks"], + queryFn: () => fetchTracks(BOTTOM_10_IDS), + staleTime: Infinity, + gcTime: Infinity, + }); - const artistGenresById = new Map(); - results?.artists.forEach((artist) => { - if (artist.genres) { - artistGenresById.set(artist.id, artist.genres); - } - }); + const artistGenresById = new Map(); + results?.artists.forEach((artist) => { + if (artist.genres) { + artistGenresById.set(artist.id, artist.genres); + } + }); - const matchesArtist = (artists: { name: string }[]) => { - const artistQuery = filters.artist.trim().toLowerCase(); - return !artistQuery || artists.some((artist) => artist.name.toLowerCase().includes(artistQuery)); - }; + const matchesArtist = (artists: { name: string }[]) => { + const artistQuery = filters.artist.trim().toLowerCase(); + return ( + !artistQuery || + artists.some((artist) => artist.name.toLowerCase().includes(artistQuery)) + ); + }; - const matchesYear = (releaseDate: string) => { - const year = filters.year.trim(); - return !year || releaseDate.startsWith(year); - }; + const matchesYear = (releaseDate: string) => { + const year = filters.year.trim(); + return !year || releaseDate.startsWith(year); + }; - const matchesAlbum = (albumName: string) => { - const albumQuery = filters.album.trim().toLowerCase(); - return !albumQuery || albumName.toLowerCase().includes(albumQuery); - }; + const matchesAlbum = (albumName: string) => { + const albumQuery = filters.album.trim().toLowerCase(); + return !albumQuery || albumName.toLowerCase().includes(albumQuery); + }; - const matchesGenre = (artists: { id: number }[]) => { - if (filters.genres.length === 0) return true; + const matchesGenre = (artists: { id: number }[]) => { + if (filters.genres.length === 0) return true; - const knownGenres = artists.flatMap((artist) => artistGenresById.get(artist.id) ?? []); - // If Spotify didn't give us genre data for these artists, don't filter the item out. - if (knownGenres.length === 0) return true; + const knownGenres = artists.flatMap( + (artist) => artistGenresById.get(artist.id) ?? [], + ); + // If Spotify didn't give us genre data for these artists, don't filter the item out. + if (knownGenres.length === 0) return true; - return filters.genres.some((genre) => knownGenres.some((artistGenre) => artistGenre.toLowerCase().includes(genre.toLowerCase()))); - }; + return filters.genres.some((genre) => + knownGenres.some((artistGenre) => + artistGenre.toLowerCase().includes(genre.toLowerCase()), + ), + ); + }; - const filteredTracks = results?.tracks.filter((track) => - matchesArtist(track.artists) && - matchesYear(track.album.release_date) && - matchesAlbum(track.album.name) && - matchesGenre(track.artists) - ) ?? []; + const filteredTracks = + results?.tracks.filter( + (track) => + matchesArtist(track.artists) && + matchesYear(track.album.release_date) && + matchesAlbum(track.album.name) && + matchesGenre(track.artists), + ) ?? []; - const filteredAlbums = results?.albums.filter((album) => - matchesArtist(album.artists) && - matchesYear(album.release_date) && - matchesAlbum(album.name) && - matchesGenre(album.artists) - ) ?? []; + const filteredAlbums = + results?.albums.filter( + (album) => + matchesArtist(album.artists) && + matchesYear(album.release_date) && + matchesAlbum(album.name) && + matchesGenre(album.artists), + ) ?? []; - const filteredArtists = results?.artists.filter((artist) => - matchesArtist([artist]) && - matchesGenre([artist]) - ) ?? []; + const filteredArtists = + results?.artists.filter( + (artist) => matchesArtist([artist]) && matchesGenre([artist]), + ) ?? []; - if (isAlbumDetailRoute) return ; - if (isArtistDetailRoute) return ; - if (isSongDetailRoute) return ; - if (isFavoritesRoute) return ; + if (isAlbumDetailRoute) + return ( + + ); + if (isArtistDetailRoute) + return ( + + ); + if (isSongDetailRoute) + return ( + + ); + if (isFavoritesRoute) + return ( + + ); - return ( -
-
-
- -
- {isFilterOpen && ( - setFilters(EMPTY_FILTERS)} - onClose={() => setIsFilterOpen(false)} - /> - )} - {hasSearched ? ( -
-
-

Tracks

-
    {filteredTracks.map((track) => handleTrackClick(track)} />)}
-
-
-

Albums

-
    {filteredAlbums.map((album) => handleAlbumClick(album)} />)}
-
-
-

Artists

-
    {filteredArtists.map((artist) => handleArtistClick(artist)} />)}
-
-
- ) : ( -
- -
-
-
- )} - {(isLoading || isFetching) &&

Loading...

} - {isError &&

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

} -
- ); + return ( +
+
+
+ +
+ {isFilterOpen && ( + setFilters(EMPTY_FILTERS)} + onClose={() => setIsFilterOpen(false)} + /> + )} + {hasSearched ? ( +
+
+

Tracks

+
    + {filteredTracks.map((track) => ( + handleTrackClick(track)} + /> + ))} +
+
+
+

Albums

+
    + {filteredAlbums.map((album) => ( + handleAlbumClick(album)} + /> + ))} +
+
+
+

Artists

+
    + {filteredArtists.map((artist) => ( + handleArtistClick(artist)} + /> + ))} +
+
+
+ ) : ( +
+ +
+
+
+ )} + {(isLoading || isFetching) &&

Loading...

} + {isError && ( +

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

+ )} +
+ ); } export default App; diff --git a/webdev-prosjekt1/src/api/itunes.ts b/webdev-prosjekt1/src/api/itunes.ts index 1607d4f..3d05be8 100644 --- a/webdev-prosjekt1/src/api/itunes.ts +++ b/webdev-prosjekt1/src/api/itunes.ts @@ -1,31 +1,29 @@ 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 +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; +} diff --git a/webdev-prosjekt1/src/api/lrclib.ts b/webdev-prosjekt1/src/api/lrclib.ts index 38999fd..d792a6f 100644 --- a/webdev-prosjekt1/src/api/lrclib.ts +++ b/webdev-prosjekt1/src/api/lrclib.ts @@ -1,23 +1,28 @@ 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() - }); +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" - } - }); + const response = await fetch(`https://lrclib.net/api/get?${params}`, { + method: "Get", + headers: { + "Lrclib-Client": "MusicWizard/1.0", + }, + }); - if (!response.ok) { - return null; - } + if (!response.ok) { + return null; + } - return await response.json() as Lyrics; -} \ No newline at end of file + return (await response.json()) as Lyrics; +} diff --git a/webdev-prosjekt1/src/api/spotify.ts b/webdev-prosjekt1/src/api/spotify.ts index 6a29a57..e88ba4f 100644 --- a/webdev-prosjekt1/src/api/spotify.ts +++ b/webdev-prosjekt1/src/api/spotify.ts @@ -1,11 +1,11 @@ import type { AlbumTrack, SearchResult, Track, Album } from "./types"; export const spotifyUriToWebUrl = (uri: string): string => { - const match = uri.match(/^spotify:(track|album|artist):([^:?]+)$/); - return match ? `https://open.spotify.com/${match[1]}/${match[2]}` : uri; + const match = uri.match(/^spotify:(track|album|artist):([^:?]+)$/); + return match ? `https://open.spotify.com/${match[1]}/${match[2]}` : uri; }; -const BASE_URL = "https://api.spotify.com/v1" +const BASE_URL = "https://api.spotify.com/v1"; const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID; const CLIENT_SECRET = import.meta.env.VITE_SPOTIFY_CLIENT_SECRET; @@ -13,173 +13,185 @@ let accessToken: string | null = null; let expiresAt = 0; export const TOP_10_IDS = [ - "5CQ30WqJwcep0pYcV4AMNc", // Stairway To Heaven - Led Zeppelin - "5HNCy40Ni5BZJFw1TKzRsC", // Comfortably Numb - Pink Floyd - "2ODUgS9kk95bqVaf9EzyOU", // Bohemian Rhapsody - Queen - "2WfaOiMkCvy7F5fcp2zZ8L", // Take On Me - a-ha - "6TSP9oSbXONHk39GHvOSs3", // Telegraph Road - Dire Straits - "2374M0fQpWi3dLnB54qaLX", // Africa - TOTO - "1mea3bSkSGXuIRvnydlB5b", // Viva La Vida - Coldplay - "3BovdzfaX4jb5KFQwoPfAw", // Beat It - Michael Jackson - "3dYD57lRAUcMHufyqn9GcI", // Take Me To Church - Hozier - "0VjIjW4GlUZAMYd2vXMi3b", // Blinding Lights - The Weeknd + "0RO9W1xJoUEpq5MEelddFb", // Stairway To Heaven - Led Zeppelin + "5HNCy40Ni5BZJFw1TKzRsC", // Comfortably Numb - Pink Floyd + "2ODUgS9kk95bqVaf9EzyOU", // Bohemian Rhapsody - Queen + "2WfaOiMkCvy7F5fcp2zZ8L", // Take On Me - a-ha + "6TSP9oSbXONHk39GHvOSs3", // Telegraph Road - Dire Straits + "2374M0fQpWi3dLnB54qaLX", // Africa - TOTO + "1mea3bSkSGXuIRvnydlB5b", // Viva La Vida - Coldplay + "3BovdzfaX4jb5KFQwoPfAw", // Beat It - Michael Jackson + "3dYD57lRAUcMHufyqn9GcI", // Take Me To Church - Hozier + "0VjIjW4GlUZAMYd2vXMi3b", // Blinding Lights - The Weeknd ]; export const BOTTOM_10_IDS = [ - "63M8OHzd0lcVT517Pnon81", // Several Species of Small Furry Animals... - Pink Floyd - "1musbempyJAw5gfSKZHXP9", // Anxiety - Doechii - "4l62h4tiuUwn7eD6hxMlVQ", // It's Everyday Bro - Jake Paul ft. Team 10 - "1KEdF3FNF9bKRCxN3KUMbx", // Friday - Rebecca Black - "2XU0oxnq2qxCpomAAuJY8K", // Dance Monkey - Tones And I - "5RIVoVdkDLEygELLCniZFr", // Watch Me (Whip / Nae Nae) - Silentó - "43ZyHQITOjhciSUUNPVRHc", // Gucci Gang - Lil Pump - "0VNDOpBbUYtSpCFY7HUA3D", // Baby - Justin Bieber ft. Ludacris - "3Ofmpyhv5UAQ70mENzB277", // Astronaut In The Ocean - Masked Wolf - "28j1U8Ojtip5NoAweQclgK", // Mil etter mil - Jahn Teigen + "63M8OHzd0lcVT517Pnon81", // Several Species of Small Furry Animals... - Pink Floyd + "1musbempyJAw5gfSKZHXP9", // Anxiety - Doechii + "4l62h4tiuUwn7eD6hxMlVQ", // It's Everyday Bro - Jake Paul ft. Team 10 + "1KEdF3FNF9bKRCxN3KUMbx", // Friday - Rebecca Black + "2XU0oxnq2qxCpomAAuJY8K", // Dance Monkey - Tones And I + "5RIVoVdkDLEygELLCniZFr", // Watch Me (Whip / Nae Nae) - Silentó + "43ZyHQITOjhciSUUNPVRHc", // Gucci Gang - Lil Pump + "0VNDOpBbUYtSpCFY7HUA3D", // Baby - Justin Bieber ft. Ludacris + "3Ofmpyhv5UAQ70mENzB277", // Astronaut In The Ocean - Masked Wolf + "28j1U8Ojtip5NoAweQclgK", // Mil etter mil - Jahn Teigen ]; - async function getSpotifyToken() { - const response = await fetch("https://accounts.spotify.com/api/token", { - method: "POST", - body: new URLSearchParams({ - grant_type: "client_credentials", - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - }), - }); - if (!response.ok) { - throw new Error(`Failed to get Spotify token: ${response.status}`); - } - - return response.json(); + const response = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new Error(`Failed to get Spotify token: ${response.status}`); + } + + return response.json(); } async function getValidToken() { - if (accessToken && Date.now() < expiresAt) { - return accessToken; - } + if (accessToken && Date.now() < expiresAt) { + return accessToken; + } - const tokenData = await getSpotifyToken(); + const tokenData = await getSpotifyToken(); - accessToken = tokenData.access_token; - expiresAt = Date.now() + tokenData.expires_in * 1000; + accessToken = tokenData.access_token; + expiresAt = Date.now() + tokenData.expires_in * 1000; - return accessToken; + return accessToken; } - - export async function search(query: string): Promise { - const token = await getValidToken(); - - const response = await fetch(`${BASE_URL}/search?q=${encodeURIComponent(query)}&type=artist,track,album&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 newSearchResult = { - artists: json.artists.items, - tracks: json.tracks.items, - albums: json.albums.items, - } - const searchResult: SearchResult = newSearchResult; - - return searchResult; + const token = await getValidToken(); + + const response = await fetch( + `${BASE_URL}/search?q=${encodeURIComponent(query)}&type=artist,track,album&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 newSearchResult = { + artists: json.artists.items, + tracks: json.tracks.items, + albums: json.albums.items, + }; + const searchResult: SearchResult = newSearchResult; + + console.log(json); + + return searchResult; } export async function fetchTrack(id: string): Promise { - const token = await getValidToken(); + const token = await getValidToken(); - const response = await fetch(`${BASE_URL}/tracks/${encodeURIComponent(id)}`, { - method: "GET", - headers: { - "Authorization": `Bearer ${token}` - } - }); + const response = await fetch(`${BASE_URL}/tracks/${encodeURIComponent(id)}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (!response.ok) { - throw new Error(`Spotify api error: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Spotify api error: ${response.status}`); + } - const json = await response.json(); - const track: Track = json; + const json = await response.json(); + const track: Track = json; - return track; + return track; } export async function fetchAlbumTracks(id: string): Promise { - const token = await getValidToken(); - - const response = await fetch(`${BASE_URL}/albums/${id}/tracks?market=NO&limit=50`, { - method: "GET", - headers: { - "Authorization": `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Spotify api error: ${response.status}`); - } - - const json = await response.json(); - return json.items; + const token = await getValidToken(); + + const response = await fetch( + `${BASE_URL}/albums/${id}/tracks?market=NO&limit=50`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Spotify api error: ${response.status}`); + } + + const json = await response.json(); + return json.items; } - export async function fetchTracks(ids: string[]): Promise { - let tracks: Track[] = []; + const tracks: Track[] = []; - for (let index = 0; index < ids.length; index++) { - const track = await fetchTrack(ids[index]); - tracks.push(track); - } + for (let index = 0; index < ids.length; index++) { + const track = await fetchTrack(ids[index]); + tracks.push(track); + } - return tracks; + 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; + 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) { - throw new Error(`Spotify API error: ${response.status} ${response.statusText}`); - } - - const json = await response.json(); - - return json.items; + 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) { + 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 8b7af6b..0519294 100644 --- a/webdev-prosjekt1/src/api/types.ts +++ b/webdev-prosjekt1/src/api/types.ts @@ -1,115 +1,110 @@ // This file defines api related data model. export type Image = { - url: string; - height: number; - width: number; -} - + url: string; + height: number; + width: number; +}; export type Artist = { - id: number; - name: string; - type: string; - href: string; - uri: string; - images: Image[]; - external_ids: { - spotify: string; - }; - genres?: string[]; -} - + id: number; + name: string; + type: string; + href: string; + uri: string; + images: Image[]; + external_ids: { + spotify: string; + }; + genres?: string[]; +}; export type Track = { - id: string; + id: string; + name: string; + type: string; + href: string; + artists: { + id: number; name: string; type: string; href: string; - artists: { - id: number; - name: string; - type: string; - href: string; - uri: string; - }[]; - album: Album; - duration_ms: number; - explicit: boolean; - track_number: number; - external_ids: { - isrc: string; - }; - is_playable: boolean; uri: string; -} + }[]; + album: Album; + duration_ms: number; + explicit: boolean; + track_number: number; + external_ids: { + isrc: string; + }; + is_playable: boolean; + uri: string; +}; export type AlbumTrack = { - id: string; - name: string; - type: string; - href: string; - artists: Track['artists']; - duration_ms: number; - track_number: number; - uri: string; -} + id: string; + name: string; + type: string; + href: string; + artists: Track["artists"]; + duration_ms: number; + track_number: number; + uri: string; +}; export type Album = { - id: string; + id: string; + name: string; + album_type: "album" | "single" | "compilation"; + href: string; + artists: { + id: number; name: string; - album_type: "album" | "single" | "compilation"; + type: string; href: string; - artists: { - id: number; - name: string; - type: string; - href: string; - uri: string; - }[]; - images: Image[]; - release_date: string; - release_date_precision: "year" | "month" | "day"; - total_tracks: number; uri: string; + }[]; + images: Image[]; + release_date: string; + release_date_precision: "year" | "month" | "day"; + total_tracks: number; + uri: string; }; - export type SearchResult = { - artists: Artist[], - tracks: Track[], - albums: Album[] -} - + artists: Artist[]; + tracks: Track[]; + albums: Album[]; +}; export type Lyrics = { - id: number; - name: string; - trackName: string; - artistName: string; - albumName: string; - duration: number; - instrumental: boolean; - plainLyrics: string | null; - syncedLyrics: string | null; + 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; + trackId: number; + trackName: string; + artistName: string; + collectionName: string; + previewUrl?: string; + trackTimeMillis: number; }; export type ITunesSearchResponse = { - resultCount: number; - results: ITunesTrack[]; + resultCount: number; + results: ITunesTrack[]; }; export type PreviewTrack = { - name: string; - artists: { name: string }[]; + name: string; + artists: { name: string }[]; }; diff --git a/webdev-prosjekt1/src/components/AlbumCard.tsx b/webdev-prosjekt1/src/components/AlbumCard.tsx index 38ccb01..8cb5178 100644 --- a/webdev-prosjekt1/src/components/AlbumCard.tsx +++ b/webdev-prosjekt1/src/components/AlbumCard.tsx @@ -1,34 +1,40 @@ -import './TrackCard.css'; -import type { Album } from '../api/types'; -import musicwizimage from '../assets/musicwiz.png'; +import "./TrackCard.css"; +import type { Album } from "../api/types"; +import musicwizimage from "../assets/musicwiz.png"; type AlbumCardProps = { - album: Album; - onClick: () => void; -} + album: Album; + onClick: () => void; +}; 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); + const image = album.images[0]?.url ?? musicwizimage; + const artistNames = album.artists.map((artist) => artist.name).join(", "); + const year = album.release_date.slice(0, 4); - return ( -
  • { - if (event.key === 'Enter' || event.key === ' ') onClick(); - }}> -
    - -
    + return ( +
  • { + if (event.key === "Enter" || event.key === " ") onClick(); + }} + > +
    + +
    -
    -
    -

    {album.name}

    - {artistNames} -
    -
    - {year} -
    -
    -
  • - ) -} +
    +
    +

    {album.name}

    + {artistNames} +
    +
    + {year} +
    +
    + + ); +}; diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.css b/webdev-prosjekt1/src/components/AlbumDetailPage.css index 04c272a..b55b1f2 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.css +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.css @@ -1,152 +1,152 @@ .album-detail-page { - min-height: 100vh; - padding-bottom: 80px; - color: var(--text_primary); - background: var(--detail_background_gradient); + min-height: 100vh; + padding-bottom: 80px; + color: var(--text_primary); + background: var(--detail_background_gradient); } .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; + 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 var(--border_gold); - background: var(--surface_dark_transparent); + display: grid; + place-items: center; + position: relative; + width: 100%; + margin: 0; + aspect-ratio: 1; + border: 1px solid var(--border_gold); + background: var(--surface_dark_transparent); } .album-detail-page .detail-art::after { - content: ''; - position: absolute; - inset: 13px; - border: 1px solid var(--border_gold_inner); + content: ""; + position: absolute; + inset: 13px; + border: 1px solid var(--border_gold_inner); } .album-detail-page .detail-art img { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - object-fit: cover; + 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; + 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; + 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; + 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; + 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: var(--text_secondary); + margin: 22px 0 0; + font-size: clamp(1.05rem, 2vw, 1.35rem); + color: var(--text_secondary); } .album-detail-page .detail-meta { - margin: 12px 0 0; - color: var(--text_muted); - text-transform: capitalize; + margin: 12px 0 0; + color: var(--text_muted); + text-transform: capitalize; } .album-detail-page .detail-divider { - width: 100%; - height: 1px; - margin: 36px 0 18px; - border: 0; - background: var(--border_gold_subtle); + width: 100%; + height: 1px; + margin: 36px 0 18px; + border: 0; + background: var(--border_gold_subtle); } .album-detail-page .preview-player { - margin-top: 24px; + margin-top: 24px; } .album-tracks { - max-width: 1000px; - margin: 30px auto 0; - padding: 56px 48px 0; - border-top: 1px solid var(--border_gold_subtle); + max-width: 1000px; + margin: 30px auto 0; + padding: 56px 48px 0; + border-top: 1px solid var(--border_gold_subtle); } .album-tracks h2 { - margin: 0; - font-size: clamp(2rem, 4vw, 3.4rem); - line-height: 1; + margin: 0; + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; } .album-tracks p { - max-width: 660px; - margin: 28px 0 0; - color: var(--text_secondary); - font-size: 1.05rem; - line-height: 1.9; + max-width: 660px; + margin: 28px 0 0; + color: var(--text_secondary); + font-size: 1.05rem; + line-height: 1.9; } .album-track-list { - display: grid; - gap: 10px; - max-width: 760px; - margin: 28px 0 0; - padding: 0; - list-style: none; + 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; - 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 + .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; + } +} diff --git a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx index 02a1036..97905d5 100644 --- a/webdev-prosjekt1/src/components/AlbumDetailPage.tsx +++ b/webdev-prosjekt1/src/components/AlbumDetailPage.tsx @@ -1,70 +1,90 @@ -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'; +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; - onTrackClick: (track: AlbumTrack) => void; - onHome: () => void; + album: Album; + onTrackClick: (track: AlbumTrack) => void; + onHome: () => void; }; -export const AlbumDetailPage = ({ album, onTrackClick, onHome }: 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, - gcTime: 1000 * 60 * 60, - }); +export const AlbumDetailPage = ({ + album, + onTrackClick, + onHome, +}: 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, + gcTime: 1000 * 60 * 60, + }); - return ( -
    -
    + return ( +
    +
    -
    -
    - {albumImage ? ( - {`Album - ) : ( - Album cover image - )} -
    +
    +
    + {albumImage ? ( + {`Album + ) : ( + Album cover image + )} +
    -
    -

    Album

    -

    {album.name}

    -

    {artistNames || 'Artist Name'}

    -

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

    - +
    +

    Album

    +

    {album.name}

    +

    {artistNames || "Artist Name"}

    +

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

    + -
    -
    -
    +
    +
    + -
    -
    -

    Track listing

    -
    - {isLoading &&

    Loading tracks...

    } - {isError &&

    Could not load tracks for this album.

    } - {tracks && ( -
      - {tracks.map((track) => ( - onTrackClick(track)} /> - ))} -
    - )} -
    -
    - ); -}; \ No newline at end of file +
    +
    +

    Track listing

    +
    + {isLoading &&

    Loading tracks...

    } + {isError &&

    Could not load tracks for this album.

    } + {tracks && ( +
      + {tracks.map((track) => ( + onTrackClick(track)} + /> + ))} +
    + )} +
    +
    + ); +}; diff --git a/webdev-prosjekt1/src/components/ArtistCard.css b/webdev-prosjekt1/src/components/ArtistCard.css index 76a485f..def2ae9 100644 --- a/webdev-prosjekt1/src/components/ArtistCard.css +++ b/webdev-prosjekt1/src/components/ArtistCard.css @@ -1,3 +1,3 @@ .artist-image-wrapper { - border-radius: 50%; + border-radius: 50%; } diff --git a/webdev-prosjekt1/src/components/ArtistCard.tsx b/webdev-prosjekt1/src/components/ArtistCard.tsx index 26c95d8..ef84fdd 100644 --- a/webdev-prosjekt1/src/components/ArtistCard.tsx +++ b/webdev-prosjekt1/src/components/ArtistCard.tsx @@ -1,30 +1,36 @@ -import './TrackCard.css'; -import './ArtistCard.css'; -import type { Artist } from '../api/types'; -import musicwizimage from '../assets/musicwiz.png'; +import "./TrackCard.css"; +import "./ArtistCard.css"; +import type { Artist } from "../api/types"; +import musicwizimage from "../assets/musicwiz.png"; type ArtistCardProps = { - artist: Artist; - onClick: () => void; -} + artist: Artist; + onClick: () => void; +}; export const ArtistCard = ({ artist, onClick }: ArtistCardProps) => { - const image = artist.images[0]?.url ?? musicwizimage; + const image = artist.images[0]?.url ?? musicwizimage; - return ( -
  • { - if (event.key === 'Enter' || event.key === ' ') onClick(); - }}> -
    - -
    + return ( +
  • { + if (event.key === "Enter" || event.key === " ") onClick(); + }} + > +
    + +
    -
    -
    -

    {artist.name}

    - Artist -
    -
    -
  • - ) -} +
    +
    +

    {artist.name}

    + Artist +
    +
    + + ); +}; diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.css b/webdev-prosjekt1/src/components/ArtistDetailPage.css index b67a228..d5b3110 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.css +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.css @@ -1,185 +1,185 @@ .artist-detail-page { - min-height: 100vh; - padding-bottom: 80px; - color: var(--text_primary); - background: var(--detail_background_gradient); + min-height: 100vh; + padding-bottom: 80px; + color: var(--text_primary); + background: var(--detail_background_gradient); } .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; + 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 var(--border_gold); - border-radius: 50%; - overflow: hidden; - background: var(--surface_dark_transparent); + display: grid; + place-items: center; + position: relative; + width: 100%; + margin: 0; + aspect-ratio: 1; + border: 1px solid var(--border_gold); + border-radius: 50%; + overflow: hidden; + background: var(--surface_dark_transparent); } .artist-detail-page .detail-art::after { - display: none; + display: none; } .artist-detail-page .detail-art img { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - object-fit: cover; + 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; + 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; + 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; + 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; + 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 .spotify-link { - display: inline-flex; - align-items: center; - gap: 9px; - margin-top: 24px; - color: var(--primary_color); - font-weight: 700; - text-decoration: none; + display: inline-flex; + align-items: center; + gap: 9px; + margin-top: 24px; + color: var(--primary_color); + font-weight: 700; + text-decoration: none; } .artist-detail-page .spotify-link:hover, .artist-detail-page .spotify-link:focus-visible { - color: var(--text_primary); + color: var(--text_primary); } .artist-detail-page .detail-divider { - width: 100%; - height: 1px; - margin: 36px 0 18px; - border: 0; - background: var(--border_gold_subtle); + width: 100%; + height: 1px; + margin: 36px 0 18px; + border: 0; + background: var(--border_gold_subtle); } .artist-content { - max-width: 1000px; - margin: 30px auto 0; - padding: 56px 48px 0; - border-top: 1px solid var(--border_gold_subtle); + max-width: 1000px; + margin: 30px auto 0; + padding: 56px 48px 0; + border-top: 1px solid var(--border_gold_subtle); } .artist-content h2 { - margin: 0; - font-size: clamp(2rem, 4vw, 3.4rem); - line-height: 1; + margin: 0; + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; } .artist-content p { - max-width: 660px; - color: var(--text_secondary); - font-size: 1.05rem; - line-height: 1.9; + max-width: 660px; + 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; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 36px; + margin-top: 38px; } .artist-overview-group { - min-width: 0; + min-width: 0; } .artist-overview-heading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; } .artist-overview-heading h3 { - margin: 0; - font-size: 1.4rem; + margin: 0; + font-size: 1.4rem; } .artist-overview-heading span { - color: var(--primary_color); - font-size: 0.85rem; + color: var(--primary_color); + font-size: 0.85rem; } .artist-card-list { - display: grid; - gap: 10px; - margin: 0; - padding: 0; - list-style: none; + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; } .artist-status { - margin: 0; - font-size: 0.95rem; + margin: 0; + font-size: 0.95rem; } @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; - } - - .artist-overview-grid { - grid-template-columns: 1fr; - } + .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; + } + + .artist-overview-grid { + grid-template-columns: 1fr; + } } diff --git a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx index 5f368a0..606b963 100644 --- a/webdev-prosjekt1/src/components/ArtistDetailPage.tsx +++ b/webdev-prosjekt1/src/components/ArtistDetailPage.tsx @@ -1,93 +1,148 @@ -import './ArtistDetailPage.css'; -import '../variables.css'; -import { useQuery } from '@tanstack/react-query'; -import { Header } from './Header'; -import { AlbumCard } from './AlbumCard'; -import { TrackCard } from './TrackCard'; -import { fetchAlbumsByArtistId, fetchTracksByArtist, spotifyUriToWebUrl } from '../api/spotify'; -import type { Album, Artist, Track } from '../api/types'; +import "./ArtistDetailPage.css"; +import "../variables.css"; +import { useQuery } from "@tanstack/react-query"; +import { Header } from "./Header"; +import { AlbumCard } from "./AlbumCard"; +import { TrackCard } from "./TrackCard"; +import { + fetchAlbumsByArtistId, + fetchTracksByArtist, + spotifyUriToWebUrl, +} from "../api/spotify"; +import type { Album, Artist, Track } from "../api/types"; type ArtistDetailPageProps = { - artist: Artist; - onTrackClick: (track: Track) => void; - onAlbumClick: (album: Album) => void; - onHome: () => void; + artist: Artist; + onTrackClick: (track: Track) => void; + onAlbumClick: (album: Album) => void; + onHome: () => void; }; -export const ArtistDetailPage = ({ artist, onTrackClick, onAlbumClick, onHome }: 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, - gcTime: 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, - gcTime: 1000 * 60 * 60, - }); +export const ArtistDetailPage = ({ + artist, + onTrackClick, + onAlbumClick, + onHome, +}: 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, + gcTime: 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, + gcTime: 1000 * 60 * 60, + }); - return ( -
    -
    + return ( +
    +
    -
    -
    - {artistImage ? ( - {`Portrait - ) : ( - Artist image - )} -
    +
    +
    + {artistImage ? ( + {`Portrait + ) : ( + Artist image + )} +
    -
    -

    Artist

    -

    {artist.name}

    - - Open in Spotify - +
    +

    Artist

    +

    {artist.name}

    + + Open in Spotify + -
    -
    -
    +
    +
    + -
    -
    -

    Artist overview

    -
    +
    +
    +

    Artist overview

    +
    -
    -
    -
    -

    Tracks

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

    Loading songs...

    } - {isTracksError &&

    Could not load songs for this artist.

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

    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)} />)} -
    - )} -
    -
    -
    -
    - ); +
    +
    +

    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/Button.css b/webdev-prosjekt1/src/components/Button.css index 000bf8e..7cd177d 100644 --- a/webdev-prosjekt1/src/components/Button.css +++ b/webdev-prosjekt1/src/components/Button.css @@ -1,29 +1,29 @@ button { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; - padding: 0 18px; + padding: 0 18px; - border: 1px solid var(--primary_color); - border-radius: var(--box_border_radius); + border: 1px solid var(--primary_color); + border-radius: var(--box_border_radius); - color: var(--primary_background_color); - background: var(--primary_color); + color: var(--primary_background_color); + background: var(--primary_color); - font: inherit; - font-weight: 700; + font: inherit; + font-weight: 700; - cursor: pointer; + cursor: pointer; } button:hover { - background: var(--primary_color_hover); + background: var(--primary_color_hover); } button img { - width: 17px; - height: 17px; + width: 17px; + height: 17px; - object-fit: contain; + object-fit: contain; } diff --git a/webdev-prosjekt1/src/components/Button.tsx b/webdev-prosjekt1/src/components/Button.tsx index c2d7f3c..03a1891 100644 --- a/webdev-prosjekt1/src/components/Button.tsx +++ b/webdev-prosjekt1/src/components/Button.tsx @@ -2,15 +2,15 @@ import type { MouseEventHandler, ReactNode } from "react"; import "./Button.css"; type ButtonProps = { - content: ReactNode; - className?: string; - onClick?: MouseEventHandler; -} + content: ReactNode; + className?: string; + onClick?: MouseEventHandler; +}; export const Button = ({ content, className, onClick }: ButtonProps) => { - return ( - - ); -}; \ No newline at end of file + return ( + + ); +}; diff --git a/webdev-prosjekt1/src/components/Card.css b/webdev-prosjekt1/src/components/Card.css index 6b31a75..967130f 100644 --- a/webdev-prosjekt1/src/components/Card.css +++ b/webdev-prosjekt1/src/components/Card.css @@ -1,5 +1,5 @@ .search-result-card { - background-color: aliceblue; - display: grid; - grid-template-columns: 1fr 2fr; -} \ No newline at end of file + background-color: aliceblue; + display: grid; + grid-template-columns: 1fr 2fr; +} diff --git a/webdev-prosjekt1/src/components/Card.tsx b/webdev-prosjekt1/src/components/Card.tsx index 40ec043..f2a7c55 100644 --- a/webdev-prosjekt1/src/components/Card.tsx +++ b/webdev-prosjekt1/src/components/Card.tsx @@ -1,14 +1,10 @@ -import './Card.css'; +import "./Card.css"; import type { ReactNode } from "react"; type CardContent = { - content: ReactNode; -} + content: ReactNode; +}; export const Card = ({ content }: CardContent) => { - return ( -
  • - {content} -
  • - ) -} \ No newline at end of file + return
  • {content}
  • ; +}; diff --git a/webdev-prosjekt1/src/components/FavButton.css b/webdev-prosjekt1/src/components/FavButton.css index 3e2004d..cb670bf 100644 --- a/webdev-prosjekt1/src/components/FavButton.css +++ b/webdev-prosjekt1/src/components/FavButton.css @@ -1,21 +1,29 @@ .fav-button { - background: none; - border: none; - padding: 0; + background: none; + border: none; + padding: 0; } .fav-button:hover { - background: none; - transform: scale(1.1); + background: none; + transform: scale(1.1); } .star-animate { - animation: bounce-click 0.4s ease-out; + animation: bounce-click 0.4s ease-out; } @keyframes bounce-click { - 0% { transform: scale(1); } - 30% { transform: scale(0.85); } /* Squash down on initial click */ - 60% { transform: scale(1.1); } /* Bounce back up past normal size */ - 100% { transform: scale(1); } /* Settle back to original size */ -} \ No newline at end of file + 0% { + transform: scale(1); + } + 30% { + transform: scale(0.85); + } /* Squash down on initial click */ + 60% { + transform: scale(1.1); + } /* Bounce back up past normal size */ + 100% { + transform: scale(1); + } /* Settle back to original size */ +} diff --git a/webdev-prosjekt1/src/components/FavButton.tsx b/webdev-prosjekt1/src/components/FavButton.tsx index ae2034a..f243c31 100644 --- a/webdev-prosjekt1/src/components/FavButton.tsx +++ b/webdev-prosjekt1/src/components/FavButton.tsx @@ -1,42 +1,42 @@ -import { Button } from './Button'; +import { Button } from "./Button"; import { StarIcon } from "@phosphor-icons/react"; -import type {AlbumTrack, Track } from '../api/types'; -import { useFavorites } from '../hooks/useFavorites'; -import { useState } from 'react'; -import './FavButton.css'; +import type { AlbumTrack, Track } from "../api/types"; +import { useFavorites } from "../hooks/useFavorites"; +import { useState } from "react"; +import "./FavButton.css"; type FavButtonProps = { - track: Track | AlbumTrack; + track: Track | AlbumTrack; }; - export const FavButton = ({ track }: FavButtonProps) => { - const { favorites, addFavorite, removeFavorite } = useFavorites(); - const isFavorite = favorites.some( - favorite => favorite.id === track.id - ); - const [isAnimating, setIsAnimating] = useState(false); + const { favorites, addFavorite, removeFavorite } = useFavorites(); + const isFavorite = favorites.some((favorite) => favorite.id === track.id); + const [isAnimating, setIsAnimating] = useState(false); - return( - - + return ( + // This is valid use of div, as no other semantic tag fits. +
    +
    event.stopPropagation()} + > +
    +

    Filter

    + +
    - + - + - + - + -
    - Genre - - {GENRE_OPTIONS.map((genre) => ( - - ))} - -
    +
    + Genre + + {GENRE_OPTIONS.map((genre) => ( + + ))} + +
    -
    - -
    -
    -
    - ); +
    + +
    + + + ); }; diff --git a/webdev-prosjekt1/src/components/Header.css b/webdev-prosjekt1/src/components/Header.css index eaaaaf8..4644065 100644 --- a/webdev-prosjekt1/src/components/Header.css +++ b/webdev-prosjekt1/src/components/Header.css @@ -1,85 +1,85 @@ .main-header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - column-gap: 24px; - max-width: 1240px; - margin: 0 auto; - padding: 18px 48px 14px; - border-bottom: 2px solid var(--border_gold_faint); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: 24px; + max-width: 1240px; + margin: 0 auto; + padding: 18px 48px 14px; + border-bottom: 2px solid var(--border_gold_faint); } .favorites-link { - color: var(--primary_color); - text-decoration: none; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + color: var(--primary_color); + text-decoration: none; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } .favorites-link:hover { - text-decoration: underline; + text-decoration: underline; } .app-title { - color: var(--primary_color); - text-decoration: none; - display: inline-flex; - align-items: stretch; - position: relative; - padding-left: 70px; + 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; + display: flex; + flex-direction: column; } .app-logo-wrapper { - position: absolute; - left: 0; - top: 0; - height: 100%; - margin: var(--margin); + position: absolute; + left: 0; + top: 0; + height: 100%; + margin: var(--margin); } .app-logo { - display: block; - height: 100%; - width: auto; - object-fit: contain; + display: block; + height: 100%; + width: auto; + object-fit: contain; } .app-title-text { - font-size: 48px; - line-height: 0.95; - margin: var(--margin); + 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; + 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 { - grid-template-columns: minmax(0, 1fr); - row-gap: 12px; - padding: 16px 24px 12px; - } + .main-header { + grid-template-columns: minmax(0, 1fr); + row-gap: 12px; + padding: 16px 24px 12px; + } - .app-title-text { - font-size: 40px; - } + .app-title-text { + font-size: 40px; + } - .favorites-link { - grid-column: 1; - margin-left: 70px; - } -} \ No newline at end of file + .favorites-link { + grid-column: 1; + margin-left: 70px; + } +} diff --git a/webdev-prosjekt1/src/components/Header.tsx b/webdev-prosjekt1/src/components/Header.tsx index 5572275..736603b 100644 --- a/webdev-prosjekt1/src/components/Header.tsx +++ b/webdev-prosjekt1/src/components/Header.tsx @@ -1,41 +1,47 @@ -import './Header.css'; -import '../variables.css'; -import type { MouseEvent } from 'react'; -import logoImage from '../assets/music-wiz-logo.png'; +import "./Header.css"; +import "../variables.css"; +import type { MouseEvent } from "react"; +import logoImage from "../assets/music-wiz-logo.png"; -const BASE_PATH = '/project1'; +const BASE_PATH = "/project1"; type HeaderProps = { - onHome?: () => void; + onHome?: () => void; }; export const Header = ({ onHome }: HeaderProps) => { - const navigateTo = (path: string) => { - window.history.pushState({}, '', `${BASE_PATH}${path}`); - window.dispatchEvent(new PopStateEvent('popstate')); - }; - const handleHomeClick = (event: MouseEvent) => { - event.preventDefault(); - onHome?.(); - navigateTo('/'); - }; - const handleFavoritesClick = (event: MouseEvent) => { - event.preventDefault(); - navigateTo('/favorites'); - }; + const navigateTo = (path: string) => { + window.history.pushState({}, "", `${BASE_PATH}${path}`); + window.dispatchEvent(new PopStateEvent("popstate")); + }; + const handleHomeClick = (event: MouseEvent) => { + event.preventDefault(); + onHome?.(); + navigateTo("/"); + }; + const handleFavoritesClick = (event: MouseEvent) => { + event.preventDefault(); + navigateTo("/favorites"); + }; - return ( -
    - -
    - -
    - -

    MUSIC WIZ

    -

    Your personal music magician

    -
    -
    - Favorites -
    - ); -}; \ No newline at end of file + return ( +
    + +
    + +
    + +

    MUSIC WIZ

    +

    Your personal music magician

    +
    +
    + + Favorites + +
    + ); +}; diff --git a/webdev-prosjekt1/src/components/PreviewPlayer.tsx b/webdev-prosjekt1/src/components/PreviewPlayer.tsx index 35cf098..5672946 100644 --- a/webdev-prosjekt1/src/components/PreviewPlayer.tsx +++ b/webdev-prosjekt1/src/components/PreviewPlayer.tsx @@ -1,115 +1,137 @@ -import { useRef, useState } from 'react'; -import { fetchPreview } from '../api/itunes'; -import { spotifyUriToWebUrl } from '../api/spotify'; -import './SpotifyLink.css'; +import { useRef, useState } from "react"; +import { fetchPreview } from "../api/itunes"; +import { spotifyUriToWebUrl } from "../api/spotify"; +import "./SpotifyLink.css"; type PreviewTrack = { - id: string; - name: string; - artists: { name: string }[]; + id: string; + name: string; + artists: { name: string }[]; }; type PreviewPlayerProps = { - tracks: PreviewTrack[]; - spotifyUri?: string; - onTrackChange?: (trackId: string | null) => void; + tracks: PreviewTrack[]; + spotifyUri?: string; + onTrackChange?: (trackId: string | null) => void; }; -export const PreviewPlayer = ({ tracks, spotifyUri, 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); +export const PreviewPlayer = ({ + tracks, + spotifyUri, + 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 })); - } + 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; + 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 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; + const handlePlayToggle = async () => { + const audio = audioRef.current; + if (!audio || tracks.length === 0) return; - if (isPlaying) { - audio.pause(); - setIsPlaying(false); - onTrackChange?.(null); - 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; - } + if (audio.src && audio.paused === true && audio.currentTime > 0) { + await audio.play(); + setIsPlaying(true); + onTrackChange?.(tracks[currentIndex].id); + return; + } - await playTrack(currentIndex); - }; + await playTrack(currentIndex); + }; - const handleEnded = () => { - setIsPlaying(false); - onTrackChange?.(null); - if (currentIndex + 1 < tracks.length) { - void playTrack(currentIndex + 1); - return; - } + 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; - }; + setCurrentIndex(0); + if (audioRef.current) audioRef.current.currentTime = 0; + }; - return ( - <> -
    - - {spotifyUri && ( - - Open in Spotify - - )} -
    - - ); -}; \ No newline at end of file + return ( + <> +
    + + {spotifyUri && ( + + Open in Spotify + + )} +
    + + ); +}; diff --git a/webdev-prosjekt1/src/components/Searchbar.css b/webdev-prosjekt1/src/components/Searchbar.css index e43b2be..66ea475 100644 --- a/webdev-prosjekt1/src/components/Searchbar.css +++ b/webdev-prosjekt1/src/components/Searchbar.css @@ -1,76 +1,76 @@ .searchbar { - flex: 1; + flex: 1; - display: flex; - align-items: center; + display: flex; + align-items: center; - min-width: 0; + min-width: 0; - padding: 0 16px; + padding: 0 16px; - border: 1px solid var(--border_color); - border-radius: var(--box_border_radius); + border: 1px solid var(--border_color); + border-radius: var(--box_border_radius); - background: var(--search_background); + background: var(--search_background); } .search-icon { - flex-shrink: 0; - margin-right: 10px; + flex-shrink: 0; + margin-right: 10px; - color: var(--primary_color); + color: var(--primary_color); } .searchbar input { - width: 100%; + width: 100%; - padding: 15px 0; + padding: 15px 0; - border: 0; - outline: 0; + border: 0; + outline: 0; - color: var(--text_primary); - background: transparent; + color: var(--text_primary); + background: transparent; - font: inherit; + font: inherit; } .searchbar input::placeholder { - color: var(--text_subtle); + color: var(--text_subtle); } -.searchbar input[type='search']::-webkit-search-cancel-button, -.searchbar input[type='search']::-webkit-search-decoration { - -webkit-appearance: none; - appearance: none; +.searchbar input[type="search"]::-webkit-search-cancel-button, +.searchbar input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; } -.searchbar input[type='search']::-ms-clear { - display: none; +.searchbar input[type="search"]::-ms-clear { + display: none; } .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; + 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); + color: var(--text_primary); + background: var(--surface_background); } .searchbar:focus-within { - border-color: var(--primary_color); + border-color: var(--primary_color); } diff --git a/webdev-prosjekt1/src/components/Searchbar.tsx b/webdev-prosjekt1/src/components/Searchbar.tsx index 051d956..b233e7d 100644 --- a/webdev-prosjekt1/src/components/Searchbar.tsx +++ b/webdev-prosjekt1/src/components/Searchbar.tsx @@ -1,42 +1,55 @@ -import './Searchbar.css'; -import '../variables.css'; -import { useEffect, useState, type SubmitEvent } from 'react'; -import { MagnifyingGlass } from '@phosphor-icons/react'; +import "./Searchbar.css"; +import "../variables.css"; +import { useState, type SubmitEvent } from "react"; +import { MagnifyingGlass } from "@phosphor-icons/react"; type SearchbarProps = { - onSearch: (query: string) => void; - initialQuery?: string; + onSearch: (query: string) => void; + initialQuery?: string; }; -export const Searchbar = ({ onSearch, initialQuery = '' }: SearchbarProps) => { - const [query, setQuery] = useState(initialQuery); - - useEffect(() => { - setQuery(initialQuery); - }, [initialQuery]); - - const handleSubmit = (event: SubmitEvent) => { - event.preventDefault(); - - if (!query.trim()) return; - - onSearch(query.trim()); - }; - - const handleClear = () => { - setQuery(''); - onSearch(''); - }; - - return ( -
    -