diff --git a/CatherineLynwood/CatherineLynwood.csproj b/CatherineLynwood/CatherineLynwood.csproj index 2c7de42..8922008 100644 --- a/CatherineLynwood/CatherineLynwood.csproj +++ b/CatherineLynwood/CatherineLynwood.csproj @@ -154,10 +154,11 @@ + - + diff --git a/CatherineLynwood/Controllers/AccessController.cs b/CatherineLynwood/Controllers/AccessController.cs index e9f1acd..f1256fd 100644 --- a/CatherineLynwood/Controllers/AccessController.cs +++ b/CatherineLynwood/Controllers/AccessController.cs @@ -1,51 +1,71 @@ -using CatherineLynwood.Controllers; -using CatherineLynwood.Models; +using CatherineLynwood.Models; using CatherineLynwood.Services; using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -public class AccessController : Controller +namespace CatherineLynwood.Controllers { - #region Private Fields - - private readonly IAccessCodeService _accessCodeService; - private readonly ILogger _logger; - private DataAccess _dataAccess; - - #endregion Private Fields - - #region Public Constructors - - public AccessController(ILogger logger, DataAccess dataAccess, IAccessCodeService accessCodeService) + [Route("access")] + public class AccessController : Controller { - _logger = logger; - _dataAccess = dataAccess; - _accessCodeService = accessCodeService; - } + #region Private Fields - #endregion Public Constructors + private readonly IAccessCodeService _accessCodeService; + private readonly ILogger _logger; + private DataAccess _dataAccess; - #region Public Methods + #endregion Private Fields - [HttpGet] - public IActionResult MailingList() - { - return View(); - } + #region Public Constructors - [HttpPost] - public async Task MailingList(Marketing marketing) - { - bool success = true; - - if (!string.IsNullOrEmpty(marketing.Email)) + public AccessController(ILogger logger, DataAccess dataAccess, IAccessCodeService accessCodeService) { - success = await _dataAccess.AddMarketingAsync(marketing); + _logger = logger; + _dataAccess = dataAccess; + _accessCodeService = accessCodeService; } - if (success) + #endregion Public Constructors + + #region Public Methods + + [HttpGet("mailinglist")] + public IActionResult MailingList() + { + return View(); + } + + [HttpPost("mailinglist")] + public async Task MailingList(Marketing marketing) + { + bool success = true; + + if (!string.IsNullOrEmpty(marketing.Email)) + { + success = await _dataAccess.AddMarketingAsync(marketing); + } + + if (success) + { + // Set cookie so we don’t ask again + Response.Cookies.Append("MailingListPrompted", "true", new CookieOptions + { + Expires = DateTimeOffset.UtcNow.AddYears(5), + IsEssential = true + }); + + // Redirect back to original destination + var returnUrl = TempData["ReturnUrl"] as string ?? "/extras"; + return Redirect(returnUrl); + } + else + { + return RedirectToAction("Prompt"); + } + } + + [HttpPost("decline")] + public IActionResult Decline() { // Set cookie so we don’t ask again Response.Cookies.Append("MailingListPrompted", "true", new CookieOptions @@ -55,70 +75,52 @@ public class AccessController : Controller }); // Redirect back to original destination - var returnUrl = TempData["ReturnUrl"] as string ?? "/Extras"; + var returnUrl = TempData["ReturnUrl"] as string ?? "/extras"; return Redirect(returnUrl); } - else + + [HttpGet("prompt")] + public async Task Prompt(string returnUrl = "/extras") { - return RedirectToAction("Prompt"); + var (pageNumber, wordIndex) = await _accessCodeService.GetCurrentChallengeAsync(); + + ViewBag.PageNumber = pageNumber; + ViewBag.WordIndex = wordIndex; + ViewBag.ReturnUrl = returnUrl; + + return View(); } - } - [HttpPost] - public IActionResult Decline() - { - // Set cookie so we don’t ask again - Response.Cookies.Append("MailingListPrompted", "true", new CookieOptions + [HttpPost("prompt")] + public async Task Prompt(string userWord, string returnUrl = "/extras") { - Expires = DateTimeOffset.UtcNow.AddYears(5), - IsEssential = true - }); + var (accessLevel, highestBook) = await _accessCodeService.ValidateWordAsync(userWord); - // Redirect back to original destination - var returnUrl = TempData["ReturnUrl"] as string ?? "/Extras"; - return Redirect(returnUrl); - } - - [HttpGet] - public async Task Prompt(string returnUrl = "/Extras") - { - var (pageNumber, wordIndex) = await _accessCodeService.GetCurrentChallengeAsync(); - - ViewBag.PageNumber = pageNumber; - ViewBag.WordIndex = wordIndex; - ViewBag.ReturnUrl = returnUrl; - - return View(); - } - - [HttpPost] - public async Task Prompt(string userWord, string returnUrl = "/Extras") - { - var (accessLevel, highestBook) = await _accessCodeService.ValidateWordAsync(userWord); - - if (accessLevel > 0 && highestBook > 0) - { - HttpContext.Session.SetInt32("BookAccessLevel", accessLevel); - HttpContext.Session.SetInt32("BookAccessMax", highestBook); - - var promptedCookie = Request.Cookies["MailingListPrompted"]; - if (string.IsNullOrEmpty(promptedCookie)) + if (accessLevel > 0 && highestBook > 0) { - TempData["ReturnUrl"] = returnUrl; - return RedirectToAction("MailingList"); + HttpContext.Session.SetInt32("BookAccessLevel", accessLevel); + HttpContext.Session.SetInt32("BookAccessMax", highestBook); + + var promptedCookie = Request.Cookies["MailingListPrompted"]; + if (string.IsNullOrEmpty(promptedCookie)) + { + TempData["ReturnUrl"] = returnUrl; + return RedirectToAction("MailingList"); + } + + return Redirect(returnUrl); } - return Redirect(returnUrl); + var (pageNumber, wordIndex) = await _accessCodeService.GetCurrentChallengeAsync(); + ViewBag.PageNumber = pageNumber; + ViewBag.WordIndex = wordIndex; + ViewBag.ReturnUrl = returnUrl; + ViewBag.Error = "Invalid word. Please try again."; + + return View(); } - var (pageNumber, wordIndex) = await _accessCodeService.GetCurrentChallengeAsync(); - ViewBag.PageNumber = pageNumber; - ViewBag.WordIndex = wordIndex; - ViewBag.ReturnUrl = returnUrl; - ViewBag.Error = "Invalid word. Please try again."; - - return View(); + #endregion Public Methods } +} - #endregion Public Methods -} \ No newline at end of file diff --git a/CatherineLynwood/Controllers/AccountController.cs b/CatherineLynwood/Controllers/AccountController.cs new file mode 100644 index 0000000..d011671 --- /dev/null +++ b/CatherineLynwood/Controllers/AccountController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using System.Security.Claims; + +namespace CatherineLynwood.Controllers +{ + public class AccountController : Controller + { + private const string HardcodedUsername = "nick"; + private const string HardcodedPassword = "ryaN9982?"; // Change this + + [HttpGet] + public IActionResult Login(string returnUrl = "/") + { + ViewBag.ReturnUrl = returnUrl; + return View(); + } + + [HttpPost] + public async Task Login(string username, string password, string returnUrl = "/") + { + if (username == HardcodedUsername && password == HardcodedPassword) + { + var claims = new List + { + new Claim(ClaimTypes.Name, username) + }; + + var identity = new ClaimsIdentity(claims, "MyCookieAuth"); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync("MyCookieAuth", principal); + + return LocalRedirect(returnUrl); + } + + ViewBag.Error = "Invalid credentials"; + ViewBag.ReturnUrl = returnUrl; + return View(); + } + + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync("MyCookieAuth"); + return RedirectToAction("Login"); + } + } +} diff --git a/CatherineLynwood/Controllers/AdminController.cs b/CatherineLynwood/Controllers/AdminController.cs new file mode 100644 index 0000000..89a9130 --- /dev/null +++ b/CatherineLynwood/Controllers/AdminController.cs @@ -0,0 +1,142 @@ +using CatherineLynwood.Models; +using CatherineLynwood.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace CatherineLynwood.Controllers +{ + [Route("admin")] + [Authorize] + public class AdminController : Controller + { + #region Private Fields + + private readonly IWebHostEnvironment _env; + private readonly ILogger _logger; + private DataAccess _dataAccess; + + #endregion Private Fields + + #region Public Constructors + + public AdminController(IWebHostEnvironment env, ILogger logger, DataAccess dataAccess) + { + _env = env; + _logger = logger; + _dataAccess = dataAccess; + } + + #endregion Public Constructors + + [Route("")] + public IActionResult Index() + { + return View(); + } + + [Route("arc-readers")] + public async Task ArcReaders() + { + ARCReaderList aRCReaderList = await _dataAccess.GetAllARCReadersAsync(); + + return View(aRCReaderList); + } + + [Route("blog")] + public async Task Blog() + { + BlogAdminIndex blogAdminIndex = await _dataAccess.GetBlogAdminIndexAsync(); + + return View(blogAdminIndex); + } + + [HttpGet("blog/edit/{slug}")] + public async Task BlogEdit(string slug) + { + Blog blog = await _dataAccess.GetBlogItemAsync(slug); + + ViewBag.ResponderList = await _dataAccess.GetResponderList(); + + return View(blog); + } + + [HttpPost("blog/edit/{slug}")] + public async Task Edit(string slug, BlogEditPage page) + { + var root = _env.WebRootPath; + var imageRoot = Path.Combine(root, "images"); + var audioRoot = Path.Combine(root, "audio"); + var videoRoot = Path.Combine(root, "videos"); + + // Ensure directories exist + Directory.CreateDirectory(imageRoot); + Directory.CreateDirectory(audioRoot); + Directory.CreateDirectory(videoRoot); + + // For Image (.png) + if (page.ImageUpload != null && page.ImageUpload.Length > 0) + { + page.ImageUrl = page.BlogUrl + ".png"; + var imagePath = Path.Combine(imageRoot, page.ImageUrl); + using (var stream = new FileStream(imagePath, FileMode.Create)) + { + await page.ImageUpload.CopyToAsync(stream); + } + } + + // For Audio Teaser (.mp3) + if (page.AudioTeaserUpload != null && page.AudioTeaserUpload.Length > 0) + { + page.AudioTeaserUrl = page.BlogUrl + "_teaser.mp3"; + var audioTeaserPath = Path.Combine(audioRoot, page.AudioTeaserUrl); + using (var stream = new FileStream(audioTeaserPath, FileMode.Create)) + { + await page.AudioTeaserUpload.CopyToAsync(stream); + } + } + + // For Audio Transcript (.mp3) + if (page.AudioTranscriptUpload != null && page.AudioTranscriptUpload.Length > 0) + { + page.AudioTranscriptUrl = page.BlogUrl + "_transcript.mp3"; + var audioTranscriptPath = Path.Combine(audioRoot, page.AudioTranscriptUrl); + using (var stream = new FileStream(audioTranscriptPath, FileMode.Create)) + { + await page.AudioTranscriptUpload.CopyToAsync(stream); + } + } + + // For Video (.mp4) + if (page.VideoUpload != null && page.VideoUpload.Length > 0) + { + page.VideoUrl = page.BlogUrl + ".mp4"; + var videoPath = Path.Combine(videoRoot, page.VideoUrl); + using (var stream = new FileStream(videoPath, FileMode.Create)) + { + await page.VideoUpload.CopyToAsync(stream); + } + } + + bool success = await _dataAccess.UpdateBlogAsync(page); + + if (success) + { + return RedirectToAction("Blog"); + } + else + { + ModelState.AddModelError(string.Empty, "An unknown error occured."); + + Blog blog = await _dataAccess.GetBlogItemAsync(slug); + + ViewBag.ResponderList = await _dataAccess.GetResponderList(); + + return View(blog); + } + + + } + + } +} diff --git a/CatherineLynwood/Controllers/AskAQuestion.cs b/CatherineLynwood/Controllers/AskAQuestion.cs index 79dccbc..881db8b 100644 --- a/CatherineLynwood/Controllers/AskAQuestion.cs +++ b/CatherineLynwood/Controllers/AskAQuestion.cs @@ -1,11 +1,9 @@ -using CatherineLynwood.Helpers; -using CatherineLynwood.Models; +using CatherineLynwood.Models; using CatherineLynwood.Services; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json; -using SendGrid.Helpers.Mail; +using Newtonsoft.Json; namespace CatherineLynwood.Controllers { @@ -13,10 +11,12 @@ namespace CatherineLynwood.Controllers public class AskAQuestion : Controller { private DataAccess _dataAccess; + private readonly IEmailService _emailService; - public AskAQuestion(DataAccess dataAccess) + public AskAQuestion(DataAccess dataAccess, IEmailService emailService) { _dataAccess = dataAccess; + _emailService = emailService; } public async Task Index(bool showThanks) @@ -48,7 +48,6 @@ namespace CatherineLynwood.Controllers if (!visible) { - var to = new EmailAddress(question.EmailAddress, question.Name); var subject = "Thank you from Catherine Lynwood Web Site"; var plainTextContent = $"Dear {question.Name},/r/nThank you for taking the time to ask me a question. "; var htmlContent = $"Dear {question.Name},
" + @@ -60,7 +59,14 @@ namespace CatherineLynwood.Controllers $"

Catherine Lynwood
" + $"Author: The Alpha Flame
" + @$"Web: www.catherinelynwood.com

"; - await SendEmail.Execute(to, subject, plainTextContent, htmlContent); + + Contact contact = new Contact + { + Name = question.Name, + EmailAddress = question.EmailAddress + }; + + await _emailService.SendEmailAsync(subject, plainTextContent, htmlContent, contact); } showThanks = true; diff --git a/CatherineLynwood/Controllers/BlogController.cs b/CatherineLynwood/Controllers/BlogController.cs new file mode 100644 index 0000000..3a93863 --- /dev/null +++ b/CatherineLynwood/Controllers/BlogController.cs @@ -0,0 +1,86 @@ +using CatherineLynwood.Models; +using CatherineLynwood.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System.Net; + +namespace CatherineLynwood.Controllers +{ + [ApiController] + [Route("api/[controller]")] + [AllowAnonymous] + public class BlogController : ControllerBase + { + private readonly IWebHostEnvironment _env; + private readonly DataAccess _dataAccess; + private readonly string _blogApiKey; + + public BlogController( + IWebHostEnvironment env, + DataAccess dataAccess, + IConfiguration configuration) + { + _env = env; + _dataAccess = dataAccess; + _blogApiKey = configuration["ApiKeys:BlogPost"]; + } + + + [HttpPost] + public async Task CreateBlog([FromBody] BlogPostRequest blog) + { + // 1️⃣ Check for API key in headers + //if (!Request.Headers.TryGetValue("X-API-Key", out var apiKey) || apiKey != _blogApiKey) + //{ + // return StatusCode(StatusCodes.Status403Forbidden, "Invalid or missing API key."); + //} + + // 2️⃣ Validate input + if (blog == null || string.IsNullOrWhiteSpace(blog.BlogUrl)) + { + return BadRequest("Invalid blog data."); + } + + // Call your DB save method + try + { + await _dataAccess.SaveBlogToDatabase(blog); + } + catch (Exception ex) + { + return StatusCode(500, $"Error saving to database: {ex.Message}"); + } + + return Ok(new { status = "success", message = "Blog post saved successfully." }); + } + + [HttpGet] + public async Task GetAllBlogs() + { + try + { + var blogs = await _dataAccess.GetAllBlogsAsync(); + + var summaries = blogs.Select(b => new BlogSummaryResponse + { + BlogUrl = b.BlogUrl, + Title = b.Title, + SubTitle = b.SubTitle, + IndexText = b.IndexText, + AiSummary = b.AiSummary, + PublishDate = b.PublishDate + }); + + return Ok(summaries); + } + catch (Exception ex) + { + return StatusCode(500, $"Error retrieving blogs: {ex.Message}"); + } + } + + } +} diff --git a/CatherineLynwood/Controllers/DiscoveryController.cs b/CatherineLynwood/Controllers/DiscoveryController.cs index 9f47faf..3caaf7a 100644 --- a/CatherineLynwood/Controllers/DiscoveryController.cs +++ b/CatherineLynwood/Controllers/DiscoveryController.cs @@ -1,10 +1,33 @@ -using Microsoft.AspNetCore.Mvc; +using CatherineLynwood.Models; +using CatherineLynwood.Services; + +using Microsoft.AspNetCore.Mvc; + +using Newtonsoft.Json; + +using System.Globalization; +using System.Text.RegularExpressions; namespace CatherineLynwood.Controllers { [Route("the-alpha-flame/discovery")] public class DiscoveryController : Controller { + #region Private Fields + + private DataAccess _dataAccess; + + #endregion Private Fields + + #region Public Constructors + + public DiscoveryController(DataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + #endregion Public Constructors + #region Public Methods [Route("chapters/chapter-1-beth")] @@ -40,9 +63,21 @@ namespace CatherineLynwood.Controllers } [Route("")] - public IActionResult Index() + public async Task Index() { - return View(); + Reviews reviews = await _dataAccess.GetReviewsAsync(); + reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3); + + return View(reviews); + } + + [Route("reviews")] + public async Task Reviews() + { + Reviews reviews = await _dataAccess.GetReviewsAsync(); + reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 100); + + return View(reviews); } [BookAccess(1, 1)] @@ -67,5 +102,152 @@ namespace CatherineLynwood.Controllers } #endregion Public Methods + + #region Private Methods + + private string GenerateBookSchemaJsonLd(Reviews reviews, int take) + { + const string imageUrl = "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp"; + const string baseUrl = "https://www.catherinelynwood.com/the-alpha-flame/discovery"; + + var schema = new Dictionary + { + ["@context"] = "https://schema.org", + ["@type"] = "Book", + ["name"] = "The Alpha Flame: Discovery", + ["alternateName"] = "The Alpha Flame Book 1", + ["image"] = imageUrl, + ["author"] = new Dictionary + { + ["@type"] = "Person", + ["name"] = "Catherine Lynwood", + ["url"] = "https://www.catherinelynwood.com" + }, + ["publisher"] = new Dictionary + { + ["@type"] = "Organization", + ["name"] = "Catherine Lynwood" + }, + ["datePublished"] = "2025-08-21", + ["description"] = "The Alpha Flame: Discovery is a powerful, character-driven novel set in 1983 Birmingham, following Maggie Grant and Beth—two young women separated by fate, reunited by truth, and bound by secrets...", + ["genre"] = "Women's Fiction, Mystery, Contemporary Historical", + ["inLanguage"] = "en-GB", + ["url"] = baseUrl + }; + + // Add review section if there are reviews + if (reviews?.Items?.Any() == true) + { + var reviewObjects = new List>(); + double total = 0; + + foreach (var review in reviews.Items.Take(take)) + { + total += review.RatingValue; + + reviewObjects.Add(new Dictionary + { + ["@type"] = "Review", + ["author"] = new Dictionary + { + ["@type"] = "Person", + ["name"] = review.AuthorName + }, + ["datePublished"] = review.DatePublished.ToString("yyyy-MM-dd"), + ["reviewBody"] = StripHtml(review.ReviewBody), + ["reviewRating"] = new Dictionary + { + ["@type"] = "Rating", + ["ratingValue"] = review.RatingValue, + ["bestRating"] = "5" + } + }); + } + + schema["review"] = reviewObjects; + schema["aggregateRating"] = new Dictionary + { + ["@type"] = "AggregateRating", + ["ratingValue"] = (total / reviews.Items.Count).ToString("0.0", CultureInfo.InvariantCulture), + ["reviewCount"] = reviews.Items.Count + }; + } + + // Add work examples + schema["workExample"] = new List> + { + new Dictionary + { + ["@type"] = "Book", + ["bookFormat"] = "https://schema.org/Hardcover", + ["isbn"] = "978-1-0682258-0-2", + ["name"] = "The Alpha Flame: Discovery – Collector's Edition", + ["image"] = imageUrl, + ["offers"] = new Dictionary + { + ["@type"] = "Offer", + ["price"] = "23.99", + ["priceCurrency"] = "GBP", + ["availability"] = "https://schema.org/InStock", + ["url"] = baseUrl + } + }, + new Dictionary + { + ["@type"] = "Book", + ["bookFormat"] = "https://schema.org/Paperback", + ["isbn"] = "978-1-0682258-1-9", + ["name"] = "The Alpha Flame: Discovery – Bookshop Edition", + ["image"] = imageUrl, + ["offers"] = new Dictionary + { + ["@type"] = "Offer", + ["price"] = "17.99", + ["priceCurrency"] = "GBP", + ["availability"] = "https://schema.org/InStock", + ["url"] = baseUrl + } + }, + new Dictionary + { + ["@type"] = "Book", + ["bookFormat"] = "https://schema.org/Paperback", + ["isbn"] = "978-1-0682258-2-6", + ["name"] = "The Alpha Flame: Discovery – Amazon Edition", + ["image"] = imageUrl, + ["offers"] = new Dictionary + { + ["@type"] = "Offer", + ["price"] = "13.99", + ["priceCurrency"] = "GBP", + ["availability"] = "https://schema.org/InStock", + ["url"] = baseUrl + } + }, + new Dictionary + { + ["@type"] = "Book", + ["bookFormat"] = "https://schema.org/EBook", + ["isbn"] = "978-1-0682258-3-3", + ["name"] = "The Alpha Flame: Discovery – eBook", + ["image"] = imageUrl, + ["offers"] = new Dictionary + { + ["@type"] = "Offer", + ["price"] = "3.95", + ["priceCurrency"] = "GBP", + ["availability"] = "https://schema.org/InStock", + ["url"] = baseUrl + } + } + }; + + return JsonConvert.SerializeObject(schema, Formatting.Indented); + } + + string StripHtml(string input) => string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty); + + + #endregion Private Methods } } \ No newline at end of file diff --git a/CatherineLynwood/Controllers/HomeController.cs b/CatherineLynwood/Controllers/HomeController.cs index 188fc75..e3b586a 100644 --- a/CatherineLynwood/Controllers/HomeController.cs +++ b/CatherineLynwood/Controllers/HomeController.cs @@ -1,14 +1,11 @@ -using System.Diagnostics; -using System.Text; -using System.Xml.Linq; - -using CatherineLynwood.Helpers; using CatherineLynwood.Models; using CatherineLynwood.Services; using Microsoft.AspNetCore.Mvc; -using SendGrid.Helpers.Mail; +using System.Diagnostics; +using System.Text; +using System.Xml.Linq; namespace CatherineLynwood.Controllers { @@ -18,25 +15,42 @@ namespace CatherineLynwood.Controllers private readonly ILogger _logger; private DataAccess _dataAccess; + private readonly IEmailService _emailService; #endregion Private Fields #region Public Constructors - public HomeController(ILogger logger, DataAccess dataAccess) + public HomeController(ILogger logger, DataAccess dataAccess, IEmailService emailService) { _logger = logger; _dataAccess = dataAccess; + _emailService = emailService; } #endregion Public Constructors #region Public Methods - [Route("collaboration-inquiry")] - public IActionResult Honeypot() + [HttpGet("arc-reader-application")] + public IActionResult ArcReaderApplication() { - return View(); + ArcReaderApplicationModel arcReaderApplicationModel = new ArcReaderApplicationModel(); + + return View(arcReaderApplicationModel); + } + + [HttpPost("arc-reader-application")] + public async Task ArcReaderApplication(ArcReaderApplicationModel arcReaderApplicationModel) + { + bool success = await _dataAccess.SaveARCReaderApplication(arcReaderApplicationModel); + + if (success) + { + return RedirectToAction("ThankYou"); + } + + return View(arcReaderApplicationModel); } [Route("about-catherine-lynwood")] @@ -62,11 +76,13 @@ namespace CatherineLynwood.Controllers { if (ModelState.IsValid) { - var to = new EmailAddress("catherine@catherinelynwood.com", "Catherine Lynwood"); + await _dataAccess.SaveContact(contact); + var subject = "Email from Catherine Lynwood Web Site"; - var plainTextContent = $"Email from: {contact.Name} ({contact.EmailAddress})/r/n{contact.Message}"; + var plainTextContent = $"Email from: {contact.Name} ({contact.EmailAddress})\r\n{contact.Message}"; var htmlContent = $"Email from: {contact.Name} ({contact.EmailAddress})
{contact.Message}"; - await SendEmail.Execute(to, subject, plainTextContent, htmlContent); + + await _emailService.SendEmailAsync(subject, plainTextContent, htmlContent, contact); return RedirectToAction("ThankYou"); } @@ -80,7 +96,7 @@ namespace CatherineLynwood.Controllers [Route("feed")] public async Task DownloadRssFeed() { - BlogIndex blogIndex = await _dataAccess.GetBlogsAsync(string.Empty); + BlogIndex blogIndex = await _dataAccess.GetBlogsAsync(); XNamespace atom = "http://www.w3.org/2005/Atom"; // Define the Atom namespace @@ -139,6 +155,12 @@ namespace CatherineLynwood.Controllers return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } + [Route("collaboration-inquiry")] + public IActionResult Honeypot() + { + return View(); + } + public async Task Index() { //await SendEmail.Execute(); @@ -146,13 +168,8 @@ namespace CatherineLynwood.Controllers return View(); } - [Route("samantha-lynwood")] - public IActionResult SamanthaLynwood() - { - return View(); - } - - public IActionResult ThankYou() + [Route("offline")] + public IActionResult Offline() { return View(); } @@ -163,8 +180,20 @@ namespace CatherineLynwood.Controllers return View(); } - [Route("offline")] - public IActionResult Offline() + [Route("samantha-lynwood")] + public IActionResult SamanthaLynwood() + { + return View(); + } + + [Route("thankyou")] + public IActionResult ThankYou() + { + return View(); + } + + [Route("verostic-genre")] + public IActionResult VerosticGenre() { return View(); } diff --git a/CatherineLynwood/Controllers/PublishingController.cs b/CatherineLynwood/Controllers/PublishingController.cs index e37fa96..f1af4ec 100644 --- a/CatherineLynwood/Controllers/PublishingController.cs +++ b/CatherineLynwood/Controllers/PublishingController.cs @@ -4,6 +4,7 @@ namespace CatherineLynwood.Controllers { public class PublishingController : Controller { + [Route("publishing")] public IActionResult Index() { return View(); diff --git a/CatherineLynwood/Controllers/SitemapController.cs b/CatherineLynwood/Controllers/SitemapController.cs index f495056..af59dc5 100644 --- a/CatherineLynwood/Controllers/SitemapController.cs +++ b/CatherineLynwood/Controllers/SitemapController.cs @@ -32,20 +32,24 @@ namespace CatherineLynwood.Controllers { var urls = new List { - new SitemapEntry { Url = Url.Action("Index", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("ContactCatherine", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("AboutCatherineLynwood", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("SamanthaLynwood", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Index", "AskAQuestion", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Index", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("ArcReaderApplication", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Blog", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Characters", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Index", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Index", "Publishing", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Privacy", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Chapter1", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, - new SitemapEntry { Url = Url.Action("Chapter2", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Chapter13", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Chapter2", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Reviews", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Characters", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("ContactCatherine", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Giveaways", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Index", "AskAQuestion", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Index", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Index", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Index", "Publishing", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Index", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("Privacy", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("SamanthaLynwood", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, + new SitemapEntry { Url = Url.Action("VerosticGenre", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, // Additional static pages }; @@ -61,7 +65,7 @@ namespace CatherineLynwood.Controllers } // Add blog URLs dynamically, with PublishDate as LastModified - BlogIndex blogIndex = await _dataAccess.GetBlogsAsync(null); + BlogIndex blogIndex = await _dataAccess.GetBlogsAsync(); foreach (var post in blogIndex.Blogs) { urls.Add(new SitemapEntry @@ -98,7 +102,7 @@ namespace CatherineLynwood.Controllers xmlWriter.WriteStartElement("url"); xmlWriter.WriteElementString("loc", entry.Url); xmlWriter.WriteElementString("lastmod", entry.LastModified.ToString("yyyy-MM-dd")); - xmlWriter.WriteElementString("changefreq", "weekly"); + xmlWriter.WriteElementString("changefreq", "daily"); xmlWriter.WriteElementString("priority", "0.5"); xmlWriter.WriteEndElement(); } diff --git a/CatherineLynwood/Controllers/TheAlphaFlameController.cs b/CatherineLynwood/Controllers/TheAlphaFlameController.cs index 068e088..b3278b6 100644 --- a/CatherineLynwood/Controllers/TheAlphaFlameController.cs +++ b/CatherineLynwood/Controllers/TheAlphaFlameController.cs @@ -7,8 +7,6 @@ using Microsoft.Identity.Client; using Newtonsoft.Json; -using SendGrid.Helpers.Mail; - using System.Text.RegularExpressions; namespace CatherineLynwood.Controllers @@ -19,14 +17,16 @@ namespace CatherineLynwood.Controllers #region Private Fields private DataAccess _dataAccess; + private readonly IEmailService _emailService; #endregion Private Fields #region Public Constructors - public TheAlphaFlameController(DataAccess dataAccess) + public TheAlphaFlameController(DataAccess dataAccess, IEmailService emailService) { _dataAccess = dataAccess; + _emailService = emailService; } #endregion Public Constructors @@ -40,72 +40,54 @@ namespace CatherineLynwood.Controllers } [Route("blog")] - public async Task Blog(BlogFilter blogFilter) + [Route("blog/{page:int}")] + public async Task Blog(BlogFilter blogFilter, int page) { - // Convert the Categories list to a comma-separated string for querying - string categoryIDs = blogFilter.Categories != null ? string.Join(",", blogFilter.Categories) : string.Empty; + blogFilter.PageNumber = page == 0 ? 1 : page; - // Retrieve the blogs filtered by categories - BlogIndex blogIndex = await _dataAccess.GetBlogsAsync(categoryIDs); + // Set up your logic as before, including sorting, filtering, and pagination... + + BlogIndex blogIndex = await _dataAccess.GetBlogsAsync(); blogIndex.BlogFilter = blogFilter; blogIndex.BlogFilter.TotalPages = (int)Math.Ceiling((double)blogIndex.Blogs.Count / blogFilter.ResultsPerPage); blogIndex.IsMobile = IsMobile(Request); - if (blogFilter.TotalPages != blogFilter.PreviousTotalPages) - { - blogFilter.PageNumber = 1; - } - - // Determine sorting direction: 1 = newest first, 2 = oldest first + // Apply sorting if (blogFilter.SortDirection == 1) - { - // Sort by newest first blogIndex.Blogs = blogIndex.Blogs.OrderByDescending(b => b.PublishDate).ToList(); - } else if (blogFilter.SortDirection == 2) - { - // Sort by oldest first blogIndex.Blogs = blogIndex.Blogs.OrderBy(b => b.PublishDate).ToList(); - } - // Paginate the results based on ResultsPerPage - int resultsPerPage = blogFilter.ResultsPerPage > 0 ? blogFilter.ResultsPerPage : 6; // Default to 6 if not specified - - // Calculate the items for the current page + // Paginate + int resultsPerPage = blogFilter.ResultsPerPage > 0 ? blogFilter.ResultsPerPage : 6; blogIndex.Blogs = blogIndex.Blogs .Skip((blogFilter.PageNumber - 1) * resultsPerPage) .Take(resultsPerPage) .ToList(); - // Show advanced options if any category filters are applied - blogIndex.ShowAdvanced = !string.IsNullOrEmpty(categoryIDs); - return View(blogIndex); } [Route("blog/{slug}")] - public async Task BlogItem(string slug, bool showThanks) + public async Task BlogItem(string slug, bool showThanks, int? pageNumber, int? sortDirection, int? resultsPerPage) { - Blog blog = await _dataAccess.GetBlogItemAsync(slug); + var blog = await _dataAccess.GetBlogItemAsync(slug); - if (blog.Title == null) - { + if (blog?.Title == null) return RedirectPermanent("/the-alpha-flame/blog"); - } blog.ShowThanks = showThanks; - - // Generate JSON-LD blog.SchemaJsonLd = GenerateBlogSchemaJsonLd(blog); - if (blog.Template == "slideshow") - { - return View("SlideShowTemplate", blog); - } + // Construct return link + string pagePart = (pageNumber.HasValue && pageNumber > 1) ? $"/{pageNumber}" : ""; + string queryPart = $"?SortDirection={sortDirection ?? 1}&ResultsPerPage={resultsPerPage ?? 6}"; + ViewBag.BlogReturnLink = $"/the-alpha-flame/blog{pagePart}{queryPart}"; - return View("DefaultTemplate", blog); + return View(blog.Template == "slideshow" ? "SlideShowTemplate" : "DefaultTemplate", blog); } + [Route("characters")] public IActionResult Characters() { @@ -123,7 +105,6 @@ namespace CatherineLynwood.Controllers if (!visible) { - var to = new EmailAddress(blogComment.EmailAddress, blogComment.Name); var subject = "Thank you from Catherine Lynwood Web Site"; var plainTextContent = $"Dear {blogComment.Name},/r/nThank you for taking the time to comment on my blog post. "; var htmlContent = $"Dear {blogComment.Name},
" + @@ -135,7 +116,14 @@ namespace CatherineLynwood.Controllers $"

Catherine Lynwood
" + $"Author: The Alpha Flame
" + @$"Web: www.catherinelynwood.com

"; - await SendEmail.Execute(to, subject, plainTextContent, htmlContent); + + Contact contact = new Contact + { + Name = blogComment.Name, + EmailAddress = blogComment.EmailAddress + }; + + await _emailService.SendEmailAsync(subject, plainTextContent, htmlContent, contact); } showThanks = true; @@ -173,6 +161,39 @@ namespace CatherineLynwood.Controllers return View(); } + [HttpGet("giveaways/enter")] + public IActionResult Enter() + { + return View(); + } + + [HttpPost("giveaways/enter")] + public async Task Enter(Marketing marketing) + { + bool success = true; + + if (!string.IsNullOrEmpty(marketing.Email)) + { + success = await _dataAccess.EnterGiveawayAsync(marketing); + } + + if (success) + { + return RedirectToAction("ThankYou"); + } + else + { + return RedirectToAction("Enter"); + } + } + + [Route("giveaways/thankyou")] + public IActionResult Thankyou() + { + return View(); + } + + [Route("characters/maggie-grant")] public IActionResult Maggie() { @@ -239,7 +260,7 @@ namespace CatherineLynwood.Controllers string contentTopPlainText = StripHtml(blog.ContentTop); string contentBottomPlainText = StripHtml(blog.ContentBottom); - string blogUrl = $"https://www.catherinelynwood.com/{blog.BlogUrl}"; + string blogUrl = $"https://www.catherinelynwood.com/the-alpha-flame/blog/{blog.BlogUrl}"; // Build the schema object var schema = new Dictionary diff --git a/CatherineLynwood/Helpers/BlogUrlHelper.cs b/CatherineLynwood/Helpers/BlogUrlHelper.cs new file mode 100644 index 0000000..0684346 --- /dev/null +++ b/CatherineLynwood/Helpers/BlogUrlHelper.cs @@ -0,0 +1,34 @@ +using CatherineLynwood.Models; + +using System.Web; + +namespace CatherineLynwood.Helpers +{ + public static class BlogUrlHelper + { + public static string GetPageUrl(int pageNumber, BlogFilter filter) + { + if (pageNumber <= 1) + return "/the-alpha-flame/blog" + ToQueryString(filter, excludePage: true); + + return $"/the-alpha-flame/blog/{pageNumber}" + ToQueryString(filter, excludePage: true); + } + + public static string ToQueryString(BlogFilter filter, bool excludePage = false) + { + var query = HttpUtility.ParseQueryString(string.Empty); + + if (filter.SortDirection != 1) + query["SortDirection"] = filter.SortDirection.ToString(); + + if (filter.ResultsPerPage != 6) + query["ResultsPerPage"] = filter.ResultsPerPage.ToString(); + + if (!excludePage && filter.PageNumber > 1) + query["PageNumber"] = filter.PageNumber.ToString(); + + string queryString = query.ToString(); + return string.IsNullOrEmpty(queryString) ? "" : "?" + queryString; + } + } +} diff --git a/CatherineLynwood/Helpers/SendEmail.cs b/CatherineLynwood/Helpers/SendEmail.cs deleted file mode 100644 index 1edf6c6..0000000 --- a/CatherineLynwood/Helpers/SendEmail.cs +++ /dev/null @@ -1,17 +0,0 @@ -using SendGrid.Helpers.Mail; -using SendGrid; - -namespace CatherineLynwood.Helpers -{ - public class SendEmail - { - public static async Task Execute(EmailAddress to, string subject, string plainTextContent, string htmlContent) - { - var apiKey = "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"; - var client = new SendGridClient(apiKey); - var from = new EmailAddress("catherine@catherinelynwood.com", "Catherine Lynwood"); - var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent); - var response = await client.SendEmailAsync(msg); - } - } -} diff --git a/CatherineLynwood/Middleware/RedirectToWwwMiddleware.cs b/CatherineLynwood/Middleware/RedirectToWwwMiddleware.cs index d9479fc..f04225f 100644 --- a/CatherineLynwood/Middleware/RedirectToWwwMiddleware.cs +++ b/CatherineLynwood/Middleware/RedirectToWwwMiddleware.cs @@ -21,12 +21,14 @@ if (host.Equals("catherinelynwood.com", StringComparison.OrdinalIgnoreCase) || schema.Equals("http", StringComparison.OrdinalIgnoreCase)) { var newUrl = $"https://www.catherinelynwood.com{context.Request.Path}{context.Request.QueryString}"; - context.Response.Redirect(newUrl, permanent: true); - return; // End the middleware pipeline. + context.Response.StatusCode = StatusCodes.Status308PermanentRedirect; + context.Response.Headers["Location"] = newUrl; + return; } } + // Continue to the next middleware. await _next(context); } diff --git a/CatherineLynwood/Middleware/RefererValidationMiddleware.cs b/CatherineLynwood/Middleware/RefererValidationMiddleware.cs index 2d0ff03..0540cdf 100644 --- a/CatherineLynwood/Middleware/RefererValidationMiddleware.cs +++ b/CatherineLynwood/Middleware/RefererValidationMiddleware.cs @@ -25,8 +25,8 @@ if (string.IsNullOrEmpty(referer) || !AllowedReferers.Any(r => referer.StartsWith(r, StringComparison.OrdinalIgnoreCase))) { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsync("Invalid referer."); + context.Response.StatusCode = StatusCodes.Status451UnavailableForLegalReasons; + await context.Response.WriteAsync("Invalid request."); return; } } diff --git a/CatherineLynwood/Middleware/SpamAndSecurityMiddleware.cs b/CatherineLynwood/Middleware/SpamAndSecurityMiddleware.cs new file mode 100644 index 0000000..fb1a85e --- /dev/null +++ b/CatherineLynwood/Middleware/SpamAndSecurityMiddleware.cs @@ -0,0 +1,153 @@ +using Microsoft.Web.Administration; + +namespace CatherineLynwood.Middleware +{ + public class SpamAndSecurityMiddleware + { + private readonly RequestDelegate _next; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + // Known bad bots + private static readonly string[] BadBots = new[] + { + "AhrefsBot", "SemrushBot", "MJ12bot", "DotBot", "Baiduspider" + }; + + // Referer whitelist + private static readonly string[] AllowedReferers = new[] + { + "https://www.catherinelynwood.com", + "http://localhost", + "https://localhost" + }; + + public SpamAndSecurityMiddleware( + RequestDelegate next, + IWebHostEnvironment environment, + ILogger logger) + { + _next = next; + _environment = environment; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var request = context.Request; + var path = request.Path.Value ?? string.Empty; + var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + + // + // 1️⃣ Enforce canonical HTTPS + www + // + if (_environment.IsProduction()) + { + var host = request.Host.Host; + var scheme = request.Scheme; + if (host.Equals("catherinelynwood.com", StringComparison.OrdinalIgnoreCase) || scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) + { + var newUrl = $"https://www.catherinelynwood.com{request.Path}{request.QueryString}"; + _logger.LogInformation("Redirecting to canonical URL: {Url}", newUrl); + + context.Response.StatusCode = StatusCodes.Status308PermanentRedirect; + context.Response.Headers["Location"] = newUrl; + return; + } + } + + // + // 2️⃣ Block .php and .env probes, and optionally ban in IIS + // + if (path.EndsWith(".php", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".env", StringComparison.OrdinalIgnoreCase)) + { + //if (!_environment.IsDevelopment() && ipAddress != null) + //{ + // TryBlockIpInIIS(ipAddress); + //} + + _logger.LogWarning("Blocked .php/.env probe from {IP}: {Path}", ipAddress, path); + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Forbidden"); + return; + } + + // + // 3️⃣ Block known bad bots + // + var userAgent = request.Headers["User-Agent"].ToString(); + if (BadBots.Any(bot => userAgent.Contains(bot, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning("Blocked known bad bot: {UserAgent} from IP {IP}", userAgent, ipAddress); + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Forbidden"); + return; + } + + // + // 4️⃣ Referer validation on POST + // + if (request.Method == HttpMethods.Post && !request.Path.StartsWithSegments("/api")) + { + var referer = request.Headers["Referer"].ToString(); + + // ✅ New logic: + // Allow if referer is missing/empty + // Only block if referer is present but NOT in the whitelist + if (!string.IsNullOrEmpty(referer) && !AllowedReferers.Any(r => referer.StartsWith(r, StringComparison.Ordinal))) + { + //if (!_environment.IsDevelopment() && ipAddress != null) + //{ + // TryBlockIpInIIS(ipAddress); + //} + + _logger.LogWarning("Blocked POST with invalid referer: {Referer} from IP {IP}", referer, ipAddress); + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Invalid request."); + return; + } + } + + + + // + // 5️⃣ All checks passed — continue pipeline + // + await _next(context); + } + + // + // Helper to add IP restriction in IIS + // + private void TryBlockIpInIIS(string ipAddress) + { + try + { + using var serverManager = new ServerManager(); + var site = serverManager.Sites["CatherineLynwood"]; + var config = site.GetWebConfiguration(); + var ipSecurity = config.GetSection("system.webServer/security/ipSecurity"); + var collection = ipSecurity.GetCollection(); + + var exists = collection.FirstOrDefault(e => e.Attributes["ipAddress"]?.Value?.ToString() == ipAddress); + if (exists == null) + { + var addElement = collection.CreateElement("add"); + addElement.SetAttributeValue("ipAddress", ipAddress); + addElement.SetAttributeValue("allowed", false); + collection.Add(addElement); + + serverManager.CommitChanges(); + _logger.LogInformation("Blocked IP {IP} in IIS", ipAddress); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to block IP in IIS: {IP}", ipAddress); + } + } + } +} diff --git a/CatherineLynwood/Models/ARCReaderApplication.cs b/CatherineLynwood/Models/ARCReaderApplication.cs new file mode 100644 index 0000000..3a97c72 --- /dev/null +++ b/CatherineLynwood/Models/ARCReaderApplication.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +namespace CatherineLynwood.Models +{ + public class ARCReaderApplication + { + #region Public Properties + + [Required, StringLength(50)] + public string ApprovedSender { get; set; } + + [Required, StringLength(50)] + public string ContentFit { get; set; } + + [Required, StringLength(200)] + public string Email { get; set; } + + public string ExtraNotes { get; set; } + + [Required, StringLength(100)] + public string FullName { get; set; } + + [Key] + public int Id { get; set; } + + [Required, StringLength(200)] + public string KindleEmail { get; set; } + + [StringLength(500)] + public string Platforms { get; set; } + + [StringLength(100)] + public string PlatformsOther { get; set; } + + [StringLength(500)] + public string PreviewChapters { get; set; } + + [Required, StringLength(50)] + public string ReviewCommitment { get; set; } + + [StringLength(500)] + public string ReviewLink { get; set; } + + // nvarchar(max) — no length limit + + [Required] + public DateTime SubmittedAt { get; set; } = DateTime.Now; + + #endregion Public Properties + + // matches DEFAULT(getdate()) + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/ARCReaderList.cs b/CatherineLynwood/Models/ARCReaderList.cs new file mode 100644 index 0000000..8950380 --- /dev/null +++ b/CatherineLynwood/Models/ARCReaderList.cs @@ -0,0 +1,7 @@ +namespace CatherineLynwood.Models +{ + public class ARCReaderList + { + public List Applications { get; set; } = new List(); + } +} diff --git a/CatherineLynwood/Models/ArcReaderApplicationModel.cs b/CatherineLynwood/Models/ArcReaderApplicationModel.cs new file mode 100644 index 0000000..02c5263 --- /dev/null +++ b/CatherineLynwood/Models/ArcReaderApplicationModel.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; + +namespace CatherineLynwood.Models +{ + public class ArcReaderApplicationModel + { + [Required(ErrorMessage = "Please enter your full name.")] + [Display(Name = "Your Full Name", Prompt = "e.g. Catherine Lynwood")] + [StringLength(100, ErrorMessage = "Name must be under 100 characters.")] + public string FullName { get; set; } + + [Required(ErrorMessage = "Please enter your correspondence email.")] + [EmailAddress(ErrorMessage = "Please enter a valid email address.")] + [Display(Name = "Correspondence Email", Prompt = "e.g. your@email.com")] + [DataType(DataType.EmailAddress)] + public string Email { get; set; } + + [Required(ErrorMessage = "Please enter your Kindle email address.")] + [Display(Name = "Kindle Email Address", Prompt = "e.g. yourname")] + public string KindleEmail { get; set; } + + [Required(ErrorMessage = "Please select whether you've approved the sender.")] + [Display(Name = "Have you added my sender email to your Approved Senders list?")] + public string ApprovedSender { get; set; } + + [Display(Name = "Where do you plan to post your review?")] + public List Platforms { get; set; } + + [Display(Name = "Have you read / listened to any of the preview chapters on this website?")] + public List PreviewChapters { get; set; } + + [Display(Name = "Other Platform (optional)", Prompt = "e.g. YouTube, Substack")] + [StringLength(100)] + public string? PlatformsOther { get; set; } + + [Display(Name = "Link to a Past Review", Prompt = "e.g. https://www.goodreads.com/review/show/...")] + [Url(ErrorMessage = "Please enter a valid URL.")] + [DataType(DataType.Url)] + public string? ReviewLink { get; set; } + + [Required(ErrorMessage = "Please select whether this book suits your reading preferences.")] + [Display(Name = "Do you enjoy verostic fiction?")] + public string ContentFit { get; set; } + + [Required(ErrorMessage = "Please indicate if you'll be able to leave a review.")] + [Display(Name = "Will you aim to leave a review close to the launch date (21st August)?")] + public string ReviewCommitment { get; set; } + + [Display(Name = "Anything else you'd like to share", Prompt = "Tell me how you found the book, or what draws you to this story...")] + [DataType(DataType.MultilineText)] + [StringLength(1000, ErrorMessage = "Please keep this under 1000 characters.")] + public string? ExtraNotes { get; set; } + } +} diff --git a/CatherineLynwood/Models/Blog.cs b/CatherineLynwood/Models/Blog.cs index 12dcf4d..3ed2c50 100644 --- a/CatherineLynwood/Models/Blog.cs +++ b/CatherineLynwood/Models/Blog.cs @@ -1,26 +1,32 @@ -namespace CatherineLynwood.Models +using System.ComponentModel.DataAnnotations; + +namespace CatherineLynwood.Models { - public class Blog + public class Blog : BlogPost { #region Public Properties - public string AudioTeaserText { get; set; } + [Display(Name = "AI Summary")] + public string? AiSummary { get; set; } - public string AudioTeaserUrl { get; set; } + [Display(Name = "Audio teaser text")] + public string? AudioTeaserText { get; set; } - public string AudioTranscriptUrl { get; set; } + [Display(Name = "Audio teaser file", Prompt = "Load audio teaser file")] + public string? AudioTeaserUrl { get; set; } + + [Display(Name = "Audio transcript file", Prompt = "Load audio transcript file")] + public string? AudioTranscriptUrl { get; set; } public BlogComment BlogComment { get; set; } = new BlogComment(); - public int BlogID { get; set; } - public List BlogImages { get; set; } = new List(); - public string BlogUrl { get; set; } + [Display(Name = "Bottom HTML content")] + public string? ContentBottom { get; set; } - public string ContentBottom { get; set; } - - public string ContentTop { get; set; } + [Display(Name = "Top HTML content")] + public string ContentTop { get; set; } = string.Empty; public string DefaultWebpImage { @@ -32,35 +38,65 @@ } } - public string ImageAlt { get; set; } + [Display(Name = "Is draft")] + public bool Draft { get; set; } = true; - public string ImageDescription { get; set; } + [Display(Name = "Image ALT text")] + public string? ImageAlt { get; set; } - public bool ImageFirst { get; set; } + [Display(Name = "Image caption")] + public string? ImageDescription { get; set; } - public string ImageUrl { get; set; } + [Display(Name = "Image left")] + public bool ImageFirst { get; set; } = false; - public string IndexText { get; set; } + [Display(Name = "Suggested AI image prompt")] + public string? ImagePrompt { get; set; } - public int Likes { get; set; } + [Display(Name = "Image file", Prompt = "Load image file")] + public string? ImageUrl { get; set; } + [Display(Name = "Has been indexed")] + public bool Indexed { get; set; } = false; + + [Display(Name = "Blog index text")] + public string IndexText { get; set; } = string.Empty; + + [Display(Name = "Likes")] + public int Likes { get; set; } = 0; + + // Default in DB + // Nullable with default + [Display(Name = "Posted by")] public string PostedBy { get; set; } public string PostedImage { get; set; } + [Display(Name = "Publish date")] public DateTime PublishDate { get; set; } + [Display(Name = "Posted by")] + public int ResponderID { get; set; } = 1; // Default constraint in DB + public string SchemaJsonLd { get; set; } public bool ShowThanks { get; set; } - public string SubTitle { get; set; } + // Nullable + [Display(Name = "Sub title")] + public string? SubTitle { get; set; } - public string Template { get; set; } + [Display(Name = "Template name")] + public string Template { get; set; } = "default"; - public string Title { get; set; } + [Display(Name = "Title")] + public string Title { get; set; } = string.Empty; - public string VideoUrl { get; set; } + // Default constraint in DB + [Display(Name = "Video file", Prompt = "Load video file")] + public string? VideoUrl { get; set; } + + // Default in DB #endregion Public Properties } diff --git a/CatherineLynwood/Models/BlogAdminIndex.cs b/CatherineLynwood/Models/BlogAdminIndex.cs new file mode 100644 index 0000000..232b008 --- /dev/null +++ b/CatherineLynwood/Models/BlogAdminIndex.cs @@ -0,0 +1,7 @@ +namespace CatherineLynwood.Models +{ + public class BlogAdminIndex + { + public List BlogItems { get; set; } + } +} diff --git a/CatherineLynwood/Models/BlogAdminIndexItem.cs b/CatherineLynwood/Models/BlogAdminIndexItem.cs new file mode 100644 index 0000000..4c16585 --- /dev/null +++ b/CatherineLynwood/Models/BlogAdminIndexItem.cs @@ -0,0 +1,23 @@ +namespace CatherineLynwood.Models +{ + public class BlogAdminIndexItem + { + #region Public Properties + + public int BlogID { get; set; } + + public string BlogUrl { get; set; } + + public bool Draft { get; set; } + + public string IndexText { get; set; } + + public DateTime PublishDate { get; set; } + + public string SubTitle { get; set; } + + public string Title { get; set; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/BlogEditPage.cs b/CatherineLynwood/Models/BlogEditPage.cs new file mode 100644 index 0000000..391e23c --- /dev/null +++ b/CatherineLynwood/Models/BlogEditPage.cs @@ -0,0 +1,13 @@ +namespace CatherineLynwood.Models +{ + public class BlogEditPage : Blog + { + public IFormFile ImageUpload { get; set; } + + public IFormFile AudioTranscriptUpload { get; set; } + + public IFormFile AudioTeaserUpload { get; set; } + + public IFormFile VideoUpload { get; set; } + } +} diff --git a/CatherineLynwood/Models/BlogFilter.cs b/CatherineLynwood/Models/BlogFilter.cs index 5c4908a..d47dd07 100644 --- a/CatherineLynwood/Models/BlogFilter.cs +++ b/CatherineLynwood/Models/BlogFilter.cs @@ -8,8 +8,6 @@ public int PageNumber { get; set; } = 1; - public List Categories { get; set; } - public int TotalPages { get; set; } public int PreviousTotalPages { get; set; } diff --git a/CatherineLynwood/Models/BlogIndex.cs b/CatherineLynwood/Models/BlogIndex.cs index 6390092..b05b5e8 100644 --- a/CatherineLynwood/Models/BlogIndex.cs +++ b/CatherineLynwood/Models/BlogIndex.cs @@ -4,15 +4,13 @@ { #region Public Properties - public List BlogCategories { get; set; } = new List(); - public BlogFilter BlogFilter { get; set; } public List Blogs { get; set; } = new List(); - public bool IsMobile { get; set; } + public Blog NextBlog { get; set; } = new Blog(); - public bool ShowAdvanced { get; set; } + public bool IsMobile { get; set; } #endregion Public Properties } diff --git a/CatherineLynwood/Models/BlogPost.cs b/CatherineLynwood/Models/BlogPost.cs new file mode 100644 index 0000000..13871d1 --- /dev/null +++ b/CatherineLynwood/Models/BlogPost.cs @@ -0,0 +1,13 @@ +namespace CatherineLynwood.Models +{ + public class BlogPost + { + #region Public Properties + + public int BlogID { get; set; } + + public string BlogUrl { get; set; } = string.Empty; + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/BlogPostRequest.cs b/CatherineLynwood/Models/BlogPostRequest.cs new file mode 100644 index 0000000..ec7ee1a --- /dev/null +++ b/CatherineLynwood/Models/BlogPostRequest.cs @@ -0,0 +1,35 @@ +namespace CatherineLynwood.Models +{ + public class BlogPostRequest + { + #region Public Properties + + public string AiSummary { get; set; } + + public string BlogUrl { get; set; } + + public string ContentBottom { get; set; } + + public string ContentTop { get; set; } + + public string ImageAlt { get; set; } + + public string ImageDescription { get; set; } + + public string ImagePosition { get; set; } + + public string ImagePrompt { get; set; } + + public string IndexText { get; set; } + + public DateTime PublishDate { get; set; } + + public int ResponderID { get; set; } + + public string SubTitle { get; set; } + + public string Title { get; set; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/BlogSummaryResponse.cs b/CatherineLynwood/Models/BlogSummaryResponse.cs new file mode 100644 index 0000000..6e66df1 --- /dev/null +++ b/CatherineLynwood/Models/BlogSummaryResponse.cs @@ -0,0 +1,23 @@ +namespace CatherineLynwood.Models +{ + public class BlogSummaryResponse + { + #region Public Properties + + public string AiSummary { get; set; } + + public string BlogUrl { get; set; } + + public bool Draft { get; set; } + + public string IndexText { get; set; } + + public DateTime PublishDate { get; set; } + + public string SubTitle { get; set; } + + public string Title { get; set; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/Reviews.cs b/CatherineLynwood/Models/Reviews.cs new file mode 100644 index 0000000..f8009ff --- /dev/null +++ b/CatherineLynwood/Models/Reviews.cs @@ -0,0 +1,32 @@ +namespace CatherineLynwood.Models +{ + public class Review + { + #region Public Properties + + public string AuthorName { get; set; } + + public DateTime DatePublished { get; set; } + + public double RatingValue { get; set; } + + public string ReviewBody { get; set; } + + public string SiteName { get; set; } + + public string URL { get; set; } + + #endregion Public Properties + } + + public class Reviews + { + #region Public Properties + + public List Items { get; set; } = new List(); + + public string SchemaJsonLd { get; set; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Program.cs b/CatherineLynwood/Program.cs index aa75b0a..d9a7ec4 100644 --- a/CatherineLynwood/Program.cs +++ b/CatherineLynwood/Program.cs @@ -25,6 +25,8 @@ namespace CatherineLynwood // Add IHttpContextAccessor for accessing HTTP context in tag helpers builder.Services.AddHttpContextAccessor(); + builder.Services.AddHostedService(); + builder.Services.AddHttpClient(); // ✅ Add session services (in-memory only) @@ -35,6 +37,20 @@ namespace CatherineLynwood options.Cookie.IsEssential = true; }); + // ✅ Add authentication with cookie settings + builder.Services.AddAuthentication("MyCookieAuth") + .AddCookie("MyCookieAuth", options => + { + options.LoginPath = "/Account/Login"; + options.LogoutPath = "/Account/Logout"; + options.Cookie.Name = "MyAuthCookie"; + options.Cookie.HttpOnly = true; + options.ExpireTimeSpan = TimeSpan.FromHours(12); + options.SlidingExpiration = true; + }); + + builder.Services.AddAuthorization(); + // Add RedirectsStore as singleton builder.Services.AddSingleton(sp => { @@ -44,13 +60,12 @@ namespace CatherineLynwood // ✅ Register the book access code service builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); - - // Add response compression services builder.Services.AddResponseCompression(options => { @@ -79,6 +94,10 @@ namespace CatherineLynwood .AddXmlMinification() .AddXhtmlMinification(); + builder.WebHost.ConfigureKestrel(options => + { + options.Limits.MaxRequestBodySize = 40 * 1024 * 1024; // 40MB + }); var app = builder.Build(); @@ -89,22 +108,19 @@ namespace CatherineLynwood app.UseHsts(); // Adds the HSTS (HTTP Strict Transport Security) header } - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); + app.UseMiddleware(); app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); - app.UseHttpsRedirection(); app.UseResponseCompression(); app.UseStaticFiles(); app.UseWebMarkupMin(); app.UseRouting(); app.UseSession(); + + // ✅ Authentication must come before Authorization + app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute( diff --git a/CatherineLynwood/Services/DataAccess.cs b/CatherineLynwood/Services/DataAccess.cs index f8886d2..69fd4bf 100644 --- a/CatherineLynwood/Services/DataAccess.cs +++ b/CatherineLynwood/Services/DataAccess.cs @@ -1,10 +1,15 @@ -using System.ComponentModel; -using System.Data; - -using CatherineLynwood.Models; +using CatherineLynwood.Models; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Data.SqlClient; +using SixLabors.ImageSharp.Web.Commands.Converters; + +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Data; + namespace CatherineLynwood.Services { public class DataAccess @@ -22,6 +27,181 @@ namespace CatherineLynwood.Services _connectionString = connectionString; } + public async Task AddBlogCommentAsync(BlogComment blogComment) + { + bool visible = false; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveBlogComment"; + cmd.Parameters.AddWithValue("@BlogID", blogComment.BlogID); + cmd.Parameters.AddWithValue("@Comment", blogComment.Comment); + cmd.Parameters.AddWithValue("@EmailAddress", blogComment.EmailAddress); + cmd.Parameters.AddWithValue("@Name", blogComment.Name); + cmd.Parameters.AddWithValue("@Sex", blogComment.Sex); + cmd.Parameters.AddWithValue("@Age", blogComment.Age); + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + visible = GetDataBool(rdr, "Visible"); + } + } + } + catch (Exception ex) + { + } + } + } + + return visible; + } + + public async Task Addhoneypot(DateTime dateTime, string ip, string country, string userAgent, string referer) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveHoneypot"; + cmd.Parameters.AddWithValue("@DateTime", dateTime); + cmd.Parameters.AddWithValue("@IP", ip); + cmd.Parameters.AddWithValue("@Country", country); + cmd.Parameters.AddWithValue("@UserAgent", userAgent); + cmd.Parameters.AddWithValue("@Referer", referer); + + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + success = false; + } + } + } + + return success; + } + + public async Task AddMarketingAsync(Marketing marketing) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveMarketingOptions"; + cmd.Parameters.AddWithValue("@Name", marketing.Name); + cmd.Parameters.AddWithValue("@Email", marketing.Email); + cmd.Parameters.AddWithValue("@OptExtras", marketing.optExtras); + cmd.Parameters.AddWithValue("@OptFutureBooks", marketing.optFutureBooks); + cmd.Parameters.AddWithValue("@OptNews", marketing.optNews); + cmd.Parameters.AddWithValue("@Age", marketing.Age); + cmd.Parameters.AddWithValue("@Sex", marketing.Sex); + + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + success = false; + } + } + } + + return success; + } + + public async Task AddQuestionAsync(Question question) + { + bool visible = false; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveQuestion"; + cmd.Parameters.AddWithValue("@Question", question.Text); + cmd.Parameters.AddWithValue("@EmailAddress", question.EmailAddress); + cmd.Parameters.AddWithValue("@Name", question.Name); + cmd.Parameters.AddWithValue("@Sex", question.Sex); + cmd.Parameters.AddWithValue("@Age", question.Age); + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + visible = GetDataBool(rdr, "Visible"); + } + } + } + catch (Exception ex) + { + } + } + } + + return visible; + } + + public async Task EnterGiveawayAsync(Marketing marketing) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveGiveawayEntry"; + cmd.Parameters.AddWithValue("@Name", marketing.Name); + cmd.Parameters.AddWithValue("@Email", marketing.Email); + cmd.Parameters.AddWithValue("@OptExtras", false); + cmd.Parameters.AddWithValue("@OptFutureBooks", true); + cmd.Parameters.AddWithValue("@OptNews", true); + cmd.Parameters.AddWithValue("@Age", marketing.Age); + cmd.Parameters.AddWithValue("@Sex", marketing.Sex); + + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + success = false; + } + } + } + + return success; + } + public List GetAccessCodes() { List accessCodes = new List(); @@ -54,7 +234,6 @@ namespace CatherineLynwood.Services } catch (Exception ex) { - } } } @@ -62,9 +241,9 @@ namespace CatherineLynwood.Services return accessCodes; } - public async Task GetQuestionsAsync() + public async Task> GetAllBlogsAsync() { - Questions questions = new Questions(); + List list = new List(); using (SqlConnection conn = new SqlConnection(_connectionString)) { @@ -75,37 +254,37 @@ namespace CatherineLynwood.Services await conn.OpenAsync(); cmd.Connection = conn; cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "GetQuestions"; + cmd.CommandText = "GetAllBlogs"; using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) { while (await rdr.ReadAsync()) { - questions.AskedQuestions.Add(new Question + list.Add(new BlogSummaryResponse { - Age = GetDataString(rdr, "Age"), - EmailAddress = GetDataString(rdr, "EmailAddress"), - Name = GetDataString(rdr, "Name"), - Text = GetDataString(rdr, "Question"), - QuestionDate = GetDataDate(rdr, "QuestionDate"), - Sex = GetDataString(rdr, "Sex") + AiSummary = GetDataString(rdr, "AiSummary"), + BlogUrl = GetDataString(rdr, "BlogUrl"), + Draft = GetDataBool(rdr, "Draft"), + IndexText = GetDataString(rdr, "IndexText"), + PublishDate = GetDataDate(rdr, "PublishDate"), + SubTitle = GetDataString(rdr, "SubTitle"), + Title = GetDataString(rdr, "Title"), }); } } } catch (Exception ex) { - } } } - return questions; + return list; } - public async Task GetBlogsAsync(string categoryIDs) + public async Task GetAllARCReadersAsync() { - BlogIndex blogIndex = new BlogIndex(); + ARCReaderList arcReaderList = new ARCReaderList(); using (SqlConnection conn = new SqlConnection(_connectionString)) { @@ -116,116 +295,79 @@ namespace CatherineLynwood.Services await conn.OpenAsync(); cmd.Connection = conn; cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "GetBlog"; - cmd.Parameters.AddWithValue("@CategoryIDs", categoryIDs); + cmd.CommandText = "GetAllARCReaders"; using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) { - while (await rdr.ReadAsync()) - { - blogIndex.BlogCategories.Add(new BlogCategory - { - CategoryID = GetDataInt(rdr, "CategoryID"), - Category = GetDataString(rdr, "Category"), - Selected = GetDataBool(rdr, "Selected") - }); - } - - await rdr.NextResultAsync(); - while (await rdr.ReadAsync()) { - blogIndex.Blogs.Add(new Blog + arcReaderList.Applications.Add(new ARCReaderApplication + { + ApprovedSender = GetDataString(rdr, "ApprovedSender"), + ContentFit = GetDataString(rdr, "ContentFit"), + Email = GetDataString(rdr, "Email"), + ExtraNotes = GetDataString(rdr, "ExtraNotes"), + FullName = GetDataString(rdr, "FullName"), + KindleEmail = GetDataString(rdr, "KindleEmail"), + Platforms = GetDataString(rdr, "Platforms"), + PlatformsOther = GetDataString(rdr, "PlatformsOther"), + PreviewChapters = GetDataString(rdr, "PreviewChapters"), + ReviewCommitment = GetDataString(rdr, "ReviewCommitment"), + ReviewLink = GetDataString(rdr, "ReviewLink"), + SubmittedAt = GetDataDate(rdr, "SubmittedAt"), + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return arcReaderList; + } + + public async Task GetBlogAdminIndexAsync() + { + BlogAdminIndex blogAdminIndex = new BlogAdminIndex(); + blogAdminIndex.BlogItems = new List(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetBlogIndex"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + blogAdminIndex.BlogItems.Add(new BlogAdminIndexItem { BlogID = GetDataInt(rdr, "BlogID"), BlogUrl = GetDataString(rdr, "BlogUrl"), - Title = GetDataString(rdr, "Title"), - SubTitle = GetDataString(rdr, "SubTitle"), - PublishDate = GetDataDate(rdr, "PublishDate"), - Likes = GetDataInt(rdr, "Likes"), + Draft = GetDataBool(rdr, "Draft"), IndexText = GetDataString(rdr, "IndexText"), - ImageUrl = GetDataString(rdr, "ImageUrl"), - ImageAlt = GetDataString(rdr, "ImageAlt"), - ImageDescription = GetDataString(rdr, "ImageDescription") + PublishDate = GetDataDate(rdr, "PublishDate"), + SubTitle = GetDataString(rdr, "SubTitle"), + Title = GetDataString(rdr, "Title"), }); } } } catch (Exception ex) { - } } } - return blogIndex; - } - - public async Task GetBlogItemAsync(string blogUrl) - { - Blog blog = new Blog(); - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "GetBlogItem"; - cmd.Parameters.AddWithValue("@BlogUrl", blogUrl); - - using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) - { - while (await rdr.ReadAsync()) - { - blog.AudioTeaserText = GetDataString(rdr, "AudioTeaserText"); - blog.AudioTeaserUrl = GetDataString(rdr, "AudioTeaserUrl"); - blog.AudioTranscriptUrl = GetDataString(rdr, "AudioTranscriptUrl"); - blog.BlogID = GetDataInt(rdr, "BlogID"); - blog.BlogUrl = GetDataString(rdr, "BlogUrl"); - blog.ContentBottom = GetDataString(rdr, "ContentBottom"); - blog.ContentTop = GetDataString(rdr, "ContentTop"); - blog.ImageAlt = GetDataString(rdr, "ImageAlt"); - blog.ImageDescription = GetDataString(rdr, "ImageDescription"); - blog.ImageFirst = GetDataBool(rdr, "ImageFirst"); - blog.ImageUrl = GetDataString(rdr, "ImageUrl"); - blog.IndexText = GetDataString(rdr, "IndexText"); - blog.Likes = GetDataInt(rdr, "Likes"); - blog.PublishDate = GetDataDate(rdr, "PublishDate"); - blog.SubTitle = GetDataString(rdr, "SubTitle"); - blog.Template = GetDataString(rdr, "Template"); - blog.Title = GetDataString(rdr, "Title"); - blog.VideoUrl = GetDataString(rdr, "VideoUrl"); - blog.PostedBy = GetDataString(rdr, "PostedBy"); - blog.PostedImage = GetDataString(rdr, "PostedImage"); - } - - await rdr.NextResultAsync(); - - while (await rdr.ReadAsync()) - { - blog.BlogImages.Add(new BlogImage - { - ImageID = GetDataInt(rdr, "ImageID"), - BlogID = GetDataInt(rdr, "BlogID"), - ImageUrl = GetDataString(rdr, "ImageUrl"), - ImageCaption = GetDataString(rdr, "ImageCaption"), - ImageText = GetDataString(rdr, "ImageText") - }); - } - } - } - catch (Exception ex) - { - - } - } - } - - return blog; + return blogAdminIndex; } public async Task GetBlogCommentsAsync(int blogID) @@ -272,7 +414,6 @@ namespace CatherineLynwood.Services } catch (Exception ex) { - } } } @@ -280,9 +421,9 @@ namespace CatherineLynwood.Services return blogComments; } - public async Task AddBlogCommentAsync(BlogComment blogComment) + public async Task GetBlogItemAsync(string blogUrl) { - bool visible = false; + Blog blog = new Blog(); using (SqlConnection conn = new SqlConnection(_connectionString)) { @@ -293,33 +434,273 @@ namespace CatherineLynwood.Services await conn.OpenAsync(); cmd.Connection = conn; cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveBlogComment"; - cmd.Parameters.AddWithValue("@BlogID", blogComment.BlogID); - cmd.Parameters.AddWithValue("@Comment", blogComment.Comment); - cmd.Parameters.AddWithValue("@EmailAddress", blogComment.EmailAddress); - cmd.Parameters.AddWithValue("@Name", blogComment.Name); - cmd.Parameters.AddWithValue("@Sex", blogComment.Sex); - cmd.Parameters.AddWithValue("@Age", blogComment.Age); + cmd.CommandText = "GetBlogItem"; + cmd.Parameters.AddWithValue("@BlogUrl", blogUrl); using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) { while (await rdr.ReadAsync()) { - visible = GetDataBool(rdr, "Visible"); + blog.AiSummary = GetDataString(rdr, "AiSummary"); + blog.AudioTeaserText = GetDataString(rdr, "AudioTeaserText"); + blog.AudioTeaserUrl = GetDataString(rdr, "AudioTeaserUrl"); + blog.AudioTranscriptUrl = GetDataString(rdr, "AudioTranscriptUrl"); + blog.BlogID = GetDataInt(rdr, "BlogID"); + blog.BlogUrl = GetDataString(rdr, "BlogUrl"); + blog.ContentBottom = GetDataString(rdr, "ContentBottom"); + blog.ContentTop = GetDataString(rdr, "ContentTop"); + blog.Draft = GetDataBool(rdr, "Draft"); + blog.ImageAlt = GetDataString(rdr, "ImageAlt"); + blog.ImageDescription = GetDataString(rdr, "ImageDescription"); + blog.ImageFirst = GetDataBool(rdr, "ImageFirst"); + blog.ImagePrompt = GetDataString(rdr, "ImagePrompt"); + blog.ImageUrl = GetDataString(rdr, "ImageUrl"); + blog.Indexed = GetDataBool(rdr, "Indexed"); + blog.IndexText = GetDataString(rdr, "IndexText"); + blog.Likes = GetDataInt(rdr, "Likes"); + blog.PostedBy = GetDataString(rdr, "PostedBy"); + blog.PostedImage = GetDataString(rdr, "PostedImage"); + blog.PublishDate = GetDataDate(rdr, "PublishDate"); + blog.SubTitle = GetDataString(rdr, "SubTitle"); + blog.Template = GetDataString(rdr, "Template"); + blog.Title = GetDataString(rdr, "Title"); + blog.VideoUrl = GetDataString(rdr, "VideoUrl"); + blog.ResponderID = GetDataInt(rdr, "ResponderID"); + } + + await rdr.NextResultAsync(); + + while (await rdr.ReadAsync()) + { + blog.BlogImages.Add(new BlogImage + { + ImageID = GetDataInt(rdr, "ImageID"), + BlogID = GetDataInt(rdr, "BlogID"), + ImageUrl = GetDataString(rdr, "ImageUrl"), + ImageCaption = GetDataString(rdr, "ImageCaption"), + ImageText = GetDataString(rdr, "ImageText") + }); } } } catch (Exception ex) { - } } } - return visible; + return blog; } - public async Task AddMarketingAsync(Marketing marketing) + public async Task GetBlogsAsync() + { + BlogIndex blogIndex = new BlogIndex(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetBlog"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + blogIndex.Blogs.Add(new Blog + { + BlogID = GetDataInt(rdr, "BlogID"), + BlogUrl = GetDataString(rdr, "BlogUrl"), + Title = GetDataString(rdr, "Title"), + SubTitle = GetDataString(rdr, "SubTitle"), + PublishDate = GetDataDate(rdr, "PublishDate"), + Likes = GetDataInt(rdr, "Likes"), + IndexText = GetDataString(rdr, "IndexText"), + ImageUrl = GetDataString(rdr, "ImageUrl"), + ImageAlt = GetDataString(rdr, "ImageAlt"), + ImageDescription = GetDataString(rdr, "ImageDescription") + }); + } + + await rdr.NextResultAsync(); + + while (await rdr.ReadAsync()) + { + blogIndex.NextBlog.PublishDate = GetDataDate(rdr, "PublishDate"); + blogIndex.NextBlog.Title = GetDataString(rdr, "Title"); + blogIndex.NextBlog.SubTitle = GetDataString(rdr, "SubTitle"); + blogIndex.NextBlog.IndexText = GetDataString(rdr, "IndexText"); + blogIndex.NextBlog.ImageAlt = GetDataString(rdr, "ImageAlt"); + blogIndex.NextBlog.ImageUrl = GetDataString(rdr, "ImageUrl"); + } + } + } + catch (Exception ex) + { + } + } + } + + return blogIndex; + } + + public async Task> GetDueBlogPostsAsync() + { + List blogPosts = new List(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + conn.Open(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetUnIndexedBlogs"; + + using (SqlDataReader rdr = cmd.ExecuteReader()) + { + while (rdr.Read()) + { + blogPosts.Add(new BlogPost + { + BlogID = GetDataInt(rdr, "BlogID"), + BlogUrl = GetDataString(rdr, "BlogUrl") + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return blogPosts; + } + + public async Task GetQuestionsAsync() + { + Questions questions = new Questions(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetQuestions"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + questions.AskedQuestions.Add(new Question + { + Age = GetDataString(rdr, "Age"), + EmailAddress = GetDataString(rdr, "EmailAddress"), + Name = GetDataString(rdr, "Name"), + Text = GetDataString(rdr, "Question"), + QuestionDate = GetDataDate(rdr, "QuestionDate"), + Sex = GetDataString(rdr, "Sex") + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return questions; + } + + public async Task> GetResponderList() + { + List selectListItems = new List(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetResponderList"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + selectListItems.Add(new SelectListItem + { + Value = GetDataInt(rdr, "ResponderID").ToString(), + Text = GetDataString(rdr, "Name"), + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return selectListItems; + } + + public async Task GetReviewsAsync() + { + Reviews reviews = new Reviews(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetReviews"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + reviews.Items.Add(new Review + { + AuthorName = GetDataString(rdr, "AuthorName"), + DatePublished = GetDataDate(rdr, "DatePublished"), + RatingValue = GetDataDouble(rdr, "RatingValue"), + ReviewBody = GetDataString(rdr, "ReviewBody"), + SiteName = GetDataString(rdr, "SiteName"), + URL = GetDataString(rdr, "URL") + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return reviews; + } + + public async Task MarkAsNotifiedAsync(int blogID) { bool success = true; @@ -332,15 +713,8 @@ namespace CatherineLynwood.Services await conn.OpenAsync(); cmd.Connection = conn; cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveMarketingOptions"; - cmd.Parameters.AddWithValue("@Name", marketing.Name); - cmd.Parameters.AddWithValue("@Email", marketing.Email); - cmd.Parameters.AddWithValue("@OptExtras", marketing.optExtras); - cmd.Parameters.AddWithValue("@OptFutureBooks", marketing.optFutureBooks); - cmd.Parameters.AddWithValue("@OptNews", marketing.optNews); - cmd.Parameters.AddWithValue("@Age", marketing.Age); - cmd.Parameters.AddWithValue("@Sex", marketing.Sex); - + cmd.CommandText = "MarkBlogAsIndexed"; + cmd.Parameters.AddWithValue("@BlogID", blogID); await cmd.ExecuteNonQueryAsync(); } catch (Exception ex) @@ -353,7 +727,7 @@ namespace CatherineLynwood.Services return success; } - public async Task Addhoneypot(DateTime dateTime, string ip, string country, string userAgent, string referer) + public async Task SaveBlogToDatabase(BlogPostRequest blog) { bool success = true; @@ -366,18 +740,24 @@ namespace CatherineLynwood.Services await conn.OpenAsync(); cmd.Connection = conn; cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveHoneypot"; - cmd.Parameters.AddWithValue("@DateTime", dateTime); - cmd.Parameters.AddWithValue("@IP", ip); - cmd.Parameters.AddWithValue("@Country", country); - cmd.Parameters.AddWithValue("@UserAgent", userAgent); - cmd.Parameters.AddWithValue("@Referer", referer); - + cmd.CommandText = "SaveBlog"; + cmd.Parameters.AddWithValue("@AiSummary", blog.AiSummary); + cmd.Parameters.AddWithValue("@BlogUrl", blog.BlogUrl); + cmd.Parameters.AddWithValue("@ContentBottom", blog.ContentBottom); + cmd.Parameters.AddWithValue("@ContentTop", blog.ContentTop); + cmd.Parameters.AddWithValue("@ImageAlt", blog.ImageAlt); + cmd.Parameters.AddWithValue("@ImageDescription", blog.ImageDescription); + cmd.Parameters.AddWithValue("@ImageFirst", blog.ImagePosition.ToLower() == "left"); + cmd.Parameters.AddWithValue("@ImagePrompt", blog.ImagePrompt); + cmd.Parameters.AddWithValue("@IndexText", blog.IndexText); + cmd.Parameters.AddWithValue("@PublishDate", blog.PublishDate); + cmd.Parameters.AddWithValue("@ResponderID", blog.ResponderID); + cmd.Parameters.AddWithValue("@SubTitle", blog.SubTitle); + cmd.Parameters.AddWithValue("@Title", blog.Title); await cmd.ExecuteNonQueryAsync(); } catch (Exception ex) { - success = false; } } } @@ -385,9 +765,9 @@ namespace CatherineLynwood.Services return success; } - public async Task AddQuestionAsync(Question question) + public async Task SaveContact(Contact contact) { - bool visible = false; + bool success = true; using (SqlConnection conn = new SqlConnection(_connectionString)) { @@ -398,29 +778,119 @@ namespace CatherineLynwood.Services await conn.OpenAsync(); cmd.Connection = conn; cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveQuestion"; - cmd.Parameters.AddWithValue("@Question", question.Text); - cmd.Parameters.AddWithValue("@EmailAddress", question.EmailAddress); - cmd.Parameters.AddWithValue("@Name", question.Name); - cmd.Parameters.AddWithValue("@Sex", question.Sex); - cmd.Parameters.AddWithValue("@Age", question.Age); - - using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) - { - while (await rdr.ReadAsync()) - { - visible = GetDataBool(rdr, "Visible"); - } - } + cmd.CommandText = "SaveContact"; + cmd.Parameters.AddWithValue("@Name", contact.Name); + cmd.Parameters.AddWithValue("@EmailAddress", contact.EmailAddress); + await cmd.ExecuteNonQueryAsync(); } catch (Exception ex) { - } } } - return visible; + return success; + } + + public async Task SaveARCReaderApplication(ArcReaderApplicationModel arcReaderApplication) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveARCReader"; + cmd.Parameters.AddWithValue("@FullName", arcReaderApplication.FullName); + cmd.Parameters.AddWithValue("@Email", arcReaderApplication.Email); + cmd.Parameters.AddWithValue("@KindleEmail", arcReaderApplication.KindleEmail); + cmd.Parameters.AddWithValue("@ApprovedSender", arcReaderApplication.ApprovedSender ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@Platforms", arcReaderApplication.Platforms != null ? string.Join(",", arcReaderApplication.Platforms) : (object)DBNull.Value); + cmd.Parameters.AddWithValue("@PreviewChapters", arcReaderApplication.PreviewChapters != null ? string.Join(",", arcReaderApplication.PreviewChapters) : (object)DBNull.Value); + cmd.Parameters.AddWithValue("@PlatformsOther", string.IsNullOrWhiteSpace(arcReaderApplication.PlatformsOther) ? (object)DBNull.Value : arcReaderApplication.PlatformsOther); + cmd.Parameters.AddWithValue("@ReviewLink", string.IsNullOrWhiteSpace(arcReaderApplication.ReviewLink) ? (object)DBNull.Value : arcReaderApplication.ReviewLink); + cmd.Parameters.AddWithValue("@ContentFit", arcReaderApplication.ContentFit ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@ReviewCommitment", arcReaderApplication.ReviewCommitment ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@ExtraNotes", string.IsNullOrWhiteSpace(arcReaderApplication.ExtraNotes) ? (object)DBNull.Value : arcReaderApplication.ExtraNotes); + + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + // Optionally log or rethrow + success = false; + } + } + } + + return success; + } + + + public async Task UpdateBlogAsync(Blog blog) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + using (SqlCommand cmd = new SqlCommand("UpdateBlog", conn)) + { + cmd.CommandType = CommandType.StoredProcedure; + + // Required ID for WHERE clause + cmd.Parameters.AddWithValue("@blogID", blog.BlogID); + + // Basic text fields + cmd.Parameters.AddWithValue("@blogUrl", blog.BlogUrl ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@publishDate", blog.PublishDate); + cmd.Parameters.AddWithValue("@responderID", blog.ResponderID); + cmd.Parameters.AddWithValue("@title", blog.Title ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@subTitle", (object?)blog.SubTitle ?? DBNull.Value); + cmd.Parameters.AddWithValue("@indexText", blog.IndexText ?? (object)DBNull.Value); + + // Audio + cmd.Parameters.AddWithValue("@audioTranscriptUrl", (object?)blog.AudioTranscriptUrl ?? DBNull.Value); + cmd.Parameters.AddWithValue("@audioTeaserUrl", (object?)blog.AudioTeaserUrl ?? DBNull.Value); + cmd.Parameters.AddWithValue("@audioTeaserText", (object?)blog.AudioTeaserText ?? DBNull.Value); + + // Numbers / bools + cmd.Parameters.AddWithValue("@likes", blog.Likes); + cmd.Parameters.AddWithValue("@imageFirst", blog.ImageFirst); + cmd.Parameters.AddWithValue("@indexed", blog.Indexed); + cmd.Parameters.AddWithValue("@draft", blog.Draft); + + // Content + cmd.Parameters.AddWithValue("@contentTop", blog.ContentTop ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@contentBottom", (object?)blog.ContentBottom ?? DBNull.Value); + + // Media + cmd.Parameters.AddWithValue("@videoUrl", (object?)blog.VideoUrl ?? DBNull.Value); + cmd.Parameters.AddWithValue("@imageUrl", (object?)blog.ImageUrl ?? DBNull.Value); + cmd.Parameters.AddWithValue("@imageAlt", (object?)blog.ImageAlt ?? DBNull.Value); + cmd.Parameters.AddWithValue("@imageDescription", (object?)blog.ImageDescription ?? DBNull.Value); + + // Template / AI + cmd.Parameters.AddWithValue("@template", blog.Template ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@aiSummary", (object?)blog.AiSummary ?? DBNull.Value); + cmd.Parameters.AddWithValue("@imagePrompt", (object?)blog.ImagePrompt ?? DBNull.Value); + + try + { + await conn.OpenAsync(); + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + success = false; + // Optionally log ex + } + } + + return success; } #endregion Public Constructors @@ -483,6 +953,13 @@ namespace CatherineLynwood.Services return rdr.IsDBNull(colIndex) ? 0 : rdr.GetDecimal(colIndex); } + protected Double GetDataDouble(SqlDataReader rdr, string field) + { + int colIndex = rdr.GetOrdinal(field); + + return rdr.IsDBNull(colIndex) ? 0 : rdr.GetDouble(colIndex); + } + protected Int32 GetDataInt(SqlDataReader rdr, string field) { int colIndex = rdr.GetOrdinal(field); diff --git a/CatherineLynwood/Services/IndexNowBackgroundService.cs b/CatherineLynwood/Services/IndexNowBackgroundService.cs new file mode 100644 index 0000000..e585d81 --- /dev/null +++ b/CatherineLynwood/Services/IndexNowBackgroundService.cs @@ -0,0 +1,113 @@ +namespace CatherineLynwood.Services +{ + public class IndexNowBackgroundService : BackgroundService + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly DataAccess _dataAccess; + + public IndexNowBackgroundService( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + DataAccess dataAccess) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _dataAccess = dataAccess; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("IndexNowBackgroundService is starting."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("Checking for due blog posts to notify at {Time}.", DateTime.UtcNow); + + // 1️⃣ Fetch due blog posts + var duePosts = await _dataAccess.GetDueBlogPostsAsync(); + + if (duePosts != null && duePosts.Count > 0) + { + _logger.LogInformation("Found {Count} post(s) to notify.", duePosts.Count); + + foreach (var post in duePosts) + { + _logger.LogInformation("Notifying IndexNow for post URL: {Url}", post.BlogUrl); + + var success = await NotifyIndexNowAsync(post.BlogUrl); + + if (success) + { + _logger.LogInformation("Successfully notified IndexNow for URL: {Url}", post.BlogUrl); + + await _dataAccess.MarkAsNotifiedAsync(post.BlogID); + } + else + { + _logger.LogWarning("Failed to notify IndexNow for URL: {Url}", post.BlogUrl); + } + } + } + else + { + _logger.LogInformation("No posts due at this time."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while processing IndexNow notifications."); + } + + // ✅ Wait for 1 minute before next poll + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + + _logger.LogInformation("IndexNowBackgroundService is stopping."); + } + + private async Task NotifyIndexNowAsync(string url) + { + var apiKey = _configuration["IndexNow:ApiKey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + { + _logger.LogError("IndexNow API key is not configured."); + return false; + } + + var endpoint = $"https://api.indexnow.org/indexnow?url={Uri.EscapeDataString(url)}&key={apiKey}"; + + url = $"https://www.catherinelynwood.com/the-alpha-flame/blog/{url}"; + + try + { + using var httpClient = _httpClientFactory.CreateClient(); + + _logger.LogInformation("Sending IndexNow request for {Url}", url); + + var response = await httpClient.GetAsync(endpoint); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("IndexNow notification successful for {Url}", url); + return true; + } + else + { + _logger.LogWarning("IndexNow notification failed for {Url} with status code {StatusCode}", url, response.StatusCode); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred while notifying IndexNow for {Url}", url); + return false; + } + } + } +} diff --git a/CatherineLynwood/Services/SmtpEmailService.cs b/CatherineLynwood/Services/SmtpEmailService.cs new file mode 100644 index 0000000..3e90cf7 --- /dev/null +++ b/CatherineLynwood/Services/SmtpEmailService.cs @@ -0,0 +1,68 @@ +using CatherineLynwood.Models; + +using MailKit.Net.Smtp; +using MailKit.Security; + +using MimeKit; + +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace CatherineLynwood.Services +{ + public interface IEmailService + { + Task SendEmailAsync( string subject, string plainText, string htmlContent, Contact contact); + } + + public class SmtpEmailService : IEmailService + { + private readonly IConfiguration _config; + + public SmtpEmailService(IConfiguration config) + { + _config = config; + } + + public async Task SendEmailAsync(string subject, string plainText, string htmlContent, Contact contact) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Catherine Lynwood", _config["Smtp:Sender"])); + message.To.Add(new MailboxAddress("Web Site", "catherine@catherinelynwood.com")); + message.ReplyTo.Add(new MailboxAddress(contact.Name, contact.EmailAddress)); + + message.Subject = subject; + + message.Body = new BodyBuilder + { + HtmlBody = htmlContent, + TextBody = plainText + }.ToMessageBody(); + + using var client = new SmtpClient(); + + // Safer override: allow certs that are valid *except* for revocation check issues + client.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + if (sslPolicyErrors == SslPolicyErrors.None) + return true; + + // Allow only if the problem is revocation + if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors && + chain.ChainStatus.All(status => status.Status == X509ChainStatusFlags.RevocationStatusUnknown || + status.Status == X509ChainStatusFlags.OfflineRevocation)) + { + return true; + } + + // Otherwise, reject + return false; + }; + + await client.ConnectAsync(_config["Smtp:Host"], int.Parse(_config["Smtp:Port"]), SecureSocketOptions.StartTls); + await client.AuthenticateAsync(_config["Smtp:Username"], _config["Smtp:Password"]); + await client.SendAsync(message); + await client.DisconnectAsync(true); + } + } +} diff --git a/CatherineLynwood/TagHelpers/MetaTagHelper.cs b/CatherineLynwood/TagHelpers/MetaTagHelper.cs index f43d4ad..3f5925d 100644 --- a/CatherineLynwood/TagHelpers/MetaTagHelper.cs +++ b/CatherineLynwood/TagHelpers/MetaTagHelper.cs @@ -8,120 +8,122 @@ namespace CatherineLynwood.TagHelpers #region Public Properties public DateTime? ArticleModifiedTime { get; set; } - - // Article specific properties public DateTime? ArticlePublishedTime { get; set; } public string MetaAuthor { get; set; } - public string MetaDescription { get; set; } - public string MetaImage { get; set; } - public string MetaImageAlt { get; set; } - public string MetaKeywords { get; set; } - - // Shared properties public string MetaTitle { get; set; } - public string MetaUrl { get; set; } - // Open Graph specific properties public string OgSiteName { get; set; } - public string OgType { get; set; } = "article"; - // Twitter specific properties public string TwitterCardType { get; set; } = "summary_large_image"; - public string TwitterCreatorHandle { get; set; } - public int? TwitterPlayerHeight { get; set; } - public int? TwitterPlayerWidth { get; set; } - public string TwitterSiteHandle { get; set; } - public string TwitterVideoUrl { get; set; } - #endregion Public Properties - - // Optional: a full URL to a video (e.g., MP4 or embedded player) + #endregion #region Public Methods public override void Process(TagHelperContext context, TagHelperOutput output) { - output.TagName = null; // Suppress output tag + output.TagName = null; // Remove wrapper tag var metaTags = new System.Text.StringBuilder(); // General meta tags if (!string.IsNullOrWhiteSpace(MetaDescription)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaKeywords)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaAuthor)) metaTags.AppendLine($""); // Open Graph meta tags if (!string.IsNullOrWhiteSpace(MetaTitle)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaDescription)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(OgType)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaUrl)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaImage)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaImageAlt)) metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(OgSiteName)) metaTags.AppendLine($""); + if (ArticlePublishedTime.HasValue) metaTags.AppendLine($""); + if (ArticleModifiedTime.HasValue) metaTags.AppendLine($""); // Twitter meta tags - if (!string.IsNullOrWhiteSpace(TwitterCardType)) - metaTags.AppendLine($""); - if (!string.IsNullOrWhiteSpace(MetaTitle)) - metaTags.AppendLine($""); - if (!string.IsNullOrWhiteSpace(MetaDescription)) - metaTags.AppendLine($""); - if (!string.IsNullOrWhiteSpace(TwitterSiteHandle)) - metaTags.AppendLine($""); - if (!string.IsNullOrWhiteSpace(TwitterCreatorHandle)) - metaTags.AppendLine($""); - if (!string.IsNullOrWhiteSpace(MetaImage)) - metaTags.AppendLine($""); - if (!string.IsNullOrWhiteSpace(MetaImageAlt)) - metaTags.AppendLine($""); if (!string.IsNullOrWhiteSpace(TwitterVideoUrl)) { + // Ensure absolute URL for player + var playerUrl = TwitterVideoUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? TwitterVideoUrl + : $"https://www.catherinelynwood.com/{TwitterVideoUrl.TrimStart('/')}"; + metaTags.AppendLine(""); - metaTags.AppendLine($""); + metaTags.AppendLine($""); + if (TwitterPlayerWidth.HasValue) metaTags.AppendLine($""); + if (TwitterPlayerHeight.HasValue) metaTags.AppendLine($""); if (!string.IsNullOrWhiteSpace(MetaImage)) metaTags.AppendLine($""); + + if (!string.IsNullOrWhiteSpace(MetaImageAlt)) + metaTags.AppendLine($""); } else { metaTags.AppendLine($""); + + if (!string.IsNullOrWhiteSpace(MetaTitle)) + metaTags.AppendLine($""); + + if (!string.IsNullOrWhiteSpace(MetaDescription)) + metaTags.AppendLine($""); + if (!string.IsNullOrWhiteSpace(MetaImage)) metaTags.AppendLine($""); + + if (!string.IsNullOrWhiteSpace(MetaImageAlt)) + metaTags.AppendLine($""); } - // Output all content + if (!string.IsNullOrWhiteSpace(TwitterSiteHandle)) + metaTags.AppendLine($""); + + if (!string.IsNullOrWhiteSpace(TwitterCreatorHandle)) + metaTags.AppendLine($""); + output.Content.SetHtmlContent(metaTags.ToString()); } - #endregion Public Methods + #endregion } -} \ No newline at end of file +} diff --git a/CatherineLynwood/Views/Account/Login.cshtml b/CatherineLynwood/Views/Account/Login.cshtml new file mode 100644 index 0000000..fa8f27d --- /dev/null +++ b/CatherineLynwood/Views/Account/Login.cshtml @@ -0,0 +1,71 @@ +@{ + ViewData["Title"] = "Login"; + var returnUrl = ViewBag.ReturnUrl ?? "/"; +} + +
+
+
+
+
+

Admin Login

+
+
+ + @if (ViewBag.Error != null) + { +
@ViewBag.Error
+ } + +
+ + +
+ + +
+ Please enter your username. +
+
+ +
+ + +
+ Please enter your password. +
+
+ +
+ +
+
+ +
+
+
+
+
+ +@section Scripts { + +} + +@section Meta{ + +} \ No newline at end of file diff --git a/CatherineLynwood/Views/Admin/ArcReaders.cshtml b/CatherineLynwood/Views/Admin/ArcReaders.cshtml new file mode 100644 index 0000000..08e89d3 --- /dev/null +++ b/CatherineLynwood/Views/Admin/ArcReaders.cshtml @@ -0,0 +1,63 @@ +@model CatherineLynwood.Models.ARCReaderList + +@{ + ViewData["Title"] = "ARC Reader List"; +} + +
+
+
+ +
+
+ +
+
+

ARC Readers List

+
+
+ +
+ @foreach (var item in Model.Applications) + { +
+
+
+
@item.FullName
+
+
+ +

Email: @item.Email

+

Kindle Email: @item.KindleEmail

+

Approved Sender: @item.ApprovedSender

+

Platforms: @item.Platforms

+ @if (!string.IsNullOrWhiteSpace(item.PlatformsOther)) + { +

Platforms Other: @item.PlatformsOther

+ } +

Preview Chapters: @item.PreviewChapters

+ @if (!string.IsNullOrWhiteSpace(item.ReviewLink)) + { +

Review Link: @item.ReviewLink

+ } +

Content Fit: @item.ContentFit

+

Review Commitment: @item.ReviewCommitment

+ @if (!string.IsNullOrWhiteSpace(item.ExtraNotes)) + { +

Extra Notes: @item.ExtraNotes

+ } +
+ +
+
+ } +
+
diff --git a/CatherineLynwood/Views/Admin/Blog.cshtml b/CatherineLynwood/Views/Admin/Blog.cshtml new file mode 100644 index 0000000..78c2210 --- /dev/null +++ b/CatherineLynwood/Views/Admin/Blog.cshtml @@ -0,0 +1,72 @@ +@model CatherineLynwood.Models.BlogAdminIndex + +@{ + ViewData["Title"] = "Blog Admin"; +} + +
+
+
+ +
+
+ +
+ @foreach (var item in Model.BlogItems) + { + string rowCSS = "bg-success text-white"; + string textCSS = "text-white"; + if (item.Draft) + { + rowCSS = "bg-warning text-black"; + textCSS = "text-black"; + } + else if (item.PublishDate > DateTime.Now) + { + rowCSS = "bg-info text-black"; + textCSS = "text-black"; + } + +
+
+
+
+ +
+ @item.PublishDate.ToShortDateString() +
+
+
+
+
+
+
+ @item.SubTitle +
+

+
+ @item.IndexText +
+
+
+ Edit +
+
+
+
+ + + +
+ + } +
+
\ No newline at end of file diff --git a/CatherineLynwood/Views/Admin/BlogEdit.cshtml b/CatherineLynwood/Views/Admin/BlogEdit.cshtml new file mode 100644 index 0000000..37756c8 --- /dev/null +++ b/CatherineLynwood/Views/Admin/BlogEdit.cshtml @@ -0,0 +1,214 @@ +@model CatherineLynwood.Models.Blog + +@{ + ViewData["Title"] = $"Edit: {Model.Title}"; +} + +
+
+
+ +
+
+
+
+ @* Validation summary *@ +
+ +
+
+

Edit Blog Post

+
+
+ +
+
+ +
Basic Info
+
+
+ + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+
+
+
+ + + +
+
+
+ + +
+
+ + + +
+
+ + + +
+
+ + +
Content
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
Image
+
+
+ +
+ + +
+ + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+ + +
Audio / Video
+
+
+ +
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+
+ + + +
+ + +
AI / Prompt
+
+ + + +
+ +
+ + + +
+ + +
+
+ +
+
+ +
+
+ + + +
+ diff --git a/CatherineLynwood/Views/Admin/Index.cshtml b/CatherineLynwood/Views/Admin/Index.cshtml new file mode 100644 index 0000000..71d6839 --- /dev/null +++ b/CatherineLynwood/Views/Admin/Index.cshtml @@ -0,0 +1,21 @@ +@{ + ViewData["Title"] = "Site Admin"; +} + +
+ +
\ No newline at end of file diff --git a/CatherineLynwood/Views/AskAQuestion/Index.cshtml b/CatherineLynwood/Views/AskAQuestion/Index.cshtml index 8f5bcbc..641a569 100644 --- a/CatherineLynwood/Views/AskAQuestion/Index.cshtml +++ b/CatherineLynwood/Views/AskAQuestion/Index.cshtml @@ -32,13 +32,8 @@

Ask A Question

- @if (ViewData["VpnWarning"] is true) - { - - } -
- The questions shown below have already been suggested, and are ones I plan to respond to in my upcoming podcast. If you have a question you'd like me + The questions shown below have already been asked, and are ones I plan to respond to in my upcoming blog posts. If you have a question you'd like me to answer, please use the form below to ask it. Try to make sure no one else has asked it previously.
@@ -69,7 +64,7 @@
-
+

Ask A Question

In the future I intend to record a question and answer podcast. The idea is that you will be able to ask me anything about the @@ -152,50 +147,14 @@
- @if (ViewData["VpnWarning"] is true) - { - - } - else - { - - - } - + +
- - @section Meta { @@ -203,4 +162,45 @@ +} + +@section Scripts{ + + + + } \ No newline at end of file diff --git a/CatherineLynwood/Views/Discovery/Chapter1.cshtml b/CatherineLynwood/Views/Discovery/Chapter1.cshtml index c7c5dc9..1c77d99 100644 --- a/CatherineLynwood/Views/Discovery/Chapter1.cshtml +++ b/CatherineLynwood/Views/Discovery/Chapter1.cshtml @@ -88,8 +88,8 @@ meta-description="Explore Chapter 1 of 'The Alpha Flame' by Catherine Lynwood. Discover Maggie's captivating story, full of determination and secrets, set in the vivid 1980s." meta-keywords="The Alpha Flame, Chapter 1, Maggie, Catherine Lynwood, 1980s fiction, family secrets, strong female characters, captivating novels, fiction by Catherine Lynwood" meta-author="Catherine Lynwood" - meta-url="https://www.catherinelynwood.com/the-alpha-flame/chapters/chapter-1-beth" - meta-image="https://www.catherinelynwood.com/images/webp/beth-12-600-600.webp" + meta-url="https://www.catherinelynwood.com/the-alpha-flame/discovery/chapters/chapter-1-beth" + meta-image="https://www.catherinelynwood.com/images/webp/beth-stood-in-bathroom-600.webp" meta-image-alt="Beth from 'The Alpha Flame' by Catherine Lynwood" og-site-name="Catherine Lynwood - The Alpha Flame" article-published-time="@new DateTime(2024,11,20)" @@ -105,7 +105,7 @@ "@@context": "https://schema.org", "@@type": "Chapter", "name": "Chapter 1: Drowning in Silence – Beth", - "url": "https://www.catherinelynwood.com/the-alpha-flame/chapters/chapter-1-beth", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery/chapters/chapter-1-beth", "description": "Beth returns home to a haunting silence, discovering her mother lifeless in the bath. This moment shatters her world, leaving her feeling utterly alone.", "position": 1, "inLanguage": "en-GB", diff --git a/CatherineLynwood/Views/Discovery/Chapter13.cshtml b/CatherineLynwood/Views/Discovery/Chapter13.cshtml index 08ece3e..d023874 100644 --- a/CatherineLynwood/Views/Discovery/Chapter13.cshtml +++ b/CatherineLynwood/Views/Discovery/Chapter13.cshtml @@ -104,8 +104,8 @@ meta-description="Explore Chapter 13 of 'The Alpha Flame' by Catherine Lynwood. Discover Susie's captivating story, full of determination and secrets, set in the vivid 1980s." meta-keywords="The Alpha Flame, Chapter 13, Susie, Catherine Lynwood, 1980s fiction, family secrets, strong female characters, captivating novels, fiction by Catherine Lynwood" meta-author="Catherine Lynwood" - meta-url="https://www.catherinelynwood.com/the-alpha-flame/chapters/chapter-2-maggie" - meta-image="https://www.catherinelynwood.com/images/webp/maggie-grant-43-600.webp" + meta-url="https://www.catherinelynwood.com/the-alpha-flame/discovery/chapters/chapter-13-susie" + meta-image="https://www.catherinelynwood.com/images/webp/pub-from-chapter-13-600.webp" meta-image-alt="Maggie from 'The Alpha Flame' by Catherine Lynwood" og-site-name="Catherine Lynwood - The Alpha Flame" article-published-time="@new DateTime(2024, 11, 20)" @@ -121,7 +121,7 @@ "@@context": "https://schema.org", "@@type": "Chapter", "name": "Chapter 13: A Name She Never Owned - Susie", - "url": "https://www.catherinelynwood.com/the-alpha-flame/chapters/chapter-2-maggie", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery/chapters/chapter-13-susie", "description": "Maggie Grant bursts onto the page with wit, confidence, and a fiery spirit. As she faces challenges at college and flirts with independence, her strength and secrets begin to unfold.", "position": 13, "inLanguage": "en-GB", diff --git a/CatherineLynwood/Views/Discovery/Chapter2.cshtml b/CatherineLynwood/Views/Discovery/Chapter2.cshtml index d3f2c1b..f1531a3 100644 --- a/CatherineLynwood/Views/Discovery/Chapter2.cshtml +++ b/CatherineLynwood/Views/Discovery/Chapter2.cshtml @@ -87,8 +87,8 @@ meta-description="Explore Chapter 2 of 'The Alpha Flame' by Catherine Lynwood. Discover Maggie's captivating story, full of determination and secrets, set in the vivid 1980s." meta-keywords="The Alpha Flame, Chapter 2, Maggie, Catherine Lynwood, 1980s fiction, family secrets, strong female characters, captivating novels, fiction by Catherine Lynwood" meta-author="Catherine Lynwood" - meta-url="https://www.catherinelynwood.com/the-alpha-flame/chapters/chapter-2-maggie" - meta-image="https://www.catherinelynwood.com/images/webp/maggie-grant-43-600.webp" + meta-url="https://www.catherinelynwood.com/the-alpha-flame/discovery/chapters/chapter-2-maggie" + meta-image="https://www.catherinelynwood.com/images/webp/maggie-with-her-tr6-2-600.webp" meta-image-alt="Maggie from 'The Alpha Flame' by Catherine Lynwood" og-site-name="Catherine Lynwood - The Alpha Flame" article-published-time="@new DateTime(2024,11,20)" @@ -104,7 +104,7 @@ "@@context": "https://schema.org", "@@type": "Chapter", "name": "Chapter 2: The Last Lesson – Maggie", - "url": "https://www.catherinelynwood.com/the-alpha-flame/chapters/chapter-2-maggie", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery/chapters/chapter-2-maggie", "description": "Maggie Grant bursts onto the page with wit, confidence, and a fiery spirit. As she faces challenges at college and flirts with independence, her strength and secrets begin to unfold.", "position": 2, "inLanguage": "en-GB", diff --git a/CatherineLynwood/Views/Discovery/Extras.cshtml b/CatherineLynwood/Views/Discovery/Extras.cshtml index ad44762..c526bf1 100644 --- a/CatherineLynwood/Views/Discovery/Extras.cshtml +++ b/CatherineLynwood/Views/Discovery/Extras.cshtml @@ -53,9 +53,7 @@ Listen to the Book
- } - @if (accessLevel >= 4) - { +
Scrapbook: Maggie’s Designs
diff --git a/CatherineLynwood/Views/Discovery/Index.cshtml b/CatherineLynwood/Views/Discovery/Index.cshtml index 545279e..6979394 100644 --- a/CatherineLynwood/Views/Discovery/Index.cshtml +++ b/CatherineLynwood/Views/Discovery/Index.cshtml @@ -1,5 +1,9 @@ -@{ +@model CatherineLynwood.Models.Reviews + +@{ ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters"; + + bool showReviews = Model.Items.Any(); }
@@ -7,7 +11,7 @@ @@ -20,7 +24,7 @@
- +

The Front Cover

This is the final front cover of The Alpha Flame: Discovery. It features Maggie stood outside the derelict Rubery Hill Hospital.

@@ -29,76 +33,238 @@
- -
-
-
-
-

The Alpha Flame: Discovery
A Gritty 1980s Birmingham Crime Novel

-

Survival, secrets, and sisters in 1980s Birmingham.

-
-
-
-
- + @if (showReviews) + { + +
+
+
+
+

The Alpha Flame: Discovery
A Gritty 1980s Birmingham Crime Novel

+

Survival, secrets, and sisters in 1980s Birmingham.

+
+
+
+

Buy the Book

-
- -
- -
-

- Listen to Catherine telling you about The Alpha Flame: Discovery + +

+ + Buy Kindle Edition + + + Buy Paperback (Bookshop Edition) + + + Buy Hardback (Collector's Edition) + +

+ Available from your local Amazon store.
+ Or order from your local bookshop using: +

    +
  • ISBN 978-1-0682258-1-9 – Bookshop Edition (Paperback)
  • +
  • ISBN 978-1-0682258-0-2 – Collector's Edition (Hardback)
  • +
+

+ + +
+

★ Reader Praise ★

+ + @foreach (var review in Model.Items.Take(3)) + { + var fullStars = (int)Math.Floor(review.RatingValue); + var hasHalfStar = review.RatingValue - fullStars >= 0.5; + var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0); + var reviewDate = review.DatePublished.ToString("d MMMM yyyy"); + +
+ + @for (int i = 0; i < fullStars; i++) + { + + } + @if (hasHalfStar) + { + + } + @for (int i = 0; i < emptyStars; i++) + { + + } + + @Html.Raw(review.ReviewBody) +
+ @review.AuthorName on + @if (string.IsNullOrEmpty(review.URL)) + { + @review.SiteName + } + else + { + @review.SiteName + } + @reviewDate +
+
+ } + + @if (Model.Items.Count > 3) + { + + } + +
-
-
-
- - Buy Kindle Edition - - - Buy Paperback (Bookshop Edition) - -

- Available from your local Amazon store.
- Or order from your local bookshop using: -

    -
  • - ISBN 978-1-0682258-1-9 - Bookshop Edition (Paperback) -
  • -
  • - ISBN 978-1-0682258-0-2 - Collector's Eidtion (Hardback) -
  • -
- +
+
+
+ + + +
+
+
+
+

The Alpha Flame: Discovery: Synopsis

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

+ Listen to Catherine telling you about The Alpha Flame: Discovery

+ + +

Synopsis

+

Set in 1983 Birmingham, The Alpha Flame: Discovery is a gritty crime novel following twin sisters Beth and Maggie as they uncover dark family secrets and fight to survive abuse in a harsh, realistic world. With unflinching honesty, it explores the bonds of family, the scars of the past, and the resilience needed to endure. This powerful first instalment in the trilogy immerses readers in the grim realities of 1980s Britain while celebrating hope in the face of darkness.

+

For Beth, the world is a cold and unforgiving place. Devastation strikes in a single moment, leaving her isolated, shattered, and vulnerable. Alone in the bleak shadows of a city that offers neither refuge nor redemption, she is forced to navigate a relentless cycle of desperation and despair. Every step of her journey tests the limits of her endurance, pushing her into harrowing situations where survival feels like a hollow victory. Beth’s existence is marked by loss, betrayal, and an almost suffocating loneliness that threatens to consume her entirely. Yet, even in the darkest corners of her ordeal, a fragile ember of defiance smoulders within her, a quiet, stubborn refusal to let the world destroy her completely.

+

Maggie, by contrast, is a force of nature, a woman who thrives on her unshakable drive and an unrelenting belief in her own power. Behind her fiery red hair and disarming charm lies a storm of determination and ferocity. Maggie doesn’t just live; she races through life, fuelled by a need for speed and the thrill of freedom. Her Triumph TR6 isn’t just a car; it’s an extension of her spirit, sleek, powerful, and unapologetically bold. On the open road, with the engine roaring and the world blurring past her, she feels invincible. But Maggie’s intensity doesn’t stop at the wheel. Her relationships burn just as brightly. As a lover, she is dominant, passionate, and unafraid to embrace her darker desires. While fiercely loving and loyal, Maggie is also formidable; crossing her isn’t a mistake anyone makes twice.

+

When fate brings Beth and Maggie together, their connection is explosive, a union of two polar opposites that burns with both tenderness and raw power. For Beth, Maggie represents a lifeline, a reminder that love and trust still exist, even in a world that has betrayed her at every turn. For Maggie, Beth awakens a fierce protectiveness and vulnerability she’s rarely allowed herself to feel. Together, they ignite a flame that challenges them to confront their own fears, desires, and limitations.

+

Set against the kaleidoscope of 1983, where synthesised anthems provide a pulsing soundtrack and the streets are alive with the bold styles and rebellious energy of the decade, their story unfolds in a city teeming with danger and intrigue. From high-speed chases along winding roads to dimly lit clubs and desolate alleyways, the heroines’ journey is a visceral exploration of survival and freedom. The neon haze of the era contrasts sharply with the stark realities they face, painting a vivid picture of a world where strength and vulnerability coexist.

+

As secrets surface and danger tightens its grip, Beth and Maggie must confront not only the challenges around them but the truths within themselves. Their bond is tested by betrayal, desire, and the shadows of their pasts, but through it all, their flame burns brighter, illuminating their courage and the unbreakable spirit of two heroines determined to rewrite their fates.

+

At its heart, The Alpha Flame is a story of survival, passion, and empowerment. It explores the devastating lows and triumphant highs of life with unflinching honesty, capturing the raw power of human connection against the gritty, vibrant backdrop of an unforgettable era. With its blend of drama, intensity, and unapologetic emotion, this is a story that will leave its mark long after the final frame.

+ +
+
+
+
+ } + else + { + +
+
+
+
+

The Alpha Flame: Discovery
A Gritty 1980s Birmingham Crime Novel

+

Survival, secrets, and sisters in 1980s Birmingham.

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

+ Listen to Catherine telling you about The Alpha Flame: Discovery +

+
+
+
+
+
+ + Buy Kindle Edition + + + Buy Paperback (Bookshop Edition) + + + Buy Hardback (Collector's Edition) + +

+ Available from your local Amazon store.
+ Or order from your local bookshop using: +

    +
  • + ISBN 978-1-0682258-1-9 - Bookshop Edition (Paperback) +
  • +
  • + ISBN 978-1-0682258-0-2 - Collector's Eidtion (Hardback) +
  • +
+ +

+
+
+
+ + +

Synopsis

+

Set in 1983 Birmingham, The Alpha Flame: Discovery is a gritty crime novel following twin sisters Beth and Maggie as they uncover dark family secrets and fight to survive abuse in a harsh, realistic world. With unflinching honesty, it explores the bonds of family, the scars of the past, and the resilience needed to endure. This powerful first instalment in the trilogy immerses readers in the grim realities of 1980s Britain while celebrating hope in the face of darkness.

+

For Beth, the world is a cold and unforgiving place. Devastation strikes in a single moment, leaving her isolated, shattered, and vulnerable. Alone in the bleak shadows of a city that offers neither refuge nor redemption, she is forced to navigate a relentless cycle of desperation and despair. Every step of her journey tests the limits of her endurance, pushing her into harrowing situations where survival feels like a hollow victory. Beth’s existence is marked by loss, betrayal, and an almost suffocating loneliness that threatens to consume her entirely. Yet, even in the darkest corners of her ordeal, a fragile ember of defiance smoulders within her, a quiet, stubborn refusal to let the world destroy her completely.

+

Maggie, by contrast, is a force of nature, a woman who thrives on her unshakable drive and an unrelenting belief in her own power. Behind her fiery red hair and disarming charm lies a storm of determination and ferocity. Maggie doesn’t just live; she races through life, fuelled by a need for speed and the thrill of freedom. Her Triumph TR6 isn’t just a car; it’s an extension of her spirit, sleek, powerful, and unapologetically bold. On the open road, with the engine roaring and the world blurring past her, she feels invincible. But Maggie’s intensity doesn’t stop at the wheel. Her relationships burn just as brightly. As a lover, she is dominant, passionate, and unafraid to embrace her darker desires. While fiercely loving and loyal, Maggie is also formidable; crossing her isn’t a mistake anyone makes twice.

+

When fate brings Beth and Maggie together, their connection is explosive, a union of two polar opposites that burns with both tenderness and raw power. For Beth, Maggie represents a lifeline, a reminder that love and trust still exist, even in a world that has betrayed her at every turn. For Maggie, Beth awakens a fierce protectiveness and vulnerability she’s rarely allowed herself to feel. Together, they ignite a flame that challenges them to confront their own fears, desires, and limitations.

+

Set against the kaleidoscope of 1983, where synthesised anthems provide a pulsing soundtrack and the streets are alive with the bold styles and rebellious energy of the decade, their story unfolds in a city teeming with danger and intrigue. From high-speed chases along winding roads to dimly lit clubs and desolate alleyways, the heroines’ journey is a visceral exploration of survival and freedom. The neon haze of the era contrasts sharply with the stark realities they face, painting a vivid picture of a world where strength and vulnerability coexist.

+

As secrets surface and danger tightens its grip, Beth and Maggie must confront not only the challenges around them but the truths within themselves. Their bond is tested by betrayal, desire, and the shadows of their pasts, but through it all, their flame burns brighter, illuminating their courage and the unbreakable spirit of two heroines determined to rewrite their fates.

+

At its heart, The Alpha Flame is a story of survival, passion, and empowerment. It explores the devastating lows and triumphant highs of life with unflinching honesty, capturing the raw power of human connection against the gritty, vibrant backdrop of an unforgettable era. With its blend of drama, intensity, and unapologetic emotion, this is a story that will leave its mark long after the final frame.

+
+
+
+
+ } - - -

Synopsis

-

Set in 1983 Birmingham, The Alpha Flame: Discovery is a gritty crime novel following twin sisters Beth and Maggie as they uncover dark family secrets and fight to survive abuse in a harsh, realistic world. With unflinching honesty, it explores the bonds of family, the scars of the past, and the resilience needed to endure. This powerful first instalment in the trilogy immerses readers in the grim realities of 1980s Britain while celebrating hope in the face of darkness.

-

For Beth, the world is a cold and unforgiving place. Devastation strikes in a single moment, leaving her isolated, shattered, and vulnerable. Alone in the bleak shadows of a city that offers neither refuge nor redemption, she is forced to navigate a relentless cycle of desperation and despair. Every step of her journey tests the limits of her endurance, pushing her into harrowing situations where survival feels like a hollow victory. Beth’s existence is marked by loss, betrayal, and an almost suffocating loneliness that threatens to consume her entirely. Yet, even in the darkest corners of her ordeal, a fragile ember of defiance smoulders within her, a quiet, stubborn refusal to let the world destroy her completely.

-

Maggie, by contrast, is a force of nature, a woman who thrives on her unshakable drive and an unrelenting belief in her own power. Behind her fiery red hair and disarming charm lies a storm of determination and ferocity. Maggie doesn’t just live; she races through life, fuelled by a need for speed and the thrill of freedom. Her Triumph TR6 isn’t just a car; it’s an extension of her spirit, sleek, powerful, and unapologetically bold. On the open road, with the engine roaring and the world blurring past her, she feels invincible. But Maggie’s intensity doesn’t stop at the wheel. Her relationships burn just as brightly. As a lover, she is dominant, passionate, and unafraid to embrace her darker desires. While fiercely loving and loyal, Maggie is also formidable; crossing her isn’t a mistake anyone makes twice.

-

When fate brings Beth and Maggie together, their connection is explosive, a union of two polar opposites that burns with both tenderness and raw power. For Beth, Maggie represents a lifeline, a reminder that love and trust still exist, even in a world that has betrayed her at every turn. For Maggie, Beth awakens a fierce protectiveness and vulnerability she’s rarely allowed herself to feel. Together, they ignite a flame that challenges them to confront their own fears, desires, and limitations.

-

Set against the kaleidoscope of 1983, where synthesised anthems provide a pulsing soundtrack and the streets are alive with the bold styles and rebellious energy of the decade, their story unfolds in a city teeming with danger and intrigue. From high-speed chases along winding roads to dimly lit clubs and desolate alleyways, the heroines’ journey is a visceral exploration of survival and freedom. The neon haze of the era contrasts sharply with the stark realities they face, painting a vivid picture of a world where strength and vulnerability coexist.

-

As secrets surface and danger tightens its grip, Beth and Maggie must confront not only the challenges around them but the truths within themselves. Their bond is tested by betrayal, desire, and the shadows of their pasts, but through it all, their flame burns brighter, illuminating their courage and the unbreakable spirit of two heroines determined to rewrite their fates.

-

At its heart, The Alpha Flame is a story of survival, passion, and empowerment. It explores the devastating lows and triumphant highs of life with unflinching honesty, capturing the raw power of human connection against the gritty, vibrant backdrop of an unforgettable era. With its blend of drama, intensity, and unapologetic emotion, this is a story that will leave its mark long after the final frame.

- - + + @if (DateTime.Now < new DateTime(2025, 9, 1)) + { +
+
+
+

Win: a Collector’s Edition of The Alpha Flame: Discovery

+

+ Enter my giveaway for your chance to own this special edition. Signed in the UK, or delivered as a premium collector’s copy worldwide. +

+ Exclusive, limited, beautiful. +
-
-
+
+ } + +
@@ -149,17 +315,17 @@ @@ -176,37 +342,40 @@ let kindleLink = "https://www.amazon.com/dp/B0FBS427VD"; let paperbackLink = "https://www.amazon.com/dp/1068225815"; + let hardbackLink = "https://www.amazon.com/dp/1068225807"; let extraRetailers = ""; switch (country) { case "GB": kindleLink = "https://www.amazon.co.uk/dp/B0FBS427VD"; paperbackLink = "https://www.amazon.co.uk/dp/1068225815"; + hardbackLink = "https://www.amazon.co.uk/dp/1068225807"; extraRetailers = 'Also available at Waterstons'; break; case "US": kindleLink = "https://www.amazon.com/dp/B0FBS427VD"; paperbackLink = "https://www.amazon.com/dp/1068225815"; + hardbackLink = "https://www.amazon.com/dp/1068225807"; extraRetailers = 'Also available at Barnes & Noble'; break; case "CA": kindleLink = "https://www.amazon.ca/dp/B0FBS427VD"; paperbackLink = "https://www.amazon.ca/dp/1068225815"; + hardbackLink = "https://www.amazon.ca/dp/1068225807"; break; case "AU": kindleLink = "https://www.amazon.com.au/dp/B0FBS427VD"; paperbackLink = "https://www.amazon.com.au/dp/1068225815"; + hardbackLink = "https://www.amazon.com.au/dp/1068225807"; break; } document.getElementById("kindleLink").setAttribute("href", kindleLink); document.getElementById("paperbackLink").setAttribute("href", paperbackLink); + document.getElementById("hardbackLink").setAttribute("href", hardbackLink); document.getElementById("extraRetailers").innerHTML = extraRetailers; }); - - - } @@ -216,7 +385,7 @@ meta-keywords="The Alpha Flame Discovery, Catherine Lynwood, 1983 novel, twin sisters, suspense fiction, Rubery, Birmingham fiction, historical drama, family secrets" meta-author="Catherine Lynwood" meta-url="https://www.catherinelynwood.com/the-alpha-flame/discovery" - meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-11-1200.webp" + meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp" meta-image-alt="Maggie from 'The Alpha Flame: Discovery' by Catherine Lynwood" og-site-name="Catherine Lynwood - The Alpha Flame: Discovery" article-published-time="@new DateTime(2024, 11, 20)" @@ -225,86 +394,8 @@ twitter-site-handle="@@CathLynwood" twitter-creator-handle="@@CathLynwood" /> - - - } diff --git a/CatherineLynwood/Views/Discovery/Listen.cshtml b/CatherineLynwood/Views/Discovery/Listen.cshtml index f7219f0..0037dae 100644 --- a/CatherineLynwood/Views/Discovery/Listen.cshtml +++ b/CatherineLynwood/Views/Discovery/Listen.cshtml @@ -58,7 +58,7 @@ const audioBlobSizes = new Map(); // id => size in bytes let totalBlobSize = 0; const MAX_BLOB_CACHE_SIZE = 100 * 1024 * 1024; // 100 MB - const BUFFER_TARGET_SECONDS = 15 * 60; // 30 minutes + const BUFFER_TARGET_SECONDS = 30 * 60; // 30 minutes let masterPlaylist = []; let chapterStartIndices = []; @@ -159,7 +159,9 @@ let listHtml = ''; const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter); const totalChapters = chapterStartIndices.length; - const windowSize = 5; + + // Dynamically determine window size based on screen width + const windowSize = getWindowSize(); const halfWindow = Math.floor(windowSize / 2); // Previous button @@ -211,6 +213,23 @@ document.getElementById("chapter-list").innerHTML = listHtml; } + function getWindowSize() { + const width = window.innerWidth; + + if (width < 576) { + return 5; // Extra small (mobile) + } else if (width < 768) { + return 7; // Small (phones / small tablets) + } else if (width < 992) { + return 9; // Medium (tablets) + } else if (width < 1200) { + return 13; // Large (small desktops) + } else { + return 19; // Extra large (wide desktop screens) + } + } + + function goToPrevChapter(currentPos) { if (currentPos <= 0) return; diff --git a/CatherineLynwood/Views/Discovery/Reviews.cshtml b/CatherineLynwood/Views/Discovery/Reviews.cshtml new file mode 100644 index 0000000..a5fdb99 --- /dev/null +++ b/CatherineLynwood/Views/Discovery/Reviews.cshtml @@ -0,0 +1,101 @@ +@model CatherineLynwood.Models.Reviews +@{ + ViewData["Title"] = "Reader Reviews – The Alpha Flame: Discovery"; +} + +
+
+
+ +
+
+
+
+
+

