diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 3c969a0..65bbf73 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -11,6 +11,7 @@ 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, search } from './api/spotify'; import filterIcon from './assets/filter.png'; @@ -27,6 +28,8 @@ const readStoredItem = (key: string): T | null => { function App() { const [query, setQuery] = useState(''); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [filters, setFilters] = useState(EMPTY_FILTERS); const [currentPath, setCurrentPath] = useState(window.location.pathname); const [selectedTrack, setSelectedTrack] = useState(() => readStoredItem('music-wiz-selected-track')); const [selectedAlbum, setSelectedAlbum] = useState(() => readStoredItem('music-wiz-selected-album')); @@ -94,6 +97,10 @@ function App() { setCurrentPath(path); }; const handleSearch = (nextQuery: string) => setQuery(nextQuery); + // 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)); @@ -116,11 +123,62 @@ function App() { navigateTo('/artist-detail'); }; const { data: results, isLoading, isFetching, isError, error } = useQuery({ - queryKey: ['search', query], - queryFn: () => search(query), + queryKey: ['search', effectiveQuery], + queryFn: () => search(effectiveQuery), enabled: query.trim().length > 0, }); + 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 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 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; + + 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 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]) + ) ?? []; + if (isAlbumDetailRoute) return ; if (isArtistDetailRoute) return ; if (isSongDetailRoute) return ; @@ -130,21 +188,32 @@ function App() {
-
+ {isFilterOpen && ( + setFilters(EMPTY_FILTERS)} + onClose={() => setIsFilterOpen(false)} + /> + )} {hasSearched ? (

Tracks

-
    {results?.tracks.map((track) => handleTrackClick(track)} />)}
+
    {filteredTracks.map((track) => handleTrackClick(track)} />)}

Albums

-
    {results?.albums.map((album) => handleAlbumClick(album)} />)}
+
    {filteredAlbums.map((album) => handleAlbumClick(album)} />)}

Artists

-
    {results?.artists.map((artist) => handleArtistClick(artist)} />)}
