diff --git a/backend/app.py b/backend/app.py index 0d21d6e..dbd9b9f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,6 +3,7 @@ from fastapi.exceptions import RequestValidationError from backend.AniZenithExchange import AniZenithRequest, AniZenithResponse +from backend.search import search_router from common.prometheus.prometheus_middleware import PrometheusMiddleware, prometheus_router from backend.utils.validation_utils import validate_anizenith_request from backend.utils.model_utils import chat_with_llm @@ -28,6 +29,7 @@ same_site=backend_app_config.same_site_protection) app.add_middleware(PrometheusMiddleware, prefix="backend") app.include_router(prometheus_router) +app.include_router(search_router) #app.include_router(auth_router) # ┌───────────────────────────────────────────────┐ diff --git a/backend/search.py b/backend/search.py new file mode 100644 index 0000000..019190e --- /dev/null +++ b/backend/search.py @@ -0,0 +1,153 @@ +from datetime import datetime +from typing import List, Optional + +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 + +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 + +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 + ), +] + +@search_router.get("/anizenith/search") +async def search( + request: Request, + q: Optional[str] = Query(None, description="Search query"), + genre: Optional[List[str]] = Query(None, description="Selected genres"), + year_min: Optional[int] = Query(None, description="Minimum year"), + year_max: Optional[int] = Query(None, description="Maximum year"), + score_min: Optional[float] = Query(None, description="Minimum score"), + score_max: Optional[float] = Query(None, description="Maximum score"), + status: Optional[str] = Query(None, description="Anime status"), + idx_from: int = Query(0, ge=0, description="Starting index (inclusive)"), + idx_to: int = Query(19, ge=0, description="Ending index (inclusive)") +) -> 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) + + shows = [] + for i in range(start, end): + show = MOCK_ANIME_LIST[i].model_copy() + shows.append(show) + + return SearchResponse( + total_count=total_results, + shows=shows + ) \ No newline at end of file diff --git a/frontend/app.py b/frontend/app.py index 866fad7..b533ba7 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -9,6 +9,7 @@ from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware import logging + from common.prometheus.prometheus_middleware import PrometheusMiddleware, prometheus_router from frontend.configs import frontend_container_config, frontend_app_config @@ -45,12 +46,27 @@ async def home(request: Request): # Collect Auth Status from backend return templates.TemplateResponse( - "home.html", + "chatbot.html", { "request": request, } ) +@app.get("/search", response_class=HTMLResponse) +async def search(request: Request): + # Return template for search page + return templates.TemplateResponse("search.html", {"request": request}) + +@app.get("/about", response_class=HTMLResponse) +async def about(request: Request): + # Return template for About Us page + return templates.TemplateResponse("about.html", {"request": request}) + +@app.get("/favorites", response_class=HTMLResponse) +async def favorites(request: Request): + # Return template for About Us page + return templates.TemplateResponse("favorites.html", {"request": request}) + # Security middleware endpoint @app.middleware("http") async def add_security_headers(request: Request, call_next): @@ -59,9 +75,9 @@ async def add_security_headers(request: Request, call_next): CSP = ( f"default-src 'self'; " - f"script-src 'self' https://cdn.jsdelivr.net; " + f"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " f"style-src 'self' https://cdnjs.cloudflare.com; " - f"img-src 'self' data:; " + f"img-src 'self' https://cdn.myanimelist.net data:; " f"font-src 'self' https://cdnjs.cloudflare.com; " f"frame-ancestors 'none';" ) @@ -73,6 +89,7 @@ async def add_security_headers(request: Request, call_next): # --------- PROXY ENDPOINT ---------- ALLOWED_PROXY_ROUTES = { "anizenith/chat": ["POST"], + "anizenith/search": ["GET"], "login/*": ["GET", "HEAD"], "logout/*": ["POST"], "auth/status": ["GET"], diff --git a/frontend/static/css/about/about.css b/frontend/static/css/about/about.css new file mode 100644 index 0000000..af93bf3 --- /dev/null +++ b/frontend/static/css/about/about.css @@ -0,0 +1,181 @@ +.about-container { + max-width: 1200px; + margin: 0 auto; +} + +/* Section titles */ +.section-title { + font-size: 2rem; + margin-top: 0rem; + margin-bottom: 1.5rem; + position: relative; + display: inline-block; + background: var(--accent-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.section-title::after { + content: ''; + position: absolute; + bottom: -8px; + left: 0; + width: 20rem; + height: 3px; + background: var(--accent-gradient); + border-radius: 3px; +} + +/* Mission Cards */ +.mission-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.8rem; + margin-bottom: 0.5rem; +} + +.mission-card { + background: var(--bg-glass-strong); + backdrop-filter: blur(8px); + border-radius: 1.5rem; + padding: 1.8rem; + border: 1px solid var(--border-soft); + transition: transform 0.2s, box-shadow 0.2s; +} + +.mission-card:hover { + transform: translateY(-5px); + border-color: var(--accent-secondary); +} + +.mission-icon { + font-size: 2.5rem; + background: var(--accent-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + margin-bottom: 1rem; +} + +.mission-card h3 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: var(--text-main); +} + +.mission-card p { + color: var(--text-muted); + line-height: 1.6; + hyphens: auto; + + font-size: 0.95rem; + font-weight: 400; + letter-spacing: 0.01em; + margin-bottom: 0; +} + +/* Team Section */ +.team-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 2rem; + margin-bottom: 2rem; +} + +.team-card { + background: var(--bg-glass-strong); + backdrop-filter: blur(8px); + border-radius: 1.5rem; + padding: 1.5rem; + text-align: center; + border: 1px solid var(--border-soft); + transition: all 0.2s; +} + +.team-card:hover { + transform: translateY(-5px); + border-color: var(--accent-secondary); +} + +.team-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + overflow: hidden; + background: #000; + margin: 0 auto 0.5rem; + box-shadow: 0 0 20px var(--accent-primary); + border: 4px solid var(--accent-primary); +} + +.team-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.team-name { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-main); +} + +.team-role { + font-size: 0.85rem; + color: var(--accent-primary); + margin: 0.3rem 0; +} + +.team-bio { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 0.8rem; +} + +/* Donation Section */ +.donate-section { + background: var(--bg-glass); + backdrop-filter: blur(12px); + border-radius: 2rem; + padding: 0.5rem; + text-align: center; + border: 1px solid var(--border-soft); + position: relative; + overflow: hidden; + margin-bottom: 2rem; +} + +.donate-section h2 { + font-size: 2rem; + margin-bottom: 1rem; + color: var(--text-main); +} + +.donate-section p { + color: var(--text-muted); + max-width: 700px; + margin: 0 auto 1.5rem; +} + +.donate-btn { + display: inline-flex; + align-items: center; + gap: 10px; + background: var(--btn-bg); + border: none; + padding: 14px 32px; + margin-bottom: 1rem; + border-radius: 50px; + font-size: 1.1rem; + font-weight: bold; + color: var(--btn-text); + cursor: pointer; + text-decoration: none; +} + +.donate-btn:hover { + background: var(--btn-bg-hover); + box-shadow: var(--btn-glow); +} \ No newline at end of file diff --git a/frontend/static/css/chat.css b/frontend/static/css/chatbot/chat.css similarity index 97% rename from frontend/static/css/chat.css rename to frontend/static/css/chatbot/chat.css index 281ac95..cea75e0 100644 --- a/frontend/static/css/chat.css +++ b/frontend/static/css/chatbot/chat.css @@ -1,3 +1,5 @@ +@import url("../theme.css"); + /* ===== CHAT BOX ===== */ .chatbot-area { position: relative; @@ -48,7 +50,7 @@ background: var(--btn-bg); border: none; border-radius: 8px; - color: white; + color: var(--text-main); width: 36px; height: 36px; display: flex; @@ -181,7 +183,7 @@ display: inline-flex; align-items: center; justify-content: center; - color: white; + color: var(--text-main); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.15); } diff --git a/frontend/static/css/home.css b/frontend/static/css/chatbot/chatbot.css similarity index 63% rename from frontend/static/css/home.css rename to frontend/static/css/chatbot/chatbot.css index cac47a8..f5c8c5e 100644 --- a/frontend/static/css/home.css +++ b/frontend/static/css/chatbot/chatbot.css @@ -1,44 +1,17 @@ -@import url('chat.css'); +@import url('./chat.css'); -/* ===== PAGE TITLE & SUBTITLE ===== */ -.page-title { - text-align: center; - font-size: 2.5rem; - margin: 1rem 0 0.5rem 0; - font-weight: 700; - line-height: 1.2; - color: var(--text-main); - - /* Fancy animation thing I found */ - background: linear-gradient(270deg, var(--accent-primary), var(--accent-secondary), white); - background-size: 600% 600%; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: gradientShift 8s ease infinite; -} - -@keyframes gradientShift { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -.fancy-z { - font-size: 3rem; -} - -.subtitle { - display: block; - text-align: center; - font-size: 1rem; - font-weight: 700; - color: var(--text-muted); - line-height: 1.4; - letter-spacing: 0.2px; - max-width: 800px; - margin-left: auto; - margin-right: auto; - margin-bottom: 1rem; +/* ===== MAIN CHAT CONTAINER ===== */ +.chat-container { + max-width: 900px; + margin: auto; + background: var(--bg-glass-strong); + backdrop-filter: blur(5px); + border-radius: 22px; + padding: 25px; + border: 1px solid var(--border-soft); + display: flex; + flex-direction: column; + height: 75vh; } /* ===== LOCAL MODEL BUTTON ===== */ @@ -61,7 +34,6 @@ transition: 0.2s ease; background: var(--btn-bg); - color: white; } .toggle-wrapper label:hover { @@ -76,44 +48,6 @@ margin-left: 6px; } -/* ===== MAL OAUTH LOGIN ===== */ -.mal-button { - display: block; - margin: 15px auto 10px auto; - background-color: #2E51A2; - color: var(--text-main); - font-weight: bold; - padding: 10px 20px; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 1rem; - transition: background-color 0.2s ease; - text-align: center; -} - -.mal-button:hover { - background-color: #3a61c2; -} - -.mal-logout { - display: none; -} - -/* ===== MAIN CHAT CONTAINER ===== */ -.chat-container { - max-width: 900px; - margin: auto; - background: var(--bg-glass); - backdrop-filter: blur(5px); - border-radius: 22px; - padding: 25px; - border: 1px solid var(--border-soft); - display: flex; - flex-direction: column; - height: 75vh; -} - /* ===== QUICK SUGGESTIONS ===== */ .quick-suggestions { display: flex; @@ -127,7 +61,7 @@ border-radius: 999px; border: none; background: var(--btn-bg); - color: white; + color: var(--text-main); font-weight: 300; cursor: pointer; white-space: nowrap; @@ -185,7 +119,7 @@ cursor: pointer; font-weight: 600; background: var(--btn-bg); - color: white; + color: var(--text-main); display: flex; justify-content: center; align-items: center; diff --git a/frontend/static/css/error.css b/frontend/static/css/error.css index 04be9b3..f46055e 100644 --- a/frontend/static/css/error.css +++ b/frontend/static/css/error.css @@ -1,17 +1,17 @@ /* Container must control layout */ #error-container { - position: fixed; /* stick to viewport */ - top: 20px; + position: fixed; + top: calc(var(--top-bar-height) + 20px); right: 20px; display: flex; - flex-direction: column; /* stack vertically */ - align-items: flex-end; /* align to right edge */ + flex-direction: column; + align-items: flex-end; - gap: 12px; /* spacing between toasts */ + gap: 12px; z-index: 9999; - pointer-events: none; /* clicks pass through */ + pointer-events: none; } /* Each error popup CSS */ diff --git a/frontend/static/css/favorites/favorites.css b/frontend/static/css/favorites/favorites.css new file mode 100644 index 0000000..8a8ec65 --- /dev/null +++ b/frontend/static/css/favorites/favorites.css @@ -0,0 +1,307 @@ +@import url("../pagination.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-options { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.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); +} + +.sort-label { + 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; +} + +.sort-select option { + background: var(--bg-main); + color: var(--text-main); +} + +.search-favorites-wrapper .search-icon { + 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; +} + +.search-favorites-input::placeholder { + 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; +} + +.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; +} + +.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; +} + +.empty-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); +} + +.empty-state 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; +} + +.browse-btn:hover { + 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 5bd32ad..4ee2676 100644 --- a/frontend/static/css/main.css +++ b/frontend/static/css/main.css @@ -1,7 +1,6 @@ @import url("theme.css"); @import url("error.css"); -/* ===== BODY, HEADER, and CONTAINER ===== */ html { font-family: 'Inter', sans-serif; font-size: 16px; @@ -9,11 +8,14 @@ html { body { margin: 0; - background: linear-gradient(rgba(12,13,35,0.45), rgba(12,13,35,0.9)), - url('/static/images/background.png'); + background: + radial-gradient(circle at 20% 30%, rgba(80, 120, 255, 0.25), transparent 40%), + linear-gradient(to bottom, rgba(12,13,35,0.3), rgba(12,13,35,0.6)), + url('/static/images/background.jpg'); background-size: cover; background-attachment: fixed; color: var(--text-main); + padding-top: var(--top-bar-height); } .no-select { @@ -23,16 +25,201 @@ body { -ms-user-select: none; } +/* ===== TOP BAR ===== */ +.top-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--top-bar-height); + z-index: 1000; + + background: linear-gradient(var(--bg-main), transparent); + box-shadow: var(--shadow-soft); + + display: flex; + align-items: center; + padding: 0 20px; + gap: 20px; +} + +/* Hamburger button */ +.hamburger { + background: none; + border: none; + color: var(--text-main); + font-size: 1.8rem; + cursor: pointer; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; +} + +.hamburger:hover { + background: var(--bg-glass); + color: var(--accent-primary); +} + +/* Logo area */ +.logo-area { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.6rem; + font-weight: 700; + color: var(--text-main); + text-shadow: 0 0 15px var(--accent-glow); +} + +.search-icon-btn { + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + font-size: 1rem; + padding: 0; + display: flex; + align-items: center; + transition: color 0.2s; +} + +.search-icon-btn:hover { + color: var(--accent-primary); +} + +.logo-link { + text-decoration: none; + cursor: pointer; +} + +.logo-link:hover .logo-area { + opacity: 0.9; +} + +/* Search box */ +.search-box { + display: flex; + align-items: center; + background: var(--bg-glass-darker); + border: 1px solid var(--border-soft); + border-radius: 40px; + padding: 4px 12px; + gap: 8px; +} + +.search-box i { + color: var(--text-muted); +} + +.search-input-wrapper { + position: relative; + flex: 1; +} + +.search-input-wrapper input { + width: 100%; + padding: 10px 70px 10px 12px; + background: transparent; + border: none; + color: var(--text-main); + font-size: 0.95rem; + outline: none; +} + +.filter-redirect-btn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: var(--bg-glass); + border-radius: 30px; + padding: 4px 12px; + font-size: 0.7rem; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + white-space: nowrap; + transition: all 0.2s; + cursor: pointer; +} + +.filter-redirect-btn:hover { + background: var(--accent-primary); + border-color: transparent; +} + +/* Random button */ +.random-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-radius: 40px; + background: var(--btn-bg); + border: 1px solid var(--btn-border); + color: var(--btn-text); + font-weight: 600; + font-size: 1rem; + cursor: pointer; + backdrop-filter: blur(8px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + white-space: nowrap; + transition: all 0.1s ease; +} + +.random-btn i { + font-size: 1.1rem; +} + +.random-btn:hover { + background: var(--btn-bg-hover); + box-shadow: var(--btn-glow); +} + +/* Language toggle */ +.lang-toggle { + display: flex; + background: var(--bg-glass-darker); + border-radius: 40px; + padding: 4px; + border: 1px solid var(--border-soft); + backdrop-filter: blur(8px); +} + +.lang-option { + padding: 8px 16px; + border-radius: 30px; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + color: var(--text-muted); + border: none; + background: transparent; +} + +.lang-option.active { + background: var(--accent-gradient); + color: var(--text-main); + box-shadow: var(--btn-glow); +} + +.lang-option:hover:not(.active) { + background: var(--bg-glass); + color: var(--text-main); +} + /* ===== SIDEBAR ===== */ .sidebar { position: fixed; - top: 0; left: 0; + top: var(--top-bar-height); height: 100vh; width: 240px; background-image: - linear-gradient(rgba(28, 20, 55, 0.7), rgba(28, 20, 55, 0.25)), + linear-gradient(rgba(28, 20, 55, 0.9), rgba(28, 20, 55, 0.05)), url('/static/images/sidebar.jpg'); background-size: cover; background-position: center; @@ -41,32 +228,29 @@ body { backdrop-filter: blur(12px); border-right: 2px solid black; - padding: 20px; + padding: 60px 20px; color: var(--text-main); display: flex; flex-direction: column; transition: transform 0.3s ease; - z-index: 10; + z-index: 999; } -/* The idea of this code is to keep sidebar mostly hidden, then make it more visible on hover, and fully expanded when toggled */ +/* Sidebar states */ .sidebar.collapsed { - transform: translateX(-240px); -} - -.sidebar.collapsed:hover { - transform: translateX(-200px); + transform: translateX(-100%); } .sidebar.expanded { transform: translateX(0) !important; } -/* ===== SIDEBAR CONTENT ===== */ +/* Sidebar content styles */ .sidebar h2 { margin-bottom: 1rem; font-size: 1.5rem; text-align: center; + color: var(--text-main); } .sidebar ul { @@ -93,5 +277,112 @@ body { .sidebar ul li a:hover { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - color: white; + color: var(--text-main); +} + +/* ===== MAL LOGIN BUTTON ===== */ +.mal-login { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + margin-left: auto; + border-radius: 40px; + background: #2E51A2; + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--text-main); + font-weight: 600; + font-size: 1rem; + cursor: pointer; + backdrop-filter: blur(8px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + white-space: nowrap; +} + +.mal-login i { + font-size: 1.1rem; +} + +.mal-login:hover { + background: #3a61c2; + box-shadow: 0 0 12px rgba(46, 81, 162, 0.5); +} + +/* ===== Loading Spinner ===== */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + color: var(--text-muted); +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.1); + border-left-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* ===== PAGE TITLE & SUBTITLE ===== */ +.page-title { + text-align: center; + font-size: 2.5rem; + margin: 1rem 0 0.5rem 0; + font-weight: 800; + line-height: 1.2; + letter-spacing: -0.02em; + + background: linear-gradient(270deg, var(--accent-primary), var(--accent-secondary), white); + background-size: 300% 300%; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: gradientShift 8s ease infinite; + color: var(--accent-primary); +} + +@keyframes gradientShift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.fancy-z { + font-size: 3rem; +} + +/* Specific to Logo */ +.logo-area .page-title { + margin: 0; + line-height: 1; + font-size: 1.6rem; +} + +.logo-area i { + color: var(--accent-primary); + font-size: 2rem; +} + +.logo-area .fancy-z { + font-size: 2rem; +} + +.subtitle { + display: block; + text-align: center; + font-size: 1rem; + font-weight: 700; + color: var(--text-muted); + line-height: 1.4; + letter-spacing: 0.2px; + max-width: 800px; + margin-left: auto; + margin-right: auto; + margin-bottom: 1rem; } \ No newline at end of file diff --git a/frontend/static/css/pagination.css b/frontend/static/css/pagination.css new file mode 100644 index 0000000..716ad9d --- /dev/null +++ b/frontend/static/css/pagination.css @@ -0,0 +1,53 @@ +.pagination-wrapper { + display: flex; + justify-content: center; + align-items: center; + padding-top: 10px; + flex-shrink: 0; + margin-top: auto; +} + +.pagination-container { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; +} + +.page-btn { + background: var(--bg-glass-strong); + border: 1px solid var(--border-soft); + color: var(--text-main); + width: 40px; + height: 40px; + border-radius: 50%; + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.page-btn:hover:not(.disabled) { + background: var(--accent-gradient); + color: var(--btn-text); + box-shadow: var(--btn-glow); + border-color: transparent; +} + +.page-btn.disabled { + opacity: 0.3; + cursor: not-allowed; + background: var(--bg-glass-darker); +} + +.page-indicator { + font-size: 1.1rem; + font-weight: 600; + background: var(--bg-glass-strong); + padding: 6px 18px; + border-radius: 40px; + border: 1px solid var(--border-soft); + color: var(--text-main); +} \ No newline at end of file diff --git a/frontend/static/css/search/search.css b/frontend/static/css/search/search.css new file mode 100644 index 0000000..3a06792 --- /dev/null +++ b/frontend/static/css/search/search.css @@ -0,0 +1,481 @@ +@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; +} + +/* ===== Search Section ===== */ +.search-section { + padding: 0 0 1.2rem 0; + flex-shrink: 0; +} + +.search-bar-wrapper { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.search-input { + flex: 2; + min-width: 200px; + padding: 12px 18px; + border: 1px solid var(--border-soft); + border-radius: 40px; + background: var(--bg-glass-darker); + color: var(--text-main); + font-size: 1rem; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 10px var(--accent-glow); +} + +/* ===== Filter Toggle Button ===== */ +.filter-toggle-btn { + background: var(--bg-glass-strong); + color: var(--text-main); + border: 1px solid var(--border-soft); + padding: 12px 20px; + border-radius: 40px; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + font-weight: 500; +} + +.filter-toggle-btn:hover { + background: var(--btn-bg-hover); + color: var(--btn-text); + border-color: transparent; + box-shadow: var(--btn-glow); +} + +.filter-toggle-btn i { + transition: transform 0.3s ease; +} + +.filter-toggle-btn.expanded i { + transform: rotate(180deg); +} + +/* ===== Collapsible Filter Panel ===== */ +.filter-panel { + max-height: 0; + margin-top: 1rem; + overflow: hidden; + transition: max-height 0.4s ease-out, opacity 0.15s ease-out 0.35s; + background: var(--bg-glass-strong); + border-radius: 18px; + border: 1px solid transparent; + padding: 0 10px; + opacity: 0; +} + +.filter-panel.active { + max-height: 600px; + border-color: var(--border-soft); + opacity: 1; + transition: + max-height 0.2s ease-out, + opacity 0.2s ease-out 0s; +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 18px; +} + +.filter-section-title { + color: var(--text-main); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; + opacity: 0.9; +} + +/* ===== Genres Checkboxes ===== */ +.genre-checkboxes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px 12px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 0.9rem; + cursor: pointer; + transition: color 0.2s; +} + +.checkbox-label:hover { + color: var(--text-main); +} + +.checkbox-label input[type="checkbox"] { + appearance: none; + width: 18px; + height: 18px; + border: 2px solid var(--text-muted); + border-radius: 4px; + background: var(--bg-glass-darker); + cursor: pointer; + position: relative; +} + +.checkbox-label input[type="checkbox"]:checked { + background: var(--accent-gradient); + border-color: var(--border-soft); +} + +/* ===== Range Inputs ===== */ +.range-inputs { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.range-input { + width: 80px; + padding: 8px 10px; + background: var(--bg-glass-darker); + border: 1px solid var(--accent-glow); + border-radius: 20px; + color: var(--text-main); + font-size: 0.9rem; + font-weight: 500; + text-align: center; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + -moz-appearance: textfield; +} + +.range-input::-webkit-outer-spin-button, +.range-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.range-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 8px var(--accent-glow); +} + +.range-separator { + color: var(--text-muted); + font-weight: 600; + font-size: 1.2rem; +} + +/* ===== noUiSlider Theming ===== */ +.nouislider { + margin: 10px 20px; + height: 4px; + background: var(--bg-glass-darker); + border: 1px solid var(--border-soft); + border-radius: 4px; +} + +.noUi-connect { + background: var(--accent-gradient); + box-shadow: 0 0 8px var(--accent-glow); +} + +.noUi-handle { + width: 18px !important; + height: 18px !important; + border-radius: 50%; + background: var(--accent-primary); + border: 2px solid var(--text-main); + box-shadow: 0 0 10px var(--accent-glow); + cursor: grab; + transition: transform 0.1s, box-shadow 0.2s; + right: -9px !important; + top: -9px !important; +} + +.noUi-handle:hover { + transform: scale(1.15); + box-shadow: 0 0 15px var(--accent-primary); +} + +.noUi-handle:active { + cursor: grabbing; +} + +.noUi-target { + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--border-soft); +} + +.noUi-base { + background: transparent; +} + +.noUi-tooltip { + display: none; +} + +.noUi-handle::before, +.noUi-handle::after { + display: none; +} + +/* ===== Status Select ===== */ +.status-select { + width: 100%; + padding: 10px 14px; + border-radius: 30px; + border: 1px solid var(--border-soft); + background: var(--bg-glass-darker); + color: var(--text-main); + font-size: 0.9rem; + outline: none; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +.status-select:hover { + border-color: var(--accent-secondary); +} + +.status-select option { + background: var(--bg-main); + color: var(--text-main); +} + +/* ===== Filter Actions ===== */ +.filter-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + margin-top: 5px; +} + +.clear-filters-btn { + padding: 10px 22px; + border-radius: 30px; + font-weight: 600; + border: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + font-size: 0.9rem; + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border-soft); + margin-bottom: 1rem; +} + +.clear-filters-btn:hover { + background: var(--bg-glass-strong); + color: var(--text-main); + border-color: var(--accent-secondary); +} + +/* ===== Search Button ===== */ +.search-btn { + background: var(--btn-bg); + border: 1px solid var(--btn-border); + color: var(--btn-text); + padding: 12px 28px; + border-radius: 40px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + white-space: nowrap; +} + +.search-btn:hover { + background: var(--btn-bg-hover); + 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; + padding: 2.5rem; + font-size: 1.1rem; + color: var(--text-muted); + background: var(--bg-glass); + border-radius: 16px; + border: 1px solid var(--border-soft); + margin: auto; +} + +.no-results i { + font-size: 2.5rem; + opacity: 0.6; + margin-bottom: 1rem; + display: block; +} \ No newline at end of file diff --git a/frontend/static/css/theme.css b/frontend/static/css/theme.css index 6cab378..fb28c13 100644 --- a/frontend/static/css/theme.css +++ b/frontend/static/css/theme.css @@ -1,5 +1,7 @@ /* This page contains base theme colors for the app */ :root { + /* Page Layout */ + --top-bar-height: 70px; /* Base backgrounds */ --bg-main: #0c0d23; --bg-glass: rgba(28, 20, 55, 0.55); @@ -30,7 +32,7 @@ --btn-bg-hover: linear-gradient(135deg, #8fd0ff, #aab3ff); --btn-bg-active: linear-gradient(135deg, #6bb6f5, #8f98ff); - --btn-text: #0c0d23; + --btn-text: #f0f0ff; --btn-border: rgba(122, 197, 255, 0.45); --btn-glow: 0 0 18px rgba(122, 197, 255, 0.55); diff --git a/frontend/static/images/background.jpg b/frontend/static/images/background.jpg new file mode 100644 index 0000000..797786d Binary files /dev/null and b/frontend/static/images/background.jpg differ diff --git a/frontend/static/images/background.png b/frontend/static/images/background.png deleted file mode 100644 index e3aa332..0000000 Binary files a/frontend/static/images/background.png and /dev/null differ diff --git a/frontend/static/js/chat_ui.js b/frontend/static/js/chat_ui.js deleted file mode 100644 index 735091c..0000000 --- a/frontend/static/js/chat_ui.js +++ /dev/null @@ -1,89 +0,0 @@ -import { messages } from "./chat_utils.js" -import { updateButtons } from "./buttons.js" - -const chatBox = document.getElementById("chatBox"); - -// Render function that uses messages list internally to render page messages -export function renderMessages() { - chatBox.innerHTML = ""; - // Add all messages from message store - messages.forEach((msg, index) => {appendUIMessage(msg, index);}); - updateButtons(); -} - -export function appendUIMessage({ role, content }, index) { - // Construct the element - const messageElement = createMessageElement({ role, content }, index); - - // Add it to UI box - chatBox.appendChild(messageElement); - - // Scroll if needed - chatBox.scrollTop = chatBox.scrollHeight; -} - -export function createMessageElement({ role, content }, index) { - const messageUI = document.createElement("div"); - messageUI.classList.add("message", role); - messageUI.dataset.index = index; - - const row = document.createElement("div"); - row.classList.add("message-row"); - - const avatar = document.createElement("img"); - avatar.classList.add("avatar", "no-select"); - avatar.src = role === "assistant" - ? "/static/images/assistant.jpg" - : "/static/images/user.jpg"; - avatar.alt = role === "assistant" ? "Assistant" : "User"; - - const textDiv = document.createElement("div"); - textDiv.classList.add("text"); - - if (role === "assistant") { - // Add UI tag for thinking - if (content === "__thinking__") { - textDiv.innerHTML = ` - - Thinking - - `; - } else { - textDiv.innerHTML = marked.parse(content); - } - row.appendChild(avatar); - row.appendChild(textDiv); - } else { - textDiv.textContent = content; - row.appendChild(textDiv); - row.appendChild(avatar); - } - - messageUI.appendChild(row); - - const actions = document.createElement("div"); - actions.classList.add("actions"); - - if (role === "assistant") { - actions.innerHTML = ` - - - - `; - } else { - actions.innerHTML = ` - - - - `; - } - - messageUI.appendChild(actions); - - return messageUI; -} - -export function addDefaultMessage() { - const defaultMessage = "Hi there! I am a friendly chatbot from Aniℤenith here to help find and recommend any anime you want! Just tell me some of your preferences, and I can help you accordingly!" - appendUIMessage({ role: "assistant", content: defaultMessage }, messages.length); -} \ No newline at end of file diff --git a/frontend/static/js/chat_history_db.js b/frontend/static/js/chatbot/chat_history_db.js similarity index 100% rename from frontend/static/js/chat_history_db.js rename to frontend/static/js/chatbot/chat_history_db.js diff --git a/frontend/static/js/chatbot/chat_ui.js b/frontend/static/js/chatbot/chat_ui.js new file mode 100644 index 0000000..d7fd588 --- /dev/null +++ b/frontend/static/js/chatbot/chat_ui.js @@ -0,0 +1,52 @@ +import { messages } from "./chat_utils.js"; +import { updateButtons } from "./chatbot_buttons.js"; + +const chatBox = document.getElementById("chatBox"); + +export function renderMessages() { + chatBox.innerHTML = ""; + messages.forEach((msg, index) => appendUIMessage(msg, index)); + updateButtons(); +} + +export function appendUIMessage({ role, content }, index) { + const messageElement = createMessageElement({ role, content }, index); + chatBox.appendChild(messageElement); + chatBox.scrollTop = chatBox.scrollHeight; +} + +export function createMessageElement({ role, content }, index) { + // Select the correct template + const templateId = role === "assistant" ? "tmpl-assistant-message" : "tmpl-user-message"; + const template = document.getElementById(templateId); + const messageUI = template.content.cloneNode(true); + + const messageDiv = messageUI.querySelector(".message"); + messageDiv.dataset.index = index; + + const textDiv = messageUI.querySelector(".text"); + + if (role === "assistant") { + if (content === "__thinking__") { + textDiv.innerHTML = ` + + Thinking + + `; + } else { + // TODO: Need to import DOMPurify and include to sanitize LLM responses + textDiv.innerHTML = marked.parse(content); + } + } else { + textDiv.textContent = content; + } + + // Convert the template into live node + const finalNode = messageUI.firstElementChild; + return finalNode; +} + +export function addDefaultMessage() { + const defaultMessage = "Hi there! I am a friendly chatbot from Aniℤenith here to help find and recommend any anime you want! Just tell me some of your preferences, and I can help you accordingly!"; + appendUIMessage({ role: "assistant", content: defaultMessage }, messages.length); +} \ No newline at end of file diff --git a/frontend/static/js/chat_utils.js b/frontend/static/js/chatbot/chat_utils.js similarity index 97% rename from frontend/static/js/chat_utils.js rename to frontend/static/js/chatbot/chat_utils.js index a248a93..9bee894 100644 --- a/frontend/static/js/chat_utils.js +++ b/frontend/static/js/chatbot/chat_utils.js @@ -1,4 +1,4 @@ -import { postError, postErrorMessage } from "./error.js" +import { postError, postErrorMessage } from "../error.js" import { pushMessages, pullMessages } from "./chat_history_db.js"; // Client-side conversation message storage (Chats are only stored on client side for now) @@ -96,7 +96,7 @@ export async function sendMessagesToBackend() { try { // If using local, detect and add additional timeout - const timeout = payload.use_local ? 180.0 : 30.0; + const timeout = payload.use_local ? 600.0 : 60.0; const response = await fetch("/proxy/anizenith/chat", { method: "POST", headers: { diff --git a/frontend/static/js/buttons.js b/frontend/static/js/chatbot/chatbot_buttons.js similarity index 99% rename from frontend/static/js/buttons.js rename to frontend/static/js/chatbot/chatbot_buttons.js index c195693..4f6eaa5 100644 --- a/frontend/static/js/buttons.js +++ b/frontend/static/js/chatbot/chatbot_buttons.js @@ -1,6 +1,6 @@ import { setLocalModelStatus, messages, addMessage, deleteMessage, editMessage, sendMessagesToBackend } from "./chat_utils.js" import { renderMessages, appendUIMessage, addDefaultMessage } from "./chat_ui.js" -import { postErrorMessage } from "./error.js" +import { postErrorMessage } from "../error.js" export function updateButtons() { // Dynamic buttons diff --git a/frontend/static/js/home.js b/frontend/static/js/chatbot/chatbot_page.js similarity index 95% rename from frontend/static/js/home.js rename to frontend/static/js/chatbot/chatbot_page.js index cc91c4f..c81a8f9 100644 --- a/frontend/static/js/home.js +++ b/frontend/static/js/chatbot/chatbot_page.js @@ -1,6 +1,6 @@ import { syncMessages } from "./chat_utils.js" import { renderMessages, appendUIMessage, addDefaultMessage } from "./chat_ui.js" -import { postErrorMessage } from "./error.js" +import { postErrorMessage } from "../error.js" document.addEventListener("DOMContentLoaded", async () => { const userInput = document.getElementById("userInput"); diff --git a/frontend/static/js/favorites/favorites.js b/frontend/static/js/favorites/favorites.js new file mode 100644 index 0000000..599c8eb --- /dev/null +++ b/frontend/static/js/favorites/favorites.js @@ -0,0 +1,246 @@ +import { renderPagination } from '../pagination.js'; +import { postErrorMessage } from '../error.js'; + +// Configuration +const FAVORITES_RETRIEVE_POINT = "/proxy/anizenith/search"; +const ITEMS_PER_PAGE = 4; +let currentPage = 1; +let favorites = []; +let filteredFavorites = []; +let sortOption = 'dateAdded'; +let searchTerm = ''; + +// DOM elements +const $ = id => document.getElementById(id); +const gridEl = $('favoritesGrid'); +const loadingEl = $('favoritesLoading'); +const emptyStateEl = $('emptyState'); +const paginationEl = $('pagination'); +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(); + } +} + + +// Lambda expressions for comparing anime favorites stored on browser +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) +}; + +// 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; +} + +// Render current page +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; + } + + // 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(); +} + +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'; + } + } + + init(); +}); \ No newline at end of file diff --git a/frontend/static/js/main.js b/frontend/static/js/main.js index 6bd72ab..4596994 100644 --- a/frontend/static/js/main.js +++ b/frontend/static/js/main.js @@ -1,16 +1,86 @@ +import { postErrorMessage } from "./error.js" + document.addEventListener("DOMContentLoaded", () => { + // ===== SIDEBAR ===== const sidebar = document.getElementById("sidebar"); - // Adds toggle for sidebar by expanding it when it is clicked sidebar.addEventListener('click', () => { sidebar.classList.add('expanded'); + sidebar.classList.remove('collapsed'); }); - // If anywhere else in document is clicked, collapses sidebar document.addEventListener('click', (e) => { + const toggleBtn = document.getElementById('sidebarToggle'); + if (toggleBtn && toggleBtn.contains(e.target)) return; if (!sidebar.contains(e.target)) { sidebar.classList.remove('expanded'); sidebar.classList.add('collapsed'); } }); + + // ===== HAMBURGER ===== + const hamburger = document.getElementById('sidebarToggle'); + if (hamburger) { + hamburger.addEventListener('click', (e) => { + e.stopPropagation(); + if (sidebar.classList.contains('collapsed')) { + sidebar.classList.remove('collapsed'); + sidebar.classList.add('expanded'); + } else { + sidebar.classList.add('collapsed'); + sidebar.classList.remove('expanded'); + } + }); + } + + // ===== SEARCH BAR ===== + const searchInput = document.querySelector('#search-input'); + const searchIconBtn = document.querySelector('.search-icon-btn'); + const filterBtn = document.querySelector('.filter-redirect-btn'); + + // Function to redirect with query + function performSearch() { + const query = searchInput.value.trim(); + window.location.href = query ? `/search?q=${encodeURIComponent(query)}` : '/search'; + } + searchIconBtn.addEventListener('click', performSearch); + filterBtn.addEventListener('click', performSearch); + + // Enter redirects to /search endpoint + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + performSearch(); + } + }); + + // ===== LANGUAGE ===== + const langToggle = document.getElementById('langToggle'); + const langOptions = document.querySelectorAll('#langToggle .lang-option'); + + function setLang(lang) { + langOptions.forEach(btn => + btn.classList.toggle('active', btn.dataset.lang === lang) + ); + + localStorage.setItem('preferredLanguage', lang); + document.dispatchEvent( + new CustomEvent('languageChange', { detail: { language: lang } }) + ); + } + + langToggle?.addEventListener('click', () => { + const active = document.querySelector('#langToggle .lang-option.active'); + const nextLang = active.dataset.lang === 'en' ? 'jp' : 'en'; + setLang(nextLang); + }); + + // Get language from local cached + setLang(localStorage.getItem('preferredLanguage') || 'en'); + + // ===== RANDOM ===== + const randomBtn = document.getElementById('randomAnimeBtn'); + randomBtn.addEventListener('click', () => { + postErrorMessage(300, "Unsupported Operation", "Random Button is not yet Supported"); + }); }); \ No newline at end of file diff --git a/frontend/static/js/pagination.js b/frontend/static/js/pagination.js new file mode 100644 index 0000000..381e445 --- /dev/null +++ b/frontend/static/js/pagination.js @@ -0,0 +1,89 @@ +// Creates a pagination item inside the pagination-container +export function createPagination({ currentPage, totalPages, onPageChange }) { + const container = document.createElement('div'); + container.className = 'pagination-container'; + + // First button + const firstBtn = document.createElement('button'); + firstBtn.className = 'page-btn'; + firstBtn.setAttribute('data-action', 'first'); + firstBtn.innerHTML = ''; + firstBtn.setAttribute('aria-label', 'First page'); + + // Previous button + const prevBtn = document.createElement('button'); + prevBtn.className = 'page-btn'; + prevBtn.setAttribute('data-action', 'prev'); + prevBtn.innerHTML = ''; + prevBtn.setAttribute('aria-label', 'Previous page'); + + // Page indicator + const indicator = document.createElement('span'); + indicator.className = 'page-indicator'; + indicator.textContent = `${currentPage} / ${totalPages}`; + + // Next button + const nextBtn = document.createElement('button'); + nextBtn.className = 'page-btn'; + nextBtn.setAttribute('data-action', 'next'); + nextBtn.innerHTML = ''; + nextBtn.setAttribute('aria-label', 'Next page'); + + // Last button + const lastBtn = document.createElement('button'); + lastBtn.className = 'page-btn'; + lastBtn.setAttribute('data-action', 'last'); + lastBtn.innerHTML = ''; + lastBtn.setAttribute('aria-label', 'Last page'); + + container.appendChild(firstBtn); + container.appendChild(prevBtn); + container.appendChild(indicator); + container.appendChild(nextBtn); + container.appendChild(lastBtn); + + // Update function to refresh state + const update = (newCurrentPage, newTotalPages) => { + // Update page info + currentPage = newCurrentPage; + totalPages = newTotalPages; + indicator.textContent = `${currentPage} / ${totalPages}`; + + // Disable buttons when necessary + const hasPrev = currentPage > 1; + const hasNext = currentPage < totalPages; + firstBtn.classList.toggle('disabled', !hasPrev); + prevBtn.classList.toggle('disabled', !hasPrev); + nextBtn.classList.toggle('disabled', !hasNext); + lastBtn.classList.toggle('disabled', !hasNext); + }; + + // Event listener for when a button is clicked + const handleClick = (action) => { + let newPage = currentPage; + if (action === 'first' && currentPage > 1) newPage = 1; + else if (action === 'prev' && currentPage > 1) newPage = currentPage - 1; + else if (action === 'next' && currentPage < totalPages) newPage = currentPage + 1; + else if (action === 'last' && currentPage < totalPages) newPage = totalPages; + else return; + + // Run page change lambda logic + onPageChange(newPage); + }; + + firstBtn.addEventListener('click', () => handleClick('first')); + prevBtn.addEventListener('click', () => handleClick('prev')); + nextBtn.addEventListener('click', () => handleClick('next')); + lastBtn.addEventListener('click', () => handleClick('last')); + + // Initial update (sets to first page) + update(currentPage, totalPages); + return container; +} + +// Attach a pagination logic to a container input in `wrapper` +export function renderPagination(wrapper, options) { + wrapper.innerHTML = ''; + const paginationEl = createPagination(options); + wrapper.appendChild(paginationEl); +} \ No newline at end of file diff --git a/frontend/static/js/search/search.js b/frontend/static/js/search/search.js new file mode 100644 index 0000000..f5a2e2c --- /dev/null +++ b/frontend/static/js/search/search.js @@ -0,0 +1,330 @@ +import { renderPagination } from '../pagination.js'; +import { postErrorMessage } from '../error.js'; + +// ===== Configuration & State ===== +const API_SEARCH_URL = '/proxy/anizenith/search'; +const ITEMS_PER_PAGE = 5; +let currentPage = 1; +let totalCount = 0; +let totalPages = 1; + +// ===== DOM Elements ===== +const $ = id => document.getElementById(id); +const searchForm = $('search-form'); +const searchQuery = $('search-query'); +const resultsContainer = $('search-results-container'); +const paginationWrapper = $('pagination-wrapper'); +const toggleBtn = $('filterToggleBtn'); +const filterPanel = $('filterPanel'); +const toggleIcon = $('toggleIcon'); +const yearMinInput = $('year-min-input'); +const yearMaxInput = $('year-max-input'); +const scoreMinInput = $('score-min-input'); +const scoreMaxInput = $('score-max-input'); +const statusSelect = $('filter-status'); +const clearFiltersBtn = $('clear-filters-btn'); +const loadingEl = $('search-loading'); + +let sliders = {}; + +// Filter Sliders Setup +const currentYear = new Date().getFullYear(); +const sliderConfigs = [ + { + id: 'year-slider', + range: { min: 1960, max: currentYear }, + start: [1960, currentYear], + step: 1, + minInput: yearMinInput, + maxInput: yearMaxInput, + }, + { + id: 'score-slider', + range: { min: 0, max: 10 }, + start: [0, 10], + step: 0.1, + minInput: scoreMinInput, + maxInput: scoreMaxInput, + }, +]; + +// Initialize noUI sliders with base state +// noUISlider is JS package that allows easier initialization and integration of browser sliders (and dual sliders) +function initSliders() { + sliderConfigs.forEach(config => { + const sliderElem = $(config.id); + noUiSlider.create(sliderElem, { + start: config.start, + connect: true, + step: config.step, + range: config.range, + }); + + // Update input fields when slider moves + sliderElem.noUiSlider.on('update', ([min, max]) => { + config.minInput.value = formatValue(min); + config.maxInput.value = formatValue(max); + }); + + // Sync slider when inputs change + config.minInput.addEventListener('change', () => + syncSliderFromInputs(sliderElem, config.minInput, config.maxInput, true) + ); + config.maxInput.addEventListener('change', () => + syncSliderFromInputs(sliderElem, config.minInput, config.maxInput, false) + ); + + // Append slider object to sliders dictionary + sliders[config.id] = sliderElem; + }); +} + +// Syncs sliders when an event occurs to change the value (e.g. user types number) +function syncSliderFromInputs(slider, minInput, maxInput, isMin) { + // Parse function values in case of invalid values + let min = parseFloat(minInput.value); + let max = parseFloat(maxInput.value); + const range = slider.noUiSlider.options.range; + + // If input numbers are invalid, sync to either default value or keep slider current value + if (isNaN(min)) min = isMin ? range.min : slider.noUiSlider.get()[0]; + if (isNaN(max)) max = isMin ? slider.noUiSlider.get()[1] : range.max; + + // Prevent min > max issue + if (min > max) { + if (isMin) maxInput.value = max = min; + else minInput.value = min = max; + } + + min = Math.max(range.min, Math.min(range.max, min)); + max = Math.max(range.min, Math.min(range.max, max)); + slider.noUiSlider.set([min, max]); +} + +function formatValue(value) { + const num = parseFloat(value); + // Format whole numbers into ints + if (Math.abs(num - Math.round(num)) < 0.0000001) return Math.round(num); + return num.toFixed(1).replace(/\.0$/, ''); +} + +// Clear function for filters box +// TODO: Change this when filter design changes +function clearAllFilters() { + document.querySelectorAll('input[name="genre"]').forEach(cb => (cb.checked = false)); + + sliderConfigs.forEach(config => { + const slider = $(config.id); + slider.noUiSlider.set(config.start); + config.minInput.value = config.start[0]; + config.maxInput.value = config.start[1]; + }); + + statusSelect.value = ''; + searchQuery.value = ''; + currentPage = 1; + performSearch(); +} + +// Grabs the current filters as a dictionary for use +// TODO: Modify filter design / inputs +function getCurrentFilters() { + const genres = [...document.querySelectorAll('input[name="genre"]:checked')].map(cb => cb.value); + const yearVals = sliders["year-slider"].noUiSlider.get().map(Math.round); + const scoreVals = sliders["score-slider"].noUiSlider.get(); + + const idxFrom = (currentPage - 1) * ITEMS_PER_PAGE; + const idxTo = idxFrom + ITEMS_PER_PAGE - 1; + + return { + q: searchQuery.value.trim(), + genre: genres, + year_min: yearVals[0], + year_max: yearVals[1], + score_min: scoreVals[0], + score_max: scoreVals[1], + status: statusSelect.value, + idx_from: idxFrom, + idx_to: idxTo, + }; +} + +// Converts dictionary into query params string +function buildQueryString(params) { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, val]) => { + if (Array.isArray(val)) val.forEach(v => query.append(key, v)); + else if (val != null && val !== '') query.append(key, val); + }); + return query.toString(); +} + +// Sends search request to backend +async function performSearch() { + const params = buildQueryString(getCurrentFilters()); + const url = `${API_SEARCH_URL}?${params}`; + history.replaceState(null, '', `?${params}`); + + try { + // TODO: Modify fetch url to include pagination params + const res = await fetch(url); + if (!res.ok) postErrorMessage(res.status, "Backend Search Error", API_SEARCH_URL); + const data = await res.json(); + totalCount = data.total_count || 0; + totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE) || 1; + renderResults(data); + updatePagination(); + } catch (err) { + // If error, show error message and UI item to user + console.error('Search failed:', err); + const errorTemplate = document.getElementById('tmpl-error').content.cloneNode(true); + resultsContainer.clear(); + resultsContainer.appendChild(errorTemplate); + paginationWrapper.innerHTML = ''; + } finally { + // Disable loading at end of search + loadingEl.style.display = 'none'; + } +} + +// 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); + + shows.forEach(show => { + resultsContainer.appendChild(renderShowRow(show)); + }); +} + +// Update controller function for pages +function updatePagination() { + renderPagination(paginationWrapper, { + currentPage, + totalPages, + onPageChange: (newPage) => { + currentPage = newPage; + performSearch(); + resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); +} + +function changePage(delta) { + currentPage += delta; + performSearch(); + 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() { + const params = new URLSearchParams(window.location.search); + + // Search query + if (params.has('q')) { + searchQuery.value = params.get('q'); + } + + // Status + if (params.has('status')) { + statusSelect.value = params.get('status'); + } + + // Year slider + const yearMin = params.get('year_min'); + const yearMax = params.get('year_max'); + if (yearMin && yearMax) { + sliders["year-slider"].noUiSlider.set([yearMin, yearMax]); + } + + // Score slider + const scoreMin = params.get('score_min'); + const scoreMax = params.get('score_max'); + if (scoreMin && scoreMax) { + sliders["score-slider"].noUiSlider.set([scoreMin, scoreMax]); + } + + // Genres + const genres = params.getAll('genre'); + if (genres.length) { + document.querySelectorAll('input[name="genre"]').forEach(cb => { + cb.checked = genres.includes(cb.value); + }); + } +} + +// Initialization on page load +document.addEventListener('DOMContentLoaded', () => { + initSliders(); + + loadStateFromURL(); + performSearch(); + + // Filter panel toggle event + toggleBtn.addEventListener('click', () => { + filterPanel.classList.toggle('active'); + toggleBtn.classList.toggle('expanded'); + }); + + // Submit search event + searchForm.addEventListener('submit', e => { + e.preventDefault(); + currentPage = 1; + performSearch(); + }); + + clearFiltersBtn.addEventListener('click', clearAllFilters); +}); \ No newline at end of file diff --git a/frontend/templates/about.html b/frontend/templates/about.html new file mode 100644 index 0000000..7fb1f0b --- /dev/null +++ b/frontend/templates/about.html @@ -0,0 +1,67 @@ +{% extends "main.html" %} + +{% block title %}About Us - Aniℤenith{% endblock %} + +{% block imports %} + + +{% endblock %} + +{% block content %} +
+ +