Reader Reviews

+

Here’s what readers are saying about The Alpha Flame: Discovery. If you’ve read the book, we’d love for you to share your thoughts too.

+
+ +
+ @if (Model?.Items?.Any() == true) + { + foreach (var review in Model.Items) + { + var fullStars = (int)Math.Floor(review.RatingValue); + var hasHalfStar = review.RatingValue - fullStars >= 0.5; + var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0); + var reviewDate = review.DatePublished.ToString("d MMMM yyyy"); + +
+
+
+ + @for (int i = 0; i < fullStars; i++) + { + + } + @if (hasHalfStar) + { + + } + @for (int i = 0; i < emptyStars; i++) + { + + } + + @Html.Raw(review.ReviewBody) +
+ @review.AuthorName on + + @if (string.IsNullOrEmpty(review.URL)) + { + @review.SiteName + } + else + { + @review.SiteName + } + @reviewDate +
+
+
+
+ + } + } + else + { +

There are no reviews to display yet. Be the first to leave one!

+ } +
+
+
+
+ +@section Meta{ + + + + +} \ No newline at end of file diff --git a/CatherineLynwood/Views/Home/AboutCatherineLynwood.cshtml b/CatherineLynwood/Views/Home/AboutCatherineLynwood.cshtml index 3052d57..1e2f3fe 100644 --- a/CatherineLynwood/Views/Home/AboutCatherineLynwood.cshtml +++ b/CatherineLynwood/Views/Home/AboutCatherineLynwood.cshtml @@ -17,7 +17,7 @@
-
Catherin Lynwood
+
Catherine Lynwood
@@ -37,9 +37,10 @@
-

