Save
This commit is contained in:
parent
27cfdd8f6d
commit
0a2f596628
@ -154,10 +154,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MailKit" Version="4.13.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" />
|
||||
<PackageReference Include="NAudio" Version="2.2.1" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="SixLabors.Fonts" Version="2.1.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.6" />
|
||||
|
||||
@ -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<HomeController> _logger;
|
||||
private DataAccess _dataAccess;
|
||||
|
||||
#endregion Private Fields
|
||||
|
||||
#region Public Constructors
|
||||
|
||||
public AccessController(ILogger<HomeController> 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<HomeController> _logger;
|
||||
private DataAccess _dataAccess;
|
||||
|
||||
#region Public Methods
|
||||
#endregion Private Fields
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult MailingList()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
#region Public Constructors
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MailingList(Marketing marketing)
|
||||
{
|
||||
bool success = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(marketing.Email))
|
||||
public AccessController(ILogger<HomeController> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
|
||||
}
|
||||
51
CatherineLynwood/Controllers/AccountController.cs
Normal file
51
CatherineLynwood/Controllers/AccountController.cs
Normal file
@ -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<IActionResult> Login(string username, string password, string returnUrl = "/")
|
||||
{
|
||||
if (username == HardcodedUsername && password == HardcodedPassword)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
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<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync("MyCookieAuth");
|
||||
return RedirectToAction("Login");
|
||||
}
|
||||
}
|
||||
}
|
||||
142
CatherineLynwood/Controllers/AdminController.cs
Normal file
142
CatherineLynwood/Controllers/AdminController.cs
Normal file
@ -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<HomeController> _logger;
|
||||
private DataAccess _dataAccess;
|
||||
|
||||
#endregion Private Fields
|
||||
|
||||
#region Public Constructors
|
||||
|
||||
public AdminController(IWebHostEnvironment env, ILogger<HomeController> logger, DataAccess dataAccess)
|
||||
{
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
_dataAccess = dataAccess;
|
||||
}
|
||||
|
||||
#endregion Public Constructors
|
||||
|
||||
[Route("")]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("arc-readers")]
|
||||
public async Task<IActionResult> ArcReaders()
|
||||
{
|
||||
ARCReaderList aRCReaderList = await _dataAccess.GetAllARCReadersAsync();
|
||||
|
||||
return View(aRCReaderList);
|
||||
}
|
||||
|
||||
[Route("blog")]
|
||||
public async Task<IActionResult> Blog()
|
||||
{
|
||||
BlogAdminIndex blogAdminIndex = await _dataAccess.GetBlogAdminIndexAsync();
|
||||
|
||||
return View(blogAdminIndex);
|
||||
}
|
||||
|
||||
[HttpGet("blog/edit/{slug}")]
|
||||
public async Task<IActionResult> BlogEdit(string slug)
|
||||
{
|
||||
Blog blog = await _dataAccess.GetBlogItemAsync(slug);
|
||||
|
||||
ViewBag.ResponderList = await _dataAccess.GetResponderList();
|
||||
|
||||
return View(blog);
|
||||
}
|
||||
|
||||
[HttpPost("blog/edit/{slug}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -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<IActionResult> 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},<br>" +
|
||||
@ -60,7 +59,14 @@ namespace CatherineLynwood.Controllers
|
||||
$"<p>Catherine Lynwood<br>" +
|
||||
$"Author: The Alpha Flame<br>" +
|
||||
@$"Web: <a href=""https://www.catherinelynwood.com"">www.catherinelynwood.com</a></p>";
|
||||
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;
|
||||
|
||||
86
CatherineLynwood/Controllers/BlogController.cs
Normal file
86
CatherineLynwood/Controllers/BlogController.cs
Normal file
@ -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<IActionResult> 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<IActionResult> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -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<IActionResult> Index()
|
||||
{
|
||||
return View();
|
||||
Reviews reviews = await _dataAccess.GetReviewsAsync();
|
||||
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
|
||||
|
||||
return View(reviews);
|
||||
}
|
||||
|
||||
[Route("reviews")]
|
||||
public async Task<IActionResult> 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<string, object>
|
||||
{
|
||||
["@context"] = "https://schema.org",
|
||||
["@type"] = "Book",
|
||||
["name"] = "The Alpha Flame: Discovery",
|
||||
["alternateName"] = "The Alpha Flame Book 1",
|
||||
["image"] = imageUrl,
|
||||
["author"] = new Dictionary<string, object>
|
||||
{
|
||||
["@type"] = "Person",
|
||||
["name"] = "Catherine Lynwood",
|
||||
["url"] = "https://www.catherinelynwood.com"
|
||||
},
|
||||
["publisher"] = new Dictionary<string, object>
|
||||
{
|
||||
["@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<Dictionary<string, object>>();
|
||||
double total = 0;
|
||||
|
||||
foreach (var review in reviews.Items.Take(take))
|
||||
{
|
||||
total += review.RatingValue;
|
||||
|
||||
reviewObjects.Add(new Dictionary<string, object>
|
||||
{
|
||||
["@type"] = "Review",
|
||||
["author"] = new Dictionary<string, object>
|
||||
{
|
||||
["@type"] = "Person",
|
||||
["name"] = review.AuthorName
|
||||
},
|
||||
["datePublished"] = review.DatePublished.ToString("yyyy-MM-dd"),
|
||||
["reviewBody"] = StripHtml(review.ReviewBody),
|
||||
["reviewRating"] = new Dictionary<string, object>
|
||||
{
|
||||
["@type"] = "Rating",
|
||||
["ratingValue"] = review.RatingValue,
|
||||
["bestRating"] = "5"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
schema["review"] = reviewObjects;
|
||||
schema["aggregateRating"] = new Dictionary<string, object>
|
||||
{
|
||||
["@type"] = "AggregateRating",
|
||||
["ratingValue"] = (total / reviews.Items.Count).ToString("0.0", CultureInfo.InvariantCulture),
|
||||
["reviewCount"] = reviews.Items.Count
|
||||
};
|
||||
}
|
||||
|
||||
// Add work examples
|
||||
schema["workExample"] = new List<Dictionary<string, object>>
|
||||
{
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["@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<string, object>
|
||||
{
|
||||
["@type"] = "Offer",
|
||||
["price"] = "23.99",
|
||||
["priceCurrency"] = "GBP",
|
||||
["availability"] = "https://schema.org/InStock",
|
||||
["url"] = baseUrl
|
||||
}
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["@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<string, object>
|
||||
{
|
||||
["@type"] = "Offer",
|
||||
["price"] = "17.99",
|
||||
["priceCurrency"] = "GBP",
|
||||
["availability"] = "https://schema.org/InStock",
|
||||
["url"] = baseUrl
|
||||
}
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["@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<string, object>
|
||||
{
|
||||
["@type"] = "Offer",
|
||||
["price"] = "13.99",
|
||||
["priceCurrency"] = "GBP",
|
||||
["availability"] = "https://schema.org/InStock",
|
||||
["url"] = baseUrl
|
||||
}
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["@type"] = "Book",
|
||||
["bookFormat"] = "https://schema.org/EBook",
|
||||
["isbn"] = "978-1-0682258-3-3",
|
||||
["name"] = "The Alpha Flame: Discovery – eBook",
|
||||
["image"] = imageUrl,
|
||||
["offers"] = new Dictionary<string, object>
|
||||
{
|
||||
["@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
|
||||
}
|
||||
}
|
||||
@ -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<HomeController> _logger;
|
||||
private DataAccess _dataAccess;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
#endregion Private Fields
|
||||
|
||||
#region Public Constructors
|
||||
|
||||
public HomeController(ILogger<HomeController> logger, DataAccess dataAccess)
|
||||
public HomeController(ILogger<HomeController> 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<IActionResult> 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 = $"<strong>Email from: {contact.Name} ({contact.EmailAddress})</strong><br>{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<IActionResult> 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<IActionResult> 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();
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ namespace CatherineLynwood.Controllers
|
||||
{
|
||||
public class PublishingController : Controller
|
||||
{
|
||||
[Route("publishing")]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View();
|
||||
|
||||
@ -32,20 +32,24 @@ namespace CatherineLynwood.Controllers
|
||||
{
|
||||
var urls = new List<SitemapEntry>
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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<IActionResult> Blog(BlogFilter blogFilter)
|
||||
[Route("blog/{page:int}")]
|
||||
public async Task<IActionResult> 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<IActionResult> BlogItem(string slug, bool showThanks)
|
||||
public async Task<IActionResult> 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},<br>" +
|
||||
@ -135,7 +116,14 @@ namespace CatherineLynwood.Controllers
|
||||
$"<p>Catherine Lynwood<br>" +
|
||||
$"Author: The Alpha Flame<br>" +
|
||||
@$"Web: <a href=""https://www.catherinelynwood.com"">www.catherinelynwood.com</a></p>";
|
||||
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<IActionResult> 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<string, object>
|
||||
|
||||
34
CatherineLynwood/Helpers/BlogUrlHelper.cs
Normal file
34
CatherineLynwood/Helpers/BlogUrlHelper.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
153
CatherineLynwood/Middleware/SpamAndSecurityMiddleware.cs
Normal file
153
CatherineLynwood/Middleware/SpamAndSecurityMiddleware.cs
Normal file
@ -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<SpamAndSecurityMiddleware> _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<SpamAndSecurityMiddleware> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
CatherineLynwood/Models/ARCReaderApplication.cs
Normal file
53
CatherineLynwood/Models/ARCReaderApplication.cs
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
7
CatherineLynwood/Models/ARCReaderList.cs
Normal file
7
CatherineLynwood/Models/ARCReaderList.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace CatherineLynwood.Models
|
||||
{
|
||||
public class ARCReaderList
|
||||
{
|
||||
public List<ARCReaderApplication> Applications { get; set; } = new List<ARCReaderApplication>();
|
||||
}
|
||||
}
|
||||
54
CatherineLynwood/Models/ArcReaderApplicationModel.cs
Normal file
54
CatherineLynwood/Models/ArcReaderApplicationModel.cs
Normal file
@ -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<string> Platforms { get; set; }
|
||||
|
||||
[Display(Name = "Have you read / listened to any of the preview chapters on this website?")]
|
||||
public List<string> 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; }
|
||||
}
|
||||
}
|
||||
@ -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<BlogImage> BlogImages { get; set; } = new List<BlogImage>();
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
7
CatherineLynwood/Models/BlogAdminIndex.cs
Normal file
7
CatherineLynwood/Models/BlogAdminIndex.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace CatherineLynwood.Models
|
||||
{
|
||||
public class BlogAdminIndex
|
||||
{
|
||||
public List<BlogAdminIndexItem> BlogItems { get; set; }
|
||||
}
|
||||
}
|
||||
23
CatherineLynwood/Models/BlogAdminIndexItem.cs
Normal file
23
CatherineLynwood/Models/BlogAdminIndexItem.cs
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
13
CatherineLynwood/Models/BlogEditPage.cs
Normal file
13
CatherineLynwood/Models/BlogEditPage.cs
Normal file
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -8,8 +8,6 @@
|
||||
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
public List<int> Categories { get; set; }
|
||||
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
public int PreviousTotalPages { get; set; }
|
||||
|
||||
@ -4,15 +4,13 @@
|
||||
{
|
||||
#region Public Properties
|
||||
|
||||
public List<BlogCategory> BlogCategories { get; set; } = new List<BlogCategory>();
|
||||
|
||||
public BlogFilter BlogFilter { get; set; }
|
||||
|
||||
public List<Blog> Blogs { get; set; } = new List<Blog>();
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
13
CatherineLynwood/Models/BlogPost.cs
Normal file
13
CatherineLynwood/Models/BlogPost.cs
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
35
CatherineLynwood/Models/BlogPostRequest.cs
Normal file
35
CatherineLynwood/Models/BlogPostRequest.cs
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
23
CatherineLynwood/Models/BlogSummaryResponse.cs
Normal file
23
CatherineLynwood/Models/BlogSummaryResponse.cs
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
32
CatherineLynwood/Models/Reviews.cs
Normal file
32
CatherineLynwood/Models/Reviews.cs
Normal file
@ -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<Review> Items { get; set; } = new List<Review>();
|
||||
|
||||
public string SchemaJsonLd { get; set; }
|
||||
|
||||
#endregion Public Properties
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,8 @@ namespace CatherineLynwood
|
||||
// Add IHttpContextAccessor for accessing HTTP context in tag helpers
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddHostedService<IndexNowBackgroundService>();
|
||||
|
||||
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<RedirectsStore>(sp =>
|
||||
{
|
||||
@ -44,13 +60,12 @@ namespace CatherineLynwood
|
||||
|
||||
// ✅ Register the book access code service
|
||||
builder.Services.AddScoped<IAccessCodeService, AccessCodeService>();
|
||||
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
|
||||
|
||||
builder.Services.AddSingleton<ChapterAudioMapCache>();
|
||||
builder.Services.AddHostedService<ChapterAudioMapService>();
|
||||
builder.Services.AddSingleton<AudioTokenService>();
|
||||
|
||||
|
||||
|
||||
// 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<BlockPhpRequestsMiddleware>();
|
||||
app.UseMiddleware<BotFilterMiddleware>();
|
||||
app.UseMiddleware<RedirectToWwwMiddleware>();
|
||||
app.UseMiddleware<RefererValidationMiddleware>();
|
||||
app.UseMiddleware<SpamAndSecurityMiddleware>();
|
||||
app.UseMiddleware<HoneypotLoggingMiddleware>();
|
||||
app.UseMiddleware<IpqsBlockMiddleware>();
|
||||
|
||||
app.UseMiddleware<RedirectMiddleware>();
|
||||
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseResponseCompression();
|
||||
app.UseStaticFiles();
|
||||
app.UseWebMarkupMin();
|
||||
app.UseRouting();
|
||||
app.UseSession();
|
||||
|
||||
// ✅ Authentication must come before Authorization
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllerRoute(
|
||||
|
||||
@ -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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<AccessCode> GetAccessCodes()
|
||||
{
|
||||
List<AccessCode> accessCodes = new List<AccessCode>();
|
||||
@ -54,7 +234,6 @@ namespace CatherineLynwood.Services
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,9 +241,9 @@ namespace CatherineLynwood.Services
|
||||
return accessCodes;
|
||||
}
|
||||
|
||||
public async Task<Questions> GetQuestionsAsync()
|
||||
public async Task<List<BlogSummaryResponse>> GetAllBlogsAsync()
|
||||
{
|
||||
Questions questions = new Questions();
|
||||
List<BlogSummaryResponse> list = new List<BlogSummaryResponse>();
|
||||
|
||||
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<BlogIndex> GetBlogsAsync(string categoryIDs)
|
||||
public async Task<ARCReaderList> 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<BlogAdminIndex> GetBlogAdminIndexAsync()
|
||||
{
|
||||
BlogAdminIndex blogAdminIndex = new BlogAdminIndex();
|
||||
blogAdminIndex.BlogItems = new List<BlogAdminIndexItem>();
|
||||
|
||||
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<Blog> 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<BlogComments> 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<bool> AddBlogCommentAsync(BlogComment blogComment)
|
||||
public async Task<Blog> 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<bool> AddMarketingAsync(Marketing marketing)
|
||||
public async Task<BlogIndex> 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<List<BlogPost>> GetDueBlogPostsAsync()
|
||||
{
|
||||
List<BlogPost> blogPosts = new List<BlogPost>();
|
||||
|
||||
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<Questions> 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<List<SelectListItem>> GetResponderList()
|
||||
{
|
||||
List<SelectListItem> selectListItems = new List<SelectListItem>();
|
||||
|
||||
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<Reviews> 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<bool> 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<bool> Addhoneypot(DateTime dateTime, string ip, string country, string userAgent, string referer)
|
||||
public async Task<bool> 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<bool> AddQuestionAsync(Question question)
|
||||
public async Task<bool> 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<bool> 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<bool> 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);
|
||||
|
||||
113
CatherineLynwood/Services/IndexNowBackgroundService.cs
Normal file
113
CatherineLynwood/Services/IndexNowBackgroundService.cs
Normal file
@ -0,0 +1,113 @@
|
||||
namespace CatherineLynwood.Services
|
||||
{
|
||||
public class IndexNowBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<IndexNowBackgroundService> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly DataAccess _dataAccess;
|
||||
|
||||
public IndexNowBackgroundService(
|
||||
ILogger<IndexNowBackgroundService> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
CatherineLynwood/Services/SmtpEmailService.cs
Normal file
68
CatherineLynwood/Services/SmtpEmailService.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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($"<meta name=\"description\" content=\"{MetaDescription}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaKeywords))
|
||||
metaTags.AppendLine($"<meta name=\"keywords\" content=\"{MetaKeywords}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaAuthor))
|
||||
metaTags.AppendLine($"<meta name=\"author\" content=\"{MetaAuthor}\">");
|
||||
|
||||
// Open Graph meta tags
|
||||
if (!string.IsNullOrWhiteSpace(MetaTitle))
|
||||
metaTags.AppendLine($"<meta property=\"og:title\" content=\"{MetaTitle}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaDescription))
|
||||
metaTags.AppendLine($"<meta property=\"og:description\" content=\"{MetaDescription}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OgType))
|
||||
metaTags.AppendLine($"<meta property=\"og:type\" content=\"{OgType}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaUrl))
|
||||
metaTags.AppendLine($"<meta property=\"og:url\" content=\"{MetaUrl}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImage))
|
||||
metaTags.AppendLine($"<meta property=\"og:image\" content=\"{MetaImage}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImageAlt))
|
||||
metaTags.AppendLine($"<meta property=\"og:image:alt\" content=\"{MetaImageAlt}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OgSiteName))
|
||||
metaTags.AppendLine($"<meta property=\"og:site_name\" content=\"{OgSiteName}\">");
|
||||
|
||||
if (ArticlePublishedTime.HasValue)
|
||||
metaTags.AppendLine($"<meta property=\"article:published_time\" content=\"{ArticlePublishedTime.Value:yyyy-MM-ddTHH:mm:ssZ}\">");
|
||||
|
||||
if (ArticleModifiedTime.HasValue)
|
||||
metaTags.AppendLine($"<meta property=\"article:modified_time\" content=\"{ArticleModifiedTime.Value:yyyy-MM-ddTHH:mm:ssZ}\">");
|
||||
|
||||
// Twitter meta tags
|
||||
if (!string.IsNullOrWhiteSpace(TwitterCardType))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:card\" content=\"{TwitterCardType}\">");
|
||||
if (!string.IsNullOrWhiteSpace(MetaTitle))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:title\" content=\"{MetaTitle}\">");
|
||||
if (!string.IsNullOrWhiteSpace(MetaDescription))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:description\" content=\"{MetaDescription}\">");
|
||||
if (!string.IsNullOrWhiteSpace(TwitterSiteHandle))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:site\" content=\"{TwitterSiteHandle}\">");
|
||||
if (!string.IsNullOrWhiteSpace(TwitterCreatorHandle))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:creator\" content=\"{TwitterCreatorHandle}\">");
|
||||
if (!string.IsNullOrWhiteSpace(MetaImage))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
|
||||
if (!string.IsNullOrWhiteSpace(MetaImageAlt))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image:alt\" content=\"{MetaImageAlt}\">");
|
||||
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("<meta name=\"twitter:card\" content=\"player\">");
|
||||
metaTags.AppendLine($"<meta name=\"twitter:player\" content=\"{TwitterVideoUrl}\">");
|
||||
metaTags.AppendLine($"<meta name=\"twitter:player\" content=\"{playerUrl}\">");
|
||||
|
||||
if (TwitterPlayerWidth.HasValue)
|
||||
metaTags.AppendLine($"<meta name=\"twitter:player:width\" content=\"{TwitterPlayerWidth}\">");
|
||||
|
||||
if (TwitterPlayerHeight.HasValue)
|
||||
metaTags.AppendLine($"<meta name=\"twitter:player:height\" content=\"{TwitterPlayerHeight}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImage))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImageAlt))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image:alt\" content=\"{MetaImageAlt}\">");
|
||||
}
|
||||
else
|
||||
{
|
||||
metaTags.AppendLine($"<meta name=\"twitter:card\" content=\"{TwitterCardType}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaTitle))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:title\" content=\"{MetaTitle}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaDescription))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:description\" content=\"{MetaDescription}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImage))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImageAlt))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image:alt\" content=\"{MetaImageAlt}\">");
|
||||
}
|
||||
|
||||
// Output all content
|
||||
if (!string.IsNullOrWhiteSpace(TwitterSiteHandle))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:site\" content=\"{TwitterSiteHandle}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(TwitterCreatorHandle))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:creator\" content=\"{TwitterCreatorHandle}\">");
|
||||
|
||||
output.Content.SetHtmlContent(metaTags.ToString());
|
||||
}
|
||||
|
||||
#endregion Public Methods
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
71
CatherineLynwood/Views/Account/Login.cshtml
Normal file
71
CatherineLynwood/Views/Account/Login.cshtml
Normal file
@ -0,0 +1,71 @@
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
var returnUrl = ViewBag.ReturnUrl ?? "/";
|
||||
}
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-dark text-light text-center">
|
||||
<h4 class="mb-0">Admin Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@if (ViewBag.Error != null)
|
||||
{
|
||||
<div class="alert alert-danger">@ViewBag.Error</div>
|
||||
}
|
||||
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<input type="hidden" name="returnUrl" value="@returnUrl" />
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" class="form-control" id="username" name="username" placeholder="Username" required>
|
||||
<label for="username">Username</label>
|
||||
<div class="invalid-feedback">
|
||||
Please enter your username.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-4">
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
|
||||
<label for="password">Password</label>
|
||||
<div class="invalid-feedback">
|
||||
Please enter your password.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Enable Bootstrap client-side validation
|
||||
(() => {
|
||||
'use strict'
|
||||
const forms = document.querySelectorAll('.needs-validation')
|
||||
Array.from(forms).forEach(form => {
|
||||
form.addEventListener('submit', event => {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
form.classList.add('was-validated')
|
||||
}, false)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
}
|
||||
|
||||
@section Meta{
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
}
|
||||
63
CatherineLynwood/Views/Admin/ArcReaders.cshtml
Normal file
63
CatherineLynwood/Views/Admin/ArcReaders.cshtml
Normal file
@ -0,0 +1,63 @@
|
||||
@model CatherineLynwood.Models.ARCReaderList
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "ARC Reader List";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Admin" asp-action="Index">Admin Centre</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">ARC Readers</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>ARC Readers List</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
@foreach (var item in Model.Applications)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header bg-info text-black">
|
||||
<h5 class="card-title">@item.FullName</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<p class="card-text mb-1"><strong>Email:</strong> @item.Email</p>
|
||||
<p class="card-text mb-1"><strong>Kindle Email:</strong> @item.KindleEmail</p>
|
||||
<p class="card-text mb-1"><strong>Approved Sender:</strong> @item.ApprovedSender</p>
|
||||
<p class="card-text mb-1"><strong>Platforms:</strong> @item.Platforms</p>
|
||||
@if (!string.IsNullOrWhiteSpace(item.PlatformsOther))
|
||||
{
|
||||
<p class="card-text mb-1"><strong>Platforms Other:</strong> @item.PlatformsOther</p>
|
||||
}
|
||||
<p class="card-text mb-1"><strong>Preview Chapters:</strong> @item.PreviewChapters</p>
|
||||
@if (!string.IsNullOrWhiteSpace(item.ReviewLink))
|
||||
{
|
||||
<p class="card-text mb-1"><strong>Review Link:</strong> <a href="@item.ReviewLink" target="_blank">@item.ReviewLink</a></p>
|
||||
}
|
||||
<p class="card-text mb-1"><strong>Content Fit:</strong> @item.ContentFit</p>
|
||||
<p class="card-text mb-1"><strong>Review Commitment:</strong> @item.ReviewCommitment</p>
|
||||
@if (!string.IsNullOrWhiteSpace(item.ExtraNotes))
|
||||
{
|
||||
<p class="card-text mb-1"><strong>Extra Notes:</strong> @item.ExtraNotes</p>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer text-muted">
|
||||
Submitted on @item.SubmittedAt.ToString("d MMM yyyy")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
72
CatherineLynwood/Views/Admin/Blog.cshtml
Normal file
72
CatherineLynwood/Views/Admin/Blog.cshtml
Normal file
@ -0,0 +1,72 @@
|
||||
@model CatherineLynwood.Models.BlogAdminIndex
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Blog Admin";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Admin" asp-action="Index">Admin Centre</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Blog Index</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
@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";
|
||||
}
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header @rowCSS">
|
||||
<div class="row align-items-center p-2 rounded-3">
|
||||
<div class="col-8">
|
||||
<a href="/the-alpha-flame/blog/@item.BlogUrl" class="@textCSS text-decoration-none">@item.Title</a>
|
||||
</div>
|
||||
<div class="col-4 @textCSS">
|
||||
@item.PublishDate.ToShortDateString()
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div>
|
||||
<em>@item.SubTitle</em>
|
||||
</div>
|
||||
<div class="w-100 my-2"><hr /></div>
|
||||
<div>
|
||||
@item.IndexText
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a asp-controller="Admin" asp-action="BlogEdit" asp-route-slug="@item.BlogUrl" class="btn btn-dark">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
214
CatherineLynwood/Views/Admin/BlogEdit.cshtml
Normal file
214
CatherineLynwood/Views/Admin/BlogEdit.cshtml
Normal file
@ -0,0 +1,214 @@
|
||||
@model CatherineLynwood.Models.Blog
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Edit: {Model.Title}";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Admin" asp-action="Index">Admin Centre</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Admin" asp-action="Blog">Blog Index</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@Model.Title</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<form asp-action="BlogEdit" method="post" class="col-12" enctype="multipart/form-data">
|
||||
@* Validation summary *@
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
|
||||
<!-- Submit -->
|
||||
<div class="row align-items-center">
|
||||
<div class="col-8 col-md-10">
|
||||
<h1>Edit Blog Post</h1>
|
||||
</div>
|
||||
<div class="d-grid col-4 col-md-2">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Basic Info -->
|
||||
<h5 class="mt-4">Basic Info</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2">
|
||||
<label asp-for="BlogID" class="form-label"></label>
|
||||
<input asp-for="BlogID" class="form-control" readonly />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label asp-for="BlogUrl" class="form-label"></label>
|
||||
<input asp-for="BlogUrl" class="form-control" />
|
||||
<span asp-validation-for="BlogUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="PublishDate" class="form-label"></label>
|
||||
<input asp-for="PublishDate" type="datetime-local" class="form-control" />
|
||||
<span asp-validation-for="PublishDate" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ResponderID" class="form-label"></label>
|
||||
<select asp-for="ResponderID" asp-items="ViewBag.ResponderList" class="form-control"></select>
|
||||
<span asp-validation-for="ResponderID" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label asp-for="Likes" class="form-label"></label>
|
||||
<input asp-for="Likes" class="form-control" />
|
||||
<span asp-validation-for="Likes" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="Template" class="form-label"></label>
|
||||
<select asp-for="Template" class="form-control">
|
||||
<option value="default">Default</option>
|
||||
<option value="slideshow">Slideshow</option>
|
||||
</select>
|
||||
<span asp-validation-for="Template" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<div class="form-check">
|
||||
<input asp-for="Indexed" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="Indexed" class="form-check-label"></label>
|
||||
<span asp-validation-for="Indexed" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<div class="form-check">
|
||||
<input asp-for="Draft" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="Draft" class="form-check-label"></label>
|
||||
<span asp-validation-for="Draft" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Title" class="form-label"></label>
|
||||
<input asp-for="Title" class="form-control" />
|
||||
<span asp-validation-for="Title" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="SubTitle" class="form-label"></label>
|
||||
<input asp-for="SubTitle" class="form-control" />
|
||||
<span asp-validation-for="SubTitle" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h5 class="mt-4">Content</h5>
|
||||
<div class="mb-3 form-floating">
|
||||
<textarea asp-for="IndexText" class="form-control" style="height: 100px"></textarea>
|
||||
<label asp-for="IndexText"></label>
|
||||
<span asp-validation-for="IndexText" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-floating">
|
||||
<textarea asp-for="ContentTop" class="form-control" style="height: 150px"></textarea>
|
||||
<label asp-for="ContentTop"></label>
|
||||
<span asp-validation-for="ContentTop" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-floating">
|
||||
<textarea asp-for="ContentBottom" class="form-control" style="height: 150px"></textarea>
|
||||
<label asp-for="ContentBottom"></label>
|
||||
<span asp-validation-for="ContentBottom" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<h5 class="mt-4">Image</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="ImageUrl" class="form-label"></label>
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text"><i class="fad fa-camera"></i></span>
|
||||
<input asp-for="ImageUrl" class="form-control" readonly />
|
||||
</div>
|
||||
<input name="ImageUpload" type="file" accept=".png" class="form-control" />
|
||||
<span asp-validation-for="ImageUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="ImageAlt" class="form-label"></label>
|
||||
<textarea asp-for="ImageAlt" class="form-control" style="height: 85px;"></textarea>
|
||||
<span asp-validation-for="ImageAlt" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="ImageDescription" class="form-label"></label>
|
||||
<textarea asp-for="ImageDescription" class="form-control" style="height: 85px;"></textarea>
|
||||
<span asp-validation-for="ImageDescription" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input asp-for="ImageFirst" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="ImageFirst" class="form-check-label"></label>
|
||||
<span asp-validation-for="ImageFirst" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- Audio / Video -->
|
||||
<h5 class="mt-4">Audio / Video</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AudioTranscriptUrl" class="form-label"></label>
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text"><i class="fad fa-microphone"></i></span>
|
||||
<input asp-for="AudioTranscriptUrl" class="form-control" readonly />
|
||||
</div>
|
||||
<input name="AudioTranscriptUpload" type="file" accept=".mp3" class="form-control" />
|
||||
<span asp-validation-for="AudioTranscriptUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AudioTeaserUrl" class="form-label"></label>
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text"><i class="fad fa-microphone"></i></span>
|
||||
<input asp-for="AudioTeaserUrl" class="form-control" readonly />
|
||||
</div>
|
||||
<input name="AudioTeaserUpload" type="file" accept=".mp3" class="form-control" />
|
||||
<span asp-validation-for="AudioTeaserUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="VideoUrl" class="form-label"></label>
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text"><i class="fad fa-camera-movie"></i></span>
|
||||
<input asp-for="VideoUrl" class="form-control" readonly />
|
||||
</div>
|
||||
<input name="VideoUpload" type="file" accept=".mp4" class="form-control" />
|
||||
<span asp-validation-for="VideoUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 form-floating">
|
||||
<textarea asp-for="AudioTeaserText" class="form-control" style="height: 100px"></textarea>
|
||||
<label asp-for="AudioTeaserText"></label>
|
||||
<span asp-validation-for="AudioTeaserText" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- AI / Prompt -->
|
||||
<h5 class="mt-4">AI / Prompt</h5>
|
||||
<div class="mb-3 form-floating">
|
||||
<textarea asp-for="AiSummary" class="form-control" style="height: 100px"></textarea>
|
||||
<label asp-for="AiSummary"></label>
|
||||
<span asp-validation-for="AiSummary" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-floating">
|
||||
<textarea asp-for="ImagePrompt" class="form-control" style="height: 100px"></textarea>
|
||||
<label asp-for="ImagePrompt"></label>
|
||||
<span asp-validation-for="ImagePrompt" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="row">
|
||||
<div class="d-grid col-4 col-md-2 offset-8 offset-md-10">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
21
CatherineLynwood/Views/Admin/Index.cshtml
Normal file
21
CatherineLynwood/Views/Admin/Index.cshtml
Normal file
@ -0,0 +1,21 @@
|
||||
@{
|
||||
ViewData["Title"] = "Site Admin";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-4">
|
||||
<h1>Admin Centre</h1>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<div class="list-group">
|
||||
<a asp-action="ArcReaders" class="list-group-item list-group-item-action">ARC Reader Admin</a>
|
||||
<a asp-action="Blog" class="list-group-item list-group-item-action">Blog Admin</a>
|
||||
|
||||
@* <a href="#" class="list-group-item list-group-item-action">A third link item</a>
|
||||
<a href="#" class="list-group-item list-group-item-action">A fourth link item</a>
|
||||
<a class="list-group-item list-group-item-action disabled">A disabled link item</a> *@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,13 +32,8 @@
|
||||
<div class="col-12">
|
||||
<h1>Ask A Question</h1>
|
||||
</div>
|
||||
@if (ViewData["VpnWarning"] is true)
|
||||
{
|
||||
<partial name="_VPNWarning" />
|
||||
}
|
||||
|
||||
<div class="col-12">
|
||||
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.
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@ -69,7 +64,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form asp-action="AskQuestion" class="row" id="question">
|
||||
<form asp-action="AskQuestion" class="row" id="question" onsubmit="disableSubmit(this)">
|
||||
<div class="col-12 pb-3">
|
||||
<h2>Ask A Question</h2>
|
||||
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 @@
|
||||
|
||||
<!-- Placeholder Button and Submit Button -->
|
||||
<div class="d-grid">
|
||||
@if (ViewData["VpnWarning"] is true)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary mb-3" disabled>Ask Question</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-secondary mb-3" id="fakeSubmitButton">Ask Question</button>
|
||||
<button type="submit" class="btn btn-dark mb-3 d-none" id="submitButton">Ask Question</button>
|
||||
}
|
||||
|
||||
<button type="button" class="btn btn-secondary mb-3" id="fakeSubmitButton">Ask Question</button>
|
||||
<button type="submit" class="btn btn-dark mb-3 d-none" id="submitButton">Ask Question</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// JavaScript to handle checkbox and button toggle functionality
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const agreeCheckbox = document.getElementById("agreeTerms");
|
||||
const fakeSubmitButton = document.getElementById("fakeSubmitButton");
|
||||
const submitButton = document.getElementById("submitButton");
|
||||
const termsWarning = document.getElementById("termsWarning");
|
||||
|
||||
// Toggle buttons based on checkbox state
|
||||
agreeCheckbox.addEventListener("change", function () {
|
||||
if (this.checked) {
|
||||
fakeSubmitButton.classList.add("d-none"); // Hide placeholder button
|
||||
submitButton.classList.remove("d-none"); // Show real submit button
|
||||
termsWarning.classList.add("d-none"); // Hide warning
|
||||
} else {
|
||||
fakeSubmitButton.classList.remove("d-none"); // Show placeholder button
|
||||
submitButton.classList.add("d-none"); // Hide real submit button
|
||||
}
|
||||
});
|
||||
|
||||
// Show warning if placeholder button is clicked
|
||||
fakeSubmitButton.addEventListener("click", function () {
|
||||
termsWarning.classList.remove("d-none"); // Show warning
|
||||
agreeCheckbox.focus(); // Focus on checkbox
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@section Meta {
|
||||
<meta name="description" content="Have a question for Catherine Lynwood? Ask anything about her books, characters, writing process, or themes explored in The Alpha Flame. Get involved today!">
|
||||
|
||||
@ -203,4 +162,45 @@
|
||||
|
||||
<meta name="author" content="Catherine Lynwood">
|
||||
|
||||
}
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
// JavaScript to handle checkbox and button toggle functionality
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const agreeCheckbox = document.getElementById("agreeTerms");
|
||||
const fakeSubmitButton = document.getElementById("fakeSubmitButton");
|
||||
const submitButton = document.getElementById("submitButton");
|
||||
const termsWarning = document.getElementById("termsWarning");
|
||||
|
||||
// Toggle buttons based on checkbox state
|
||||
agreeCheckbox.addEventListener("change", function () {
|
||||
if (this.checked) {
|
||||
fakeSubmitButton.classList.add("d-none"); // Hide placeholder button
|
||||
submitButton.classList.remove("d-none"); // Show real submit button
|
||||
termsWarning.classList.add("d-none"); // Hide warning
|
||||
} else {
|
||||
fakeSubmitButton.classList.remove("d-none"); // Show placeholder button
|
||||
submitButton.classList.add("d-none"); // Hide real submit button
|
||||
}
|
||||
});
|
||||
|
||||
// Show warning if placeholder button is clicked
|
||||
fakeSubmitButton.addEventListener("click", function () {
|
||||
termsWarning.classList.remove("d-none"); // Show warning
|
||||
agreeCheckbox.focus(); // Focus on checkbox
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function disableSubmit(form) {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerText = 'Sending...'; // Optional
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -53,9 +53,7 @@
|
||||
<a asp-controller="Discovery" asp-action="Listen" class="btn btn-dark btn-sm">Listen to the Book</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (accessLevel >= 4)
|
||||
{
|
||||
|
||||
<div class="card extra-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Scrapbook: Maggie’s Designs</h5>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
@ -7,7 +11,7 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Discovery</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -20,7 +24,7 @@
|
||||
<div class="col-md-4">
|
||||
<section id="book-cover">
|
||||
<div class="card character-card" id="cover-card">
|
||||
<responsive-image src="the-alpha-flame-11.png" class="card-img-top" alt="The Alpha Flame book cover — gritty 1980s Birmingham crime novel about twin sisters uncovering secrets and surviving abuse" display-width-percentage="50"></responsive-image>
|
||||
<responsive-image src="the-alpha-flame-discovery-cover.png" class="card-img-top" alt="The Alpha Flame book cover — gritty 1980s Birmingham crime novel about twin sisters uncovering secrets and surviving abuse" display-width-percentage="50"></responsive-image>
|
||||
<div class="card-body border-top border-3 border-dark">
|
||||
<h3 class="card-title">The Front Cover</h3>
|
||||
<p class="card-text">This is the final front cover of The Alpha Flame: Discovery. It features Maggie stood outside the derelict Rubery Hill Hospital.</p>
|
||||
@ -29,76 +33,238 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Synopsis Section -->
|
||||
<div class="col-md-8">
|
||||
<section id="synopsis">
|
||||
<div class="card character-card" id="synopsis-card">
|
||||
<div class="card-header">
|
||||
<h1>The Alpha Flame: <span class="fw-light">Discovery</span><br /><span class="h2">A Gritty 1980s Birmingham Crime Novel</span></h1>
|
||||
<h2 class="h3">Survival, secrets, and sisters in 1980s Birmingham.</h2>
|
||||
</div>
|
||||
<div class="card-body" id="synopsis-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-2">
|
||||
<responsive-image src="catherine-lynwood-16.png" class="img-fluid rounded-circle border border-2 border-dark" alt="Catherine Lynwood" display-width-percentage="100"></responsive-image>
|
||||
@if (showReviews)
|
||||
{
|
||||
<!-- Buy Section -->
|
||||
<div class="col-md-8">
|
||||
<section id="purchase-and-reviews">
|
||||
<div class="card character-card" id="companion-card">
|
||||
<div class="card-header">
|
||||
<h1>The Alpha Flame: <span class="fw-light">Discovery</span><br /><span class="h2">A Gritty 1980s Birmingham Crime Novel</span></h1>
|
||||
<h2 class="h3">Survival, secrets, and sisters in 1980s Birmingham.</h2>
|
||||
</div>
|
||||
<div class="card-body" id="companion-body">
|
||||
<div class="p-2">
|
||||
<h2 class="h5">Buy the Book</h2>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<!-- Audio Section -->
|
||||
<div class="audio-player text-center">
|
||||
<audio id="player">
|
||||
<source src="/audio/the-alpha-flame-discovery-synopsis.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
<p class="text-center text-muted small">
|
||||
Listen to Catherine telling you about The Alpha Flame: Discovery
|
||||
<!-- Buy Now Section -->
|
||||
<div id="buy-now" class="mb-4">
|
||||
<a id="kindleLink" href="https://www.amazon.com/dp/B0FBS427VD" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Kindle Edition
|
||||
</a>
|
||||
<a id="paperbackLink" href="https://www.amazon.co.uk/dp/1068225815" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Paperback (Bookshop Edition)
|
||||
</a>
|
||||
<a id="hardbackLink" href="https://www.amazon.co.uk/dp/1068225807" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Hardback (Collector's Edition)
|
||||
</a>
|
||||
<p id="geoNote" class="text-muted small mt-2">
|
||||
Available from your local Amazon store.<br />
|
||||
Or order from your local bookshop using:
|
||||
<ul class="small text-muted pl-3 mb-1">
|
||||
<li>ISBN 978-1-0682258-1-9 – Bookshop Edition (Paperback)</li>
|
||||
<li>ISBN 978-1-0682258-0-2 – Collector's Edition (Hardback)</li>
|
||||
</ul>
|
||||
<span id="extraRetailers"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reader Reviews -->
|
||||
<div class="reader-reviews">
|
||||
<h3 class="h6 text-uppercase text-muted">★ Reader Praise ★</h3>
|
||||
|
||||
@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");
|
||||
|
||||
<blockquote class="blockquote mb-4">
|
||||
<span class="mb-2 text-warning">
|
||||
@for (int i = 0; i < fullStars; i++)
|
||||
{
|
||||
<i class="fad fa-star"></i>
|
||||
}
|
||||
@if (hasHalfStar)
|
||||
{
|
||||
<i class="fad fa-star-half-alt"></i>
|
||||
}
|
||||
@for (int i = 0; i < emptyStars; i++)
|
||||
{
|
||||
<i class="fad fa-star" style="--fa-primary-opacity: 0.2; --fa-secondary-opacity: 0.2;"></i>
|
||||
}
|
||||
</span>
|
||||
@Html.Raw(review.ReviewBody)
|
||||
<footer>
|
||||
@review.AuthorName on <cite title="@review.SiteName">
|
||||
@if (string.IsNullOrEmpty(review.URL))
|
||||
{
|
||||
@review.SiteName
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@review.URL" target="_blank">@review.SiteName</a>
|
||||
}
|
||||
</cite> — <span class="text-muted smaller">@reviewDate</span>
|
||||
</footer>
|
||||
</blockquote>
|
||||
}
|
||||
|
||||
@if (Model.Items.Count > 3)
|
||||
{
|
||||
<div class="text-end">
|
||||
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">
|
||||
Read More Reviews
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="buy-now" class="my-4">
|
||||
<a id="kindleLink" href="https://www.amazon.com/dp/B0FBS427VD" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Kindle Edition
|
||||
</a>
|
||||
<a id="paperbackLink" href="https://www.amazon.co.uk/dp/1068225815" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Paperback (Bookshop Edition)
|
||||
</a>
|
||||
<p id="geoNote" class="text-muted small mt-2">
|
||||
Available from your local Amazon store.<br />
|
||||
Or order from your local bookshop using:
|
||||
<ul class="small text-muted">
|
||||
<li>
|
||||
ISBN 978-1-0682258-1-9 - Bookshop Edition (Paperback)
|
||||
</li>
|
||||
<li>
|
||||
ISBN 978-1-0682258-0-2 - Collector's Eidtion (Hardback)
|
||||
</li>
|
||||
</ul>
|
||||
<span id="extraRetailers"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Synopsis Section -->
|
||||
<div class="col-md-12">
|
||||
<section id="synopsis">
|
||||
<div class="card character-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title h1">The Alpha Flame: <span class="fw-light">Discovery:</span> Synopsis</h2>
|
||||
</div>
|
||||
<div class="card-body" id="synopsis-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-2">
|
||||
<responsive-image src="catherine-lynwood-16.png" class="img-fluid rounded-circle border border-2 border-dark" alt="Catherine Lynwood" display-width-percentage="20"></responsive-image>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<!-- Audio Section -->
|
||||
<div class="audio-player text-center">
|
||||
<audio id="player">
|
||||
<source src="/audio/the-alpha-flame-discovery-synopsis.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
<p class="text-center text-muted small">
|
||||
Listen to Catherine telling you about The Alpha Flame: Discovery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Synopsis Content -->
|
||||
<h3 class="card-title">Synopsis</h3>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Synopsis Section -->
|
||||
<div class="col-md-8">
|
||||
<section id="synopsis">
|
||||
<div class="card character-card" id="companion-card">
|
||||
<div class="card-header">
|
||||
<h1>The Alpha Flame: <span class="fw-light">Discovery</span><br /><span class="h2">A Gritty 1980s Birmingham Crime Novel</span></h1>
|
||||
<h2 class="h3">Survival, secrets, and sisters in 1980s Birmingham.</h2>
|
||||
</div>
|
||||
<div class="card-body" id="companion-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-2">
|
||||
<responsive-image src="catherine-lynwood-16.png" class="img-fluid rounded-circle border border-2 border-dark" alt="Catherine Lynwood" display-width-percentage="20"></responsive-image>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<!-- Audio Section -->
|
||||
<div class="audio-player text-center">
|
||||
<audio id="player">
|
||||
<source src="/audio/the-alpha-flame-discovery-synopsis.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
<p class="text-center text-muted small">
|
||||
Listen to Catherine telling you about The Alpha Flame: Discovery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="buy-now" class="my-4">
|
||||
<a id="kindleLink" href="https://www.amazon.co.uk/dp/B0FBS427VD" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Kindle Edition
|
||||
</a>
|
||||
<a id="paperbackLink" href="https://www.amazon.co.uk/dp/1068225815" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Paperback (Bookshop Edition)
|
||||
</a>
|
||||
<a id="hardbackLink" href="https://www.amazon.co.uk/dp/1068225807" target="_blank" class="btn btn-dark mb-2">
|
||||
Buy Hardback (Collector's Edition)
|
||||
</a>
|
||||
<p id="geoNote" class="text-muted small mt-2">
|
||||
Available from your local Amazon store.<br />
|
||||
Or order from your local bookshop using:
|
||||
<ul class="small text-muted">
|
||||
<li>
|
||||
ISBN 978-1-0682258-1-9 - Bookshop Edition (Paperback)
|
||||
</li>
|
||||
<li>
|
||||
ISBN 978-1-0682258-0-2 - Collector's Eidtion (Hardback)
|
||||
</li>
|
||||
</ul>
|
||||
<span id="extraRetailers"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Synopsis Content -->
|
||||
<h3 class="card-title">Synopsis</h3>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Synopsis Content -->
|
||||
<h3 class="card-title">Synopsis</h3>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
<p class="card-text">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.</p>
|
||||
|
||||
|
||||
<!-- Giveway Section -->
|
||||
@if (DateTime.Now < new DateTime(2025, 9, 1))
|
||||
{
|
||||
<div class="col-md-12 mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="display-6 fw-bold">Win: <span class="fw-light">a Collector’s Edition of The Alpha Flame: Discovery</span></h2>
|
||||
<p class="mb-2">
|
||||
Enter my giveaway for your chance to own this special edition. Signed in the UK, or delivered as a premium collector’s copy worldwide.
|
||||
</p>
|
||||
<em>Exclusive, limited, beautiful.</em>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-8 col-md-4 mt-4">
|
||||
<a asp-controller="TheAlphaFlame" asp-action="Giveaways" class="btn btn-dark">Enter now for your chance.</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Chapter Previews Section -->
|
||||
@ -149,17 +315,17 @@
|
||||
<script>
|
||||
window.addEventListener("load", function () {
|
||||
const coverCard = document.getElementById("cover-card");
|
||||
const synopsisCard = document.getElementById("synopsis-card");
|
||||
const synopsisBody = document.getElementById("synopsis-body");
|
||||
const companionCard = document.getElementById("companion-card");
|
||||
const companionBody = document.getElementById("companion-body");
|
||||
|
||||
if (coverCard && synopsisCard && synopsisBody) {
|
||||
if (coverCard && companionCard && companionBody) {
|
||||
// Match the height of the synopsis card to the cover card
|
||||
const coverHeight = coverCard.offsetHeight;
|
||||
synopsisCard.style.height = `${coverHeight}px`;
|
||||
companionCard.style.height = `${coverHeight}px`;
|
||||
|
||||
// Adjust the synopsis body to scroll within the matched height
|
||||
const headerHeight = synopsisCard.querySelector(".card-header").offsetHeight;
|
||||
synopsisBody.style.maxHeight = `${coverHeight - headerHeight}px`;
|
||||
const headerHeight = companionCard.querySelector(".card-header").offsetHeight;
|
||||
companionBody.style.maxHeight = `${coverHeight - headerHeight}px`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -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 <a href="https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225819" target="_blank">Waterstons</a>';
|
||||
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 <a href="https://www.barnesandnoble.com/s/9781068225810" target="_blank">Barnes & Noble</a>';
|
||||
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;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "Book",
|
||||
"name": "The Alpha Flame: Discovery",
|
||||
"alternateName": "The Alpha Flame Book 1",
|
||||
"image": "https://www.catherinelynwood.com/images/webp/the-alpha-flame-11-1200.webp",
|
||||
"author": {
|
||||
"@@type": "Person",
|
||||
"name": "Catherine Lynwood",
|
||||
"url": "https://www.catherinelynwood.com"
|
||||
},
|
||||
"publisher": {
|
||||
"@@type": "Organization",
|
||||
"name": "Catherine Lynwood Publishing",
|
||||
"url": "https://www.catherinelynwood.com"
|
||||
},
|
||||
"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. As past traumas resurface and danger closes in, their journey through survival, sisterhood, and redemption begins.",
|
||||
"genre": "Women's Fiction, Mystery, Contemporary Historical",
|
||||
"inLanguage": "en-GB",
|
||||
"url": "https://www.catherinelynwood.com/the-alpha-flame/discovery",
|
||||
"workExample": [
|
||||
{
|
||||
"@@type": "Book",
|
||||
"bookFormat": "https://schema.org/Hardcover",
|
||||
"isbn": "978-1-0682258-0-2",
|
||||
"name": "The Alpha Flame: Discovery – Hardback",
|
||||
"url": "https://www.catherinelynwood.com/the-alpha-flame/discovery",
|
||||
"offers": {
|
||||
"@@type": "Offer",
|
||||
"price": 23.99,
|
||||
"priceCurrency": "GBP",
|
||||
"availability": "https://schema.org/InStock"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@@type": "Book",
|
||||
"bookFormat": "https://schema.org/Paperback",
|
||||
"isbn": "978-1-0682258-1-9",
|
||||
"name": "The Alpha Flame: Discovery – Paperback (Bookshop Edition)",
|
||||
"url": "https://www.catherinelynwood.com/the-alpha-flame/discovery",
|
||||
"offers": {
|
||||
"@@type": "Offer",
|
||||
"price": 17.99,
|
||||
"priceCurrency": "GBP",
|
||||
"availability": "https://schema.org/InStock"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@@type": "Book",
|
||||
"bookFormat": "https://schema.org/Paperback",
|
||||
"isbn": "978-1-0682258-2-6",
|
||||
"name": "The Alpha Flame: Discovery – Amazon Edition",
|
||||
"url": "https://www.catherinelynwood.com/the-alpha-flame/discovery",
|
||||
"offers": {
|
||||
"@@type": "Offer",
|
||||
"price": 14.99,
|
||||
"priceCurrency": "GBP",
|
||||
"availability": "https://schema.org/InStock"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@@type": "Book",
|
||||
"bookFormat": "https://schema.org/EBook",
|
||||
"isbn": "978-1-0682258-3-3",
|
||||
"name": "The Alpha Flame: Discovery – eBook",
|
||||
"url": "https://www.catherinelynwood.com/the-alpha-flame/discovery",
|
||||
"offers": {
|
||||
"@@type": "Offer",
|
||||
"price": 3.95,
|
||||
"priceCurrency": "GBP",
|
||||
"availability": "https://schema.org/InStock"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@Html.Raw(Model.SchemaJsonLd)
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
101
CatherineLynwood/Views/Discovery/Reviews.cshtml
Normal file
101
CatherineLynwood/Views/Discovery/Reviews.cshtml
Normal file
@ -0,0 +1,101 @@
|
||||
@model CatherineLynwood.Models.Reviews
|
||||
@{
|
||||
ViewData["Title"] = "Reader Reviews – The Alpha Flame: Discovery";
|
||||
}
|
||||
|
||||
<div class="container my-5">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Reviews</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<section class="mb-5 text-center">
|
||||
<h1 class="display-5 fw-bold">Reader Reviews</h1>
|
||||
<p class="lead">Here’s what readers are saying about <em>The Alpha Flame: Discovery</em>. If you’ve read the book, we’d love for you to share your thoughts too.</p>
|
||||
</section>
|
||||
|
||||
<section class="reader-reviews">
|
||||
@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");
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<blockquote class="blockquote ms-3 me-3 mt-3">
|
||||
<span class="mb-2 text-warning">
|
||||
@for (int i = 0; i < fullStars; i++)
|
||||
{
|
||||
<i class="fad fa-star"></i>
|
||||
}
|
||||
@if (hasHalfStar)
|
||||
{
|
||||
<i class="fad fa-star-half-alt"></i>
|
||||
}
|
||||
@for (int i = 0; i < emptyStars; i++)
|
||||
{
|
||||
<i class="fad fa-star" style="--fa-primary-opacity: 0.2; --fa-secondary-opacity: 0.2;"></i>
|
||||
}
|
||||
</span>
|
||||
@Html.Raw(review.ReviewBody)
|
||||
<footer class="blockquote-footer mt-2">
|
||||
@review.AuthorName on
|
||||
<cite title="@review.SiteName">
|
||||
@if (string.IsNullOrEmpty(review.URL))
|
||||
{
|
||||
@review.SiteName
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@review.URL" target="_blank" rel="noopener">@review.SiteName</a>
|
||||
}
|
||||
</cite> — <span class="text-muted small">@reviewDate</span>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted text-center">There are no reviews to display yet. Be the first to leave one!</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Meta{
|
||||
<MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood"
|
||||
meta-description="A gritty 1980s Birmingham crime novel about twin sisters uncovering dark family secrets and surviving abuse. Realistic, powerful, and unflinching — discover The Alpha Flame today."
|
||||
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-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)"
|
||||
article-modified-time="@new DateTime(2025, 06, 07)"
|
||||
twitter-card-type="summary_large_image"
|
||||
twitter-site-handle="@@CathLynwood"
|
||||
twitter-creator-handle="@@CathLynwood" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
@Html.Raw(Model.SchemaJsonLd)
|
||||
</script>
|
||||
|
||||
}
|
||||
@ -17,7 +17,7 @@
|
||||
<div class="float-md-start w-md-50 p-3">
|
||||
<figure>
|
||||
<responsive-image src="catherine-lynwood-15.png" alt="Catherine Lynwood" class="img-fluid rounded-5 border border-3 border-dark shadow-lg" display-width-percentage="50"></responsive-image>
|
||||
<figcaption>Catherin Lynwood</figcaption>
|
||||
<figcaption>Catherine Lynwood</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<header>
|
||||
@ -37,9 +37,10 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p>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 <a asp-controller="TheAlphaFlame" asp-action="Index" class="link-dark fw-bold">The Alpha Flame</a> 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.</p>
|
||||
<p>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 <a asp-controller="TheAlphaFlame" asp-action="Index" class="link-dark fw-bold">The Alpha Flame</a> as a deeply personal exploration of resilience, mystery, and identity. The novel helped define a new genre Catherine now refers to as <a asp-controller="Home" asp-action="VerosticGenre" class="link-dark fw-bold">Verostic fiction</a> — a style grounded in emotional realism, psychological depth, and unapologetic truth. <em>The Alpha Flame</em>, set in the emotional and cultural landscape of 1980s Britain, is also <strong>Aeverostic</strong> in tone — where the past is not just a backdrop, but a living, breathing part of the story’s truth.</p>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<p>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.</p>
|
||||
</section>
|
||||
@ -48,9 +49,9 @@
|
||||
<p>Balancing family life with her career, Catherine believes in nurturing a creative household. She encourages her daughter <a asp-action="SamanthaLynwood" class="link-dark fw-bold">Samantha</a> 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.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@section Meta {
|
||||
<meta name="description" content="Learn about Catherine Lynwood, author of *The Alpha Flame*, a compelling novel of romance, mystery, and family secrets. Discover Catherine’s inspirations, her journey as a novelist, and her commitment to stories featuring strong female characters and intricate plots.">
|
||||
<meta name="keywords" content="Catherine Lynwood author, about Catherine Lynwood, Catherine Lynwood biography, women’s fiction author, mystery and romance novelist, family secrets book author, female protagonists in fiction, Catherine Lynwood novels, women’s novel writer, The Alpha Flame author, female fiction writers">
|
||||
|
||||
344
CatherineLynwood/Views/Home/ArcReaderApplication.cshtml
Normal file
344
CatherineLynwood/Views/Home/ArcReaderApplication.cshtml
Normal file
@ -0,0 +1,344 @@
|
||||
@model CatherineLynwood.Models.ArcReaderApplicationModel
|
||||
@{
|
||||
ViewData["Title"] = "ARC Reader Application";
|
||||
}
|
||||
|
||||
<section class="container my-5">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h2 class="mb-0">Catherine Lynwood ARC Reader Application</h2>
|
||||
<p class="mb-0"><em>For <strong>The Alpha Flame: Discovery</strong> – Advance Reader Copy (ARC)</em></p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form asp-action="ArcReaderApplication" id="arcWizardForm" novalidate method="post">
|
||||
<div class="progress mb-4" style="height: 1.5rem;">
|
||||
<div id="arcWizardProgress" class="progress-bar bg-success" style="width: 25%;">
|
||||
Step 1 of 5
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arc-step" style="min-height: 65vh;" data-step="1">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<section class="arc-intro">
|
||||
<h1 class="display-5"><i class="fad fa-book-reader me-2 text-primary"></i>Become an ARC Reader</h1>
|
||||
<p class="lead">Fancy reading <strong>The Alpha Flame: Discovery</strong> before anyone else? I'm looking for passionate early readers to receive a free Kindle copy in exchange for an honest review.</p>
|
||||
|
||||
<div class="alert alert-warning mt-4">
|
||||
<i class="fad fa-exclamation-triangle me-2"></i>
|
||||
<strong>Note:</strong> This novel is raw and emotional. Please check the themes below.
|
||||
</div>
|
||||
<h2 class="h5 mt-4"><i class="fad fa-tags me-2 text-secondary"></i>Major Themes and Topics</h2>
|
||||
<ul class="fa-ul">
|
||||
<li><span class="fa-li"><i class="fad fa-skull-crossbones text-danger"></i></span> Death, trauma, and grief through the eyes of a teenage girl</li>
|
||||
<li><span class="fa-li"><i class="fad fa-hand-rock text-danger"></i></span> Physical and sexual abuse (non-graphic, but deeply affecting)</li>
|
||||
<li><span class="fa-li"><i class="fad fa-procedures text-muted"></i></span> Mental health, including suicidal thoughts and PTSD</li>
|
||||
<li><span class="fa-li"><i class="fad fa-money-bill-wave text-warning"></i></span> Prostitution, exploitation, and coercion</li>
|
||||
<li><span class="fa-li"><i class="fad fa-bomb text-danger"></i></span> Violence against women (threats, assaults, murder)</li>
|
||||
<li><span class="fa-li"><i class="fad fa-hand-holding-heart text-success"></i></span> Love, trust, tenderness, and emotional recovery</li>
|
||||
<li><span class="fa-li"><i class="fad fa-transgender text-info"></i></span> Sexuality, orientation, and identity discovery</li>
|
||||
<li><span class="fa-li"><i class="fad fa-user-friends text-success"></i></span> Found family, sisterhood, and female empowerment</li>
|
||||
<li><span class="fa-li"><i class="fad fa-mask text-dark"></i></span> Corruption, manipulation, and cover-ups</li>
|
||||
<li><span class="fa-li"><i class="fad fa-gavel text-dark"></i></span> Justice, revenge, and difficult moral choices</li>
|
||||
<li><span class="fa-li"><i class="fad fa-music text-secondary"></i></span> Music and fashion as creative expression and survival</li>
|
||||
</ul>
|
||||
<p class="mt-4">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.</p>
|
||||
<p><strong>All I ask:</strong> read the book and leave a review on Goodreads, Amazon, or wherever you normally post.</p>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<responsive-image src="the-alpha-flame-discovery-cover.png" class="img-fluid rounded-5 border border-3 border-dark" alt="The Alpha Flame book cover — gritty 1980s Birmingham crime novel about twin sisters uncovering secrets and surviving abuse" display-width-percentage="50"></responsive-image>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary arc-next">Continue</button>
|
||||
</div>
|
||||
<div class="arc-step d-none" style="min-height: 65vh;" data-step="2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
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.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="FullName"></label>
|
||||
<input asp-for="FullName" class="form-control" />
|
||||
<span asp-validation-for="FullName" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email"></label>
|
||||
<input asp-for="Email" class="form-control" />
|
||||
<small class="form-text text-muted">This is where I’ll send updates and reminders.</small>
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<button type="button" class="btn btn-secondary arc-back">Back</button>
|
||||
<button type="button" class="btn btn-primary arc-next">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arc-step d-none" style="min-height: 65vh;" data-step="3">
|
||||
<div class="row">
|
||||
<div class="col-9">
|
||||
<div class="alert alert-info">
|
||||
<p>ARCs are sent via Kindle only. You can use the free Kindle app or a Kindle device.</p>
|
||||
<p>Add the following sender to your Amazon Kindle Approved Senders list: <strong id="arc-email">[enable JavaScript to view]</strong></p>
|
||||
<p>
|
||||
To find your kindle email follow this link <a href="https://www.amazon.co.uk/myk" target="_blank">amazon.co.uk/myk</a>.
|
||||
Once there click on Preferences and then scroll down to Personal Document Settings, as shown in the screen shot.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="KindleEmail"></label>
|
||||
<div class="input-group mb-3">
|
||||
<input asp-for="KindleEmail" class="form-control" />
|
||||
<span class="input-group-text">@@kindle.com</span>
|
||||
</div>
|
||||
<span asp-validation-for="KindleEmail" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ApprovedSender"></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ApprovedSender" value="Yes" id="ApprovedYes" />
|
||||
<label class="form-check-label" for="ApprovedYes">Yes — I’ve added your email</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ApprovedSender" value="NotYet" id="ApprovedNotYet" />
|
||||
<label class="form-check-label" for="ApprovedNotYet">Not yet — but I will</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ApprovedSender" value="NeedHelp" id="ApprovedNeedHelp" />
|
||||
<label class="form-check-label" for="ApprovedNeedHelp">I need help</label>
|
||||
</div>
|
||||
<span asp-validation-for="ApprovedSender" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<responsive-image src="kindle-setup.png" class="img-fluid rounded-5 border border-3 border-dark" alt="Kindle setup screen shot" display-width-percentage="50"></responsive-image>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<button type="button" class="btn btn-secondary arc-back">Back</button>
|
||||
<button type="button" class="btn btn-primary arc-next">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arc-step d-none" style="min-height: 65vh;" data-step="4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- Review Platforms -->
|
||||
<div class="alert alert-info mt-3">
|
||||
Please let me know what platforms you usually post your reviews on.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Platforms"></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="Platforms" value="Amazon" id="PlatformAmazon" />
|
||||
<label class="form-check-label" for="PlatformAmazon">Amazon</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="Platforms" value="Goodreads" id="PlatformGoodreads" />
|
||||
<label class="form-check-label" for="PlatformGoodreads">Goodreads</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="Platforms" value="Instagram" id="PlatformInstagram" />
|
||||
<label class="form-check-label" for="PlatformInstagram">Instagram</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="Platforms" value="TikTok" id="PlatformTikTok" />
|
||||
<label class="form-check-label" for="PlatformTikTok">TikTok</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="Platforms" value="YourTube" id="PlatformYouTube" />
|
||||
<label class="form-check-label" for="PlatformYouTube">YouTube</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="Platforms" value="Blog" id="PlatformBlog" />
|
||||
<label class="form-check-label" for="PlatformBlog">Blog</label>
|
||||
</div>
|
||||
<label asp-for="PlatformsOther"></label>
|
||||
<input class="form-control mt-2" asp-for="PlatformsOther" />
|
||||
<span asp-validation-for="PlatformsOther" class="text-danger"></span>
|
||||
</div>
|
||||
<!-- Review Link -->
|
||||
<div class="form-group">
|
||||
<label asp-for="ReviewLink"></label>
|
||||
<input asp-for="ReviewLink" class="form-control" />
|
||||
<span asp-validation-for="ReviewLink" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<button type="button" class="btn btn-secondary arc-back">Back</button>
|
||||
<button type="button" class="btn btn-primary arc-next">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arc-step d-none" style="min-height: 65vh;" data-step="5">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- Content Fit -->
|
||||
<div class="alert alert-info mt-3">
|
||||
I'm interested in the type of fiction you enjoy reading. I write what I describe as <a asp-controller="Home" asp-action="VerosticGenre" class="alert-link">verostic fiction</a>, I've even got a page on this website describing it.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="ContentFit"></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ContentFit" value="Yes" id="ContentYes" />
|
||||
<label class="form-check-label" for="ContentYes">Yes — I love that sort of book</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ContentFit" value="Maybe" id="ContentMaybe" />
|
||||
<label class="form-check-label" for="ContentMaybe">Sounds interesting</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ContentFit" value="No" id="ContentNo" />
|
||||
<label class="form-check-label" for="ContentNo">I prefer lighter reads</label>
|
||||
</div>
|
||||
<span asp-validation-for="ContentFit" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- Preview Chapters -->
|
||||
<div class="form-group">
|
||||
<label asp-for="PreviewChapters"></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="PreviewChapters" value="1" id="Chapter1" />
|
||||
<label class="form-check-label" for="Chapter1">Chapter 1</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="PreviewChapters" value="2" id="Chapter2" />
|
||||
<label class="form-check-label" for="Chapter2">Chapter 2</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="PreviewChapters" value="13" id="Chapter13" />
|
||||
<label class="form-check-label" for="Chapter13">Chapter 13</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Commitment -->
|
||||
<div class="form-group">
|
||||
<label asp-for="ReviewCommitment"></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ReviewCommitment" value="Yes" id="ReviewYes" />
|
||||
<label class="form-check-label" for="ReviewYes">Yes</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ReviewCommitment" value="Try" id="ReviewTry" />
|
||||
<label class="form-check-label" for="ReviewTry">I’ll try my best</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="ReviewCommitment" value="No" id="ReviewNo" />
|
||||
<label class="form-check-label" for="ReviewNo">Probably not</label>
|
||||
</div>
|
||||
<span asp-validation-for="ReviewCommitment" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- Extra Notes -->
|
||||
<div class="form-group">
|
||||
<label asp-for="ExtraNotes"></label>
|
||||
<textarea asp-for="ExtraNotes" class="form-control" rows="4" placeholder="How you found me, favourite genres, etc."></textarea>
|
||||
<span asp-validation-for="ExtraNotes" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mt-3">
|
||||
<strong>Privacy note:</strong> Your details will only be used for ARC-related communication and Kindle delivery. You can opt out at any time.
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary arc-back">Back</button>
|
||||
<button type="submit" class="btn btn-success">Submit Application</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const user = "catherine";
|
||||
const domain = "catherinelynwood.com";
|
||||
document.getElementById("arc-email").textContent = user + "@@" + domain;
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const steps = document.querySelectorAll('.arc-step');
|
||||
const progress = document.getElementById('arcWizardProgress');
|
||||
const form = document.getElementById('arcWizardForm');
|
||||
let currentStep = 1;
|
||||
|
||||
function showStep(step) {
|
||||
steps.forEach(s => s.classList.add('d-none'));
|
||||
const stepEl = document.querySelector(`.arc-step[data-step="${step}"]`);
|
||||
if (stepEl) stepEl.classList.remove('d-none');
|
||||
|
||||
const percent = (step / steps.length) * 100;
|
||||
if (progress) {
|
||||
progress.style.width = percent + '%';
|
||||
progress.textContent = `Step ${step} of ${steps.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCurrentStep() {
|
||||
const currentFields = document.querySelector(`.arc-step[data-step="${currentStep}"]`);
|
||||
const inputs = currentFields.querySelectorAll('input, select, textarea');
|
||||
let isValid = true;
|
||||
|
||||
inputs.forEach(input => {
|
||||
// This uses jQuery Unobtrusive Validation to validate Razor-bound fields
|
||||
if (!$(input).valid()) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
|
||||
document.querySelectorAll('.arc-next').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
if (validateCurrentStep()) {
|
||||
currentStep++;
|
||||
showStep(currentStep);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.arc-back').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
currentStep--;
|
||||
showStep(currentStep);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (!form.checkValidity()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Try to find first step with invalid field
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const stepFields = steps[i].querySelectorAll('input, select, textarea');
|
||||
for (const field of stepFields) {
|
||||
if (!field.checkValidity()) {
|
||||
currentStep = parseInt(steps[i].dataset.step);
|
||||
showStep(currentStep);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
showStep(currentStep);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
}
|
||||
@ -17,16 +17,12 @@
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<form asp-action="ContactCatherine" class="row">
|
||||
<form asp-action="ContactCatherine" method="post" onsubmit="disableSubmit(this)">
|
||||
<div class="col-12 col-sm-8 col-md-12">
|
||||
<h1>Contact Catherine</h1>
|
||||
<p>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.</p>
|
||||
<p>Use the form below to send Catherine a message.</p>
|
||||
</div>
|
||||
@if (ViewData["VpnWarning"] is true)
|
||||
{
|
||||
<partial name="_VPNWarning" />
|
||||
}
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="col-12 text-dark">
|
||||
<div class="form-floating mb-3">
|
||||
@ -46,15 +42,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
@if (ViewData["VpnWarning"] is true)
|
||||
{
|
||||
<button type="submit" class="btn btn-dark btn-block mb-3" disabled>Send Message</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="submit" class="btn btn-dark btn-block mb-3">Send Message</button>
|
||||
}
|
||||
|
||||
<button type="submit" class="btn btn-dark btn-block mb-3">Send Message</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -74,4 +62,17 @@
|
||||
"description": "Get in touch with Catherine Lynwood, author of *The Alpha Flame*, for inquiries and updates on her latest work."
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
function disableSubmit(form) {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerText = 'Sending...'; // Optional
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
@{
|
||||
ViewData["Title"] = "Collaboration Inquiry";
|
||||
}
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-md-10 offset-md-1">
|
||||
@ -39,3 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Meta{
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
}
|
||||
@ -43,13 +43,22 @@
|
||||
<a asp-controller="Discovery" asp-action="Extras" class="btn btn-outline-dark">Unlock Extras</a>
|
||||
</div>
|
||||
<p class="mt-4">
|
||||
<em>The Alpha Flame: Discovery</em>, writen by <a asp-controller="Home" asp-action="AboutCatherineLynwood" class="link-dark fw-semibold">Catherine Lynwood</a>, 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.
|
||||
<h2 class="h6 d-inline"><em>The Alpha Flame: Discovery</em></h2>, writen by <a asp-controller="Home" asp-action="AboutCatherineLynwood" class="link-dark fw-semibold">Catherine Lynwood</a>, 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 <strong>Reckoning</strong> (Spring 2026) and concludes with <strong>Redemption</strong> (Autumn 2026).
|
||||
Learn more about the full trilogy on the <a asp-controller="TheAlphaFlame" asp-action="Index" class="link-dark fw-semibold">Alpha Flame series page</a>.
|
||||
</p>
|
||||
@if (DateTime.Now < new DateTime(2025, 9, 1))
|
||||
{
|
||||
<p class="mt-4">
|
||||
<h2 class="display-6 fw-bold">Win: <span class="fw-light">a Collector’s Edition of The Alpha Flame: Discovery</span></h2>
|
||||
<em>Exclusive, limited, beautiful.</em>
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap mt-4">
|
||||
<a asp-controller="TheAlphaFlame" asp-action="Giveaways" class="btn btn-dark">Enter now for your chance.</a>
|
||||
</div>
|
||||
}
|
||||
<p class="mt-4">
|
||||
<h3 class="h4">About The Alpha Flame Trilogy</h3>
|
||||
Catherine Lynwood’s <em>The Alpha Flame trilogy</em> 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.
|
||||
<h3 class="h6 d-inline"><em>About The Alpha Flame Trilogy</em></h3>, 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => {
|
||||
|
||||
@ -57,3 +57,20 @@
|
||||
<a href="https://www.gov.uk/data-protection" target="_blank" rel="noopener noreferrer">www.gov.uk/data-protection</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@section Meta{
|
||||
<MetaTag meta-title="Privacy Policy – Catherine Lynwood Author Website"
|
||||
meta-description="Read the privacy policy for Catherine Lynwood’s official author website. Learn how visitor data is collected, stored, and used responsibly."
|
||||
meta-keywords="Catherine Lynwood privacy policy, data protection, author website privacy, GDPR compliance, visitor data"
|
||||
meta-author="Catherine Lynwood"
|
||||
meta-url="https://www.catherinelynwood.com/privacy"
|
||||
meta-image="https://www.catherinelynwood.com/images/catherine-lynwood-logo.png"
|
||||
meta-image-alt="Catherine Lynwood Author Logo"
|
||||
og-site-name="Catherine Lynwood – Privacy Policy"
|
||||
article-published-time="@new DateTime(2024, 11, 20)"
|
||||
article-modified-time="@new DateTime(2025, 06, 17)"
|
||||
twitter-card-type="summary_large_image"
|
||||
twitter-site-handle="@@CathLynwood"
|
||||
twitter-creator-handle="@@CathLynwood" />
|
||||
|
||||
}
|
||||
221
CatherineLynwood/Views/Home/VerosticGenre.cshtml
Normal file
221
CatherineLynwood/Views/Home/VerosticGenre.cshtml
Normal file
@ -0,0 +1,221 @@
|
||||
@{
|
||||
ViewData["Title"] = "The Verostic Genre";
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">The Verostic Genre</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="container py-2">
|
||||
<h1 class="display-4 mb-4">The Verostic Genre</h1>
|
||||
|
||||
<!-- Dictionary Definition -->
|
||||
<div class="mb-4 border-start border-4 border-dark ps-3">
|
||||
<h2 class="h5 text-muted mb-1"><strong>verostic</strong> /ˈvɛr.ɒst.ɪk/ <em>adj.</em></h2>
|
||||
<p class="mb-1"><strong>Origin:</strong> Latin <em>veritas</em> (truth) + stylistic suffix</p>
|
||||
<p class="mb-0">
|
||||
A literary genre or descriptive tone characterised by raw emotional realism, unflinching psychological depth, and grounded human truth.
|
||||
Often gritty, sometimes painful, but always sincere. The hallmark of a story that bleeds... and still finds beauty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Genre Definition -->
|
||||
<div class="mb-5">
|
||||
<h2 class="h4 text-muted">Definition</h2>
|
||||
<p class="lead">
|
||||
<strong>Verostic fiction</strong> is a genre defined by emotional rawness, psychological intensity, and unfiltered human truth.
|
||||
It explores trauma, morality, resilience, and connection through unapologetically honest storytelling.
|
||||
Gritty, intimate, and unflinching, Verostic works don't shy away from darkness... yet always make space for tenderness, hope, and truth.
|
||||
</p>
|
||||
<blockquote>
|
||||
<p class="mb-0">Verostic fiction doesn’t just tell the truth, it makes you feel it.</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<div class="container overflow-hidden mb-5">
|
||||
<div class="row gy-5">
|
||||
<div class="col-md-4 border-start border-4 border-dark">
|
||||
<div class="badge bg-dark">Neoverostic</div>
|
||||
<h3 class="mt-4">Neoverostic (adj.)</h3>
|
||||
<p><strong>From</strong> <em>neo</em> (Greek: new) + Verostic</p>
|
||||
<p>
|
||||
<strong>Definition:</strong><br>
|
||||
Neoverostic fiction applies Verostic principles to modern contexts, exploring contemporary trauma, digital identity, intersectionality, and generational tension with the same emotional honesty. It often experiments with form, voice, or structure to reflect fractured lives or shifting perspectives.
|
||||
</p>
|
||||
<p>Expect raw emotion filtered through today's chaos: fractured families, economic precarity, gender politics, mental health, loneliness in the age of connection.</p>
|
||||
<p><strong>Neoverostic stories are razor-sharp and present-tense, burning with modern truth.</strong></p>
|
||||
</div>
|
||||
<div class="col-md-4 border-start border-4 border-dark">
|
||||
<div class="badge bg-dark">Postverostic</div>
|
||||
<h3 class="mt-4">Postverostic (adj.)</h3>
|
||||
<p><strong>From</strong> <em>post</em> (Latin: after) + Verostic</p>
|
||||
<p>
|
||||
<strong>Definition:</strong><br>
|
||||
Postverostic fiction interrogates or deconstructs the Verostic mode. Often reflective, ironic, or stylistically fragmented, it explores what happens after trauma, truth, or confrontation, the silences, the scars, the reconstruction of meaning.
|
||||
</p>
|
||||
<p>These stories may feature unreliable narrators, metafictional layers, or broken timelines. They don’t abandon emotional realism but question how memory, narrative, and identity distort it.</p>
|
||||
<p><strong>Postverostic fiction doesn’t just explore the truth, it wonders whether truth survives the telling.</strong></p>
|
||||
</div>
|
||||
<div class="col-md-4 border-start border-4 border-dark">
|
||||
<div class="badge bg-dark">Aeverostic</div>
|
||||
<h3 class="mt-4">Aeverostic (adj.)</h3>
|
||||
<p><strong>From</strong> <em>aevum</em> (Latin: era, lifetime) + Verostic</p>
|
||||
<p>
|
||||
<strong>Definition:</strong><br>
|
||||
Aeverostic fiction is a time-rooted subgenre of Verostic storytelling, most often set in the 1980s. It captures emotionally raw narratives within a specific cultural and historical moment, where truth is shaped by the era’s unique pressures: repression, rebellion, subculture, class division, family shame, and gender expectation.
|
||||
</p>
|
||||
<p>The term is not nostalgic. Aeverostic fiction does not look back fondly, it digs into the past with clarity and courage, reanimating the emotional texture of a generation.</p>
|
||||
<p><strong>Aeverostic stories live and bleed in their time, but their truths still echo today.</strong></p>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="mt-4">
|
||||
<strong><a href="/the-alpha-flame/discovery">Read The Alpha Flame: Discovery</a></strong>, a work of unapologetically Verostic fiction, and feel the fire for yourself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Manifesto -->
|
||||
<div class="mb-5">
|
||||
<h2 class="h4 text-muted">The Verostic Manifesto</h2>
|
||||
<div class="accordion" id="verosticManifesto">
|
||||
@{
|
||||
var principles = new[]
|
||||
{
|
||||
"Verostic fiction is truth, undressed.",
|
||||
"Verostic fiction bleeds.",
|
||||
"Verostic fiction is character-first.",
|
||||
"Verostic fiction isn’t loyal to genre, only to truth.",
|
||||
"Verostic fiction always makes space for softness.",
|
||||
"Verostic fiction evolves."
|
||||
};
|
||||
|
||||
var descriptions = new[]
|
||||
{
|
||||
"It doesn’t flinch, doesn’t tidy things up, doesn’t protect you from pain. It trusts the reader to witness the mess, and find meaning in it.",
|
||||
"It’s emotionally intense, sometimes harrowing, but never gratuitous. Its power lies in the tension between raw exposure and quiet humanity.",
|
||||
"Plot follows truth. The story bends around the emotional journey, not the other way around.",
|
||||
"You’ll find crime, romance, psychological twists, even moments of joy. But these are tools, not rules. Truth decides the shape.",
|
||||
"Amid trauma, there is tenderness. Amid fear, hope. Amid silence, connection. Verostic fiction doesn’t just show wounds... it shows how people live with them.",
|
||||
"It can be modern, reflective, or rooted in time. Whether it's today’s truth, yesterday’s echo, or tomorrow’s reckoning... the soul is the same."
|
||||
};
|
||||
@for (int i = 0; i < principles.Length; i++)
|
||||
{
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading@(i)">
|
||||
<button class="accordion-button @((i > 0 ? "collapsed" : ""))"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#collapse@(i)"
|
||||
aria-expanded="@(i == 0 ? "true" : "false")"
|
||||
aria-controls="collapse@(i)">
|
||||
<strong>@principles[i]</strong>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse@(i)"
|
||||
class="accordion-collapse collapse @(i == 0 ? "show" : "")"
|
||||
aria-labelledby="heading@(i)"
|
||||
data-bs-parent="#verosticManifesto">
|
||||
<div class="accordion-body">
|
||||
@descriptions[i]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Genre Diagram -->
|
||||
<div class="mb-5">
|
||||
<h2 class="h4 text-muted text-center">Visual Genre Chart</h2>
|
||||
|
||||
<div class="d-flex justify-content-center align-items-center flex-column my-4">
|
||||
<div class="fw-bold fs-3">Verostic</div>
|
||||
<div class="d-flex flex-row justify-content-center align-items-start mt-3 genre-branches">
|
||||
<div class="text-center mx-3">
|
||||
<div class="badge bg-dark mb-2">Neoverostic</div>
|
||||
<p class="small text-muted">Modern emotional realism, digital identity, intersectional trauma</p>
|
||||
</div>
|
||||
<div class="text-center mx-3">
|
||||
<div class="badge bg-dark mb-2">Postverostic</div>
|
||||
<p class="small text-muted">Reflective, fragmented, aftermath of truth</p>
|
||||
</div>
|
||||
<div class="text-center mx-3">
|
||||
<div class="badge bg-dark mb-2">Aeverostic</div>
|
||||
<p class="small text-muted">Era-rooted (1980s), cultural grit, timeless emotional truth</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted fst-italic text-center">
|
||||
<blockquote class="blockquote">
|
||||
<p class="mb-0">Verostic isn’t a style. It’s a vow.</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@section Meta{
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "WebPage",
|
||||
"name": "The Verostic Genre",
|
||||
"url": "https://www.catherinelynwood.com/verostic-genre",
|
||||
"description": "A literary genre defined by emotional rawness, psychological depth, and unflinching truth. Includes the Verostic manifesto, genre definition, and visual subgenre chart.",
|
||||
"mainEntity": {
|
||||
"@@type": "DefinedTerm",
|
||||
"name": "Verostic",
|
||||
"termCode": "verostic-fiction",
|
||||
"inDefinedTermSet": "https://www.catherinelynwood.com/verostic-genre",
|
||||
"description": "A genre of fiction marked by gritty emotional realism, psychological intensity, and unapologetic storytelling. Verostic fiction explores trauma, morality, and human resilience through unfiltered emotional truth.",
|
||||
"alternateName": [
|
||||
"Verostic fiction",
|
||||
"Grit-lit with soul",
|
||||
"Emotionally raw psychological realism"
|
||||
],
|
||||
"hasDefinedTerm": [
|
||||
{
|
||||
"@@type": "DefinedTerm",
|
||||
"name": "Neoverostic",
|
||||
"description": "Modern Verostic fiction dealing with contemporary trauma, digital identity, and intersectional emotional truth.",
|
||||
"termCode": "neoverostic"
|
||||
},
|
||||
{
|
||||
"@@type": "DefinedTerm",
|
||||
"name": "Postverostic",
|
||||
"description": "Reflective or deconstructed Verostic fiction that explores aftermath, ambiguity, and memory distortion.",
|
||||
"termCode": "postverostic"
|
||||
},
|
||||
{
|
||||
"@@type": "DefinedTerm",
|
||||
"name": "Aeverostic",
|
||||
"description": "Era-specific Verostic fiction rooted in the emotional and cultural landscape of the 1980s.",
|
||||
"termCode": "aeverostic"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
}
|
||||
@ -73,6 +73,21 @@
|
||||
</div>
|
||||
|
||||
@section Meta{
|
||||
<MetaTag meta-title="Publishing with Catherine Lynwood – The Alpha Flame Trilogy"
|
||||
meta-description="Learn about Catherine Lynwood’s publishing journey for *The Alpha Flame Trilogy*, including distribution partners, formats, and exclusive editions available to readers."
|
||||
meta-keywords="Catherine Lynwood publishing, The Alpha Flame Trilogy, indie author, book distribution, IngramSpark, Amazon KDP, Waterstones, hardback editions"
|
||||
meta-author="Catherine Lynwood"
|
||||
meta-url="https://www.catherinelynwood.com/publishing"
|
||||
meta-image="https://www.catherinelynwood.com/images/catherine-lynwood-logo.png"
|
||||
meta-image-alt="Publishing details for Catherine Lynwood’s The Alpha Flame Trilogy"
|
||||
og-site-name="Catherine Lynwood – The Alpha Flame Publishing"
|
||||
article-published-time="@new DateTime(2024, 11, 20)"
|
||||
article-modified-time="@new DateTime(2025, 06, 17)"
|
||||
twitter-card-type="summary_large_image"
|
||||
twitter-site-handle="@@CathLynwood"
|
||||
twitter-creator-handle="@@CathLynwood" />
|
||||
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
|
||||
@ -43,17 +43,13 @@
|
||||
}
|
||||
|
||||
|
||||
<form asp-action="Comment" class="row" id="comment">
|
||||
<form asp-action="Comment" class="row" id="comment" onsubmit="disableSubmit(this)">
|
||||
<div class="col-12 pb-3">
|
||||
<h2>Leave a Comment</h2>
|
||||
In invite you to leave comments regarding this blog post. As you can see I ask that you give me your name and email address, as well as your age and sex.
|
||||
This is so I can better understand my audience and tailor my content to suit your needs. I will never share your information with anyone else. I look forward to hearing from you.
|
||||
Please note that I may email you and ask for a reply before publishing your comment. This is to ensure that I am not publishing spam comments.
|
||||
</div>
|
||||
@if (ViewData["VpnWarning"] is true)
|
||||
{
|
||||
<partial name="_VPNWarning" />
|
||||
}
|
||||
<div class="col-12 text-dark">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
</div>
|
||||
@ -127,16 +123,8 @@
|
||||
|
||||
<!-- Placeholder Button and Submit Button -->
|
||||
<div class="d-grid">
|
||||
@if (ViewData["VpnWarning"] is true)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary mb-3" disabled>Post Comment</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-secondary mb-3" id="fakeSubmitButton">Post Comment</button>
|
||||
<button type="submit" class="btn btn-dark mb-3 d-none" id="submitButton">Post Comment</button>
|
||||
}
|
||||
|
||||
<button type="button" class="btn btn-secondary mb-3" id="fakeSubmitButton">Post Comment</button>
|
||||
<button type="submit" class="btn btn-dark mb-3 d-none" id="submitButton">Post Comment</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Fields -->
|
||||
|
||||
@ -46,7 +46,8 @@
|
||||
|
||||
<link rel="alternate" type="application/rss+xml" title="Catherine Lynwood Blog Feed" href="https://www.catherinelynwood.com/feed">
|
||||
|
||||
<link rel="canonical" href="@($"https://www.catherinelynwood.com{Context.Request.Path}{Context.Request.QueryString}")" />
|
||||
|
||||
<link rel="canonical" href="@($"https://www.catherinelynwood.com{Context.Request.Path}")" />
|
||||
|
||||
@RenderSection("Meta", required: false)
|
||||
|
||||
@ -86,26 +87,42 @@
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="AboutCatherineLynwood">About Catherine</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="TheAlphaFlame" asp-action="Blog">Blog</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="AskAQuestion" asp-action="Index">Ask Catherine</a>
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="TheAlphaFlame" asp-action="Blog">A Cuppa With Catherine</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle text-primary" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
The Alpha Flame
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="py-2"><a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Index">The Trilogy</a></li>
|
||||
@* <li><hr class="dropdown-divider"></li> *@
|
||||
<li class="py-2"><a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Characters">Meet the Characters</a></li>
|
||||
<li class="py-2">
|
||||
<a class="dropdown-item" asp-controller="Discovery" asp-action="Index">Discovery (Book 1)</a>
|
||||
</li>
|
||||
<li class="py-2">
|
||||
<a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Index">The Trilogy</a>
|
||||
</li>
|
||||
<li class="py-2">
|
||||
<a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Characters">Meet the Characters</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="ContactCatherine">Contact Catherine</a>
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="ArcReaderApplication">ARC Reader Application</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="VerosticGenre">The Verostic Genre</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-light" asp-controller="Admin" asp-action="Index" rel="nofollow" title="Admin">
|
||||
<i class="fad fa-user-shield fa-lg"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
@model CatherineLynwood.Models.BlogIndex
|
||||
|
||||
@using Humanizer
|
||||
@using CatherineLynwood.Helpers
|
||||
@{
|
||||
ViewData["Title"] = "Catherine's Blog";
|
||||
ViewData["Title"] = "A Cuppa With Catherine";
|
||||
}
|
||||
|
||||
@* <style>
|
||||
@ -25,31 +26,39 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">The Alpha Flame Blog</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">A Cuppa With Catherine</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1>The Alpha Flame Blog</h1>
|
||||
<p>Follow along as Catherine Lynwood shares updates, reflections, and glimpses into the worlds and characters she is passionate about bringing to life.</p>
|
||||
<div class="row my-4">
|
||||
<div class="col-12 text-center">
|
||||
<h1 class="display-4">
|
||||
<i class="fad fa-coffee" style="--fa-primary-color: #ffffff; --fa-secondary-color: #042830;"></i>
|
||||
A Cuppa With Catherine
|
||||
</h1>
|
||||
<p class="lead mt-3 px-3">
|
||||
Pop the kettle on and settle in for a cuppa. Catherine Lynwood invites you to share in her updates, reflections, and the worlds and characters she brings to life—like old friends having a natter over tea.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form asp-action="Blog" method="get" class="row border border-1 rounded-3 bg-white mx-0 mb-3">
|
||||
|
||||
<form method="get" class="row border border-1 rounded-3 bg-white mx-0 mb-3">
|
||||
<div class="col-12 pt-3">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-2 col-md-10 col-lg-9 col-xl-8 col-xxl-7">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text d-none d-sm-inline" id="basic-addon1">Sort by</span>
|
||||
<span class="input-group-text d-none d-sm-inline">Sort by</span>
|
||||
|
||||
<select asp-for="BlogFilter.SortDirection" class="form-select js-submit-on-change" name="SortDirection" aria-label="Sort direction">
|
||||
<select asp-for="BlogFilter.SortDirection" class="form-select js-submit-on-change" aria-label="Sort direction">
|
||||
<option value="1">Newest first</option>
|
||||
<option value="2">Oldest first</option>
|
||||
</select>
|
||||
<span class="input-group-text d-none d-sm-inline" id="basic-addon1">Results per page</span>
|
||||
<select asp-for="BlogFilter.ResultsPerPage" class="form-select js-submit-on-change" name="ResultsPerPage" aria-label="Results per page">
|
||||
|
||||
<span class="input-group-text d-none d-sm-inline">Results per page</span>
|
||||
|
||||
<select asp-for="BlogFilter.ResultsPerPage" class="form-select js-submit-on-change" aria-label="Results per page">
|
||||
<option value="6">6</option>
|
||||
<option value="12">12</option>
|
||||
<option value="18">18</option>
|
||||
@ -57,69 +66,31 @@
|
||||
<option value="30">30</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-dark" type="button" data-bs-toggle="collapse" data-bs-target="#categories">Show Categories</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field for PageNumber -->
|
||||
<input type="hidden" asp-for="BlogFilter.PageNumber" value="@Model.BlogFilter.PageNumber" name="PageNumber" id="PageNumber" />
|
||||
<input type="hidden" asp-for="BlogFilter.TotalPages" value="@Model.BlogFilter.TotalPages" name="PreviousTotalPages" />
|
||||
|
||||
<div class="col-12 @(Model.ShowAdvanced ? "collapse show" : "collapse")" id="categories">
|
||||
<div class="row">
|
||||
<div class="col-md-10">
|
||||
<ul class="list-inline">
|
||||
@foreach (var item in Model.BlogCategories)
|
||||
{
|
||||
<li class="list-inline-item p-1">
|
||||
<div class="form-check form-switch text-dark">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="switch-@item.CategoryID"
|
||||
name="Categories"
|
||||
value="@item.CategoryID"
|
||||
checked="@item.Selected">
|
||||
<label class="form-check-label" for="switch-@item.CategoryID">
|
||||
@item.Category
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-dark w-100" type="submit">Apply Category Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@if (Model.BlogFilter.TotalPages > 1)
|
||||
{
|
||||
<div class="col-12">
|
||||
<!-- Pagination Component -->
|
||||
<nav aria-label="Page navigation" class="d-flex justify-content-center mt-3">
|
||||
<ul class="pagination">
|
||||
<!-- Previous button -->
|
||||
<li class="page-item @(Model.BlogFilter.PageNumber <= 1 ? "disabled" : "")">
|
||||
<a class="page-link" href="javascript:void(0);" onclick="setPageNumber(@(Model.BlogFilter.PageNumber - 1))" aria-label="Previous">
|
||||
<a class="page-link" href="@BlogUrlHelper.GetPageUrl(Model.BlogFilter.PageNumber - 1, Model.BlogFilter)" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Page numbers -->
|
||||
@for (int i = 1; i <= Model.BlogFilter.TotalPages; i++)
|
||||
{
|
||||
var url = BlogUrlHelper.GetPageUrl(i, Model.BlogFilter);
|
||||
<li class="page-item @(Model.BlogFilter.PageNumber == i ? "active" : "")">
|
||||
<a class="page-link" href="javascript:void(0);" onclick="setPageNumber(@i)">@i</a>
|
||||
<a class="page-link" href="@url">@i</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
<!-- Next button -->
|
||||
<li class="page-item @(Model.BlogFilter.PageNumber >= Model.BlogFilter.TotalPages ? "disabled" : "")">
|
||||
<a class="page-link" href="javascript:void(0);" onclick="setPageNumber(@(Model.BlogFilter.PageNumber + 1))" aria-label="Next">
|
||||
<a class="page-link" href="@BlogUrlHelper.GetPageUrl(Model.BlogFilter.PageNumber + 1, Model.BlogFilter)" aria-label="Next">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
@ -127,9 +98,13 @@
|
||||
</nav>
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@if (Model.IsMobile)
|
||||
{
|
||||
<div class="row">
|
||||
@ -140,7 +115,11 @@
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="row w-100">
|
||||
<div class="col-4">
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl" class="me-3">
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl"
|
||||
asp-route-PageNumber="@Model.BlogFilter.PageNumber"
|
||||
asp-route-SortDirection="@Model.BlogFilter.SortDirection"
|
||||
asp-route-ResultsPerPage="@Model.BlogFilter.ResultsPerPage"
|
||||
class="me-3">
|
||||
<responsive-image src="@item.ImageUrl" alt="@item.ImageAlt" class="img-fluid" display-width-percentage="30" style="width: 100%; height: auto;"></responsive-image>
|
||||
</a>
|
||||
</div>
|
||||
@ -150,7 +129,11 @@
|
||||
<div class="card-text mb-2" style="max-height: 120px; overflow-y: auto;">
|
||||
@Html.Raw(item.IndexText)
|
||||
</div>
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl" class="text-dark float-end">Read more...</a>
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl"
|
||||
asp-route-PageNumber="@Model.BlogFilter.PageNumber"
|
||||
asp-route-SortDirection="@Model.BlogFilter.SortDirection"
|
||||
asp-route-ResultsPerPage="@Model.BlogFilter.ResultsPerPage"
|
||||
class="text-dark float-end">Read more...</a>
|
||||
<div class="text-muted mt-2">@item.PublishDate.ToString("dd MMMM yyyy")</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -163,6 +146,25 @@
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
@if (!string.IsNullOrEmpty(Model.NextBlog.Title))
|
||||
{
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card h-100 shadow-lg border-3 border-dark">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-1">
|
||||
<responsive-image src="@Model.NextBlog.ImageUrl" class="img-fluid rounded-start-3" alt="@Model.NextBlog.ImageAlt" display-width-percentage="30"></responsive-image>
|
||||
</div>
|
||||
<div class="col-md-11">
|
||||
<div class="card-body">
|
||||
<p class="h5">Coming up in @Model.NextBlog.PublishDate.Humanize(true) - <em>@Model.NextBlog.Title</em></p>
|
||||
<p class="card-text">@Html.Raw(Model.NextBlog.IndexText)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var item in Model.Blogs)
|
||||
{
|
||||
|
||||
@ -172,12 +174,21 @@ else
|
||||
<h2 class="h5 card-title">@item.Title</h2>
|
||||
<small class="text-muted">@item.SubTitle</small>
|
||||
</div>
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl">
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl"
|
||||
asp-route-PageNumber="@Model.BlogFilter.PageNumber"
|
||||
asp-route-SortDirection="@Model.BlogFilter.SortDirection"
|
||||
asp-route-ResultsPerPage="@Model.BlogFilter.ResultsPerPage">
|
||||
<responsive-image src="@item.ImageUrl" alt="@item.ImageAlt" class="card-img rounded-0" display-width-percentage="30"></responsive-image>
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<p class="card-text">@Html.Raw(item.IndexText)</p>
|
||||
<p class="float-end"><a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl" class="text-primary-emphasis">Read more...</a></p>
|
||||
<p class="float-end">
|
||||
<a asp-controller="TheAlphaFlame" asp-action="BlogItem" asp-route-slug="@item.BlogUrl"
|
||||
asp-route-PageNumber="@Model.BlogFilter.PageNumber"
|
||||
asp-route-SortDirection="@Model.BlogFilter.SortDirection"
|
||||
asp-route-ResultsPerPage="@Model.BlogFilter.ResultsPerPage"
|
||||
class="text-primary-emphasis">Read more...</a>
|
||||
</p>
|
||||
<h6>@item.PublishDate.ToString("dd MMMM yyyy")</h6>
|
||||
</div>
|
||||
</div>
|
||||
@ -196,6 +207,15 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="h5">Got an idea for a future blog post?</p>
|
||||
<p>If you would like to ask me a question or have a suggestion for a future blog them please don't be shy. <a asp-controller="AskAQuestion" asp-action="Index"> Go to my ask a question form and let me know.</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@section Meta {
|
||||
@ -243,18 +263,17 @@ else
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function setPageNumber(pageNumber) {
|
||||
// Set the hidden PageNumber input value
|
||||
document.getElementById('PageNumber').value = pageNumber;
|
||||
|
||||
// Submit the form
|
||||
document.querySelector('form').submit();
|
||||
}
|
||||
|
||||
$(document).on('change', '.js-submit-on-change', function(e){
|
||||
$(document).on('change', '.js-submit-on-change', function () {
|
||||
$(this).closest('form').submit();
|
||||
})
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
function disableSubmit(form) {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerText = 'Sending...'; // Optional
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
}
|
||||
@ -30,7 +30,7 @@
|
||||
<h1 class="d-inline-block">@Model.Title</h1><br /><h2 class="h4 d-inline-block">@Model.SubTitle</h2>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<img src="~/images/webp/@Model.PostedImage" class="rounded-circle border border-dark" style="height: 50px;" />
|
||||
<img src="~/images/webp/@Model.PostedImage" alt="@Model.PostedBy Lynwood" class="rounded-circle border border-dark" style="height: 50px;" />
|
||||
</div>
|
||||
<div class="col">
|
||||
Posted on @Model.PublishDate.ToString("MMMM d, yyyy") by @Model.PostedBy Lynwood
|
||||
@ -184,6 +184,17 @@
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
<script>
|
||||
function disableSubmit(form) {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerText = 'Sending...'; // Optional
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
}
|
||||
|
||||
@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)" />
|
||||
|
||||
|
||||
92
CatherineLynwood/Views/TheAlphaFlame/Enter.cshtml
Normal file
92
CatherineLynwood/Views/TheAlphaFlame/Enter.cshtml
Normal file
@ -0,0 +1,92 @@
|
||||
@model CatherineLynwood.Models.Marketing
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Enter The Alpha Flame Discovery Giveaway";
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Giveaways">Giveaways</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Enter</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<h1 class="h2 mb-4">To enter our giveaway simply complete the entry form below</h1>
|
||||
|
||||
<form asp-action="Enter" id="entryForm" class="row">
|
||||
<div class="col-12 text-dark">
|
||||
<div class="form-floating mb-3">
|
||||
<input asp-for="Name" name="Name" class="form-control">
|
||||
<label asp-for="Name"></label>
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 text-dark">
|
||||
<div class="form-floating mb-3">
|
||||
<input asp-for="Email" name="Email" type="email" class="form-control">
|
||||
<label asp-for="Email"></label>
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="col-6 text-dark">
|
||||
<div class="form-floating mb-3">
|
||||
<select asp-for="Age" name="Age" class="form-select" aria-label="Select your age">
|
||||
<option value="" selected>Select your age</option>
|
||||
<option value="18-24">18–24</option>
|
||||
<option value="25-34">25–34</option>
|
||||
<option value="35-44">35–44</option>
|
||||
<option value="45-54">45–54</option>
|
||||
<option value="55-64">55–64</option>
|
||||
<option value="65+">65+</option>
|
||||
<option value="0">Prefer not to say</option>
|
||||
</select>
|
||||
<label asp-for="Age"></label>
|
||||
<span asp-validation-for="Age" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 text-dark">
|
||||
<div class="form-floating mb-3">
|
||||
<select asp-for="Sex" name="Sex" class="form-select" aria-label="Select your sex">
|
||||
<option value="" selected>Select Sex</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="other">Other</option>
|
||||
<option value="prefer-not-to-say">Prefer not to say</option>
|
||||
</select>
|
||||
<label asp-for="Sex"></label>
|
||||
<span asp-validation-for="Sex" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p class="small">
|
||||
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 <a href="/Privacy">privacy policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<div class="row">
|
||||
<div class="col-12 d-grid">
|
||||
<button type="submit" form="entryForm" class="btn btn-primary">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@section Meta{
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
}
|
||||
@ -2,13 +2,26 @@
|
||||
ViewData["Title"] = "Giveaways - The Alpha Flame";
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Giveaways</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="py-3 text-center">
|
||||
<div class="container">
|
||||
<div class="card bg-dark text-white">
|
||||
<div class="card bg-dark text-white border-primary">
|
||||
<div class="card-body">
|
||||
<h1 class="display-4 fw-bold">Win a Collector’s Edition</h1>
|
||||
<p class="lead mb-4">A special chance to own <em>The Alpha Flame: Discovery</em> in a limited edition, signed or delivered to you.</p>
|
||||
<a href="/newsletter/signup" class="btn btn-primary btn-lg">Enter Now</a>
|
||||
<a asp-action="Enter" class="btn btn-primary btn-lg">Enter Now</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -51,20 +64,20 @@
|
||||
|
||||
<section class="py-3">
|
||||
<div class="container">
|
||||
<div class="card bg-dark text-white">
|
||||
<div class="card bg-dark text-white border-primary">
|
||||
<div class="card-body">
|
||||
<h2 class="h4 fw-bold text-center mb-4">The Prize</h2>
|
||||
<div class="row text-center">
|
||||
<div class="col-md-4 mb-4">
|
||||
<responsive-image src="the-alpha-flame-11.png" class="img-fluid rounded-5 border border-3 border-dark shadow-lg" alt="Signed Collector’s Edition" display-width-percentage="50"></responsive-image>
|
||||
<responsive-image src="the-alpha-flame-discovery-stood-up.png" class="img-fluid rounded-5 border border-3 border-primary shadow-lg" alt="Signed Collector’s Edition" display-width-percentage="50"></responsive-image>
|
||||
<p class="mt-2">Signed by the author (UK only)</p>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4">
|
||||
<responsive-image src="the-alpha-flame-11.png" class="img-fluid rounded-5 border border-3 border-dark shadow-lg" alt="Exclusive cover detail" display-width-percentage="50"></responsive-image>
|
||||
<p class="mt-2">Exclusive cover design</p>
|
||||
<responsive-image src="the-alpha-flame-discovery-open-on-the-table-maggie.png" class="img-fluid rounded-5 border border-3 border-primary shadow-lg" alt="Exclusive imagery" display-width-percentage="50"></responsive-image>
|
||||
<p class="mt-2">Exclusive imagery</p>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4">
|
||||
<responsive-image src="the-alpha-flame-11.png" class="img-fluid rounded-5 border border-3 border-dark shadow-lg" alt="Interior preview" display-width-percentage="50"></responsive-image>
|
||||
<responsive-image src="the-alpha-flame-discovery-open-on-the-table-beth.png" class="img-fluid rounded-5 border border-3 border-primary shadow-lg" alt="Interior preview" display-width-percentage="50"></responsive-image>
|
||||
<p class="mt-2">Premium print quality</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -78,7 +91,7 @@
|
||||
<div class="container">
|
||||
<h2 class="h4 fw-bold mb-3">Don’t Miss Out</h2>
|
||||
<p class="mb-4">Sign up today for your chance to win. Plus, get exclusive updates, early previews, and behind-the-scenes content.</p>
|
||||
<a href="/newsletter/signup" class="btn btn-dark btn-lg">Enter the Giveaway</a>
|
||||
<a asp-action="Enter" class="btn btn-dark btn-lg">Enter the Giveaway</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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)" />
|
||||
|
||||
@ -30,12 +30,12 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-3 d-none d-md-block">
|
||||
<a href="/the-alpha-flame/discovery">
|
||||
<responsive-image src="the-alpha-flame-11.png" class="img-fluid rounded-start-3" alt="The Alpha Flame: Discovery" display-width-percentage="50"></responsive-image>
|
||||
<responsive-image src="the-alpha-flame-discovery-cover.png" class="img-fluid rounded-start-3" alt="The Alpha Flame: Discovery" display-width-percentage="50"></responsive-image>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 d-md-none">
|
||||
<a href="/the-alpha-flame/discovery">
|
||||
<responsive-image src="the-alpha-flame-11.png" class="img-fluid rounded-top-3" alt="The Alpha Flame: Discovery" display-width-percentage="50"></responsive-image>
|
||||
<responsive-image src="the-alpha-flame-discovery-cover.png" class="img-fluid rounded-top-3" alt="The Alpha Flame: Discovery" display-width-percentage="50"></responsive-image>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
|
||||
@ -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)
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function disableSubmit(form) {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerText = 'Sending...'; // Optional
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
ul {
|
||||
|
||||
50
CatherineLynwood/Views/TheAlphaFlame/Thankyou.cshtml
Normal file
50
CatherineLynwood/Views/TheAlphaFlame/Thankyou.cshtml
Normal file
@ -0,0 +1,50 @@
|
||||
@model CatherineLynwood.Models.Marketing
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Enter The Alpha Flame Discovery Giveaway";
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Giveaways">Giveaways</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Thank you</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<section class="py-5 bg-dark text-center rounded-3 border border-3 border-light">
|
||||
<div class="container">
|
||||
<h1 class="display-4 fw-bold mb-4">Thank You for Joining the Journey</h1>
|
||||
<p class="lead mb-4">
|
||||
Your entry has been received, and I'm truly grateful you've chosen to be part of The Alpha Flame community.
|
||||
Whether you're here for the giveaway or to follow my writing journey, it means the world to me.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
As a subscriber, you'll get exclusive updates about <em>The Alpha Flame Trilogy</em>, sneak peeks at future books,
|
||||
behind-the-scenes notes, and other special surprises. I promise to respect your inbox and only send things worth reading.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
The giveaway closes on <strong>31st August 2025</strong>. The lucky winner will be announced on <strong>7th September 2025</strong>
|
||||
via email and on the Giveaways page. Keep an eye out!
|
||||
</p>
|
||||
<a asp-controller="TheAlphaFlame" asp-action="Index" class="btn btn-primary btn-lg">Explore The Alpha Flame</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@section Meta {
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
|
||||
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Blog">The Alpha Flame Blog</a></li>
|
||||
<li class="breadcrumb-item"><a href="@ViewBag.BlogReturnLink">A Cuppa With Catherine</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@Model.Title</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@ -2,13 +2,23 @@
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Database=CatherineLynwood;Trusted_Connection=True;MultipleActiveResultSets=true;Encrypt=false;"
|
||||
},
|
||||
"Email": {
|
||||
"APIKey": "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"
|
||||
"Smtp": {
|
||||
"Host": "smtpout.secureserver.net",
|
||||
"Port": "587",
|
||||
"Sender": "your-email@catherinelynwood.com",
|
||||
"Username": "catherine@catherinelynwood.com",
|
||||
"Password": "ryaN9982?"
|
||||
},
|
||||
"AudioSecurity": {
|
||||
"HmacSecretKey": "SG.7xaVKHzRQsS5os1IJUJZ2Q.2osFDJIRkjlDl3eM05uZ9R1IUA6Wv",
|
||||
"TokenExpirySeconds": 86400
|
||||
},
|
||||
"ApiKeys": {
|
||||
"BlogPost": "d73dbc3429dh3ycn79f3dfc0nfhyu98q"
|
||||
},
|
||||
"IndexNow": {
|
||||
"ApiKey": "cc6ff72c3d1a48d0b0b7c2c2b543f15f"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@ -1,15 +1,26 @@
|
||||
{
|
||||
"/the-alpha-flame/chapters/chapter-2-maggie": "/the-alpha-flame/discovery/chapters/chapter-2-maggie",
|
||||
"/the-alpha-flame/chapters/chapter-1-beth": "/the-alpha-flame/discovery/chapters/chapter-1-beth",
|
||||
"/5-reasons-to-preorder-the-alpha-flame": "/the-alpha-flame/blog/5-reasons-to-preorder-the-alpha-flame",
|
||||
"/asking-for-reviews": "/the-alpha-flame/blog/asking-for-reviews",
|
||||
"/books-like-verity": "/the-alpha-flame/blog/books-like-verity",
|
||||
"/if-you-loved-gone-girl": "/the-alpha-flame/blog/if-you-loved-gone-girl",
|
||||
"/sisterhood-blog-highlight": "/the-alpha-flame/blog/sisterhood-survival-self-discovery-books",
|
||||
"/the-alpha-flame/front-cover": "/the-alpha-flame/discovery",
|
||||
"/the-alpah-flame/blog/1983-birmingham-behind-the-alpha-flame": "/the-alpha-flame/blog/1983-birmingham-behind-the-alpha-flame",
|
||||
"/the-alpah-flame/blog/books-like-verity": "/the-alpha-flame/blog/books-like-verity",
|
||||
"/the-alpah-flame/blog/sisterhood-survival-self-discovery-books": "/the-alpha-flame/blog/sisterhood-survival-self-discovery-books",
|
||||
"/the-alpah-flame/blog/who-is-maggie-grant-alpha-flame": "/the-alpha-flame/blog/who-is-maggie-grant-alpha-flame",
|
||||
"/the-alpah-flame/blog/why-do-i-bother-indie-author-life": "/the-alpha-flame/blog/why-do-i-bother-indie-author-life",
|
||||
"/the-alpah-flame/blog/why-i-wrote-the-alpha-flame": "/the-alpha-flame/blog/why-i-wrote-the-alpha-flame",
|
||||
"/the-alpah-flame/blog/books-like-verity": "/the-alpha-flame/blog/books-like-verity",
|
||||
"/the-alpha-flame/chapters/chapter-13-susie": "/the-alpha-flame/discovery/chapters/chapter-13-susie",
|
||||
"/the-alpha-flame/chapters/chapter-1-beth": "/the-alpha-flame/discovery/chapters/chapter-1-beth",
|
||||
"/the-alpha-flame/chapters/chapter-2-maggie": "/the-alpha-flame/discovery/chapters/chapter-2-maggie",
|
||||
"/the-alpha-flame/characters/beth": "/the-alpha-flame/characters/beth-fletcher",
|
||||
"/TheAlphaFlame/Discovery": "/the-alpha-flame/discovery",
|
||||
"/the-alpha-flame/meet-the-characters": "/the-alpha-flame/characters",
|
||||
"/the-alpha-flame/front-cover": "/the-alpha-flame/discovery",
|
||||
"/the-alpha-flame/maggie-grant": "/the-alpha-flame/characters/maggie-grant",
|
||||
"/the-alpha-flame/characters/beth": "/the-alpha-flame/characters/beth-fletcher"
|
||||
"/the-alpha-flame/meet-the-characters": "/the-alpha-flame/characters",
|
||||
"/the-broken-girl-in-fiction": "/the-alpha-flame/blog/the-broken-girl-in-fiction",
|
||||
"/up-and-coming-indie-author": "/the-alpha-flame/blog/up-and-coming-indie-author",
|
||||
"/who-is-maggie-grant-alpha-flame": "/the-alpha-flame/blog/who-is-maggie-grant-alpha-flame",
|
||||
"/why-do-i-bother-indie-author-life": "/the-alpha-flame/blog/why-do-i-bother-indie-author-life",
|
||||
"/why-verity-fans-will-love-the-alpha-flame": "/the-alpha-flame/blog/why-verity-fans-will-love-the-alpha-flame"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@
|
||||
cc6ff72c3d1a48d0b0b7c2c2b543f15f
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user