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 @@ + \ No newline at end of file diff --git a/frontend/templates/components/pagination-template.html b/frontend/templates/components/pagination-template.html new file mode 100644 index 0000000..b8e5baa --- /dev/null +++ b/frontend/templates/components/pagination-template.html @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/frontend/templates/favorites.html b/frontend/templates/favorites.html index 96b7a01..f929922 100644 --- a/frontend/templates/favorites.html +++ b/frontend/templates/favorites.html @@ -7,75 +7,62 @@ {% endblock %} {% block content %} -

- Favorite Anime -

-
-
- -
- - - -
- -
- - -
+

Favorite Anime

+ +
+
+
+ + +
+
+ + +
+
-
- -
-
-

Loading your favorites...

-
+
+ +
+
+

Loading your favorites...

+
- -
+ {% include 'components/anime-card-template.html' %} + {% include 'components/pagination-template.html' %} - -
-
- -
-

Your favorites list is empty

-

Start adding anime you love and they'll appear here.

- - - Browse Anime - + + + +
+
+
- - +
+ +
- - {% endblock %} {% block scripts %} diff --git a/frontend/templates/search.html b/frontend/templates/search.html index e1ad90e..b55be5e 100644 --- a/frontend/templates/search.html +++ b/frontend/templates/search.html @@ -11,163 +11,117 @@

Anienith Anime Search

-
-
-
-
- - - +
+
+ +
+ + + - -
+ +
-
-
- -
-

Genres

-
- - - - - - -
+
+
+ +
+

Genres

+
+ + + + + +
+
- -
-

Year Range

-
-
- - - -
-
+ +
+

Year Range

+
+
+ + +
+
+
- -
-

Score

-
-
- - - -
-
+ +
+

Score

+
+
+ + +
-
- - -
-

Status

- +
-
- + +
+

Status

+
- -
- - - - - - - - + +
- + {% include 'components/anime-card-template.html' %} + {% include 'components/pagination-template.html' %} -
-
-
-

Searching anime...

-
+
+
+
+

Searching anime...

+
-
- -
+
+
+
+ +
{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 6764281..2413ca2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pymongo>=4.16.0", "sentence-transformers>=5.4.0", "requests>=2.33.1", + "cachetools>=7.0.6", ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index 558824c..7d98af1 100644 --- a/uv.lock +++ b/uv.lock @@ -47,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/1d/7cbe8f84352789be3dcbb1239f425984f353efa253deeeff38af600870b3/cachetools-7.1.0.tar.gz", hash = "sha256:ea5406e92956f9006b121f8032177c6b02cc5f9a488d1e53b2e4d9cb5aae15c6", size = 40085, upload-time = "2026-05-01T21:20:04.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/42/e09974bc74ea2791f985dff3d7f06065d11abcc31da6c46fc5f83046516e/cachetools-7.1.0-py3-none-any.whl", hash = "sha256:05afd1d309309e7c8971db462b4cf516d93fa8c9aea1b906e26e61b4399d0d82", size = 16748, upload-time = "2026-05-01T21:20:02.879Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -155,6 +164,7 @@ name = "cs553-casestudy1" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "cachetools" }, { name = "fastapi", extra = ["standard"] }, { name = "huggingface-hub" }, { name = "itsdangerous" }, @@ -171,6 +181,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=7.0.6" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" }, { name = "huggingface-hub", specifier = ">=1.3.5" }, { name = "itsdangerous", specifier = ">=2.2.0" }, @@ -1531,26 +1542,26 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl", upload-time = "2026-03-23T14:59:01Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:02Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:03Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl", upload-time = "2026-03-23T14:59:04Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl", upload-time = "2026-03-23T14:59:04Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:04Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:05Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl", upload-time = "2026-03-23T14:59:06Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl", upload-time = "2026-03-23T14:59:07Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:07Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:07Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl", upload-time = "2026-03-23T14:59:09Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl", upload-time = "2026-03-23T14:59:09Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:10Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:11Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl", upload-time = "2026-03-23T14:59:12Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl", upload-time = "2026-03-23T14:59:12Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:12Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:13Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl", upload-time = "2026-03-23T14:59:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:2db3ae5404e32cb42b5fcbd94f13607761eaec0cf1687fde95095289d1e26cfb", upload-time = "2026-04-28T00:06:06Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:70ecb2659af6373b7c5336e692e665605b0201ea21ff51aaea47e1d75ea6b5aa", upload-time = "2026-04-28T00:06:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f82e2ae20c1545bb03997d1cc3143d94e14b800038669ee1aca45808a9acc338", upload-time = "2026-04-28T00:06:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:1abeaa46fa7532ed35ed79146f4de5d7a9d4b30462c98052ea4ddfe781ea3eca", upload-time = "2026-04-28T00:06:34Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:d1eff25ccc454faf21c9666c81bfab8e405e87c12d300708d4559620bc191a36", upload-time = "2026-04-28T00:06:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:48b3e21a311445acdd0b27f13830e21d93adef70d4721e051e9f059baeb9b8f9", upload-time = "2026-04-28T00:06:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:45025d7752dbc6b4c784c03afaee9c5f19730ce084b2e43fc9a2fe1677d9ff86", upload-time = "2026-04-28T00:07:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:ed70d4a4fc9f8b826c02fa1a9800a83820fb2fa6ae607680b53390f9ef394d85", upload-time = "2026-04-28T00:07:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:65d427a196ab0abe359b93c5bffedd76ded02df2b1b1d2d9f11a2609b69f426a", upload-time = "2026-04-28T00:07:19Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8f13dc7075ae04ca5f876a9f40b4e47522a04c23e30824b4409f42a3f3e57aa4", upload-time = "2026-04-28T00:07:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8713bb8679376ea0ec25742100b6cfb8447e0904c48bddefb9eb0ac1abbfa60a", upload-time = "2026-04-28T00:07:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:62ec1f1694c185f601eab74eb7fc0e8e10c64c06ae82f13c3592774c231c4877", upload-time = "2026-04-28T00:07:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:c9a14c367f470623b978e273a4e1915995b4ba7a0ae999178b06c273eea3536f", upload-time = "2026-04-28T00:07:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:71676f6a9a84bbd385e010198b51fa1c2324fb8f3c512a32d2c81af65f68f4c9", upload-time = "2026-04-28T00:08:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f8481ea9088e4e5b81178a75aabdbb658bde8639bc1a15fd5d8f930abc966735", upload-time = "2026-04-28T00:08:11Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:7575af4c9f7f7500ed62b1dafeb069aa0ba35b368a5f09793b3976b3d50f4fe4", upload-time = "2026-04-28T00:08:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:825f1596878280a3a4c861441674888bc2d792e4ab7b045cb35feeab3f4f5dd7", upload-time = "2026-04-28T00:08:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c8a0bdfb2fd915b6c2cd27c856f63f729c366a4917772eba6b2b02aa3bce70d5", upload-time = "2026-04-28T00:08:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:768f22924a25cad2adeb9c6cbac5159e71067c8d4019b1511960d7435a5ca652", upload-time = "2026-04-28T00:08:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:6db45e7b2526d996fbf47c3d08737807a60a4e17996a6d91a97027fe260832c8", upload-time = "2026-04-28T00:08:57Z" }, ] [[package]]