It was only after years of juggling her design work and her love for storytelling that Catherine decided to take the plunge and begin writing her first novel. Drawing on her life experiences, her love for strong, complex characters, and the rich settings of her hometown, Catherine wrote The Alpha Flame as a deeply personal exploration of resilience, mystery, and identity. Her transition from graphic design to the literary world has allowed her to combine her eye for visual storytelling with the depth of narrative, creating a unique voice that speaks to readers on many levels.

+

It was only after years of juggling her design work and her love for storytelling that Catherine decided to take the plunge and begin writing her first novel. Drawing on her life experiences, her love for strong, complex characters, and the rich settings of her hometown, Catherine wrote The Alpha Flame as a deeply personal exploration of resilience, mystery, and identity. The novel helped define a new genre Catherine now refers to as Verostic fiction — a style grounded in emotional realism, psychological depth, and unapologetic truth. The Alpha Flame, set in the emotional and cultural landscape of 1980s Britain, is also Aeverostic in tone — where the past is not just a backdrop, but a living, breathing part of the story’s truth.

+

Outside her creative career, Catherine’s life is deeply rooted in family. She lives just outside Birmingham with her daughter, who is her greatest inspiration and, often, her toughest critic. She describes her as having curious, quick-witted, and strong-willed—traits she values deeply and strives to cultivate in her own writing. Catherine finds joy in observing the unique perspectives her daughter brings to the world, and she often reminds her of the importance of resilience and self-discovery, themes central to her work.

