diff --git a/backend/configs/app_config.yml b/backend/configs/app_config.yml
index 63b1d3b..eb70f00 100644
--- a/backend/configs/app_config.yml
+++ b/backend/configs/app_config.yml
@@ -7,4 +7,8 @@ default:
mongo_anime_collection_name: "anime_enriched"
mongo_anime_db_name: "anizenith"
+ # Count Caching for Pagination
+ count_cache_time_seconds: 120
+ count_cache_max_size: 1000
+
log_level: "info"
\ No newline at end of file
diff --git a/backend/configs/backend_config.py b/backend/configs/backend_config.py
index 9908734..32fc0e9 100644
--- a/backend/configs/backend_config.py
+++ b/backend/configs/backend_config.py
@@ -23,6 +23,9 @@ class BackendAppConfig(Config):
max_session_cookie_age: Optional[int] = None
same_site_protection: Optional[str] = None
+ count_cache_time_seconds: Optional[int] = None
+ count_cache_max_size: Optional[int] = None
+
HF_TOKEN: str = os.getenv("HF_TOKEN", "")
MAL_CLIENT_ID: str = os.getenv("MAL_CLIENT_ID", "")
MAL_CLIENT_SECRET: str = os.getenv("MAL_CLIENT_SECRET", "")
diff --git a/backend/mongo/AniZenithMongoClient.py b/backend/mongo/AniZenithMongoClient.py
index 9ce9899..2e9ad3c 100644
--- a/backend/mongo/AniZenithMongoClient.py
+++ b/backend/mongo/AniZenithMongoClient.py
@@ -39,7 +39,8 @@ def anime_collection(self):
@property decorator defines this as a property of a class, rather than a class method
"""
if self._anime_collection is None:
- self._anime_collection = self.db_client[backend_app_config.mongo_anime_db_name][backend_app_config.mongo_anime_collection_name]
+ # TODO: Move the hardcoded DB name and collection name into a central Config object
+ self._anime_collection = self.db_client["anizenith"]["anime_enriched"]
return self._anime_collection
@@ -64,12 +65,14 @@ def add_anime(self, anime_document: AnimeDocument) -> None:
self.anime_collection.insert_one(anime_document_dict)
- def execute_read_query(self, query, limit = None) -> List[Dict]:
+ def execute_read_query(self, query, skip = None, limit = None) -> List[Dict]:
try:
if isinstance(query, dict):
# Execute a standard find operation (e.g., {"score": {"$gt": 8.0}})
# "find()" is a standard read-only MongoDB command
cursor = self.anime_collection.find(query)
+ if skip is not None:
+ cursor = cursor.skip(skip)
if limit is not None:
cursor = cursor.limit(limit)
diff --git a/backend/search.py b/backend/search.py
index 019190e..03b8278 100644
--- a/backend/search.py
+++ b/backend/search.py
@@ -1,124 +1,81 @@
from datetime import datetime
-from typing import List, Optional
+from typing import List, Optional, Tuple, Dict, Any
+from cachetools import TTLCache
+from bson import json_util
from fastapi import Query, APIRouter
from pydantic import BaseModel
from starlette.requests import Request
-# TODO: This file will be modified in future PR and complete code will be removed
+from backend.mongo.AnimeDocument import AnimeDocument
+from backend.utils.model_utils import DB_CLIENT
+
+from backend.configs import backend_app_config
search_router = APIRouter()
-class Anime(BaseModel):
- id: int
- cover_image_url: str
- title: str
- genres: List[str]
- short_description: str
- score: float
- date_added: int
+count_cache = TTLCache(maxsize=backend_app_config.count_cache_max_size, ttl=backend_app_config.count_cache_time_seconds)
class SearchResponse(BaseModel):
total_count: int
- shows: List[Anime]
-
-def date_to_millis(date_str: str) -> int:
- dt = datetime.strptime(date_str, "%b %d, %Y")
- return int(dt.timestamp() * 1000)
-
-# TODO: Remove this and hook up to real database
-MOCK_ANIME_LIST = [
- Anime(
- id=1,
- cover_image_url="https://cdn.myanimelist.net/images/anime/1223/96541.jpg",
- title="Fullmetal Alchemist: Brotherhood",
- genres=["Action", "Adventure", "Drama", "Fantasy"],
- short_description="Two brothers search for the Philosopher's Stone to restore their bodies after a failed alchemy experiment.",
- date_added=date_to_millis("Apr 5, 2009"),
- score=9.09
- ),
- Anime(
- id=2,
- cover_image_url="https://cdn.myanimelist.net/images/anime/1935/127974.jpg",
- title="Steins;Gate",
- genres=["Sci-Fi", "Thriller", "Drama"],
- short_description="A group of friends accidentally invent a method of sending messages to the past, altering the present.",
- date_added=date_to_millis("Apr 6, 2011"),
- score=9.07
- ),
- Anime(
- id=3,
- cover_image_url="https://cdn.myanimelist.net/images/anime/1337/99013.jpg",
- title="Hunter x Hunter (2011)",
- genres=["Action", "Adventure", "Fantasy"],
- short_description="Gon Freecss aspires to become a Hunter to find his father, meeting friends and facing deadly challenges.",
- date_added=date_to_millis("Oct 2, 2011"),
- score=9.03
- ),
- Anime(
- id=4,
- cover_image_url="https://cdn.myanimelist.net/images/anime/10/73274.jpg",
- title="Gintama",
- genres=["Action", "Comedy", "Sci-Fi"],
- short_description="In an alternate Edo period invaded by aliens, a samurai freelancer takes odd jobs to make ends meet.",
- date_added=date_to_millis("Apr 4, 2006"),
- score=8.94
- ),
- Anime(
- id=5,
- cover_image_url="https://cdn.myanimelist.net/images/anime/1000/110531.jpg",
- title="Attack on Titan Final Season",
- genres=["Action", "Drama", "Fantasy"],
- short_description="The epic conclusion of humanity's battle against the Titans and the truth behind their existence.",
- date_added=date_to_millis("Dec 7, 2020"),
- score=8.79
- ),
- Anime(
- id=6,
- cover_image_url="https://cdn.myanimelist.net/images/anime/1295/106551.jpg",
- title="Kaguya-sama: Love is War",
- genres=["Comedy", "Romance", "School"],
- short_description="Two geniuses at a prestigious academy engage in psychological warfare to make the other confess love first.",
- date_added=date_to_millis("Apr 11, 2020"),
- score=8.41
- ),
- Anime(
- id=7,
- cover_image_url="https://cdn.myanimelist.net/images/anime/1500/103005.jpg",
- title="Vinland Saga",
- genres=["Action", "Adventure", "Drama", "Historical"],
- short_description="A young Viking seeks revenge against his father's killer while navigating a world of war and slavery.",
- date_added=date_to_millis("Jul 7, 2019"),
- score=8.75
- ),
- Anime(
- id=8,
- cover_image_url="https://cdn.myanimelist.net/images/anime/6/86733.jpg",
- title="Made in Abyss",
- genres=["Adventure", "Drama", "Fantasy", "Mystery"],
- short_description="An orphan girl and a robot boy descend into a mysterious, perilous chasm to find her mother.",
- date_added=date_to_millis("Jul 7, 2017"),
- score=8.65
- ),
- Anime(
- id=9,
- cover_image_url="https://cdn.myanimelist.net/images/anime/5/87048.jpg",
- title="Your Name.",
- genres=["Drama", "Romance", "Supernatural"],
- short_description="Two teenagers swap bodies across time and space, leading to a race against fate.",
- date_added=date_to_millis("Aug 26, 2016"),
- score=8.84
- ),
- Anime(
- id=10,
- cover_image_url="https://cdn.myanimelist.net/images/anime/6/79597.jpg",
- title="Spirited Away",
- genres=["Adventure", "Fantasy", "Supernatural"],
- short_description="A young girl becomes trapped in a spirit world and must work in a bathhouse to free herself and her parents.",
- date_added=date_to_millis("Jul 20, 2001"),
- score=8.77
- ),
-]
+ shows: List[AnimeDocument]
+
+def get_mongo_query(
+ q: Optional[str] = None,
+ genre: Optional[List[str]] = None,
+ year_min: Optional[int] = None,
+ year_max: Optional[int] = None,
+ score_min: Optional[float] = None,
+ score_max: Optional[float] = None,
+ status: Optional[str] = None,
+ idx_from: int = 0,
+ idx_to: int = 19,
+) -> Tuple[Dict[str, Any], int, int]:
+ query: Dict[str, Any] = {}
+
+ # Text search
+ if q:
+ regex = {"$regex": q, "$options": "i"}
+ query["$or"] = [
+ {"title": regex},
+ {"synopsis": regex},
+ ]
+
+ # Genre filter
+ if genre:
+ genre = [g.capitalize() for g in genre] # Consistent with capitalized genres in db
+ query["genres"] = {"$all": genre} # Check that all genres requested match
+
+ # Year range
+ if year_min is not None or year_max is not None:
+ year_filter: Dict[str, Any] = {}
+ if year_min is not None:
+ year_filter["$gte"] = datetime(year_min, 1, 1)
+ if year_max is not None:
+ year_filter["$lt"] = datetime(year_max + 1, 1, 1)
+
+ if year_filter:
+ query["date_aired"] = year_filter
+
+ # Score range
+ if score_min is not None or score_max is not None:
+ score_filter: Dict[str, Any] = {}
+ if score_min is not None:
+ score_filter["$gte"] = score_min
+ if score_max is not None:
+ score_filter["$lte"] = score_max
+ if score_filter:
+ query["score"] = score_filter
+
+ # Exact status match
+ if status:
+ query["status"] = status
+
+ # Convert idx_from / idx_to to skip + limit
+ skip = idx_from
+ limit = max(0, idx_to - idx_from + 1)
+
+ return query, skip, limit
@search_router.get("/anizenith/search")
async def search(
@@ -135,19 +92,37 @@ async def search(
) -> SearchResponse:
"""
Search endpoint that returns paginated results.
- TODO: Integrate with real backend DB queries
"""
# Calculate how many items to return in this page
- total_results = len(MOCK_ANIME_LIST)
- start = idx_from
- end = min(idx_to + 1, total_results)
+ filter_query, skip, limit = get_mongo_query(
+ q=q,
+ genre=genre,
+ year_min=year_min,
+ year_max=year_max,
+ score_min=score_min,
+ score_max=score_max,
+ status=status,
+ idx_from=idx_from,
+ idx_to=idx_to,
+ )
+
+ print(filter_query)
+
+ # Convert filter query into string for hashing
+ filter_key = json_util.dumps(filter_query, sort_keys=True)
+ if filter_key in count_cache:
+ # Retrieve the total count for this query from cache
+ total_count = count_cache[filter_key]
+ else:
+ # Cache expensive DB operation if not in cache
+ total_count = DB_CLIENT.anime_collection.count_documents(filter_query)
+ count_cache[filter_key] = total_count
+
+ docs = DB_CLIENT.execute_read_query(query=filter_query, skip=skip, limit=limit)
+ shows = [AnimeDocument(**doc) for doc in docs]
- shows = []
- for i in range(start, end):
- show = MOCK_ANIME_LIST[i].model_copy()
- shows.append(show)
return SearchResponse(
- total_count=total_results,
+ total_count=total_count,
shows=shows
)
\ No newline at end of file
diff --git a/frontend/app.py b/frontend/app.py
index b533ba7..6656034 100644
--- a/frontend/app.py
+++ b/frontend/app.py
@@ -77,7 +77,7 @@ async def add_security_headers(request: Request, call_next):
f"default-src 'self'; "
f"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
f"style-src 'self' https://cdnjs.cloudflare.com; "
- f"img-src 'self' https://cdn.myanimelist.net data:; "
+ f"img-src 'self' https://cdn.myanimelist.net https://myanimelist.net data:; "
f"font-src 'self' https://cdnjs.cloudflare.com; "
f"frame-ancestors 'none';"
)
@@ -136,7 +136,7 @@ async def proxy(path: str, request: Request):
url=backend_url,
content=body,
headers=dict(request.headers),
- params=request.query_params,
+ params=list(request.query_params.multi_items()), # Line supports multi-query parameters (e.g. lists)
)
except (httpx.ConnectError, httpx.TimeoutException):
return JSONResponse({"error": "Backend server has timed out. Please try again later."}, status_code=504)
diff --git a/frontend/static/css/animegrid.css b/frontend/static/css/animegrid.css
new file mode 100644
index 0000000..1e6599e
--- /dev/null
+++ b/frontend/static/css/animegrid.css
@@ -0,0 +1,326 @@
+/* ===== Anime Card Grid ===== */
+.results-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ justify-content: flex-start;
+ align-items: stretch;
+ padding: 15px 0;
+}
+
+.results-container.few-cards {
+ justify-content: flex-start;
+}
+
+.results-wrapper {
+ background: var(--bg-glass-darker);
+ backdrop-filter: blur(18px);
+ border-radius: 18px;
+ border: 1px solid var(--border-soft);
+ padding: 15px;
+
+ height: 100%;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ margin-bottom: 10px;
+}
+
+.anime-card {
+ display: flex;
+ flex-direction: row;
+ background: var(--bg-glass);
+ border-radius: 20px;
+ overflow: hidden;
+ border: 1px solid var(--border-soft);
+ box-shadow: var(--shadow-soft);
+ transition: transform 0.25s ease, box-shadow 0.3s ease;
+ cursor: default;
+ width: auto;
+ min-width: 200px;
+ height: 300px;
+ position: relative;
+}
+
+.anime-card:hover {
+ transform: translateY(-8px);
+ box-shadow: 0 20px 30px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--accent-primary);
+}
+
+.card-image-area {
+ position: relative;
+ width: 200px;
+ height: 100%;
+ border-radius: 20px;
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-color: var(--bg-glass-darker);
+ flex-shrink: 0;
+}
+
+.card-image {
+ width: 100%;
+ height: 100%;
+ border-radius: 20px;
+ cursor: pointer;
+}
+
+.card-genres {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ z-index: 2;
+}
+
+.genre-pill {
+ background: var(--bg-glass-strong);
+ backdrop-filter: blur(4px);
+ padding: 4px 10px;
+ border-radius: 40px;
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: var(--text-main);
+ border: 1px solid var(--accent-glow);
+ letter-spacing: 0.3px;
+ white-space: nowrap;
+}
+
+.card-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 0.8rem;
+ background: var(--bg-glass-strong);
+ background: linear-gradient(to top, var(--bg-glass-strong) 0%, transparent 100%);
+ backdrop-filter: blur(3px);
+ color: var(--text-main);
+ border-radius: 0 0 20px 20px;
+ z-index: 1;
+ box-sizing: border-box;
+ text-align: center;
+}
+
+.card-title {
+ font-weight: 700;
+ font-size: 0.85rem;
+ margin: 0 0 14px 0;
+ white-space: normal;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
+ display: block;
+ max-height: none;
+ -webkit-line-clamp: unset;
+ -webkit-box-orient: vertical;
+ overflow: visible;
+}
+
+/* ===== Card Bottom Icons ===== */
+.card-bottom-icons {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ right: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.favorite-heart,
+.chat-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--bg-glass-strong);
+ backdrop-filter: blur(4px);
+ border: 1px solid var(--border-soft);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+ transition: all 0.2s ease;
+ box-shadow: var(--shadow-soft);
+ pointer-events: auto;
+ cursor: pointer;
+}
+
+.favorite-heart {
+ color: var(--accent-primary);
+ cursor: pointer;
+}
+
+.favorite-heart:hover,
+.chat-icon:hover {
+ background: var(--accent-secondary);
+ color: white;
+ transform: scale(1.1);
+ border-color: transparent;
+}
+
+.card-score {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #ffb83d;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.card-score i {
+ font-size: 0.7rem;
+ color: #ffb83d;
+}
+
+/* ===== Broken Heart animation ===== */
+.favorite-heart.holding i {
+ opacity: 0;
+}
+
+.favorite-heart.holding::before,
+.favorite-heart.holding::after {
+ content: "\f004";
+ font-family: "Font Awesome 6 Free";
+ font-weight: 900;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ color: var(--accent-primary);
+ font-size: inherit;
+ pointer-events: none;
+}
+
+.favorite-heart.holding::before {
+ clip-path: polygon(0% 0%, 50% 0%, 50% 100%, 0% 100%);
+ animation: leftHalfBreak 1.8s ease-in-out forwards;
+}
+
+.favorite-heart.holding::after {
+ clip-path: polygon(50% 0%, 100% 0%, 100% 100%, 50% 100%);
+ animation: rightHalfBreak 1.8s ease-in-out forwards;
+}
+
+@keyframes leftHalfBreak {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(-200%, -120%) rotate(-45deg);
+ opacity: 0.5;
+ }
+}
+
+@keyframes rightHalfBreak {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(100%, -120%) rotate(45deg);
+ opacity: 0.5;
+ }
+}
+
+/* ===== Expanded Side Section ===== */
+.expand-arrow {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 32px;
+ height: 32px;
+ background: var(--bg-glass-strong);
+ backdrop-filter: blur(4px);
+ border: 1px solid var(--border-soft);
+ border-radius: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--text-main);
+ font-size: 1rem;
+ transition: all 0.2s ease;
+ z-index: 3;
+ box-shadow: var(--shadow-soft);
+}
+
+.expand-arrow:hover {
+ background: var(--accent-primary);
+ color: white;
+ transform: translateY(-50%) scale(1.05);
+ border-color: transparent;
+}
+
+.card-expand-panel {
+ width: 0;
+ overflow: hidden;
+ transition: width 0.3s ease-out;
+ background: var(--bg-glass-strong);
+ border-left: 1px solid transparent;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 0;
+ white-space: normal;
+ word-break: break-word;
+ flex: 1 1 0;
+}
+
+.anime-card.expanded .card-expand-panel {
+ width: 190px;
+ padding: 16px 12px;
+ border-left-color: var(--border-soft);
+ cursor: default;
+
+ overflow-y: auto;
+}
+
+.anime-card.expanded .card-image-area,
+.anime-card.expanded .card-overlay,
+.anime-card.expanded .card-image {
+ border-radius: 20px 0 0 20px;
+}
+
+.expand-arrow i {
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.anime-card.expanded .expand-arrow i {
+ transform: rotate(-180deg);
+}
+
+.expand-info p {
+ margin: 8px 0;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+
+.expand-info strong {
+ color: var(--text-main);
+ font-weight: 600;
+}
+
+.expand-description {
+ font-family: 'Segoe UI', system-ui, sans-serif;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ color: var(--text-muted);
+ text-align: left;
+ hyphens: auto;
+}
+
+.expand-description .synopsis {
+ white-space: pre-line;
+}
\ No newline at end of file
diff --git a/frontend/static/css/favorites/favorites.css b/frontend/static/css/favorites/favorites.css
index 8a8ec65..a165401 100644
--- a/frontend/static/css/favorites/favorites.css
+++ b/frontend/static/css/favorites/favorites.css
@@ -1,307 +1,130 @@
@import url("../pagination.css");
+@import url("../animegrid.css");
-/* ===== Primary Container ===== */
-.favorites-container {
- max-width: 900px;
- height: 60vh;
- margin: 0 auto 1rem;
- background: var(--bg-glass-strong);
- border-radius: 22px;
- padding: 25px;
- border: 1px solid var(--border-soft);
- box-shadow: var(--shadow-soft);
-
- display: flex;
- flex-direction: column;
- overflow-y: auto;
-}
-
-/* ===== Favorites Options ===== */
+/* ===== Favorites Search Options ===== */
.favorites-options {
- display: flex;
- align-items: center;
- gap: 1rem;
- flex-wrap: wrap;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
}
.sort-wrapper,
.search-favorites-wrapper {
- display: flex;
- align-items: center;
- background: var(--bg-glass-darker);
- border-radius: 40px;
- padding: 0.2rem 0.2rem 0.2rem 1rem;
- border: 1px solid var(--border-soft);
- backdrop-filter: blur(8px);
+ display: flex;
+ align-items: center;
+ background: var(--bg-glass-darker);
+ border-radius: 40px;
+ padding: 0.2rem 0.2rem 0.2rem 1rem;
+ border: 1px solid var(--border-soft);
+ backdrop-filter: blur(8px);
}
.sort-label {
- color: var(--text-muted);
- margin-right: 8px;
+ color: var(--text-muted);
+ margin-right: 8px;
}
.sort-select {
- background: transparent;
- border: none;
- color: var(--text-main);
- font-weight: 500;
- padding: 10px 30px 10px 10px;
- border-radius: 40px;
- cursor: pointer;
- outline: none;
- appearance: none;
+ background: transparent;
+ border: none;
+ color: var(--text-main);
+ font-weight: 500;
+ padding: 10px 30px 10px 10px;
+ border-radius: 40px;
+ cursor: pointer;
+ outline: none;
+ appearance: none;
}
.sort-select option {
- background: var(--bg-main);
- color: var(--text-main);
+ background: var(--bg-main);
+ color: var(--text-main);
}
.search-favorites-wrapper .search-icon {
- color: var(--text-muted);
- margin-right: 8px;
+ color: var(--text-muted);
+ margin-right: 8px;
}
.search-favorites-input {
- background: transparent;
- border: none;
- color: var(--text-main);
- padding: 10px 10px 10px 0;
- width: 220px;
- outline: none;
- font-size: 0.95rem;
+ background: transparent;
+ border: none;
+ color: var(--text-main);
+ padding: 10px 10px 10px 0;
+ width: 220px;
+ outline: none;
+ font-size: 0.95rem;
}
.search-favorites-input::placeholder {
- color: var(--text-muted);
- opacity: 0.7;
+ color: var(--text-muted);
+ opacity: 0.7;
}
.clear-search-btn {
- background: transparent;
- border: none;
- color: var(--text-muted);
- cursor: pointer;
- padding: 8px 12px;
- border-radius: 50%;
- transition: background 0.2s;
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 8px 12px;
+ border-radius: 50%;
+ transition: background 0.2s;
}
.clear-search-btn:hover {
- background: var(--bg-glass);
- color: var(--text-main);
-}
-
-/* ===== Favorites Grid Section ===== */
-.favorites-bottom {
- display: flex;
- flex-direction: column;
- justify-content: center;
- flex: 1;
-}
-
-.favorites-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
- gap: 1.8rem;
- margin: 3rem 0rem;
+ background: var(--bg-glass);
+ color: var(--text-main);
}
-.anime-card {
- position: relative;
- border-radius: 20px;
- overflow: hidden;
- border: 1px solid var(--border-soft);
- transition: transform 0.25s ease, box-shadow 0.3s ease;
- box-shadow: var(--shadow-soft);
- cursor: pointer;
- aspect-ratio: 2/3;
-}
-
-.anime-card:hover {
- transform: translateY(-8px);
- box-shadow: 0 20px 30px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--accent-primary);
-}
-
-.anime-card:hover .card-cover img {
- transform: scale(1.05);
-}
-
-.favorite-heart {
- position: absolute;
- top: 10px;
- right: 10px;
- width: 36px;
- height: 36px;
- border-radius: 50%;
- background: var(--bg-glass-strong);
- backdrop-filter: blur(4px);
- border: 1px solid var(--border-soft);
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--accent-primary);
- font-size: 1.3rem;
- cursor: pointer;
- transition: all 0.2s ease;
- z-index: 5;
- box-shadow: var(--shadow-soft);
-}
-
-.favorite-heart:hover {
- background: var(--accent-secondary);
- color: white;
- transform: scale(1.1);
- border-color: transparent;
-}
-
-/* Hide the normal heart icon during hold */
-.favorite-heart.holding i {
- opacity: 0;
-}
-
-/* Left half of broken heart */
-.favorite-heart.holding::before {
- content: "\f004";
- font-family: "Font Awesome 6 Free";
- font-weight: 900;
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- color: var(--accent-primary);
- font-size: inherit;
- clip-path: polygon(0% 0%, 50% 0%, 50% 100%, 0% 100%);
- animation: leftHalfBreak 1.8s ease-in-out forwards;
- pointer-events: none;
-}
-
-/* Right half of broken heart */
-.favorite-heart.holding::after {
- content: "\f004";
- font-family: "Font Awesome 6 Free";
- font-weight: 900;
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- color: var(--accent-primary);
- font-size: inherit;
- clip-path: polygon(50% 0%, 100% 0%, 100% 100%, 50% 100%);
- animation: rightHalfBreak 1.8s ease-in-out forwards;
- pointer-events: none;
-}
-
-.favorite-heart.holding::before,
-.favorite-heart.holding::after {
- animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-
-@keyframes leftHalfBreak {
- 0% {
- transform: translate(-50%, -50%) rotate(0deg);
- opacity: 1;
- }
- 100% {
- transform: translate(-200%, -120%) rotate(-45deg);
- opacity: 0.5;
- }
-}
-
-@keyframes rightHalfBreak {
- 0% {
- transform: translate(-50%, -50%) rotate(0deg);
- opacity: 1;
- }
- 100% {
- transform: translate(100%, -120%) rotate(45deg);
- opacity: 0.5;
- }
-}
-
-.card-overlay {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- padding: 1.2rem 0.8rem 0.8rem;
- background: linear-gradient(to top, var(--bg-glass-strong) 0%, transparent 100%);
- backdrop-filter: blur(6px);
- color: var(--text-main);
- border-radius: 0 0 20px 20px;
- overflow: hidden;
-}
-
-.card-title {
- font-weight: 700;
- font-size: 1rem;
- margin-bottom: 4px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.card-meta {
- display: flex;
- justify-content: space-between;
- color: var(--text-muted);
- font-size: 0.8rem;
-}
-
-.card-rating {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.card-rating i {
- color: #ffb83d;
-}
-
-/* ===== No favorites state ===== */
-.empty-state {
- text-align: center;
- padding: 4rem 2rem;
- background: var(--bg-glass);
- border-radius: 40px;
- backdrop-filter: blur(12px);
- border: 1px solid var(--border-soft);
- margin: 1rem 0;
+/* ===== No Results State ===== */
+.no-results {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: var(--bg-glass);
+ border-radius: 40px;
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--border-soft);
+ margin: 1rem 0;
+ width: 100%;
+ box-sizing: border-box;
}
-.empty-illustration {
- font-size: 5rem;
- color: var(--accent-primary);
- opacity: 0.7;
- margin-bottom: 1.5rem;
+.no-results-illustration {
+ font-size: 5rem;
+ color: var(--accent-primary);
+ opacity: 0.7;
+ margin-bottom: 1.5rem;
}
-.empty-state h2 {
- font-size: 2rem;
- margin-bottom: 0.5rem;
- color: var(--text-main);
+.no-results h2 {
+ font-size: 2rem;
+ margin-bottom: 0.5rem;
+ color: var(--text-main);
}
-.empty-state p {
- color: var(--text-muted);
- margin-bottom: 2rem;
+.no-results p {
+ color: var(--text-muted);
+ margin-bottom: 2rem;
}
.browse-btn {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- padding: 12px 28px;
- background: var(--accent-gradient);
- border-radius: 40px;
- color: white;
- font-weight: 600;
- text-decoration: none;
- box-shadow: var(--btn-glow);
- transition: transform 0.2s, box-shadow 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 28px;
+ background: var(--accent-gradient);
+ border-radius: 40px;
+ color: white;
+ font-weight: 600;
+ text-decoration: none;
+ box-shadow: var(--btn-glow);
+ transition: transform 0.2s, box-shadow 0.2s;
}
.browse-btn:hover {
- transform: translateY(-3px);
- box-shadow: 0 0 25px var(--accent-glow);
+ transform: translateY(-3px);
+ box-shadow: 0 0 25px var(--accent-glow);
}
\ No newline at end of file
diff --git a/frontend/static/css/main.css b/frontend/static/css/main.css
index 4ee2676..780c92e 100644
--- a/frontend/static/css/main.css
+++ b/frontend/static/css/main.css
@@ -25,6 +25,30 @@ body {
-ms-user-select: none;
}
+.truncate {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+
+ overflow: hidden;
+}
+
+/* ===== Main Container ===== */
+.page-container {
+ max-width: 900px;
+ height: 70vh;
+ margin: 0 auto 1rem;
+ background: var(--bg-glass-strong);
+ border-radius: 22px;
+ padding: 25px;
+ border: 1px solid var(--border-soft);
+ box-shadow: var(--shadow-soft);
+
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
/* ===== TOP BAR ===== */
.top-bar {
position: fixed;
@@ -385,4 +409,30 @@ body {
margin-left: auto;
margin-right: auto;
margin-bottom: 1rem;
+}
+
+/* ===== Custom Scrollbar Object ===== */
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: var(--accent-primary) var(--bg-glass-darker);
+}
+
+.custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: var(--bg-glass-darker);
+ border-radius: 10px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: var(--accent-primary);
+ border-radius: 10px;
+ opacity: 0.7;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: var(--accent-secondary);
+ opacity: 1;
}
\ No newline at end of file
diff --git a/frontend/static/css/search/search.css b/frontend/static/css/search/search.css
index 3a06792..1eb5619 100644
--- a/frontend/static/css/search/search.css
+++ b/frontend/static/css/search/search.css
@@ -1,24 +1,9 @@
@import url("../pagination.css");
-
-/* ===== Main Search Container ===== */
-.search-container {
- max-width: 900px;
- height: 70vh;
- margin: 0 auto 1rem;
- background: var(--bg-glass-strong);
- border-radius: 22px;
- padding: 25px;
- border: 1px solid var(--border-soft);
- box-shadow: var(--shadow-soft);
-
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
+@import url("../animegrid.css");
/* ===== Search Section ===== */
.search-section {
- padding: 0 0 1.2rem 0;
+ padding: 0;
flex-shrink: 0;
}
@@ -326,141 +311,6 @@
box-shadow: var(--btn-glow);
}
-/* ===== Results Container ===== */
-.results-wrapper {
- background: var(--bg-glass-darker);
- backdrop-filter: blur(18px);
- border-radius: 18px;
- border: 1px solid var(--border-soft);
- padding: 15px;
-
- height: 100%;
-
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- justify-content: center;
-
- scrollbar-width: thin;
- scrollbar-color: var(--accent-primary) var(--bg-glass-darker);
-}
-
-.results-wrapper:has(.results-table) {
- justify-content: flex-start;
-}
-
-.results-wrapper::-webkit-scrollbar {
- width: 6px;
-}
-
-.results-wrapper::-webkit-scrollbar-track {
- background: var(--bg-glass-darker);
- border-radius: 10px;
-}
-
-.results-wrapper::-webkit-scrollbar-thumb {
- background: var(--accent-primary);
- border-radius: 10px;
- opacity: 0.7;
-}
-
-/* ===== Results Table ===== */
-.results-table {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 10px;
- margin: 0;
-}
-
-.results-header {
- display: flex;
- align-items: center;
- padding: 0 10px 8px;
- margin-bottom: 2px;
-}
-
-.results-header span {
- color: var(--text-main);
- font-weight: 500;
- font-size: 0.85rem;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.result-row {
- display: flex;
- align-items: center;
- background: var(--bg-glass);
- backdrop-filter: blur(4px);
- border-radius: 14px;
- padding: 12px 10px;
- margin-bottom: 10px;
- box-shadow: var(--shadow-soft);
- border: 1px solid var(--border-soft);
- transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
-}
-
-.result-row:hover {
- transform: translateY(-2px);
- box-shadow: 0 8px 20px rgba(0,0,0,0.3), 0 0 15px var(--accent-glow);
- border-color: var(--accent-secondary);
-}
-
-.col-cover {
- flex: 0 0 80px;
-}
-
-.col-title {
- flex: 2;
- min-width: 0;
- padding: 0 8px;
-}
-
-.col-genres {
- flex: 1.5;
- min-width: 0;
- padding: 0 8px;
-}
-
-.col-desc {
- flex: 3;
- min-width: 0;
- padding: 0 8px;
-}
-
-.cover-art img {
- width: 60px;
- height: 85px;
- object-fit: cover;
- border-radius: 8px;
- box-shadow: 0 4px 8px rgba(0,0,0,0.4);
- display: block;
-}
-
-.title-col {
- font-weight: 600;
- font-size: 1rem;
- color: var(--text-main);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.genres-col {
- color: var(--text-muted);
- font-size: 0.9rem;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.desc-col {
- color: var(--text-muted);
- font-size: 0.9rem;
- line-height: 1.4;
-}
-
/* ===== No Results State ===== */
.no-results {
text-align: center;
@@ -478,4 +328,38 @@
opacity: 0.6;
margin-bottom: 1rem;
display: block;
+}
+
+.no-results {
+ margin: 2rem auto;
+ width: 100%;
+}
+
+/* ===== Go to Favorites Button ===== */
+.bottom-controls {
+ display: flex;
+ justify-content: center;
+ position: relative;
+}
+
+.favorited-btn {
+ position: absolute;
+ right: 0;
+ top: 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 24px;
+ background: var(--accent-gradient);
+ border-radius: 40px;
+ color: white;
+ font-weight: 600;
+ text-decoration: none;
+ border: none;
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.favorited-btn:hover {
+ box-shadow: 0 0 25px var(--accent-glow);
}
\ No newline at end of file
diff --git a/frontend/static/js/animecard.js b/frontend/static/js/animecard.js
new file mode 100644
index 0000000..2f32c7e
--- /dev/null
+++ b/frontend/static/js/animecard.js
@@ -0,0 +1,135 @@
+import { getFavorites, saveFavorites, isAnimeFavorited, addFavorite, removeFavorite } from './localDBs/favoritesDB.js'
+import { postErrorMessage } from './error.js';
+
+// Genres that are not to be displayed on front card icon
+const excludedGenres = ["Award Winning", "Adult Cast", "Gore", "Parody", "Team Sports", "High Stakes Game", "Urban Fantasy"];
+
+// Template cloning and base setup
+export function renderAnimeCard(show) {
+ const template = document.getElementById('tmpl-anime-card');
+ const card = template.content.cloneNode(true).firstElementChild;
+ card.setAttribute('data-id', show.mal_id);
+
+ // Card image
+ const imageArea = card.querySelector('.card-image');
+ imageArea.style.backgroundImage = `url(${show.cover_image_url})`;
+ // Clicking on image sends to the anime page
+ imageArea.addEventListener('click', (e) => {
+ window.location.href = `/anime/${show.mal_id}`;
+ });
+
+ // Genre pills
+ const genresContainer = card.querySelector('.card-genres');
+ show.genres.filter(genre => !excludedGenres.includes(genre)).slice(0, 3).forEach(genre => {
+ const pill = document.createElement('span');
+ pill.className = 'genre-pill';
+ pill.textContent = genre;
+ genresContainer.appendChild(pill);
+ });
+
+ // Title and score
+ const titleEl = card.querySelector('.card-title');
+ titleEl.textContent = show.name;
+ titleEl.title = show.name;
+ const scoreSpan = card.querySelector('.card-score span');
+ scoreSpan.textContent = parseFloat(show.score).toFixed(1);
+
+ // Expand panel (studio, aired, synopsis)
+ const studioSpan = card.querySelector('.studio');
+ studioSpan.textContent = show.publishing_company;
+ const airedSpan = card.querySelector('.aired');
+ const date = new Date(show.date_aired);
+ airedSpan.textContent = date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const synopsisSpan = card.querySelector('.synopsis');
+ synopsisSpan.innerHTML = show.synopsis;
+
+ // Favorite heart (add/remove with long press)
+ const heartBtn = card.querySelector('.favorite-heart');
+ heartBtn.setAttribute('data-id', show.mal_id);
+ const currentlyFavorited = isAnimeFavorited(show.mal_id);
+ heartBtn.innerHTML = currentlyFavorited ? '' : '';
+ heartBtn.setAttribute('data-favorited', currentlyFavorited ? 'true' : 'false');
+
+ let pressTimer = null, isLongPress = false;
+ const addFavoriteBtnFunc = async () => {
+ if (heartBtn.getAttribute('data-favorited') === 'true') return;
+ heartBtn.innerHTML = '';
+ heartBtn.setAttribute('data-favorited', 'true');
+ addFavorite(show);
+ postErrorMessage(101, "Added Favorite", `Added "${show.name}" to favorites`);
+ };
+ const removeFavoriteBtnFunc = async () => {
+ if (heartBtn.getAttribute('data-favorited') === 'false') return;
+ heartBtn.addEventListener('animationend', () => {
+ heartBtn.classList.remove('holding');
+ heartBtn.innerHTML = '';
+ heartBtn.setAttribute('data-favorited', 'false');
+ removeFavorite(show.mal_id);
+ postErrorMessage(102, "Removed Favorite", `Removed "${show.name}" from favorites`);
+ }, { once: true });
+ };
+ const cancelLongPress = () => {
+ if (pressTimer) clearTimeout(pressTimer);
+ heartBtn.classList.remove('holding');
+ isLongPress = false;
+ };
+ const handlePointerDown = (e) => {
+ e.preventDefault();
+ isLongPress = false;
+ heartBtn.classList.add('holding');
+ pressTimer = setTimeout(() => { isLongPress = true; removeFavoriteBtnFunc(); }, 800);
+ };
+ const handlePointerUp = () => {
+ if (pressTimer) clearTimeout(pressTimer);
+ if (!isLongPress) heartBtn.classList.remove('holding');
+ isLongPress = false;
+ };
+ const handleClickInactive = (e) => { e.stopPropagation(); e.preventDefault(); addFavoriteBtnFunc(); };
+ function updateHeartListeners() {
+ const isFavorited = heartBtn.getAttribute('data-favorited') === 'true';
+ heartBtn.removeEventListener('click', handleClickInactive);
+ heartBtn.removeEventListener('pointerdown', handlePointerDown);
+ heartBtn.removeEventListener('pointerup', handlePointerUp);
+ heartBtn.removeEventListener('pointercancel', cancelLongPress);
+ heartBtn.removeEventListener('pointerleave', cancelLongPress);
+ if (!isFavorited) {
+ heartBtn.addEventListener('click', handleClickInactive);
+ } else {
+ heartBtn.addEventListener('pointerdown', handlePointerDown);
+ heartBtn.addEventListener('pointerup', handlePointerUp);
+ heartBtn.addEventListener('pointercancel', cancelLongPress);
+ heartBtn.addEventListener('pointerleave', cancelLongPress);
+ heartBtn.addEventListener('click', (e) => e.preventDefault());
+ }
+ }
+ updateHeartListeners();
+ const origSetAttr = heartBtn.setAttribute.bind(heartBtn);
+ heartBtn.setAttribute = (name, val) => { origSetAttr(name, val); if (name === 'data-favorited') updateHeartListeners(); };
+
+ // Chat icon sends user to chatbot
+ const chatBtn = card.querySelector('.chat-icon');
+ chatBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ window.location.href = `/?request-info-id=${show.mal_id}`;
+ });
+
+ // Expand arrow event
+ const arrowBtn = card.querySelector('.expand-arrow');
+ arrowBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ card.classList.toggle('expanded');
+ if (card.classList.contains('expanded')) {
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+ });
+
+ return card;
+}
+
+// Escape HTML
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
\ No newline at end of file
diff --git a/frontend/static/js/chatbot/chat_utils.js b/frontend/static/js/chatbot/chat_utils.js
index 9bee894..e2c5bd0 100644
--- a/frontend/static/js/chatbot/chat_utils.js
+++ b/frontend/static/js/chatbot/chat_utils.js
@@ -1,5 +1,5 @@
import { postError, postErrorMessage } from "../error.js"
-import { pushMessages, pullMessages } from "./chat_history_db.js";
+import { pushMessages, pullMessages } from "../localDBs/chatHistoryDB.js";
// Client-side conversation message storage (Chats are only stored on client side for now)
export let messages = [];
diff --git a/frontend/static/js/favorites/favorites.js b/frontend/static/js/favorites/favorites.js
index 599c8eb..7efa86c 100644
--- a/frontend/static/js/favorites/favorites.js
+++ b/frontend/static/js/favorites/favorites.js
@@ -1,246 +1,114 @@
import { renderPagination } from '../pagination.js';
-import { postErrorMessage } from '../error.js';
+import { renderAnimeCard } from '../animecard.js';
+import { getFavorites } from '../localDBs/favoritesDB.js'
-// Configuration
-const FAVORITES_RETRIEVE_POINT = "/proxy/anizenith/search";
-const ITEMS_PER_PAGE = 4;
+// ===== Configuration & State =====
+const ITEMS_PER_PAGE = 8;
let currentPage = 1;
-let favorites = [];
let filteredFavorites = [];
let sortOption = 'dateAdded';
let searchTerm = '';
-// DOM elements
+// ===== DOM Elements =====
const $ = id => document.getElementById(id);
-const gridEl = $('favoritesGrid');
-const loadingEl = $('favoritesLoading');
-const emptyStateEl = $('emptyState');
-const paginationEl = $('pagination');
+const gridEl = $('results-container');
+const noResultsTemplate = $('tmpl-no-results');
+const paginationEl = $('pagination-wrapper');
const sortSelect = $('sortSelect');
const searchInput = $('searchFavoritesInput');
const clearSearchBtn = $('clearSearchBtn');
-const cardTemplate = $('anime-card-template');
-
-// Fetch favorites from backend
-async function fetchFavorites() {
- try {
- // TODO: Remove this and rely solely on browser loading, this is purely for testing
- const params = new URLSearchParams({ idx_from: '0', idx_to: '999' });
- const response = await fetch(`${FAVORITES_RETRIEVE_POINT}?${params}`);
-
- if (!response.ok) {
- // Custom error reporting (passes HTTP status, user message, endpoint)
- postErrorMessage(response.status, "Could not fetch Favorites", FAVORITES_RETRIEVE_POINT);
- return [];
- }
-
- const data = await response.json();
- // Cache the fresh data locally so it's available offline
- localStorage.setItem('anizenith_favorites', JSON.stringify(data.shows));
- return data.shows;
-
- } catch (error) {
- console.error('Failed to fetch favorites from API:', error);
-
- // If network/other error, try to serve cached favorites
- const cached = localStorage.getItem('anizenith_favorites');
- if (cached) {
- return JSON.parse(cached);
- }
- return [];
- }
-}
-
-// Save favorites to local browser storage
-function saveFavorites(favs) {
- localStorage.setItem('anizenith_favorites', JSON.stringify(favs));
-}
-
-// Removes a single favorite anime by its ID
-async function removeFavorite(animeId) {
- const index = favorites.findIndex(a => a.id === animeId);
- if (index !== -1) {
- const animeTitle = favorites[index].title;
- favorites.splice(index, 1);
- saveFavorites(favorites);
-
- // Notify the user (custom status 102 means "Removed Favorite Anime")
- postErrorMessage(102, "Removed Anime", `Removed "${animeTitle}" from favorites`);
- applyFiltersAndRender();
- }
-}
-
+const loadingEl = $('favoritesLoading');
-// Lambda expressions for comparing anime favorites stored on browser
+// Sorting functions
const sortFunctions = {
- titleAsc: (a, b) => a.title.localeCompare(b.title),
- titleDesc: (a, b) => b.title.localeCompare(a.title),
- score: (a, b) => (b.score || 0) - (a.score || 0),
- dateAdded: (a, b) => (b.date_added || 0) - (a.date_added || 0)
+ titleAsc: (a, b) => a.name.localeCompare(b.name),
+ titleDesc: (a, b) => b.name.localeCompare(a.name),
+ score: (a, b) => (b.score || 0) - (a.score || 0),
+ dateAdded: (a, b) => (b.date_added || 0) - (a.date_added || 0)
};
-// Apply search and sort
-function applyFilters() {
- let result = [...favorites];
-
- // Filter by search term
- if (searchTerm.trim()) {
- const term = searchTerm.toLowerCase();
- result = result.filter(anime => anime.title.toLowerCase().includes(term));
- }
-
- // Sort using the appropriate function, falling back to dateAdded
- result.sort(sortFunctions[sortOption] || sortFunctions.dateAdded);
-
- filteredFavorites = result;
- return result;
-}
-
-function createAnimeCard(anime) {
- // Clone template from HTML to build anime card
- const clone = cardTemplate.content.cloneNode(true);
- const card = clone.querySelector('.anime-card');
- const img = clone.querySelector('img');
- const heartBtn = clone.querySelector('.favorite-heart');
- const titleEl = clone.querySelector('.card-title');
- const ratingSpan = clone.querySelector('.rating-value');
-
- // Populate the card with anime data
- card.dataset.animeId = anime.id;
- img.src = anime.cover_image_url;
- img.alt = anime.title;
- heartBtn.dataset.id = anime.id;
- titleEl.textContent = anime.title;
- titleEl.title = anime.title;
- ratingSpan.textContent = anime.score?.toFixed(1) ?? 'N/A';
-
- // Hold to remove feature: A long press (800ms) triggers removal, a short press does nothing.
- const handlePointerDown = (e) => {
- e.preventDefault(); // prevent selecting the text or area behind
- heartBtn.classList.add('holding');
- heartBtn.holdTimer = setTimeout(() => removeFavorite(anime.id), 800);
- };
-
- const handlePointerUp = () => {
- clearTimeout(heartBtn.holdTimer);
- heartBtn.classList.remove('holding');
- };
-
- // Hold events for different devices
- heartBtn.addEventListener('pointerdown', handlePointerDown);
- heartBtn.addEventListener('pointerup', handlePointerUp);
- heartBtn.addEventListener('pointercancel', handlePointerUp);
- heartBtn.addEventListener('pointerleave', handlePointerUp);
-
- // Prevent the heart button click from triggering card navigation
- heartBtn.addEventListener('click', (e) => e.preventDefault());
-
- // Clicking anywhere on the card (except the heart) navigates to the anime's page
- card.addEventListener('click', (e) => {
- if (!e.target.closest('.favorite-heart')) {
- window.location.href = `/anime/${anime.id}`;
- }
- });
-
- return card;
+function applyFiltersAndSort() {
+ let favorites = getFavorites();
+ let result = [...favorites];
+ if (searchTerm.trim()) {
+ const term = searchTerm.toLowerCase();
+ result = result.filter(anime => anime.name.toLowerCase().includes(term));
+ }
+ result.sort(sortFunctions[sortOption] || sortFunctions.dateAdded);
+ filteredFavorites = result;
+ return result;
}
-// Render current page
+// Render current page using the shared card component
function renderPage() {
- const filtered = filteredFavorites;
- const totalItems = filtered.length;
- const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
-
- // No results - show empty state, hide grid and pagination
- if (totalItems === 0) {
- gridEl.style.display = 'none';
- paginationEl.style.display = 'none';
- emptyStateEl.style.display = 'block';
- return;
+ const totalItems = filteredFavorites.length;
+ const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
+
+ if (totalItems === 0) {
+ // If no favorites, show no-results template, hide pagination
+ paginationEl.style.display = 'none';
+ gridEl.replaceChildren(noResultsTemplate.content.cloneNode(true));
+ return;
+ }
+
+ // Clear current grid items, add new ones
+ gridEl.replaceChildren();
+ paginationEl.style.display = 'flex';
+
+ const start = (currentPage - 1) * ITEMS_PER_PAGE;
+ const end = Math.min(start + ITEMS_PER_PAGE, totalItems);
+ const pageItems = filteredFavorites.slice(start, end);
+
+ pageItems.forEach(anime => {
+ const card = renderAnimeCard(anime);
+ gridEl.appendChild(card);
+ });
+
+ renderPagination(paginationEl, {
+ currentPage,
+ totalPages,
+ onPageChange: (newPage) => {
+ currentPage = newPage;
+ renderPage();
+ window.scrollTo({ top: 0, behavior: 'smooth' });
}
-
- // Show the grid, hide empty state
- emptyStateEl.style.display = 'none';
- gridEl.style.display = 'grid';
-
- // Slice the visible page of items
- const start = (currentPage - 1) * ITEMS_PER_PAGE;
- const end = Math.min(start + ITEMS_PER_PAGE, totalItems);
- const pageItems = filtered.slice(start, end);
-
- // Clear previous cards and append new ones
- gridEl.innerHTML = '';
- pageItems.forEach(anime => {
- gridEl.appendChild(createAnimeCard(anime));
- });
-
- // Register and render pagination
- renderPagination(paginationEl, {
- currentPage,
- totalPages,
- onPageChange: (newPage) => { // When page is changed, set new page and render the page
- currentPage = newPage;
- renderPage();
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
- });
-
- paginationEl.style.display = 'flex';
+ });
}
-// Apply filters and re-render
-function applyFiltersAndRender() {
- currentPage = 1;
- applyFilters();
- renderPage();
+function refresh() {
+ applyFiltersAndSort();
+ renderPage();
+ if (loadingEl) loadingEl.style.display = 'none';
}
+// Listen for favorites change and refresh favorites list
+window.addEventListener('favoritesUpdated', () => {
+ refresh();
+});
+
document.addEventListener('DOMContentLoaded', () => {
- // Apply filters if user types and then stops typing (for 300ms)
- let searchTimeout;
- searchInput.addEventListener('input', (e) => {
- clearTimeout(searchTimeout);
- searchTimeout = setTimeout(() => {
- searchTerm = e.target.value;
- applyFiltersAndRender();
- }, 300);
- });
-
- // Clear search button resets the input and the results
- clearSearchBtn.addEventListener('click', () => {
- searchInput.value = '';
- searchTerm = '';
- applyFiltersAndRender();
- });
-
- // Sort selector triggers a full re‑filter/re‑render
- sortSelect.addEventListener('change', (e) => {
- sortOption = e.target.value;
- applyFiltersAndRender();
- });
-
- // Main page init function: show loading animation, fetch data, then render (requires async due to fetch)
- async function init() {
- // Initial state: only the loading indicator is visible
- emptyStateEl.style.display = 'none';
- gridEl.style.display = 'none';
- paginationEl.style.display = 'none';
- loadingEl.style.display = 'flex';
-
- try {
- // If fetch succeeds
- favorites = await fetchFavorites();
- applyFilters();
- renderPage();
- } catch (error) {
- // If backend is down / corrupted / other error, show empty state
- postErrorMessage(500, "Load Failed", "Could not load favorites. Please try again.");
- loadingEl.style.display = 'none';
- emptyStateEl.style.display = 'block';
- } finally {
- loadingEl.style.display = 'none';
- }
- }
+ let searchTimeout;
+ searchInput.addEventListener('input', (e) => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ searchTerm = e.target.value;
+ currentPage = 1;
+ refresh();
+ }, 300);
+ });
+
+ clearSearchBtn.addEventListener('click', () => {
+ searchInput.value = '';
+ searchTerm = '';
+ currentPage = 1;
+ refresh();
+ });
+
+ sortSelect.addEventListener('change', (e) => {
+ sortOption = e.target.value;
+ currentPage = 1;
+ refresh();
+ });
- init();
+ refresh();
});
\ No newline at end of file
diff --git a/frontend/static/js/chatbot/chat_history_db.js b/frontend/static/js/localDBs/chatHistoryDB.js
similarity index 100%
rename from frontend/static/js/chatbot/chat_history_db.js
rename to frontend/static/js/localDBs/chatHistoryDB.js
diff --git a/frontend/static/js/localDBs/favoritesDB.js b/frontend/static/js/localDBs/favoritesDB.js
new file mode 100644
index 0000000..759ee92
--- /dev/null
+++ b/frontend/static/js/localDBs/favoritesDB.js
@@ -0,0 +1,42 @@
+const STORAGE_KEY = 'anizenith_favorites';
+
+function getStore() {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : {};
+}
+
+function setStore(store) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
+}
+
+export function getFavorites() {
+ return Object.values(getStore());
+}
+
+export function saveFavorites(favoritesArray) {
+ const store = {};
+ favoritesArray.forEach(anime => { if (anime?.mal_id) store[anime.mal_id] = anime; });
+ setStore(store);
+}
+
+export function isAnimeFavorited(id) {
+ return getStore().hasOwnProperty(id);
+}
+
+export function addFavorite(anime) {
+ const store = getStore();
+ if (!store[anime.mal_id]) {
+ store[anime.mal_id] = anime;
+ setStore(store);
+ window.dispatchEvent(new CustomEvent('favoritesUpdated', { detail: Object.values(store) }));
+ }
+}
+
+export function removeFavorite(id) {
+ const store = getStore();
+ if (store[id]) {
+ delete store[id];
+ setStore(store);
+ window.dispatchEvent(new CustomEvent('favoritesUpdated', { detail: Object.values(store) }));
+ }
+}
\ No newline at end of file
diff --git a/frontend/static/js/search/search.js b/frontend/static/js/search/search.js
index f5a2e2c..086abce 100644
--- a/frontend/static/js/search/search.js
+++ b/frontend/static/js/search/search.js
@@ -1,9 +1,10 @@
import { renderPagination } from '../pagination.js';
import { postErrorMessage } from '../error.js';
+import { renderAnimeCard } from '../animecard.js';
// ===== Configuration & State =====
const API_SEARCH_URL = '/proxy/anizenith/search';
-const ITEMS_PER_PAGE = 5;
+const ITEMS_PER_PAGE = 8;
let currentPage = 1;
let totalCount = 0;
let totalPages = 1;
@@ -12,7 +13,7 @@ let totalPages = 1;
const $ = id => document.getElementById(id);
const searchForm = $('search-form');
const searchQuery = $('search-query');
-const resultsContainer = $('search-results-container');
+const resultsContainer = $('results-container');
const paginationWrapper = $('pagination-wrapper');
const toggleBtn = $('filterToggleBtn');
const filterPanel = $('filterPanel');
@@ -164,12 +165,14 @@ async function performSearch() {
const params = buildQueryString(getCurrentFilters());
const url = `${API_SEARCH_URL}?${params}`;
history.replaceState(null, '', `?${params}`);
+ console.log(url);
try {
- // TODO: Modify fetch url to include pagination params
- const res = await fetch(url);
+ const timeout = 60.0
+ const res = await fetch(url, { "X-Request-Timeout": timeout.toString() });
if (!res.ok) postErrorMessage(res.status, "Backend Search Error", API_SEARCH_URL);
const data = await res.json();
+ console.log(data);
totalCount = data.total_count || 0;
totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE) || 1;
renderResults(data);
@@ -187,57 +190,28 @@ async function performSearch() {
}
}
-// Renders a row object template to show a short panel describing an anime show dynamically
-function renderShowRow(show) {
- const template = document.getElementById('tmpl-result-row');
- const row = template.content.cloneNode(true);
-
- // Cover image of anime
- const img = row.querySelector('img');
- img.src = show.cover_image_url || '';
- img.alt = escapeHtml(show.title);
-
- // Anime title
- const titleEl = row.querySelector('.col-title');
- titleEl.textContent = show.title;
- titleEl.title = show.title;
-
- // Anime genre
- const genres = Array.isArray(show.genres) ? show.genres.join(', ') : show.genres || '';
- const genresEl = row.querySelector('.col-genres');
- genresEl.textContent = genres;
- genresEl.title = genres;
-
- // Anime short description
- const descEl = row.querySelector('.col-desc');
- descEl.textContent = show.short_description || '';
-
- // Adds row where clicking opens the anime's page
- const rowElement = row.querySelector('.result-row')
- rowElement.style.cursor = 'pointer';
- rowElement.addEventListener('click', () => {
- window.location.href = `/anime/${show.id}`;
- });
-
- return row;
-}
-
// Renders all page results in the current page
function renderResults({ shows = [] }) {
- resultsContainer.innerHTML = '';
- paginationWrapper.innerHTML = '';
-
if (!shows.length) {
const noResults = document.getElementById('tmpl-no-results').content.cloneNode(true);
resultsContainer.appendChild(noResults);
return;
}
- const header = document.getElementById('tmpl-results-header').content.cloneNode(true);
- resultsContainer.appendChild(header);
+ // Clear children
+ resultsContainer.replaceChildren();
+ // Check if container layout needs to be updated in case of low results
+ if (shows.length < ITEMS_PER_PAGE) {
+ resultsContainer.classList.add('few-cards');
+ } else {
+ resultsContainer.classList.remove('few-cards');
+ }
+
+ // Add new shows as cards
shows.forEach(show => {
- resultsContainer.appendChild(renderShowRow(show));
+ const card = renderAnimeCard(show);
+ resultsContainer.appendChild(card);
});
}
@@ -260,14 +234,6 @@ function changePage(delta) {
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
-// Utility to escape HTML for elements that are not string-safe (e.g. images)
-function escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
// Loads the filters on the page if a direct URL with query params is used (stateless)
// TODO: Remove idx_from and idx_to parameters or include logic to support it
function loadStateFromURL() {
diff --git a/frontend/templates/components/anime-card-template.html b/frontend/templates/components/anime-card-template.html
new file mode 100644
index 0000000..d3b8e89
--- /dev/null
+++ b/frontend/templates/components/anime-card-template.html
@@ -0,0 +1,36 @@
+
+