Initial commit of AnimeAnnouncer.Web

This commit is contained in:
2026-09-17 14:10:13 -04:00
parent 2198f6ad84
commit 943aef2f7b
76 changed files with 75146 additions and 0 deletions
+26
View File
@@ -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>
+27
View File
@@ -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;
}
}
+125
View File
@@ -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>
+150
View File
@@ -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";
}
}
}
}
+8
View File
@@ -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">
&copy; 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";
}