@@ -48,9 +49,9 @@

Balancing family life with her career, Catherine believes in nurturing a creative household. She encourages her daughter Samantha to explore her passions, whether through art, music, or reading, hoping to instill in them a love for creativity and storytelling. Their family time often involves visits to museums, local theaters, or simply gathering around a table for sketching and brainstorming, making Catherine’s home a space where stories are shared and ideas take shape.

- + @section Meta { diff --git a/CatherineLynwood/Views/Home/ArcReaderApplication.cshtml b/CatherineLynwood/Views/Home/ArcReaderApplication.cshtml new file mode 100644 index 0000000..7ded4d8 --- /dev/null +++ b/CatherineLynwood/Views/Home/ArcReaderApplication.cshtml @@ -0,0 +1,344 @@ +@model CatherineLynwood.Models.ArcReaderApplicationModel +@{ + ViewData["Title"] = "ARC Reader Application"; +} + +
+
+
+

Catherine Lynwood ARC Reader Application

+

For The Alpha Flame: Discovery – Advance Reader Copy (ARC)

+
+
+
+
+
+ Step 1 of 5 +
+
+ +
+
+
+
+

Become an ARC Reader

+

Fancy reading The Alpha Flame: Discovery before anyone else? I'm looking for passionate early readers to receive a free Kindle copy in exchange for an honest review.

+ +
+ + Note: This novel is raw and emotional. Please check the themes below. +
+

Major Themes and Topics

+
    +
  • Death, trauma, and grief through the eyes of a teenage girl
  • +
  • Physical and sexual abuse (non-graphic, but deeply affecting)
  • +
  • Mental health, including suicidal thoughts and PTSD
  • +
  • Prostitution, exploitation, and coercion
  • +
  • Violence against women (threats, assaults, murder)
  • +
  • Love, trust, tenderness, and emotional recovery
  • +
  • Sexuality, orientation, and identity discovery
  • +
  • Found family, sisterhood, and female empowerment
  • +
  • Corruption, manipulation, and cover-ups
  • +
  • Justice, revenge, and difficult moral choices
  • +
  • Music and fashion as creative expression and survival
  • +
+

If you’re still interested, I’d love to have you on board. ARC readers help spread the word and offer early feedback that matters.

+

All I ask: read the book and leave a review on Goodreads, Amazon, or wherever you normally post.

+
+
+
+ +
+
+ +
+
+
+
+
+ So that I can address you in our communcations I need your name. I don't need your full name if you don't want to give it. Your first name or even a nickname will suffice. +
+
+ + + +
+
+ + + This is where I’ll send updates and reminders. + +
+
+
+ + +
+
+
+
+
+
+
+

ARCs are sent via Kindle only. You can use the free Kindle app or a Kindle device.

+

Add the following sender to your Amazon Kindle Approved Senders list: [enable JavaScript to view]

+

+ To find your kindle email follow this link amazon.co.uk/myk. + Once there click on Preferences and then scroll down to Personal Document Settings, as shown in the screen shot. +

+
+
+ +
+ + @@kindle.com +
+ +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+ Please let me know what platforms you usually post your reviews on. +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ +
+ + + +
+
+
+ + +
+
+
+
+
+
+ +
+ I'm interested in the type of fiction you enjoy reading. I write what I describe as verostic fiction, I've even got a page on this website describing it. +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + +
+ +
+
+
+ Privacy note: Your details will only be used for ARC-related communication and Kindle delivery. You can opt out at any time. +
+ + +
+
+
+ +
+
+
+
+ +@section Scripts{ + + + + + +} \ No newline at end of file diff --git a/CatherineLynwood/Views/Home/ContactCatherine.cshtml b/CatherineLynwood/Views/Home/ContactCatherine.cshtml index 2cbdd93..7a323cb 100644 --- a/CatherineLynwood/Views/Home/ContactCatherine.cshtml +++ b/CatherineLynwood/Views/Home/ContactCatherine.cshtml @@ -17,16 +17,12 @@
-
+

Contact Catherine

I would love to hear your thoughts regarding my work. Please feel free to contact me and I will try my best to reply as soon as I can.

Use the form below to send Catherine a message.

- @if (ViewData["VpnWarning"] is true) - { - - }
@@ -46,15 +42,7 @@
- @if (ViewData["VpnWarning"] is true) - { - - } - else - { - - } - +
@@ -74,4 +62,17 @@ "description": "Get in touch with Catherine Lynwood, author of *The Alpha Flame*, for inquiries and updates on her latest work." } +} + +@section Scripts{ + + } \ No newline at end of file diff --git a/CatherineLynwood/Views/Home/Honeypot.cshtml b/CatherineLynwood/Views/Home/Honeypot.cshtml index b528a9d..3c36c9a 100644 --- a/CatherineLynwood/Views/Home/Honeypot.cshtml +++ b/CatherineLynwood/Views/Home/Honeypot.cshtml @@ -1,8 +1,6 @@ @{ ViewData["Title"] = "Collaboration Inquiry"; } - -
@@ -39,3 +37,7 @@
+ +@section Meta{ + +} \ No newline at end of file diff --git a/CatherineLynwood/Views/Home/Index.cshtml b/CatherineLynwood/Views/Home/Index.cshtml index 2363bb7..0f643f4 100644 --- a/CatherineLynwood/Views/Home/Index.cshtml +++ b/CatherineLynwood/Views/Home/Index.cshtml @@ -43,13 +43,22 @@ Unlock Extras

- The Alpha Flame: Discovery, writen by Catherine Lynwood, is the first in a powerful trilogy following the tangled lives of Maggie and Beth , two women bound by fate, fire, and secrets too dangerous to stay buried. +

The Alpha Flame: Discovery

, writen by Catherine Lynwood, is the first in a powerful trilogy following the tangled lives of Maggie and Beth, two women bound by fate, fire, and secrets too dangerous to stay buried. The journey continues in Reckoning (Spring 2026) and concludes with Redemption (Autumn 2026). Learn more about the full trilogy on the Alpha Flame series page.

+ @if (DateTime.Now < new DateTime(2025, 9, 1)) + { +

+

Win: a Collector’s Edition of The Alpha Flame: Discovery

+ Exclusive, limited, beautiful. +

+ + }

-

About The Alpha Flame Trilogy

- Catherine Lynwood’s The Alpha Flame trilogy is gripping UK historical fiction set in 1980s Birmingham. These novels combine family drama, dark secrets, and emotional suspense as two sisters fight for truth and redemption. Perfect for readers who enjoy tense, character-driven stories and literary suspense in a richly described real-world setting. +

About The Alpha Flame Trilogy

, is gripping UK historical fiction set in 1980s Birmingham. These novels combine family drama, dark secrets, and emotional suspense as two sisters fight for truth and redemption. Perfect for readers who enjoy tense, character-driven stories and literary suspense in a richly described real-world setting.

