Skip to content

Commit 7df19cf

Browse files
committed
feat: Add Trending & Seasonal discovery pages
- Refactored `AnimeCard` into a reusable component for use across the app. - Created `/trending` and `/seasonal` pages to fetch and display anime from the Jikan API. - Implemented `react-router-dom` with `<NavLink>` in the sidebar for navigation and active link styling. - Users can now add anime to their list directly from the new discovery pages. fix(layout): Overhaul Anime Detail Page UI - Reworked the detail page layout to be more compact and eliminate all empty space, improving UX on both desktop and mobile.
1 parent 1f1a5f8 commit 7df19cf

10 files changed

Lines changed: 816 additions & 336 deletions

File tree

src/App.jsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import Sidebar from "./components/Sidebar";
88
import TopBar from "./components/TopBar";
99
import Dashboard from "./components/Dashboard";
1010
import AuthPage from "./components/AuthPage";
11-
import AnimeDetailPage from "./components/AnimeDetailPage"; // <-- IMPORT THE NEW PAGE
11+
import AnimeDetailPage from "./components/AnimeDetailPage";
12+
import TrendingPage from "./pages/TrendingPage"; // <-- NEW IMPORT
13+
import SeasonalPage from "./pages/SeasonalPage"; // <-- NEW IMPORT
1214
import "./App.css";
1315
import "./Responsive.css";
1416