+ About Anienith +

+ +

About our Dream

+
+
+
+

Our Mission

+

To connect anime fans through a vibrant community, delivering personalized recommendations that make discovering new shows effortless and enjoyable.

+
+
+
+

Our Vision

+

To build a seamless, intelligent platform where fans everywhere can explore anime together, find what they love faster, and share in the joy of discovery.

+
+
+
+

Our Values

+

We believe in community-driven experiences, joyful discovery, and efficient access to anime. We honor the creators by helping fans connect with their work more easily and meaningfully.

+
+
+ +

Meet the AniZenith Crew

+
+
+
Shafath Zaman
+
Shafath Zaman
+
Team Lead and Lead Frontend Developer
+
Endlessly searching for the Steins;Gate in the lab.
+
+
+
Suryansh Goyal
+
Suryansh Goyal
+
Lead Backend, DevOps, and ML Engineer
+
Working out daily at the gym like Saitama.
+
+
+
Amaan Shirwani
+
Amaan Shirwani
+
DevOps and ML Engineer, QA Analyst
+
Doing jobs like Scout missions outside the walls.
+
+
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/frontend/templates/chatbot.html b/frontend/templates/chatbot.html new file mode 100644 index 0000000..837645a --- /dev/null +++ b/frontend/templates/chatbot.html @@ -0,0 +1,95 @@ +{% extends "main.html" %} + +{% block imports %} + +{% endblock %} + +{% block title %}Chat - Aniℤenith{% endblock %} + +{% block content %} + +

