Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions webdev-prosjekt1/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +28,8 @@ const readStoredItem = <T,>(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<Track | null>(() => readStoredItem<Track>('music-wiz-selected-track'));
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(() => readStoredItem<Album>('music-wiz-selected-album'));
Expand Down Expand Up @@ -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));
Expand All @@ -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<number, string[]>();
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 <AlbumDetailPage album={selectedAlbum ?? dummyAlbum} onTrackClick={handleAlbumTrackClick} />;
if (isArtistDetailRoute) return <ArtistDetailPage artist={selectedArtist ?? dummyArtist} />;
if (isSongDetailRoute) return <SongDetailPage song={selectedTrack ?? dummySong} />;
Expand All @@ -130,21 +188,32 @@ function App() {
<Header />
<section className='search-section'>
<Searchbar onSearch={handleSearch} />
<Button content={<><img src={filterIcon} alt='' /> <span>Filter</span></>} />
<Button
content={<><img src={filterIcon} alt='' /> <span>Filter</span></>}
onClick={() => setIsFilterOpen(true)}
/>
</section>
{isFilterOpen && (
<FilterPopup
filters={filters}
onChange={setFilters}
onClear={() => setFilters(EMPTY_FILTERS)}
onClose={() => setIsFilterOpen(false)}
/>
)}
{hasSearched ? (
<section className='results-section'>
<div className='results-group'>
<h2 className='results-heading'>Tracks</h2>
<ul className='card-list'>{results?.tracks.map((track) => <TrackCard key={track.id} track={track} onClick={() => handleTrackClick(track)} />)}</ul>
<ul className='card-list'>{filteredTracks.map((track) => <TrackCard key={track.id} track={track} onClick={() => handleTrackClick(track)} />)}</ul>
</div>
<div className='results-group'>
<h2 className='results-heading'>Albums</h2>
<ul className='card-list'>{results?.albums.map((album) => <AlbumCard key={album.id} album={album} onClick={() => handleAlbumClick(album)} />)}</ul>
<ul className='card-list'>{filteredAlbums.map((album) => <AlbumCard key={album.id} album={album} onClick={() => handleAlbumClick(album)} />)}</ul>
</div>
<div className='results-group'>
<h2 className='results-heading'>Artists</h2>
<ul className='card-list'>{results?.artists.map((artist) => <ArtistCard key={artist.id} artist={artist} onClick={() => handleArtistClick(artist)} />)}</ul>
<ul className='card-list'>{filteredArtists.map((artist) => <ArtistCard key={artist.id} artist={artist} onClick={() => handleArtistClick(artist)} />)}</ul>
</div>
</section>
) : (
Expand Down
1 change: 1 addition & 0 deletions webdev-prosjekt1/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Artist = {
href: string;
uri: string;
images: Image[];
genres?: string[];
}


Expand Down
7 changes: 4 additions & 3 deletions webdev-prosjekt1/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { ReactNode } from "react";
import type { MouseEventHandler, ReactNode } from "react";
import './Button.css';

type ButtonProps = {
content: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
}

export const Button = ({ content }: ButtonProps) => {
export const Button = ({ content, onClick }: ButtonProps) => {
return (
<button>
<button onClick={onClick}>
{content}
</button>
)
Expand Down
193 changes: 193 additions & 0 deletions webdev-prosjekt1/src/components/FilterPopup.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading