From 6c88287e433659a03fab26cb8728378ddb3fffd5 Mon Sep 17 00:00:00 2001 From: Martin Sunnset Date: Tue, 15 Sep 2026 11:48:07 +0200 Subject: [PATCH 1/5] Added main functionality and UI for the filter button --- webdev-prosjekt1/src/App.tsx | 76 ++++++++- webdev-prosjekt1/src/api/types.ts | 1 + webdev-prosjekt1/src/components/Button.tsx | 7 +- .../src/components/FilterPopup.css | 149 ++++++++++++++++++ .../src/components/FilterPopup.tsx | 100 ++++++++++++ webdev-prosjekt1/src/filterOptions.ts | 14 ++ 6 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 webdev-prosjekt1/src/components/FilterPopup.css create mode 100644 webdev-prosjekt1/src/components/FilterPopup.tsx create mode 100644 webdev-prosjekt1/src/filterOptions.ts diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index c11b91c..64f49e0 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, Artist, Track } from './api/types'; import { search } from './api/spotify'; import filterIcon from './assets/filter.png'; @@ -18,6 +19,8 @@ import musicWizImage from './assets/musicwiz.png'; function App() { const [query, setQuery] = useState(''); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [filters, setFilters] = useState(EMPTY_FILTERS); const hasSearched = query.trim().length > 0; const isSongDetailRoute = window.location.pathname === '/song-detail' || window.location.pathname === '/song-detail/'; const isAlbumDetailRoute = window.location.pathname === '/album-detail' || window.location.pathname === '/album-detail/'; @@ -71,12 +74,62 @@ function App() { uri: 'spotify:album:album-id', }; const handleSearch = (nextQuery: string) => setQuery(nextQuery); + const effectiveQuery = filters.onlyNew ? `${query} tag:new` : query; 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; + return artists.some((artist) => { + const genres = artistGenresById.get(artist.id) ?? []; + return filters.genres.some((genre) => genres.includes(genre)); + }); + }; + + 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 ; @@ -86,21 +139,32 @@ function App() {
-
+ {isFilterOpen && ( + setFilters(EMPTY_FILTERS)} + onClose={() => setIsFilterOpen(false)} + /> + )} {hasSearched ? (

Tracks

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

Albums

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

Artists

-
    {results?.artists.map((artist) => )}
+
    {filteredArtists.map((artist) => )}
) : ( diff --git a/webdev-prosjekt1/src/api/types.ts b/webdev-prosjekt1/src/api/types.ts index 816cb1c..8964fdb 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..91ab70c --- /dev/null +++ b/webdev-prosjekt1/src/components/FilterPopup.css @@ -0,0 +1,149 @@ +.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 { + padding: 10px 12px; + + border: 1px solid #716044; + border-radius: var(--box_border_radius); + + background: rgba(20, 18, 15, 0.8); + color: #f8f0df; + + font: inherit; +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: 8px; + + font-size: 0.85rem; + cursor: pointer; +} + +.filter-checkbox input { + accent-color: var(--primary_color); +} + +.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..9364584 --- /dev/null +++ b/webdev-prosjekt1/src/components/FilterPopup.tsx @@ -0,0 +1,100 @@ +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', +]; From 9b2055f0632e684c1fa992cd1ed419bf16d58031 Mon Sep 17 00:00:00 2001 From: Martin Sunnset Date: Tue, 15 Sep 2026 11:59:44 +0200 Subject: [PATCH 2/5] Made New release button have a better UI --- .../src/components/FilterPopup.css | 71 +++++++++++++++++-- .../src/components/FilterPopup.tsx | 8 ++- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/webdev-prosjekt1/src/components/FilterPopup.css b/webdev-prosjekt1/src/components/FilterPopup.css index 91ab70c..797f84a 100644 --- a/webdev-prosjekt1/src/components/FilterPopup.css +++ b/webdev-prosjekt1/src/components/FilterPopup.css @@ -58,7 +58,8 @@ } .filter-field input { - padding: 10px 12px; + height: 38px; + padding: 0 12px; border: 1px solid #716044; border-radius: var(--box_border_radius); @@ -69,17 +70,75 @@ font: inherit; } -.filter-checkbox { +.filter-toggle { display: flex; align-items: center; - gap: 8px; + gap: 10px; + + height: 38px; + padding: 0 14px; + + border: 1px solid #716044; + border-radius: var(--box_border_radius); + + background: rgba(20, 18, 15, 0.8); - font-size: 0.85rem; cursor: pointer; } -.filter-checkbox input { - accent-color: var(--primary_color); +.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-toggle-label { + font-size: 0.8rem; + font-weight: 600; } .filter-genres { diff --git a/webdev-prosjekt1/src/components/FilterPopup.tsx b/webdev-prosjekt1/src/components/FilterPopup.tsx index 9364584..ac16692 100644 --- a/webdev-prosjekt1/src/components/FilterPopup.tsx +++ b/webdev-prosjekt1/src/components/FilterPopup.tsx @@ -65,13 +65,17 @@ export const FilterPopup = ({ filters, onChange, onClear, onClose }: FilterPopup /> -