+ Anienith Recommender +

+ +
+ +
+ + +
+ + +
+ + + + +
+ +
+
+ + + +
+
+ + + +
+ + + +
+ + + +
+ + + +
+ +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/frontend/templates/favorites.html b/frontend/templates/favorites.html new file mode 100644 index 0000000..96b7a01 --- /dev/null +++ b/frontend/templates/favorites.html @@ -0,0 +1,83 @@ +{% extends "main.html" %} + +{% block title %}My Favorites • Aniℤenith{% endblock %} + +{% block imports %} + +{% endblock %} + +{% block content %} +

+ Favorite Anime +

+
+
+ +
+ + + +
+ +
+ + +
+
+ +
+ +
+
+

Loading your favorites...

+
+ + +
+ + +
+
+ +
+

Your favorites list is empty

+

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

+ + + Browse Anime + +
+
+ + + +
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/frontend/templates/home.html b/frontend/templates/home.html deleted file mode 100644 index 798facc..0000000 --- a/frontend/templates/home.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "main.html" %} - -{% block imports %} - -{% endblock %} - -{% block title %}AniZenith{% endblock %} - -{% block content %} - -

- - Anienith -

- - - An AI designed to give recommendations of the best anime options based on your preferences! - - -
- -
- - -
- - -
- - -
- - -
-
- - - - - -
-
- - - -
-
- - - -
- - - -
- - - -
- - - -
- -
- - - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/main.html b/frontend/templates/main.html index 9a395f6..6b5a41d 100644 --- a/frontend/templates/main.html +++ b/frontend/templates/main.html @@ -1,30 +1,83 @@ - - -{% block imports %}{% endblock%} - - {% block title %}Aniℤenith{% endblock %} + + + + {% block imports %}{% endblock %} - + +
+ + + + + +
+ +

+ Anienith +

+
+
+ + + + + + + + +
+ + +
+ + + +
+ + +
{% block content %} {% endblock %}
+ + {% block scripts %}{% endblock %} + + - + \ No newline at end of file diff --git a/frontend/templates/search.html b/frontend/templates/search.html new file mode 100644 index 0000000..e1ad90e --- /dev/null +++ b/frontend/templates/search.html @@ -0,0 +1,177 @@ +{% extends "main.html" %} + +{% block title %}Search Anime - Aniℤenith{% endblock %} + +{% block imports %} + + +{% endblock %} + +{% block content %} +

+ Anienith Anime Search +

+
+
+
+
+ + + + + +
+ +
+
+ +
+

Genres

+
+ + + + + + +
+
+ + +
+

Year Range

+
+
+ + + +
+
+
+
+ + +
+

Score

+
+
+ + + +
+
+
+
+ + +
+

Status

+ +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + +
+
+
+

Searching anime...

+
+ +
+ +
+
+
+ +
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file