diff --git a/webdev-prosjekt1/package-lock.json b/webdev-prosjekt1/package-lock.json index d64e3da..4045023 100644 --- a/webdev-prosjekt1/package-lock.json +++ b/webdev-prosjekt1/package-lock.json @@ -8,6 +8,7 @@ "name": "webdev-prosjekt1", "version": "0.0.0", "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.102.8", "react": "^19.2.8", "react-dom": "^19.2.8" @@ -525,6 +526,19 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", diff --git a/webdev-prosjekt1/package.json b/webdev-prosjekt1/package.json index 76c7624..dce5521 100644 --- a/webdev-prosjekt1/package.json +++ b/webdev-prosjekt1/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.102.8", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/webdev-prosjekt1/src/App.tsx b/webdev-prosjekt1/src/App.tsx index 510a04d..e9b8bc7 100644 --- a/webdev-prosjekt1/src/App.tsx +++ b/webdev-prosjekt1/src/App.tsx @@ -7,6 +7,7 @@ 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'; @@ -38,6 +39,7 @@ function App() { 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, @@ -126,6 +128,13 @@ function App() { 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), @@ -199,6 +208,7 @@ function App() { if (isAlbumDetailRoute) return ; if (isArtistDetailRoute) return ; if (isSongDetailRoute) return ; + if (isFavoritesRoute) return ; return ( diff --git a/webdev-prosjekt1/src/components/Button.tsx b/webdev-prosjekt1/src/components/Button.tsx index 3070060..c2d7f3c 100644 --- a/webdev-prosjekt1/src/components/Button.tsx +++ b/webdev-prosjekt1/src/components/Button.tsx @@ -1,15 +1,16 @@ import type { MouseEventHandler, ReactNode } from "react"; -import './Button.css'; +import "./Button.css"; type ButtonProps = { content: ReactNode; + className?: string; onClick?: MouseEventHandler; } -export const Button = ({ content, onClick }: ButtonProps) => { - return ( - - {content} - - ) -} +export const Button = ({ content, className, onClick }: ButtonProps) => { + return ( + + {content} + + ); +}; \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/FavButton.css b/webdev-prosjekt1/src/components/FavButton.css new file mode 100644 index 0000000..3e2004d --- /dev/null +++ b/webdev-prosjekt1/src/components/FavButton.css @@ -0,0 +1,21 @@ +.fav-button { + background: none; + border: none; + padding: 0; +} + +.fav-button:hover { + background: none; + transform: scale(1.1); +} + +.star-animate { + 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 diff --git a/webdev-prosjekt1/src/components/FavButton.tsx b/webdev-prosjekt1/src/components/FavButton.tsx new file mode 100644 index 0000000..ae2034a --- /dev/null +++ b/webdev-prosjekt1/src/components/FavButton.tsx @@ -0,0 +1,42 @@ +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'; + +type FavButtonProps = { + 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); + + return( + setIsAnimating(false)} + /> + } + className="fav-button" + onClick={(event) => { + event.stopPropagation(); + + isFavorite + ? removeFavorite(track.id) + : addFavorite(track); + + setIsAnimating(true); + }} + /> + ) +} \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/FavoritesPage.css b/webdev-prosjekt1/src/components/FavoritesPage.css new file mode 100644 index 0000000..0413b89 --- /dev/null +++ b/webdev-prosjekt1/src/components/FavoritesPage.css @@ -0,0 +1,108 @@ +.favorites-page { + min-height: 100vh; + padding-bottom: 80px; + color: var(--text_primary); + background: var(--page_background_gradient); +} + +.favorites-layout { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 48px; + max-width: 1400px; + width: calc(100% - 96px); + margin: 0 auto; +} + +.favorite-page-content { + flex: 1; + min-width: 0; + padding: 56px 48px 0; +} + +.favorite-page-content h1 { + margin: 0; + color: var(--primary_color); + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; +} + +.favorites-empty { + max-width: 660px; + margin: 28px 0 0; + color: var(--text_secondary); + font-size: 1.05rem; + line-height: 1.9; +} + +.favorite-list { + display: grid; + gap: 10px; + margin: 28px 0 0; + padding: 0; + list-style: none; +} + +.favorites-art { + position: sticky; + top: 24px; + width: 520px; + min-height: 460px; + margin: 24px 0 0; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.favorites-art img { + position: relative; + z-index: 1; + width: 100%; + max-width: 480px; + height: auto; + object-fit: contain; + filter: drop-shadow(0 20px 25px var(--shadow_hero)); +} + +@media (max-width: 1100px) { + .favorites-layout { + flex-direction: column; + } + + .favorite-page-content { + width: 100%; + } + + .favorites-art { + position: relative; + top: 0; + align-self: center; + } +} + +@media (max-width: 900px) { + .favorite-page-content { + padding: 42px 32px 0; + } +} + +@media (max-width: 700px) { + .favorites-layout { + width: calc(100% - 48px); + } + + .favorite-page-content { + padding: 42px 0 0; + } + + .favorites-art { + width: 320px; + min-height: 380px; + } + + .favorites-art img { + max-width: 290px; + } + +} diff --git a/webdev-prosjekt1/src/components/FavoritesPage.tsx b/webdev-prosjekt1/src/components/FavoritesPage.tsx new file mode 100644 index 0000000..4accd37 --- /dev/null +++ b/webdev-prosjekt1/src/components/FavoritesPage.tsx @@ -0,0 +1,40 @@ +import './FavoritesPage.css'; +import '../variables.css'; +import { Header } from './Header'; +import { TrackCard } from './TrackCard'; +import { useFavorites } from '../hooks/useFavorites'; +import type { AlbumTrack, Track } from '../api/types'; +import musicWizImage from '../assets/musicwiz.png'; + +type FavoritesPageProps = { + onTrackClick: (track: Track | AlbumTrack) => void; +}; + +export const FavoritesPage = ({ onTrackClick }: FavoritesPageProps) => { + const { favorites } = useFavorites(); + + return ( + + + + + + Favorites + + {favorites.length === 0 ? ( + You haven't favorited any tracks yet. + ) : ( + + {favorites.map((favorite) => ( + onTrackClick(favorite)} /> + ))} + + )} + + + + + + + ); +}; diff --git a/webdev-prosjekt1/src/components/Header.css b/webdev-prosjekt1/src/components/Header.css index 4d88695..2ac84a5 100644 --- a/webdev-prosjekt1/src/components/Header.css +++ b/webdev-prosjekt1/src/components/Header.css @@ -1,10 +1,26 @@ .main-header { + display: flex; + align-items: center; + justify-content: space-between; 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; +} + +.favorites-link:hover { + text-decoration: underline; +} + .app-title { color: var(--primary_color); text-decoration: none; diff --git a/webdev-prosjekt1/src/components/Header.tsx b/webdev-prosjekt1/src/components/Header.tsx index 79b2532..0c512d0 100644 --- a/webdev-prosjekt1/src/components/Header.tsx +++ b/webdev-prosjekt1/src/components/Header.tsx @@ -18,6 +18,7 @@ export const Header = ({ onHome }: HeaderProps) => { Your personal music magician + Favorites ); }; \ No newline at end of file diff --git a/webdev-prosjekt1/src/components/TrackCard.css b/webdev-prosjekt1/src/components/TrackCard.css index a208453..117adad 100644 --- a/webdev-prosjekt1/src/components/TrackCard.css +++ b/webdev-prosjekt1/src/components/TrackCard.css @@ -1,6 +1,7 @@ .search-result-card { display: flex; padding: 0.3rem; + position: relative; width: 100%; height: 72px; @@ -39,6 +40,7 @@ display: flex; justify-content: space-between; width: 100%; + gap: 1rem; padding: 0.3rem 1rem; } @@ -79,6 +81,11 @@ display: flex; align-items: center; justify-content: center; + gap: 1rem; +} + +.duration-display { + color: var(--primary_color); } .duration-display { diff --git a/webdev-prosjekt1/src/components/TrackCard.tsx b/webdev-prosjekt1/src/components/TrackCard.tsx index 56a1a09..18d3f0f 100644 --- a/webdev-prosjekt1/src/components/TrackCard.tsx +++ b/webdev-prosjekt1/src/components/TrackCard.tsx @@ -1,6 +1,7 @@ import './TrackCard.css'; import type { Album, AlbumTrack, Track } from '../api/types'; import musicwizimage from '../assets/musicwiz.png'; +import { FavButton } from './FavButton'; type TrackCardProps = { track: Track | AlbumTrack; @@ -36,6 +37,7 @@ export const TrackCard = ({ track, album, isActive = false, onClick }: TrackCard {formatDuration(track.duration_ms)} + diff --git a/webdev-prosjekt1/src/hooks/useFavorites.tsx b/webdev-prosjekt1/src/hooks/useFavorites.tsx new file mode 100644 index 0000000..451edf1 --- /dev/null +++ b/webdev-prosjekt1/src/hooks/useFavorites.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import type { Track, AlbumTrack } from '../api/types'; + +type FavoritesContextValue = { + favorites: (Track | AlbumTrack)[]; + addFavorite: (favorite: Track | AlbumTrack) => void; + removeFavorite: (id: string) => void; +}; + +const FavoritesContext = createContext(null); + +export function FavoritesProvider({ children }: { children: ReactNode }) { + const [favorites, setFavorites] = useState<(Track | AlbumTrack)[]>(() => { + const stored = localStorage.getItem("favorites"); + return stored ? JSON.parse(stored) : []; + }); + + function addFavorite(favorite: Track | AlbumTrack) { + setFavorites(prev => { + const updated = [...prev, favorite]; + localStorage.setItem("favorites", JSON.stringify(updated)); + return updated; + }); + } + + function removeFavorite(id: string) { + setFavorites(prev => { + const updated = prev.filter(favorite => favorite.id !== id); + localStorage.setItem("favorites", JSON.stringify(updated)); + return updated; + }); + } + + return ( + + {children} + + ); +} + +export function useFavorites() { + const context = useContext(FavoritesContext); + if (!context) { + throw new Error("useFavorites must be used within a FavoritesProvider"); + } + return context; +} diff --git a/webdev-prosjekt1/src/main.tsx b/webdev-prosjekt1/src/main.tsx index e534854..88627d8 100644 --- a/webdev-prosjekt1/src/main.tsx +++ b/webdev-prosjekt1/src/main.tsx @@ -4,13 +4,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import './index.css' import './variables.css' import App from './App.tsx' +import { FavoritesProvider } from './hooks/useFavorites.tsx' const queryClient = new QueryClient() createRoot(document.getElementById('root')!).render( - + + + , )
You haven't favorited any tracks yet.