@@ -32,6 +34,8 @@ const AppContent = () => {
3234
<TopBar onMenuClick={() => setIsSidebarOpen(true)} />
3335
<Routes>
3436
<Route path="/" element={<Dashboard />} />
37+
<Route path="/trending" element={<TrendingPage />} />
38+
<Route path="/seasonal" element={<SeasonalPage />} />
3539
<Route path="/anime/:id" element={<AnimeDetailPage />} />
3640
</Routes>
3741
</main>
@@ -44,7 +48,7 @@ function App() {
4448
<DataProvider>
4549
<BrowserRouter>
4650
<Toaster
47-
position="bottom-center"
51+
position="bottom-left"
4852
toastOptions={{
4953
style: {
5054
background: "#242424",

src/Responsive.css

Lines changed: 25 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
11
/* src/Responsive.css */
22

3-
/* --- Base Layout Adjustments --- */
3+
/* --- Universal Layout Adjustments (Applies to ALL screen sizes) --- */
44
.main-content {
55
flex-grow: 1;
6-
transition: margin-left 0.3s ease-in-out;
76
width: 100%;
87
}
98

10-
/* --- Mobile-First Elements (Hidden on Desktop) --- */
9+
.sidebar {
10+
position: fixed;
11+
top: 0;
12+
left: 0;
13+
height: 100vh;
14+
transform: translateX(-100%);
15+
transition: transform 0.3s ease-in-out;
16+
z-index: 100;
17+
border-right: 1px solid var(--bg-tertiary);
18+
width: 280px;
19+
}
20+
21+
.sidebar.open {
22+
transform: translateX(0);
23+
}
24+
1125
.hamburger-btn {
12-
display: none;
26+
display: flex; /* Always display the hamburger button */
1327
flex-direction: column;
1428
justify-content: space-around;
1529
width: 2rem;
@@ -19,6 +33,7 @@
1933
cursor: pointer;
2034
padding: 0;
2135
z-index: 200;
36+
flex-shrink: 0; /* Prevent it from shrinking */
2237
}
2338

2439
.hamburger-btn span {
@@ -32,7 +47,7 @@
3247
}
3348

3449
.close-btn {
35-
display: none;
50+
display: block; /* Always display the close button inside the sidebar */
3651
background: none;
3752
border: none;
3853
color: var(--text-primary);
@@ -43,7 +58,7 @@
4358

4459
.sidebar-header {
4560
display: flex;
46-
justify-content: center; /* Centered on desktop */
61+
justify-content: space-between;
4762
align-items: center;
4863
margin-bottom: 40px;
4964
}
@@ -60,47 +75,23 @@
6075
height: 100%;
6176
background: rgba(0, 0, 0, 0.5);
6277
z-index: 50;
63-
display: none; /* Hidden by default */
78+
display: none;
6479
}
6580

6681
.app-container.sidebar-open .overlay {
6782
display: block;
6883
}
6984

70-
/* --- Mobile View (max-width: 768px) --- */
85+
/* --- Specific Mobile Tweaks (Smaller screens) --- */
7186
@media (max-width: 768px) {
72-
.sidebar {
73-
position: fixed;
74-
top: 0;
75-
left: 0;
76-
height: 100vh;
77-
transform: translateX(-100%);
78-
transition: transform 0.3s ease-in-out;
79-
z-index: 100;
80-
border-right: 1px solid var(--bg-tertiary);
81-
width: 280px;
82-
}
83-
84-
.sidebar.open {
85-
transform: translateX(0);
86-
}
87-
88-
.sidebar-header {
89-
justify-content: space-between; /* Space out on mobile */
90-
}
91-
92-
.hamburger-btn, .close-btn {
93-
display: flex;
94-
}
95-
9687
.topbar {
9788
padding: 12px 16px;
9889
}
9990

100-
.user-profile span {
91+
.user-profile-email {
10192
display: none;
10293
}
103-
94+
10495
.user-profile {
10596
padding: 4px;
10697
}
@@ -116,13 +107,4 @@
116107
.anime-list-section h2 {
117108
font-size: 18px;
118109
}
119-
}
120-
121-
/* --- Desktop View (min-width: 769px) --- */
122-
@media (min-width: 769px) {
123-
.sidebar {
124-
position: static;
125-
transform: translateX(0);
126-
height: 100vh;
127-
}
128110
}

src/components/AnimeCard.jsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// src/components/AnimeCard.jsx
2+
import React, { useState, useEffect, useRef } from 'react';
3+
import { Link } from 'react-router-dom';
4+
import { toast } from 'react-hot-toast';
5+
import { useData } from '../context/DataContext';
6+
import './styles.css';
7+
8+
const ALL_STATUSES = [
9+
{ key: "watching", label: "Watching" },
10+
{ key: "completed", label: "Completed" },
11+
{ key: "on-hold", label: "On-Hold" },
12+
{ key: "plan-to-watch", label: "Plan to Watch" },
13+
{ key: "dropped", label: "Dropped" },
14+
];
15+
16+
const AnimeCard = ({ anime, isDashboardCard = false, onUpdateProgress, onUpdateStatus, onDelete }) => {
17+
// --- State and Refs for Dashboard Card functionality ---
18+
const [isMenuOpen, setIsMenuOpen] = useState(false);
19+
const [isEditingProgress, setIsEditingProgress] = useState(false);
20+
const [editableProgress, setEditableProgress] = useState(isDashboardCard ? anime.progress : 0);
21+
const menuRef = useRef(null);
22+
23+
// --- State for Discovery Card functionality ---
24+
const { handleAddAnime } = useData();
25+
const [isAdding, setIsAdding] = useState(false);
26+
27+
const handleDiscoveryAdd = async (e) => {
28+
e.preventDefault();
29+
setIsAdding(true);
30+
// The Jikan API response for discovery pages needs to be adapted for our Firestore schema
31+
const animeDataForAdd = {
32+
mal_id: anime.mal_id,
33+
title: anime.title,
34+
images: anime.images,
35+
episodes: anime.episodes,
36+
};
37+
await handleAddAnime(animeDataForAdd, 'plan-to-watch');
38+
setIsAdding(false);
39+
};
40+
41+
// --- All the logic from the old Dashboard AnimeCard ---
42+
useEffect(() => {
43+
const handleClickOutside = (event) => {
44+
if (menuRef.current && !menuRef.current.contains(event.target)) {
45+
setIsMenuOpen(false);
46+
}
47+
};
48+
if (isMenuOpen) document.addEventListener("mousedown", handleClickOutside);
49+
return () => document.removeEventListener("mousedown", handleClickOutside);
50+
}, [isMenuOpen]);
51+
52+
const availableStatuses = isDashboardCard ? ALL_STATUSES.filter((s) => s.key !== anime.status) : [];
53+
const currentProgress = isDashboardCard ? parseInt(anime.progress, 10) || 0 : 0;
54+
const totalEpisodes = isDashboardCard ? parseInt(anime.total_episodes, 10) || 0 : parseInt(anime.episodes, 10) || 0;
55+
56+
const handleIncrement = (e) => { e.stopPropagation(); if (totalEpisodes > 0 && currentProgress >= totalEpisodes) { toast("Already completed!"); return; } onUpdateProgress(anime.id, currentProgress + 1); };
57+
const handleDecrement = (e) => { e.stopPropagation(); if (currentProgress > 0) { onUpdateProgress(anime.id, currentProgress - 1); } };
58+
const handleSaveProgress = () => { const newProgress = parseInt(editableProgress, 10); if (!isNaN(newProgress)) { onUpdateProgress(anime.id, newProgress); } setIsEditingProgress(false); };
59+
const handleKeyDown = (e) => { if (e.key === "Enter") { handleSaveProgress(); } };
60+
const handleStatusChange = (e, newStatus) => { e.stopPropagation(); onUpdateStatus(anime.id, newStatus); setIsMenuOpen(false); };
61+
const handleDeleteClick = (e) => { e.stopPropagation(); toast((t) => (<span>Delete <b>{anime.title}</b>?<div style={{ display: "flex", gap: "8px", marginTop: '8px' }}><button className="toast-button-confirm" onClick={() => { onDelete(anime.id); toast.dismiss(t.id); }}>Confirm</button><button className="toast-button-cancel" onClick={() => toast.dismiss(t.id)}>Cancel</button></div></span>), { duration: 5000 }); setIsMenuOpen(false); };
62+
63+
return (
64+
<Link to={`/anime/${anime.mal_id}`} className="anime-card-link">
65+
<div className="anime-card">
66+
{isDashboardCard && (
67+
<>
68+
<button className="options-menu-btn" onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsMenuOpen(!isMenuOpen); }}>...</button>
69+
{isMenuOpen && (
70+
<div className="options-dropdown" ref={menuRef}>
71+
{availableStatuses.map((status) => (<button key={status.key} className="menu-item" onClick={(e) => { e.preventDefault(); handleStatusChange(e, status.key); }}>Move to {status.label}</button>))}
72+
<button className="menu-item delete-item" onClick={(e) => { e.preventDefault(); handleDeleteClick(e); }}>Delete</button>
73+
</div>
74+
)}
75+
</>
76+
)}
77+
<img src={anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url || anime.image} alt={anime.title} />
78+
<div className="anime-info">
79+
<p className="anime-title">{anime.title}</p>
80+
{isDashboardCard ? (
81+
<div className="progress-container">
82+
{(anime.status === "watching" || anime.status === "on-hold") ? ( isEditingProgress ? (<input type="number" className="progress-input" value={editableProgress} onChange={(e) => setEditableProgress(e.target.value)} onBlur={(e) => { e.preventDefault(); handleSaveProgress(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault(); handleKeyDown(e); }} onClick={(e) => e.preventDefault()} min="0" max={totalEpisodes > 0 ? totalEpisodes : undefined} autoFocus />) : (<p className="anime-progress progress-text" onClick={(e) => { e.preventDefault(); setEditableProgress(currentProgress); setIsEditingProgress(true); }}>{`${currentProgress} / ${totalEpisodes || "?"}`}</p>) ) : (<p className="anime-progress">{anime.status.replace(/-/g, " ")}</p>)}
83+
{(anime.status === "watching" || anime.status === "on-hold") && (<div className="progress-buttons-container"><button className="progress-increment-btn" onClick={(e) => { e.preventDefault(); handleDecrement(e); }}>-</button><button className="progress-increment-btn" onClick={(e) => { e.preventDefault(); handleIncrement(e); }}>+</button></div>)}
84+
</div>
85+
) : (
86+
<button className="discovery-add-btn" onClick={handleDiscoveryAdd} disabled={isAdding}>
87+
{isAdding ? 'Adding...' : '+ Plan to Watch'}
88+
</button>
89+
)}
90+
</div>
91+
</div>
92+
</Link>
93+
);
94+
};
95+
96+
export default AnimeCard;

src/components/AnimeDetailPage.jsx

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,120 @@
11
// src/components/AnimeDetailPage.jsx
2-
import React, { useState, useEffect } from 'react';
2+
import React, { useState, useEffect, useMemo } from 'react';
33
import { useParams, Link } from 'react-router-dom';
4-
import './styles.css'; // We will add styles to this file later
4+
import { useData } from '../context/DataContext';
5+
import './styles.css';
6+
7+
const DetailPageActions = ({ animeInList, jikanAnime }) => {
8+
const { handleAddAnime, handleUpdateStatus, handleDeleteAnime } = useData();
9+
const [adding, setAdding] = useState(false);
10+
11+
const handleAddClick = async () => {
12+
setAdding(true);
13+
await handleAddAnime(jikanAnime);
14+
setAdding(false);
15+
};
16+
17+
if (animeInList) {
18+
return (
19+
<div className="detail-actions in-list">
20+
<h4>In Your Library</h4>
21+
<div className="action-controls">
22+
<select
23+
className="status-select"
24+
value={animeInList.status}
25+
onChange={(e) => handleUpdateStatus(animeInList.id, e.target.value)}
26+
>
27+
<option value="watching">Watching</option>
28+
<option value="plan-to-watch">Plan to Watch</option>
29+
<option value="completed">Completed</option>
30+
<option value="on-hold">On-Hold</option>
31+
<option value="dropped">Dropped</option>
32+
</select>
33+
<button className="delete-button" onClick={() => handleDeleteAnime(animeInList.id)}>
34+
Remove
35+
</button>
36+
</div>
37+
</div>
38+
);
39+
} else {
40+
return (
41+
<div className="detail-actions">
42+
<button className="add-to-list-btn" onClick={handleAddClick} disabled={adding}>
43+
{adding ? 'Adding...' : '+ Add to List'}
44+
</button>
45+
</div>
46+
);
47+
}
48+
};
549

650
const AnimeDetailPage = () => {
7-
const { id } = useParams(); // Gets the ':id' from the URL
8-
const [anime, setAnime] = useState(null);
51+
const { id } = useParams();
52+
const { animeList } = useData();
53+
const [jikanAnime, setJikanAnime] = useState(null);
954
const [loading, setLoading] = useState(true);
1055
const [error, setError] = useState(null);
1156

57+
const animeInList = useMemo(() => {
58+
const numericId = parseInt(id, 10);
59+
return animeList.find(item => item.mal_id === numericId);
60+
}, [animeList, id]);
61+
1262
useEffect(() => {
1363
const fetchAnimeDetails = async () => {
1464
setLoading(true);
1565
setError(null);
1666
try {
1767
const response = await fetch(`https://api.jikan.moe/v4/anime/${id}/full`);
18-
if (!response.ok) {
19-
throw new Error('Failed to fetch anime details. It might not exist.');
20-
}
68+
if (!response.ok) throw new Error('Failed to fetch anime details.');
2169
const data = await response.json();
22-
setAnime(data.data);
70+
setJikanAnime(data.data);
2371
} catch (err) {
2472
setError(err.message);
25-
console.error("Error fetching anime details:", err);
2673
} finally {
2774
setLoading(false);
2875
}
2976
};
30-
3177
fetchAnimeDetails();
32-
}, [id]); // Re-run this effect if the ID in the URL changes
78+
}, [id]);
3379

3480
if (loading) {
3581
return <div className="detail-page-container"><h2>Loading details...</h2></div>;
3682
}
37-
3883
if (error) {
3984
return <div className="detail-page-container"><h2>Error: {error}</h2></div>;
4085
}
41-
42-
if (!anime) {
86+
if (!jikanAnime) {
4387
return <div className="detail-page-container"><h2>No anime data found.</h2></div>;
4488
}
4589

4690
return (
4791
<div className="detail-page-container">
4892
<div className="detail-page-header">
4993
<Link to="/" className="back-button">← Back to Dashboard</Link>
50-
<h1>{anime.title_english || anime.title}</h1>
94+
<h1>{jikanAnime.title_english || jikanAnime.title}</h1>
5195
</div>
96+
97+
{/* === THIS IS THE NEW, CORRECT STRUCTURE === */}
5298
<div className="detail-content">
99+
{/* --- LEFT COLUMN: Image and Genres --- */}
53100
<div className="detail-left">
54-
<img src={anime.images.jpg.large_image_url} alt={anime.title} />
101+
<img src={jikanAnime.images.jpg.large_image_url} alt={jikanAnime.title} />
102+
<div className="detail-genres">
103+
{jikanAnime.genres.map(genre => <span key={genre.mal_id} className="genre-tag">{genre.name}</span>)}
104+
</div>
55105
</div>
106+
107+
{/* --- RIGHT COLUMN: Meta, Synopsis, and Actions --- */}
56108
<div className="detail-right">
57109
<div className="detail-meta">
58-
<span><strong>Type:</strong> {anime.type}</span>
59-
<span><strong>Episodes:</strong> {anime.episodes || 'N/A'}</span>
60-
<span><strong>Status:</strong> {anime.status}</span>
61-
<span><strong>Score:</strong> {anime.score ? `${anime.score} ⭐` : 'N/A'}</span>
62-
</div>
63-
<div className="detail-genres">
64-
{anime.genres.map(genre => <span key={genre.mal_id} className="genre-tag">{genre.name}</span>)}
110+
<span><strong>Type:</strong> {jikanAnime.type}</span>
111+
<span><strong>Episodes:</strong> {jikanAnime.episodes || 'N/A'}</span>
112+
<span><strong>Status:</strong> {jikanAnime.status}</span>
113+
<span><strong>Score:</strong> {jikanAnime.score ? `${jikanAnime.score} ⭐` : 'N/A'}</span>
65114
</div>
66115
<h2>Synopsis</h2>
67-
<p className="synopsis">{anime.synopsis || 'No synopsis available.'}</p>
116+
<p className="synopsis">{jikanAnime.synopsis || 'No synopsis available.'}</p>
117+
<DetailPageActions animeInList={animeInList} jikanAnime={jikanAnime} />
68118
</div>
69119
</div>
70120
</div>

0 commit comments

Comments
 (0)