Initial commit of AnimeAnnouncer.Web
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
@page
|
||||
@model ErrorModel
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (Model.ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AnimeAnnouncer.Web.Pages;
|
||||
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class ErrorModel : PageModel
|
||||
{
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
private readonly ILogger<ErrorModel> _logger;
|
||||
|
||||
public ErrorModel(ILogger<ErrorModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
@page
|
||||
@model IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Airing Soon Anime";
|
||||
}
|
||||
|
||||
<div class="container-fluid airing-container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-2">
|
||||
<h1 class="display-5 mb-0 d-flex align-items-center flex-wrap">
|
||||
<span>🗓️ Airing Soon Anime</span>
|
||||
<span class="badge rounded-pill bg-dark text-white ms-2 fs-6">@(Model.AiringShows == null ? "No data" : (Model.AiringShows.Count + (Model.AiringShows.Count == 1 ? " show" : " shows")))</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p class="lead text-secondary mb-4">Anime with an upcoming or recent latest episode, fetched from the TMDB cache. Sorted by air date (<strong>newest first</strong>).</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger shadow-sm" role="alert">
|
||||
<strong>Error:</strong> @Model.ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.AiringShows == null || Model.AiringShows.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-3">
|
||||
<span class="display-1">📺</span>
|
||||
</div>
|
||||
<h3>No airing soon shows found</h3>
|
||||
<p class="text-secondary">The Redis cache either returned no entries or the airing soon list is empty.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 row-cols-xxl-6 g-4">
|
||||
@foreach (var show in Model.AiringShows)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 anime-card @(show.IsFuture ? "border-danger" : "")">
|
||||
<div class="anime-poster-container">
|
||||
@if (!string.IsNullOrWhiteSpace(show.PosterUrl))
|
||||
{
|
||||
<img src="@show.PosterUrl" class="anime-poster" alt="@show.Title" title="@show.Title" loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="anime-poster d-flex align-items-center justify-content-center text-secondary">
|
||||
<span class="display-4">🗂️</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (show.VoteAverage.HasValue && show.VoteAverage > 0)
|
||||
{
|
||||
<span class="position-absolute top-0 start-0 badge rounded-pill bg-warning text-dark m-2 shadow-sm">⭐ @string.Format("{0:0.0}", show.VoteAverage)</span>
|
||||
}
|
||||
|
||||
@if (show.EffectiveLastAirDate.HasValue)
|
||||
{
|
||||
@if (show.IsFuture)
|
||||
{
|
||||
<span class="position-absolute top-0 end-0 badge rounded-pill bg-danger text-white m-2 shadow-sm">Airs in @Math.Max(show.DaysUntil, 1)d</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="position-absolute top-0 end-0 badge rounded-pill bg-secondary text-white m-2 shadow-sm">Aired @Math.Abs(show.DaysUntil)d ago</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title text-truncate" title="@show.Title">@show.Title</h5>
|
||||
|
||||
<div class="show-meta mb-2 d-flex flex-wrap gap-1">
|
||||
@if (show.LatestSeasonNumber.HasValue && show.LatestSeasonNumber > 0)
|
||||
{
|
||||
<span class="badge rounded-pill bg-light text-dark border">Season @show.LatestSeasonNumber</span>
|
||||
}
|
||||
@if (show.LatestEpisodeNumber > 0)
|
||||
{
|
||||
@if (show.LastEpisodeNumber.HasValue && show.LastEpisodeNumber > 0)
|
||||
{
|
||||
<span class="badge rounded-pill bg-light text-dark border">Ep @show.LatestEpisodeNumber / @show.LastEpisodeNumber</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge rounded-pill bg-light text-dark border">Ep @show.LatestEpisodeNumber</span>
|
||||
}
|
||||
}
|
||||
@if (show.LatestOrdinalEpisodeNumber.HasValue && show.LatestOrdinalEpisodeNumber > 0)
|
||||
{
|
||||
<span class="badge rounded-pill bg-light text-dark border">Total: @show.LatestOrdinalEpisodeNumber</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="show-dates mb-2">
|
||||
@if (show.EffectiveLastAirDate.HasValue)
|
||||
{
|
||||
<small class="text-primary fw-semibold">📅 Last air date: @show.AirDateFormatted</small>
|
||||
}
|
||||
else
|
||||
{
|
||||
<small class="text-secondary">📅 Last air date: @show.AirDateFormatted</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
@if (!string.IsNullOrWhiteSpace(show.Overview))
|
||||
{
|
||||
<p class="show-overview text-muted" title="@show.Overview">@show.Overview</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="show-overview text-secondary fst-italic">No overview available.</p>
|
||||
}
|
||||
<div class="d-grid mt-2">
|
||||
<a href="https://www.themoviedb.org/tv/@show.ShowID" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary">View on TMDB</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
using AnimeAnnouncer.Cache;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AnimeAnnouncer.Web.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly ILogger<IndexModel> _logger;
|
||||
private readonly TMDBCache? _tmdbCache;
|
||||
|
||||
public List<AiringShowViewModel> AiringShows { get; set; } = new();
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public IndexModel(ILogger<IndexModel> logger, IServiceProvider serviceProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_tmdbCache = serviceProvider.GetService<TMDBCache>();
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
if (_tmdbCache == null)
|
||||
{
|
||||
ErrorMessage = "Redis cache connection is not configured or unavailable.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<AiringSoonItem>? airingList = await _tmdbCache.GetAiringList();
|
||||
|
||||
if (airingList == null || airingList.Count == 0)
|
||||
{
|
||||
AiringShows = new List<AiringShowViewModel>();
|
||||
return;
|
||||
}
|
||||
|
||||
var viewModels = new List<AiringShowViewModel>();
|
||||
|
||||
foreach (var airingItem in airingList)
|
||||
{
|
||||
TMDBCacheItem? cacheItem = null;
|
||||
try
|
||||
{
|
||||
cacheItem = await _tmdbCache.GetCacheItem($"ShowCache-{airingItem.Title}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to retrieve cache item for title: {Title}", airingItem.Title);
|
||||
}
|
||||
|
||||
viewModels.Add(new AiringShowViewModel
|
||||
{
|
||||
AiringSoon = airingItem,
|
||||
CachedDetails = cacheItem
|
||||
});
|
||||
}
|
||||
|
||||
// Exclude placeholder/sentinel last-air-dates (e.g. DateTime.MaxValue == 12/31/9999,
|
||||
// used by the announcer when an exact air date is unknown), then sort descending
|
||||
// by LastAirDate (nulls last).
|
||||
var displayable = viewModels.Where(x =>
|
||||
{
|
||||
var date = x.EffectiveLastAirDate;
|
||||
// Keep items with a real, displayable date; drop the MaxValue sentinel.
|
||||
return !date.HasValue || date.Value < DateTime.MaxValue.AddMonths(-1);
|
||||
}).ToList();
|
||||
|
||||
AiringShows = displayable
|
||||
.Where(x => x.EffectiveLastAirDate.HasValue && x.EffectiveLastAirDate.Value > DateTime.Now)
|
||||
.OrderBy(x => x.EffectiveLastAirDate)
|
||||
.ThenBy(x => x.Title)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading airing soon list");
|
||||
ErrorMessage = $"Unable to load airing soon list: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public class AiringShowViewModel
|
||||
{
|
||||
public required AiringSoonItem AiringSoon { get; set; }
|
||||
public TMDBCacheItem? CachedDetails { get; set; }
|
||||
|
||||
public string Title => AiringSoon.Title;
|
||||
public int ShowID => AiringSoon.ShowID;
|
||||
public DateTime? EffectiveLastAirDate => AiringSoon.LastAirDate ?? CachedDetails?.LastAirDate;
|
||||
public int LatestEpisodeNumber => AiringSoon.LatestEpisodeNumber > 0
|
||||
? AiringSoon.LatestEpisodeNumber
|
||||
: (CachedDetails?.LatestEpisodeNumber ?? 0);
|
||||
public int? LastEpisodeNumber => CachedDetails?.LastEpisodeNumber;
|
||||
public int? LatestSeasonNumber => CachedDetails?.LatestSeasonNumber;
|
||||
public int? LatestOrdinalEpisodeNumber => CachedDetails?.LatestOrdinalEpisodeNumber;
|
||||
public double? VoteAverage => CachedDetails?.VoteAverage;
|
||||
public string? Overview => !string.IsNullOrWhiteSpace(CachedDetails?.LatestSeasonOverview)
|
||||
? CachedDetails.LatestSeasonOverview
|
||||
: CachedDetails?.Overview;
|
||||
|
||||
public string? PosterUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CachedDetails?.LatestSeasonPosterPath))
|
||||
return CachedDetails.LatestSeasonPosterPath;
|
||||
if (!string.IsNullOrWhiteSpace(CachedDetails?.PosterPath))
|
||||
return CachedDetails.PosterPath;
|
||||
if (!string.IsNullOrWhiteSpace(CachedDetails?.AnilistPosterPath))
|
||||
return CachedDetails.AnilistPosterPath;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public string? BackdropUrl => CachedDetails?.BackdropPath;
|
||||
|
||||
public DateTime AirReferenceNow => DateTime.Now;
|
||||
|
||||
public bool IsFuture
|
||||
{
|
||||
get
|
||||
{
|
||||
var date = EffectiveLastAirDate;
|
||||
return date.HasValue && date.Value > AirReferenceNow;
|
||||
}
|
||||
}
|
||||
|
||||
public int DaysUntil
|
||||
{
|
||||
get
|
||||
{
|
||||
var date = EffectiveLastAirDate;
|
||||
if (!date.HasValue)
|
||||
return 0;
|
||||
return (int)((date.Value - AirReferenceNow).Ticks / TimeSpan.FromDays(1).Ticks);
|
||||
}
|
||||
}
|
||||
|
||||
public string AirDateFormatted
|
||||
{
|
||||
get
|
||||
{
|
||||
var date = EffectiveLastAirDate;
|
||||
return date.HasValue ? date.Value.ToString("MM/dd/yyyy") : "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@page
|
||||
@model PrivacyModel
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
|
||||
<p>Use this page to detail your site's privacy policy.</p>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AnimeAnnouncer.Web.Pages;
|
||||
|
||||
public class PrivacyModel : PageModel
|
||||
{
|
||||
private readonly ILogger<PrivacyModel> _logger;
|
||||
|
||||
public PrivacyModel(ILogger<PrivacyModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - AnimeAnnouncer.Web</title>
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/AnimeAnnouncer.Web.styles.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" asp-area="" asp-page="/Index">AnimeAnnouncer.Web</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||
<ul class="navbar-nav flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
<main role="main" class="pb-3">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="border-top footer text-muted">
|
||||
<div class="container">
|
||||
© 2026 - AnimeAnnouncer.Web - <a asp-area="" asp-page="/Privacy">Privacy</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
for details on configuring this project to bundle and minify static web assets. */
|
||||
|
||||
a.navbar-brand {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0077cc;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.box-shadow {
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
button.accept-policy {
|
||||
font-size: 1rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 60px;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
|
||||
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
|
||||
@@ -0,0 +1,3 @@
|
||||
@using AnimeAnnouncer.Web
|
||||
@namespace AnimeAnnouncer.Web.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
Reference in New Issue
Block a user