@@ -62,7 +71,7 @@ meta-keywords="Catherine Lynwood, The Alpha Flame, historical fiction, 1983 novel, Birmingham author, twin sisters, suspense trilogy, female-led fiction" meta-author="Catherine Lynwood" meta-url="https://www.catherinelynwood.com/" - meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-11-600.webp" + meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-600.webp" meta-image-alt="Cover artwork from 'The Alpha Flame: Discovery' by Catherine Lynwood" og-site-name="Catherine Lynwood – The Alpha Flame" article-published-time="@new DateTime(2024, 11, 20)" @@ -101,6 +110,7 @@ "@@type": "Book", "name": "The Alpha Flame: Discovery", "alternateName": "The Alpha Flame Book 1", + "image": "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp", "author": { "@@type": "Person", "name": "Catherine Lynwood", @@ -120,32 +130,62 @@ "@@type": "Book", "bookFormat": "https://schema.org/Hardcover", "isbn": "978-1-0682258-0-2", - "name": "The Alpha Flame: Discovery – Hardback" + "name": "The Alpha Flame: Discovery – Collector's Edition", + "image": "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp", + "offers": { + "@@type": "Offer", + "price": "23.99", + "priceCurrency": "GBP", + "availability": "https://schema.org/InStock", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery" + } }, { "@@type": "Book", "bookFormat": "https://schema.org/Paperback", "isbn": "978-1-0682258-1-9", - "name": "The Alpha Flame: Discovery – Softback" + "name": "The Alpha Flame: Discovery – Bookshop Edition", + "image": "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp", + "offers": { + "@@type": "Offer", + "price": "17.99", + "priceCurrency": "GBP", + "availability": "https://schema.org/InStock", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery" + } }, { "@@type": "Book", "bookFormat": "https://schema.org/Paperback", "isbn": "978-1-0682258-2-6", - "name": "The Alpha Flame: Discovery – Amazon Edition" + "name": "The Alpha Flame: Discovery – Amazon Edition", + "image": "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp", + "offers": { + "@@type": "Offer", + "price": "13.99", + "priceCurrency": "GBP", + "availability": "https://schema.org/InStock", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery" + } }, { "@@type": "Book", "bookFormat": "https://schema.org/EBook", "isbn": "978-1-0682258-3-3", - "name": "The Alpha Flame: Discovery – eBook" + "name": "The Alpha Flame: Discovery – eBook", + "image": "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp", + "offers": { + "@@type": "Offer", + "price": "3.95", + "priceCurrency": "GBP", + "availability": "https://schema.org/InStock", + "url": "https://www.catherinelynwood.com/the-alpha-flame/discovery" + } } ] } - - + +} \ No newline at end of file diff --git a/CatherineLynwood/Views/Publishing/Index.cshtml b/CatherineLynwood/Views/Publishing/Index.cshtml index f2e8a3b..e1cbc93 100644 --- a/CatherineLynwood/Views/Publishing/Index.cshtml +++ b/CatherineLynwood/Views/Publishing/Index.cshtml @@ -73,6 +73,21 @@ @section Meta{ + + + + - - } \ No newline at end of file diff --git a/CatherineLynwood/Views/TheAlphaFlame/DefaultTemplate.cshtml b/CatherineLynwood/Views/TheAlphaFlame/DefaultTemplate.cshtml index a84e82d..05e8746 100644 --- a/CatherineLynwood/Views/TheAlphaFlame/DefaultTemplate.cshtml +++ b/CatherineLynwood/Views/TheAlphaFlame/DefaultTemplate.cshtml @@ -30,7 +30,7 @@

@Model.Title


@Model.SubTitle

- + @Model.PostedBy Lynwood
Posted on @Model.PublishDate.ToString("MMMM d, yyyy") by @Model.PostedBy Lynwood @@ -184,6 +184,17 @@ }); } + + + } @section Meta { @@ -192,7 +203,7 @@ meta-description="@Model.IndexText" meta-keywords="Catherine Lynwood blog, The Alpha Flame, psychological thriller, indie fiction, 1980s fiction, dark secrets, strong female characters" meta-author="Catherine Lynwood" - meta-url="https://www.catherinelynwood.com/@Model.BlogUrl" + meta-url="https://www.catherinelynwood.com/the-alpha-flame/blog/@Model.BlogUrl" meta-image="@(string.IsNullOrWhiteSpace(Model.ImageUrl) ? null : $"https://www.catherinelynwood.com/images/webp/{Model.DefaultWebpImage}")" meta-image-alt="@Model.ImageAlt" og-site-name="Catherine Lynwood - The Alpha Flame" @@ -201,7 +212,7 @@ twitter-card-type="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? "summary_large_image" : "player")" twitter-site-handle="@@CathLynwood" twitter-creator-handle="@@CathLynwood" - twitter-video-url="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : Model.VideoUrl)" + twitter-video-url="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : $"https://www.catherinelynwood.com/videos/{Model.VideoUrl}")" twitter-player-width="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : 480)" twitter-player-height="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : 80)" /> diff --git a/CatherineLynwood/Views/TheAlphaFlame/Enter.cshtml b/CatherineLynwood/Views/TheAlphaFlame/Enter.cshtml new file mode 100644 index 0000000..324b76d --- /dev/null +++ b/CatherineLynwood/Views/TheAlphaFlame/Enter.cshtml @@ -0,0 +1,92 @@ +@model CatherineLynwood.Models.Marketing + +@{ + ViewData["Title"] = "Enter The Alpha Flame Discovery Giveaway"; +} +
+
+ +
+
+ +
+
+
+

To enter our giveaway simply complete the entry form below

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

You don't have to tell me your age or sex, but it would really help me understand my readers if you do. If you don't want to then simply select the "Prefer not to say" option.

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

+ By entering this giveaway and joining the newsletter, you consent to receive emails about The Alpha Flame Trilogy, future books, special promotions, and exclusive author updates. You can unsubscribe at any time using the link in any email. View our privacy policy. +

+
+ +
+
+
+ +
+
+
+
+ +
+ +@section Meta{ + + +} \ No newline at end of file diff --git a/CatherineLynwood/Views/TheAlphaFlame/Giveaways.cshtml b/CatherineLynwood/Views/TheAlphaFlame/Giveaways.cshtml index 3d352e7..26182e0 100644 --- a/CatherineLynwood/Views/TheAlphaFlame/Giveaways.cshtml +++ b/CatherineLynwood/Views/TheAlphaFlame/Giveaways.cshtml @@ -2,13 +2,26 @@ ViewData["Title"] = "Giveaways - The Alpha Flame"; } +
+
+ +
+
+
-
+