+
    {filteredArtists.map((artist) => handleArtistClick(artist)} />)}
) : ( diff --git a/webdev-prosjekt1/src/api/types.ts b/webdev-prosjekt1/src/api/types.ts index 5a53178..5dcdf5b 100644 --- a/webdev-prosjekt1/src/api/types.ts +++ b/webdev-prosjekt1/src/api/types.ts @@ -14,6 +14,7 @@ export type Artist = { href: string; uri: string; images: Image[]; + genres?: string[]; } diff --git a/webdev-prosjekt1/src/components/Button.tsx b/webdev-prosjekt1/src/components/Button.tsx index be1f196..3070060 100644 --- a/webdev-prosjekt1/src/components/Button.tsx +++ b/webdev-prosjekt1/src/components/Button.tsx @@ -1,13 +1,14 @@ -import type { ReactNode } from "react"; +import type { MouseEventHandler, ReactNode } from "react"; import './Button.css'; type ButtonProps = { content: ReactNode; + onClick?: MouseEventHandler; } -export const Button = ({ content }: ButtonProps) => { +export const Button = ({ content, onClick }: ButtonProps) => { return ( - ) diff --git a/webdev-prosjekt1/src/components/FilterPopup.css b/webdev-prosjekt1/src/components/FilterPopup.css new file mode 100644 index 0000000..9f11c20 --- /dev/null +++ b/webdev-prosjekt1/src/components/FilterPopup.css @@ -0,0 +1,193 @@ +.filter-overlay { + position: fixed; + inset: 0; + z-index: 50; + + display: flex; + align-items: center; + justify-content: center; + + padding: 24px; + + background: rgba(0, 0, 0, 0.6); +} + +.filter-popup { + width: 100%; + max-width: 420px; + + display: flex; + flex-direction: column; + gap: 18px; + + padding: 24px; + + border: 1px solid rgba(212, 175, 55, 0.35); + border-radius: var(--box_border_radius); + + background: #1c1814; + color: #f8f0df; +} + +.filter-popup-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.filter-popup-header h2 { + margin: 0; + color: var(--primary_color); +} + +.filter-close { + border: none; + background: transparent; + color: #f8f0df; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; +} + +.filter-field { + display: flex; + flex-direction: column; + gap: 6px; + + font-size: 0.85rem; +} + +.filter-field input { + height: 38px; + padding: 0 12px; + + border: 1px solid #716044; + border-radius: var(--box_border_radius); + + background: rgba(20, 18, 15, 0.8); + color: #f8f0df; + + font: inherit; +} + +.filter-toggle { + cursor: pointer; +} + +.filter-toggle-input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.filter-toggle-track { + position: relative; + flex-shrink: 0; + + width: 30px; + height: 17px; + + border: 1px solid #716044; + border-radius: 999px; + + background: rgba(0, 0, 0, 0.4); + + transition: background 0.2s ease, border-color 0.2s ease; +} + +.filter-toggle-thumb { + position: absolute; + top: 1px; + left: 1px; + + width: 13px; + height: 13px; + + border-radius: 50%; + background: #f8f0df; + + transition: transform 0.2s ease; +} + +.filter-toggle-input:checked + .filter-toggle-track { + border-color: var(--primary_color); + background: var(--primary_color); +} + +.filter-toggle-input:checked + .filter-toggle-track .filter-toggle-thumb { + background: var(--primary_background_color); + transform: translateX(13px); +} + +.filter-toggle-input:focus-visible + .filter-toggle-track { + outline: 2px solid var(--primary_color); + outline-offset: 2px; +} + +.filter-genres { + border: none; + padding: 0; + margin: 0; +} + +.filter-genres legend { + padding: 0 0 8px; + font-size: 0.85rem; +} + +.genre-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.genre-chip { + padding: 6px 14px; + + border: 1px solid #716044; + border-radius: 999px; + + background: transparent; + color: #f8f0df; + + font: inherit; + font-size: 0.8rem; + text-transform: capitalize; + + cursor: pointer; +} + +.genre-chip-active { + border-color: var(--primary_color); + background: var(--primary_color); + color: var(--primary_background_color); +} + +.filter-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.filter-clear, +.filter-apply { + padding: 10px 18px; + + border-radius: var(--box_border_radius); + border: 1px solid var(--primary_color); + + font: inherit; + font-weight: 700; + + cursor: pointer; +} + +.filter-clear { + background: transparent; + color: var(--primary_color); +} + +.filter-apply { + background: var(--primary_color); + color: var(--primary_background_color); +} diff --git a/webdev-prosjekt1/src/components/FilterPopup.tsx b/webdev-prosjekt1/src/components/FilterPopup.tsx new file mode 100644 index 0000000..64924c3 --- /dev/null +++ b/webdev-prosjekt1/src/components/FilterPopup.tsx @@ -0,0 +1,104 @@ +import './FilterPopup.css'; +import { GENRE_OPTIONS } from '../filterOptions'; + +export type Filters = { + artist: string; + year: string; + album: string; + genres: string[]; + onlyNew: boolean; +}; + +export const EMPTY_FILTERS: Filters = { artist: '', year: '', album: '', genres: [], onlyNew: false }; + +type FilterPopupProps = { + filters: Filters; + onChange: (filters: Filters) => void; + onClear: () => void; + onClose: () => void; +}; + +export const FilterPopup = ({ filters, onChange, onClear, onClose }: FilterPopupProps) => { + const toggleGenre = (genre: string) => { + const nextGenres = filters.genres.includes(genre) + ? filters.genres.filter((selected) => selected !== genre) + : [...filters.genres, genre]; + + onChange({ ...filters, genres: nextGenres }); + }; + + return ( +
+
event.stopPropagation()}> +
+

Filter

+ +
+ + + + + + + + + +
+ Genre +
+ {GENRE_OPTIONS.map((genre) => ( + + ))} +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/webdev-prosjekt1/src/filterOptions.ts b/webdev-prosjekt1/src/filterOptions.ts new file mode 100644 index 0000000..9e7000f --- /dev/null +++ b/webdev-prosjekt1/src/filterOptions.ts @@ -0,0 +1,14 @@ +export const GENRE_OPTIONS = [ + 'pop', + 'rock', + 'hip hop', + 'r&b', + 'jazz', + 'classical', + 'electronic', + 'country', + 'metal', + 'indie', + 'folk', + 'reggae', +];