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
14 changes: 14 additions & 0 deletions webdev-prosjekt1/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webdev-prosjekt1/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions webdev-prosjekt1/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -199,6 +208,7 @@ function App() {
if (isAlbumDetailRoute) return <AlbumDetailPage album={selectedAlbum ?? dummyAlbum} onTrackClick={handleAlbumTrackClick} />;
if (isArtistDetailRoute) return <ArtistDetailPage artist={selectedArtist ?? dummyArtist} onTrackClick={handleTrackClick} onAlbumClick={handleAlbumClick} />;
if (isSongDetailRoute) return <TrackDetailPage song={selectedTrack ?? dummyTrack} onAlbumClick={handleAlbumClick} />;
if (isFavoritesRoute) return <FavoritesPage onTrackClick={handleFavoriteTrackClick} />;

return (
<main className='app-shell'>
Expand Down
17 changes: 9 additions & 8 deletions webdev-prosjekt1/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { MouseEventHandler, ReactNode } from "react";
import './Button.css';
import "./Button.css";

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

export const Button = ({ content, onClick }: ButtonProps) => {
return (
<button onClick={onClick}>
{content}
</button>
)
}
export const Button = ({ content, className, onClick }: ButtonProps) => {
return (
<button className={className} onClick={onClick}>
{content}
</button>
);
};
21 changes: 21 additions & 0 deletions webdev-prosjekt1/src/components/FavButton.css
Original file line number Diff line number Diff line change
@@ -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 */
}
42 changes: 42 additions & 0 deletions webdev-prosjekt1/src/components/FavButton.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Button
content = {<StarIcon
size={24}
color={isFavorite ? "#d4af37" : "#f8f0df"}
weight={isFavorite ? "fill" : "regular"}
className={isAnimating ? "star-animate" : ""}
onAnimationEnd={() => setIsAnimating(false)}
/>
}
className="fav-button"
onClick={(event) => {
event.stopPropagation();

isFavorite
? removeFavorite(track.id)
: addFavorite(track);

setIsAnimating(true);
}}
/>
)
}
108 changes: 108 additions & 0 deletions webdev-prosjekt1/src/components/FavoritesPage.css
Original file line number Diff line number Diff line change
@@ -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;
}

}
40 changes: 40 additions & 0 deletions webdev-prosjekt1/src/components/FavoritesPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className='favorites-page'>
<Header />

<section className='favorites-layout'>
<section className='favorite-page-content' aria-labelledby='favorites-title'>
<h1 id='favorites-title'>Favorites</h1>

{favorites.length === 0 ? (
<p className='favorites-empty'>You haven't favorited any tracks yet.</p>
) : (
<ul className='card-list favorite-list'>
{favorites.map((favorite) => (
<TrackCard key={favorite.id} track={favorite} onClick={() => onTrackClick(favorite)} />
))}
</ul>
)}
</section>
<figure className='favorites-art'>
<img src={musicWizImage} alt='A musical wizard' />
</figure>
</section>
</main>
);
};
16 changes: 16 additions & 0 deletions webdev-prosjekt1/src/components/Header.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
1 change: 1 addition & 0 deletions webdev-prosjekt1/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const Header = ({ onHome }: HeaderProps) => {
<h3 className='header-note'>Your personal music magician</h3>
</span>
</a>
<a className='favorites-link' href='/favorites'>Favorites</a>
</header>
);
};
7 changes: 7 additions & 0 deletions webdev-prosjekt1/src/components/TrackCard.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.search-result-card {
display: flex;
padding: 0.3rem;
position: relative;

width: 100%;
height: 72px;
Expand Down Expand Up @@ -39,6 +40,7 @@
display: flex;
justify-content: space-between;
width: 100%;
gap: 1rem;

padding: 0.3rem 1rem;
}
Expand Down Expand Up @@ -79,6 +81,11 @@
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}

.duration-display {
color: var(--primary_color);
}

.duration-display {
Expand Down
2 changes: 2 additions & 0 deletions webdev-prosjekt1/src/components/TrackCard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,6 +37,7 @@ export const TrackCard = ({ track, album, isActive = false, onClick }: TrackCard
</div>
<div className='duration-display-container'>
<span className='duration-display'>{formatDuration(track.duration_ms)}</span>
<FavButton track={track}></FavButton>
</div>
</div>
</li>
Expand Down
Loading