Win a Collector’s Edition

A special chance to own The Alpha Flame: Discovery in a limited edition, signed or delivered to you.

- Enter Now + Enter Now
@@ -51,20 +64,20 @@
-
+

The Prize

- +

Signed by the author (UK only)

- -

Exclusive cover design

+ +

Exclusive imagery

- +

Premium print quality

@@ -78,7 +91,7 @@

Don’t Miss Out

Sign up today for your chance to win. Plus, get exclusive updates, early previews, and behind-the-scenes content.

- Enter the Giveaway + Enter the Giveaway
@@ -88,7 +101,7 @@ meta-keywords="The Alpha Flame giveaway, signed book giveaway, Catherine Lynwood, book contest, limited edition book" meta-author="Catherine Lynwood" meta-url="https://www.catherinelynwood.com/the-alpha-flame/giveaways" - meta-image="https://www.catherinelynwood.com/images/alpha-flame-collector-edition.webp" + meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-stood-up-600.webp" meta-image-alt="The Alpha Flame: Discovery Collector’s Edition Giveaway" og-site-name="Catherine Lynwood – The Alpha Flame" article-published-time="@new DateTime(2025, 07, 01)" /> diff --git a/CatherineLynwood/Views/TheAlphaFlame/Index.cshtml b/CatherineLynwood/Views/TheAlphaFlame/Index.cshtml index 67f6c09..4b007be 100644 --- a/CatherineLynwood/Views/TheAlphaFlame/Index.cshtml +++ b/CatherineLynwood/Views/TheAlphaFlame/Index.cshtml @@ -30,12 +30,12 @@
diff --git a/CatherineLynwood/Views/TheAlphaFlame/SlideShowTemplate.cshtml b/CatherineLynwood/Views/TheAlphaFlame/SlideShowTemplate.cshtml index d5fd73f..73a6cc6 100644 --- a/CatherineLynwood/Views/TheAlphaFlame/SlideShowTemplate.cshtml +++ b/CatherineLynwood/Views/TheAlphaFlame/SlideShowTemplate.cshtml @@ -172,7 +172,7 @@ meta-description="@Model.IndexText" meta-keywords="Catherine Lynwood blog, The Alpha Flame, psychological thriller, indie fiction, 1980s fiction, dark secrets, strong female characters" meta-author="Catherine Lynwood" - meta-url="https://www.catherinelynwood.com/@Model.BlogUrl" + meta-url="https://www.catherinelynwood.com/the-alpha-flame/blog/@Model.BlogUrl" meta-image="@(string.IsNullOrWhiteSpace(Model.ImageUrl) ? null : $"https://www.catherinelynwood.com/images/webp/{Model.DefaultWebpImage}")" meta-image-alt="@Model.ImageAlt" og-site-name="Catherine Lynwood - The Alpha Flame" @@ -181,7 +181,7 @@ twitter-card-type="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? "summary_large_image" : "player")" twitter-site-handle="@@CathLynwood" twitter-creator-handle="@@CathLynwood" - twitter-video-url="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : Model.VideoUrl)" + twitter-video-url="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : $"https://www.catherinelynwood.com/videos/{Model.VideoUrl}")" twitter-player-width="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : 480)" twitter-player-height="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? null : 80)" /> @@ -190,6 +190,17 @@ @Html.Raw(Model.SchemaJsonLd) + + +