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
35 changes: 13 additions & 22 deletions webdev-prosjekt1/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import './App.css';
import './variables.css';
import { Button } from './components/Button';
import { Searchbar } from './components/Searchbar';
import { SongDetailPage } from './components/SongDetailPage';
import { TrackDetailPage } from './components/TrackDetailPage';
import { AlbumDetailPage } from './components/AlbumDetailPage';
import { ArtistDetailPage } from './components/ArtistDetailPage';
import { Header } from './components/Header';
import { TrackCard } from './components/TrackCard';
import { AlbumCard } from './components/AlbumCard';
import { ArtistCard } from './components/ArtistCard';
import { FilterPopup, EMPTY_FILTERS } from './components/FilterPopup';
import type { Album, AlbumTrack, Artist, SearchResult, StoredSearch, Track } from './api/types';
import type { Album, AlbumTrack, Artist, Track } from './api/types';
import { fetchTrack, fetchTracks, search, TOP_10_IDS, BOTTOM_10_IDS } from './api/spotify';
import filterIcon from './assets/filter.png';
import musicWizImage from './assets/musicwiz.png';
Expand All @@ -27,16 +27,15 @@ const readStoredItem = <T,>(key: string): T | null => {
};

function App() {
const storedSearch = readStoredItem<StoredSearch>('music-wiz-search');
const [query, setQuery] = useState(storedSearch?.query ?? '');
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'));
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(() => readStoredItem<Artist>('music-wiz-selected-artist'));
const hasSearched = query.trim().length > 0;
const isSongDetailRoute = currentPath === '/song-detail' || currentPath === '/song-detail/';
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 dummyAlbumImage = {
Expand All @@ -51,8 +50,9 @@ function App() {
href: 'https://api.spotify.com/v1/artists/artist-id',
uri: 'spotify:artist:artist-id',
images: [dummyAlbumImage],
external_ids: { spotify: 'artist-id' },
};
const dummySong: Track = {
const dummyTrack: Track = {
id: 'song-id',
name: 'Song Name',
type: 'track',
Expand All @@ -62,6 +62,7 @@ function App() {
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',
Expand All @@ -80,6 +81,7 @@ function App() {
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',
Expand All @@ -106,7 +108,7 @@ function App() {
const handleTrackClick = (track: Track) => {
setSelectedTrack(track);
localStorage.setItem('music-wiz-selected-track', JSON.stringify(track));
navigateTo('/song-detail');
navigateTo('/track-detail');
};
const handleAlbumClick = (album: Album) => {
setSelectedAlbum(album);
Expand All @@ -117,7 +119,7 @@ function App() {
const fullTrack = await fetchTrack(track.id);
setSelectedTrack(fullTrack);
localStorage.setItem('music-wiz-selected-track', JSON.stringify(fullTrack));
navigateTo('/song-detail');
navigateTo('/track-detail');
};
const handleArtistClick = (artist: Artist) => {
setSelectedArtist(artist);
Expand All @@ -128,7 +130,7 @@ function App() {
queryKey: ['search', effectiveQuery],
queryFn: () => search(effectiveQuery),
enabled: query.trim().length > 0,
initialData: storedSearch?.effectiveQuery === effectiveQuery ? storedSearch.results : undefined,
staleTime: 1000 * 60 * 60,
});

const { data: topTracks = [] } = useQuery({
Expand All @@ -143,17 +145,6 @@ function App() {
staleTime: Infinity,
});

useEffect(() => {
if (!results || !query.trim()) return;

const searchToStore: StoredSearch = {
query,
effectiveQuery,
results: results as SearchResult,
};
localStorage.setItem('music-wiz-search', JSON.stringify(searchToStore));
}, [effectiveQuery, query, results]);

const artistGenresById = new Map<number, string[]>();
results?.artists.forEach((artist) => {
if (artist.genres) {
Expand Down Expand Up @@ -206,8 +197,8 @@ function App() {
) ?? [];

if (isAlbumDetailRoute) return <AlbumDetailPage album={selectedAlbum ?? dummyAlbum} onTrackClick={handleAlbumTrackClick} />;
if (isArtistDetailRoute) return <ArtistDetailPage artist={selectedArtist ?? dummyArtist} />;
if (isSongDetailRoute) return <SongDetailPage song={selectedTrack ?? dummySong} />;
if (isArtistDetailRoute) return <ArtistDetailPage artist={selectedArtist ?? dummyArtist} onTrackClick={handleTrackClick} onAlbumClick={handleAlbumClick} />;
if (isSongDetailRoute) return <TrackDetailPage song={selectedTrack ?? dummyTrack} onAlbumClick={handleAlbumClick} />;

return (
<main className='app-shell'>
Expand Down
31 changes: 31 additions & 0 deletions webdev-prosjekt1/src/api/itunes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ITunesSearchResponse, PreviewTrack } from "./types";



export async function fetchPreview(track: PreviewTrack): Promise<string | null> {
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;
}
23 changes: 23 additions & 0 deletions webdev-prosjekt1/src/api/lrclib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Lyrics } from "./types";

export async function getLyrics(artist: string, track: string, album: string, durationMs: number): Promise<Lyrics | null> {
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"
}
});

if (!response.ok) {
return null;
}

return await response.json() as Lyrics;
}
51 changes: 48 additions & 3 deletions webdev-prosjekt1/src/api/spotify.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AlbumTrack, SearchResult, Track } from "./types";
import type { AlbumTrack, SearchResult, Track, Album } from "./types";


const BASE_URL = "https://api.spotify.com/v1"
Expand Down Expand Up @@ -87,15 +87,16 @@ export async function search(query: string): Promise<SearchResult> {
albums: json.albums.items,
}
const searchResult: SearchResult = newSearchResult;
console.log(searchResult);

console.log(json);

return searchResult;
}

export async function fetchTrack(id: string): Promise<Track> {
const token = await getValidToken();

const response = await fetch(`${BASE_URL}/tracks/${id}`, {
const response = await fetch(`${BASE_URL}/tracks/${encodeURIComponent(id)}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`
Expand Down Expand Up @@ -143,3 +144,47 @@ export async function fetchTracks(ids: string[]): Promise<Track[]> {

return tracks;
}

export async function fetchTracksByArtist(name: string): Promise<Track[]> {
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<Album[]> {
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) {
const error = await response.text();
console.error("Spotify response body:", error);

throw new Error(
`Spotify API error: ${response.status} ${response.statusText}`
);
}

const json = await response.json();

return json.items;
}
41 changes: 36 additions & 5 deletions webdev-prosjekt1/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export type Artist = {
href: string;
uri: string;
images: Image[];
external_ids: {
spotify: string;
};
genres?: string[];
}

Expand Down Expand Up @@ -56,6 +59,7 @@ export type Album = {
id: string;
name: string;
album_type: "album" | "single" | "compilation";
href: string;
artists: {
id: number;
name: string;
Expand All @@ -77,8 +81,35 @@ export type SearchResult = {
albums: Album[]
}

export type StoredSearch = {
query: string;
effectiveQuery: string;
results: SearchResult;
}

export type Lyrics = {
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;
};

export type ITunesSearchResponse = {
resultCount: number;
results: ITunesTrack[];
};

export type PreviewTrack = {
name: string;
artists: { name: string }[];
};
4 changes: 4 additions & 0 deletions webdev-prosjekt1/src/components/AlbumDetailPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
background: var(--border_gold_subtle);
}

.album-detail-page .preview-player {
margin-top: 24px;
}

.album-tracks {
max-width: 1000px;
margin: 30px auto 0;
Expand Down
7 changes: 6 additions & 1 deletion webdev-prosjekt1/src/components/AlbumDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
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;
};

export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) => {
const [playingTrackId, setPlayingTrackId] = useState<string | null>(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
});

return (
Expand All @@ -39,6 +43,7 @@ export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) =
<p className='detail-meta'>
{album.album_type} · {album.release_date} · {album.total_tracks} tracks
</p>
<PreviewPlayer key={album.id} tracks={tracks ?? []} href={album.href} onTrackChange={setPlayingTrackId} />

<hr className='detail-divider' />
</header>
Expand All @@ -53,7 +58,7 @@ export const AlbumDetailPage = ({ album, onTrackClick }: AlbumDetailPageProps) =
{tracks && (
<ul className='album-track-list'>
{tracks.map((track) => (
<TrackCard key={track.id} track={track} album={album} onClick={() => onTrackClick(track)} />
<TrackCard key={track.id} track={track} album={album} isActive={track.id === playingTrackId} onClick={() => onTrackClick(track)} />
))}
</ul>
)}
Expand Down
Loading