Compare commits

...

7 Commits

Author SHA1 Message Date
nick
6d0c7423ad Changed appsetting 2026-08-02 18:44:18 +01:00
nick
3f29b4580e Restore Reckoning casefile styling 2026-07-31 22:06:42 +01:00
nick
9a3bf18a31 Polish facelift forms and clean package warnings 2026-07-31 21:36:05 +01:00
nick
8cca5ee347 Refine author site facelift visuals 2026-07-31 20:49:13 +01:00
nick
3262923f91 Create static visual facelift restore point 2026-07-31 19:35:55 +01:00
nick
0c14806520 Used Codex to fix audio player and add it to Reckoning. 2026-05-27 19:28:37 +01:00
nick
fec02ca22d Save 2026-04-26 20:52:22 +01:00
244 changed files with 5550 additions and 2647 deletions

View File

@ -154,7 +154,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Humanizer.Core" Version="3.0.10" /> <PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="MailKit" Version="4.15.1" /> <PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
<PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" /> <PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" />
<PackageReference Include="NAudio" Version="2.3.0" /> <PackageReference Include="NAudio" Version="2.3.0" />
@ -164,7 +164,6 @@
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" /> <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.2.0" /> <PackageReference Include="SixLabors.ImageSharp.Web" Version="3.2.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" /> <PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="WebMarkupMin.AspNetCoreLatest" Version="2.21.0" /> <PackageReference Include="WebMarkupMin.AspNetCoreLatest" Version="2.21.0" />
</ItemGroup> </ItemGroup>

View File

@ -34,7 +34,7 @@ namespace CatherineLynwood.Controllers
return NotFound($"File not found: {filePath}"); return NotFound($"File not found: {filePath}");
var stream = System.IO.File.OpenRead(filePath); var stream = System.IO.File.OpenRead(filePath);
return File(stream, "audio/mpeg"); return File(stream, "audio/mpeg", enableRangeProcessing: true);
} }
@ -51,7 +51,7 @@ namespace CatherineLynwood.Controllers
return NotFound($"File not found: {filePath}"); return NotFound($"File not found: {filePath}");
var stream = System.IO.File.OpenRead(filePath); var stream = System.IO.File.OpenRead(filePath);
return File(stream, "audio/mpeg"); return File(stream, "audio/mpeg", enableRangeProcessing: true);
} }
[HttpGet("playlist/chapter/{chapterNumber:int}")] [HttpGet("playlist/chapter/{chapterNumber:int}")]
@ -83,9 +83,10 @@ namespace CatherineLynwood.Controllers
} }
[HttpGet("playlist/all")] [HttpGet("playlist/all")]
public IActionResult GetAllChapters() public IActionResult GetAllChapters([FromQuery] string? book = null)
{ {
var segments = _cache.GetAll() var segments = _cache.GetAll()
.Where(s => string.IsNullOrWhiteSpace(book) || s.ChapterFolder.Contains(book, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.ChapterFolder) .OrderBy(s => s.ChapterFolder)
.ThenBy(s => s.File) .ThenBy(s => s.File)
.ToList(); .ToList();

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
namespace CatherineLynwood.Controllers
{
[Route("the-calendar-house")]
public class CalendarHouseController : Controller
{
[Route("")]
public IActionResult Index() => View();
[Route("house-plan")]
public IActionResult HousePlan() => View();
[Route("the-pattern")]
public IActionResult ThePattern() => View();
}
}

View File

@ -141,7 +141,7 @@ namespace CatherineLynwood.Controllers
[Route("extras/soundtrack")] [Route("extras/soundtrack")]
public async Task<IActionResult> Soundtrack() public async Task<IActionResult> Soundtrack()
{ {
List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack(); List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack("Discovery");
return View(soundtrackTrackModels); return View(soundtrackTrackModels);
} }

View File

@ -95,8 +95,15 @@ namespace CatherineLynwood.Controllers
[Route("contact-catherine")] [Route("contact-catherine")]
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ContactCatherine(Contact contact) public async Task<IActionResult> ContactCatherine(Contact contact)
{ {
if (IsLikelySpamContact(contact))
{
_logger.LogInformation("Discarded likely spam contact submission.");
return RedirectToAction("ThankYou");
}
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
await _dataAccess.SaveContact(contact, false); await _dataAccess.SaveContact(contact, false);
@ -113,6 +120,29 @@ namespace CatherineLynwood.Controllers
return View(contact); return View(contact);
} }
private bool IsLikelySpamContact(Contact contact)
{
var form = Request.HasFormContentType ? Request.Form : null;
var website = form?["Website"].ToString();
if (!string.IsNullOrWhiteSpace(website))
{
return true;
}
if (long.TryParse(form?["ContactStartedAt"].ToString(), out var startedAt))
{
var elapsedSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - startedAt;
if (elapsedSeconds >= 0 && elapsedSeconds < 4)
{
return true;
}
}
var message = contact.Message ?? string.Empty;
var linkCount = System.Text.RegularExpressions.Regex.Matches(message, @"https?://|www\.", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count;
return linkCount > 2;
}
[Route("feed.xml")] [Route("feed.xml")]
[Route("rss.xml")] [Route("rss.xml")]
[Route("rss")] [Route("rss")]

View File

@ -27,6 +27,29 @@ namespace CatherineLynwood.Controllers
_country = country; _country = country;
} }
[Route("/investigation-confirmation")]
public IActionResult InvestigationConfirmation()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SubmitTheory(TheorySubmission model)
{
if (!ModelState.IsValid)
{
return View("Investigation", model);
}
await _dataAccess.SaveTheorySubmission(model);
// Optional: Temp message
TempData["Success"] = "Your theory has been submitted.";
return RedirectToAction("InvestigationConfirmation");
}
[Route("")] [Route("")]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
@ -107,8 +130,8 @@ namespace CatherineLynwood.Controllers
public IActionResult Extras() => View(); public IActionResult Extras() => View();
[BookAccess(1, 2)] [BookAccess(1, 2)]
[Route("extras/epilogue")] [Route("extras/investigation")]
public IActionResult Epilogue() => View(); public IActionResult Investigation() => View();
[BookAccess(1, 2)] [BookAccess(1, 2)]
[Route("extras/scrap-book")] [Route("extras/scrap-book")]
@ -126,7 +149,7 @@ namespace CatherineLynwood.Controllers
[Route("extras/soundtrack")] [Route("extras/soundtrack")]
public async Task<IActionResult> Soundtrack() public async Task<IActionResult> Soundtrack()
{ {
List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack(); List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack("Reckoning");
return View(soundtrackTrackModels); return View(soundtrackTrackModels);
} }

View File

@ -0,0 +1,10 @@
namespace CatherineLynwood.Models
{
public class TheorySubmission
{
public string Suspect { get; set; }
public string Reason { get; set; }
public string EmailAddress { get; set; }
public bool MarketingOptIn { get; set; }
}
}

View File

@ -27,15 +27,20 @@ namespace CatherineLynwood.Services
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires) if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires)
return false; return false;
if (string.IsNullOrWhiteSpace(token))
return false;
var expected = GenerateToken(id, expires); var expected = GenerateToken(id, expires);
return expected == token; return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(token));
} }
public string GenerateUrl(string id) public string GenerateUrl(string id)
{ {
var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _expirySeconds; var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _expirySeconds;
var token = GenerateToken(id, expires); var token = GenerateToken(id, expires);
return $"/api/audio/stream/{id}?expires={expires}&token={Uri.EscapeDataString(token)}"; return $"/api/audio/secure-stream/{id}?expires={expires}&token={Uri.EscapeDataString(token)}";
} }
} }
} }

View File

@ -811,7 +811,7 @@ namespace CatherineLynwood.Services
return reviews; return reviews;
} }
public async Task<List<SoundtrackTrackModel>> GetSoundtrack() public async Task<List<SoundtrackTrackModel>> GetSoundtrack(string title)
{ {
List<SoundtrackTrackModel> soundtrackTrackModels = new List<SoundtrackTrackModel>(); List<SoundtrackTrackModel> soundtrackTrackModels = new List<SoundtrackTrackModel>();
@ -825,6 +825,7 @@ namespace CatherineLynwood.Services
cmd.Connection = conn; cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure; cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetSoundtrack"; cmd.CommandText = "GetSoundtrack";
cmd.Parameters.AddWithValue("@Title", title);
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{ {
@ -1045,6 +1046,40 @@ namespace CatherineLynwood.Services
return success; return success;
} }
public async Task<bool> SaveTheorySubmission(TheorySubmission submission)
{
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 = "SaveTheorySubmission";
cmd.Parameters.AddWithValue("@Suspect", submission.Suspect ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@Reason", submission.Reason ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@EmailAddress", submission.EmailAddress ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@MarketingOptIn", submission.MarketingOptIn);
cmd.Parameters.AddWithValue("@CreatedAt", DateTime.UtcNow);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
success = false;
// optional: log ex
}
}
}
return success;
}
public async Task<bool> SaveContact(Contact contact, bool subscribe = false) public async Task<bool> SaveContact(Contact contact, bool subscribe = false)
{ {
bool success = true; bool success = true;

View File

@ -41,72 +41,144 @@ namespace CatherineLynwood.TagHelpers
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{ {
// Adjust for mobile display width if applicable try
if (IsMobile(_httpContextAccessor.HttpContext.Request))
{ {
DisplayWidthPercentage = 100; if (IsMobile(_httpContextAccessor.HttpContext.Request))
}
string rootPath = Path.Combine(Directory.GetCurrentDirectory(), _wwwRoot, "images", Source);
string directory = Path.GetDirectoryName(rootPath);
string fileName = Path.GetFileNameWithoutExtension(rootPath);
string extension = Path.GetExtension(rootPath);
string year = DateTime.Now.Year.ToString();
// Define paths for "webp" and "jpg" subfolders
string webpDirectory = Path.Combine(directory, "webp");
string jpgDirectory = Path.Combine(directory, "jpg");
string watermarkText = $"© Catherine Lynwood {year}";
// Ensure the subdirectories exist
Directory.CreateDirectory(webpDirectory);
Directory.CreateDirectory(jpgDirectory);
// Generate HTML for <picture> tag with WebP sources only
output.TagName = "picture";
foreach (string res in _resolutions)
{
int effectiveWidth = int.Parse(res) * DisplayWidthPercentage / 100;
string webpPath = Path.Combine(webpDirectory, $"{fileName}-{effectiveWidth}.webp");
if (!File.Exists(webpPath))
{ {
await CreateResizedImage(rootPath, webpPath, effectiveWidth, watermarkText, true); // WebP format DisplayWidthPercentage = 100;
} }
string rootPath = Path.Combine(Directory.GetCurrentDirectory(), _wwwRoot, "images", Source);
if (string.IsNullOrWhiteSpace(Source))
{
RenderError(output, "responsive-image error: src is empty.");
return;
}
if (!File.Exists(rootPath))
{
RenderError(output, $"responsive-image error: source image not found: {rootPath}");
return;
}
string directory = Path.GetDirectoryName(rootPath);
string fileName = Path.GetFileNameWithoutExtension(rootPath);
string extension = Path.GetExtension(rootPath).ToLowerInvariant();
if (extension == ".gif")
{
RenderError(output, $"responsive-image error: GIF files are not supported by this helper. Use a normal <img> tag for: {Source}");
return;
}
string year = DateTime.Now.Year.ToString();
string webpDirectory = Path.Combine(directory, "webp");
string jpgDirectory = Path.Combine(directory, "jpg");
string watermarkText = $"© Catherine Lynwood {year}";
Directory.CreateDirectory(webpDirectory);
Directory.CreateDirectory(jpgDirectory);
string sourceDirectoryUrl = Path.GetDirectoryName(Source)?.Replace("\\", "/") ?? "";
string webpUrlBase = string.IsNullOrWhiteSpace(sourceDirectoryUrl)
? "/images/webp"
: $"/images/{sourceDirectoryUrl}/webp";
output.TagName = "picture";
foreach (string res in _resolutions)
{
int effectiveWidth = int.Parse(res) * DisplayWidthPercentage / 100;
string webpPath = Path.Combine(webpDirectory, $"{fileName}-{effectiveWidth}.webp");
if (!File.Exists(webpPath))
{
try
{
await CreateResizedImage(rootPath, webpPath, effectiveWidth, watermarkText, true);
}
catch (IOException)
{
// Another request is probably generating it — safe to ignore
}
}
output.Content.AppendHtml(
$"<source srcset='{webpUrlBase}/{fileName}-{effectiveWidth}.webp' media='(min-width: {res}px)'>");
}
string webpPathFallback = Path.Combine(webpDirectory, $"{fileName}-400.webp");
if (!File.Exists(webpPathFallback))
{
await CreateResizedImage(rootPath, webpPathFallback, 400, watermarkText, true);
}
string jpgFallbackPath = Path.Combine(jpgDirectory, $"{fileName}-400.jpg");
if (!File.Exists(jpgFallbackPath))
{
await CreateResizedImage(rootPath, jpgFallbackPath, 400, watermarkText, false);
}
string noIndexAttr = NoIndex ? " data-nosnippet" : "";
output.Content.AppendHtml( output.Content.AppendHtml(
$"<source srcset='/images/webp/{fileName}-{effectiveWidth}.webp' media='(min-width: {res}px)'>"); $"<img src='{webpUrlBase}/{fileName}-400.webp' class='{CssClass}' alt='{AltText}'{noIndexAttr}>");
} }
catch (Exception ex)
string webpPathFallback = Path.Combine(webpDirectory, $"{fileName}-400.webp");
if (!File.Exists(webpPathFallback))
{ {
await CreateResizedImage(rootPath, webpPathFallback, 400, watermarkText, true); // WebP format RenderError(output, $"responsive-image error for '{Source}': {ex.Message}");
} }
// Generate the 400px JPEG for RSS feed or other uses (without adding to the <picture> element)
string jpgFallbackPath = Path.Combine(jpgDirectory, $"{fileName}-400.jpg");
if (!File.Exists(jpgFallbackPath))
{
await CreateResizedImage(rootPath, jpgFallbackPath, 400, watermarkText, false); // JPEG format
}
// Add the default WebP fallback in the <img> tag (JPEG is not included in HTML)
string noIndexAttr = NoIndex ? " data-nosnippet" : "";
output.Content.AppendHtml(
$"<img src='/images/webp/{fileName}-400.webp' class='{CssClass}' alt='{AltText}'{noIndexAttr}>");
} }
private void RenderError(TagHelperOutput output, string message)
{
output.TagName = "div";
output.Attributes.SetAttribute("style",
"background:#5b1f1f;color:#fff4dc;border:2px solid #d3a45f;padding:12px;margin:12px 0;border-radius:8px;font-family:monospace;white-space:pre-wrap;");
output.Content.SetHtmlContent(System.Net.WebUtility.HtmlEncode(message));
}
private Font? GetWatermarkFont()
{
string[] preferredFonts =
{
"Arial",
"DejaVu Sans",
"Liberation Sans",
"Noto Sans",
"Ubuntu"
};
foreach (string fontName in preferredFonts)
{
if (SystemFonts.TryGet(fontName, out FontFamily family))
{
return family.CreateFont(12);
}
}
return null;
}
private async Task CreateResizedImage(string originalPath, string outputPath, int width, string watermarkText, bool isWebP) private async Task CreateResizedImage(string originalPath, string outputPath, int width, string watermarkText, bool isWebP)
{ {
using (var image = await Image.LoadAsync(originalPath)) using (var image = await Image.LoadAsync(originalPath))
{ {
image.Mutate(x => x int resizeWidth = Math.Min(width, image.Width);
.Resize(width, 0) Font? watermarkFont = GetWatermarkFont();
.DrawText(watermarkText, SystemFonts.CreateFont("Arial", 12), Color.White, new PointF(30, image.Height - 20)));
image.Mutate(x =>
{
x.Resize(resizeWidth, 0);
if (watermarkFont != null)
{
x.DrawText(watermarkText, watermarkFont, Color.White, new PointF(30, image.Height - 20));
}
});
if (isWebP) if (isWebP)
{ {

View File

@ -0,0 +1,110 @@
@{
Layout = "_Layout";
ViewData["Title"] = "The Calendar House Plan";
var rooms = new[]
{
("01", "Threshold Room", "Spring", "Ground"), ("02", "Blue Breakfast Room", "Spring", "Ground"),
("03", "Observatory Parlour", "Spring", "Ground"), ("04", "East Gallery", "Spring", "Ground"),
("05", "Rain Cabinet", "Spring", "Ground"), ("06", "Guest Room of Clocks", "Spring", "Ground"),
("07", "Sunday Door", "Spring", "Ground"), ("08", "Orchard Room", "Spring", "Ground"),
("09", "West Linen Hall", "Spring", "Ground"), ("10", "Glass Pantry", "Spring", "Ground"),
("11", "Green Drawing Room", "Spring", "Ground"), ("12", "Letter Room", "Spring", "Ground"),
("13", "Kitchen of Twelve Fires", "Summer", "Ground"), ("14", "Garden Stair", "Summer", "Ground"),
("15", "North Dining Room", "Summer", "Ground"), ("16", "The Long Warm Hall", "Summer", "Ground"),
("17", "Map Room", "Summer", "Ground"), ("18", "The Still Room", "Summer", "Ground"),
("19", "Ellie's Chamber", "Summer", "First"), ("20", "Moon Landing", "Summer", "First"),
("21", "Brother's Room", "Summer", "First"), ("22", "Sister's Room", "Summer", "First"),
("23", "The Red Passage", "Summer", "First"), ("24", "Mirror Bath", "Summer", "First"),
("25", "The Nursery Without Toys", "Autumn", "First"), ("26", "July Balcony", "Autumn", "First"),
("27", "The Amber Library", "Autumn", "First"), ("28", "Aunt's Room", "Autumn", "First"),
("29", "The Quiet Wardrobe", "Autumn", "First"), ("30", "Room of Names", "Autumn", "First"),
("31", "Northwest Chamber", "Autumn", "First"), ("32", "The Bell Room", "Autumn", "First"),
("33", "October Study", "Autumn", "First"), ("34", "The Locked Sitting Room", "Autumn", "First"),
("35", "Salt Corridor", "Autumn", "First"), ("36", "The Listening Room", "Autumn", "First"),
("37", "Winter Stair", "Winter", "Top"), ("38", "The Archive", "Winter", "Top"),
("39", "Moth Room", "Winter", "Top"), ("40", "Servants' Sky Hall", "Winter", "Top"),
("41", "The Twelfth Chimney", "Winter", "Top"), ("42", "Black Attic", "Winter", "Top"),
("43", "The Closed Chapel", "Winter", "Top"), ("44", "Room of Spare Keys", "Winter", "Top"),
("45", "The North Watch", "Winter", "Top"), ("46", "The Kept Room", "Winter", "Top"),
("47", "Dead Calendar Room", "Winter", "Top"), ("48", "February Box Room", "Winter", "Top"),
("49", "The Empty Room", "Winter", "Top"), ("50", "The Last Landing", "Winter", "Top"),
("51", "The Room That Moves", "Winter", "Top"), ("52", "The Room With No Date", "Winter", "Top")
};
}
<section class="calendar-page-hero">
<p class="calendar-kicker">The plan</p>
<h1>Fifty-two rooms. Three floors. Four wings.</h1>
<p>
The house presents itself as architecture, but its real floor plan is time.
The rooms below are arranged like weeks, grouped into seasonal wings, and watched over by twelve chimneys.
</p>
</section>
<section class="calendar-section calendar-house-stats" aria-label="Calendar House statistics">
<div><strong>52</strong><span>rooms</span></div>
<div><strong>3</strong><span>floors</span></div>
<div><strong>4</strong><span>wings</span></div>
<div><strong>12</strong><span>chimneys</span></div>
<div><strong>7</strong><span>doors</span></div>
<div><strong>365</strong><span>windows</span></div>
</section>
<section class="calendar-section plan-board">
<div class="plan-controls" role="tablist" aria-label="Floor selector">
<button type="button" class="plan-filter active" data-floor="All">All</button>
<button type="button" class="plan-filter" data-floor="Ground">Ground</button>
<button type="button" class="plan-filter" data-floor="First">First</button>
<button type="button" class="plan-filter" data-floor="Top">Top</button>
</div>
<div class="house-diagram" aria-label="Simplified Calendar House floor plan">
<div class="wing wing-spring">Spring Wing</div>
<div class="wing wing-summer">Summer Wing</div>
<div class="wing wing-autumn">Autumn Wing</div>
<div class="wing wing-winter">Winter Wing</div>
<div class="central-hall">Central Hall</div>
<div class="chimneys" aria-hidden="true">
@for (var i = 0; i < 12; i++)
{
<span></span>
}
</div>
<div class="doors" aria-hidden="true">
@for (var i = 0; i < 7; i++)
{
<span></span>
}
</div>
</div>
<div class="room-grid" aria-live="polite">
@foreach (var room in rooms)
{
<button type="button" class="room-tile" data-floor="@room.Item4" data-season="@room.Item3">
<span>@room.Item1</span>
<strong>@room.Item2</strong>
<em>@room.Item3 / @room.Item4 floor</em>
</button>
}
</div>
</section>
<section class="calendar-section calendar-note">
<h2>The wrongness is deliberate.</h2>
<p>
The building is almost symmetrical, but never quite. The Spring and Winter wings do not align.
The seventh door has no matching corridor. The windows are counted from the outside, but the rooms
refuse to agree from within.
</p>
</section>
@section Meta {
<MetaTag meta-title="The Calendar House Plan"
meta-description="Explore the fictional architecture of The Calendar House: 52 rooms, 3 floors, 4 wings, 12 chimneys, 7 doors, and 365 windows."
meta-author="Catherine Lynwood"
meta-url="https://www.catherinelynwood.com/the-calendar-house/house-plan"
meta-image="https://www.catherinelynwood.com/images/calendar-house-hero.png"
twitter-card-type="summary_large_image" />
}

View File

@ -0,0 +1,109 @@
@{
Layout = "_Layout";
ViewData["Title"] = "The Calendar House";
}
<section class="calendar-hero" aria-labelledby="calendar-house-title">
<img class="calendar-hero-image" src="/images/calendar-house-hero.png" alt="The Calendar House standing at dusk with many lit windows and chimneys" />
<div class="calendar-hero-shade"></div>
<div class="calendar-hero-content">
<p class="calendar-kicker">Standalone psychological mystery</p>
<h1 id="calendar-house-title">The Calendar House</h1>
<p class="calendar-hero-copy">
A lonely woman enters a house that offers belonging, intimacy, and safety.
Then she begins to understand the cost.
</p>
<div class="calendar-hero-actions">
<a class="calendar-button" asp-action="HousePlan">
<i class="fad fa-drafting-compass" aria-hidden="true"></i>
Explore the house
</a>
<a class="calendar-button calendar-button-ghost" asp-action="ThePattern">
<i class="fad fa-key-skeleton" aria-hidden="true"></i>
Find the pattern
</a>
</div>
</div>
<div class="calendar-hero-numbers" aria-label="House structure">
<span><strong>52</strong> rooms</span>
<span><strong>4</strong> wings</span>
<span><strong>7</strong> doors</span>
<span><strong>365</strong> windows</span>
</div>
</section>
<section class="calendar-section calendar-intro-grid">
<div>
<p class="calendar-kicker">The invitation</p>
<h2>Ellie did not come here by accident.</h2>
<p>
After betrayal, humiliation, and a period of sleeping on a friend's sofa, Ellie receives a trunk
of belongings and an invitation to an isolated house built around a system no one fully admits
to understanding.
</p>
<p>
At first, it feels like retreat. Then like a game. Then like a place that has been waiting for her
to need it badly enough.
</p>
</div>
<div class="calendar-rule-panel">
<div class="rule-line">
<span>Fifty-two rooms</span>
<b>one for every week</b>
</div>
<div class="rule-line">
<span>Four wings</span>
<b>one for every season</b>
</div>
<div class="rule-line">
<span>Seven doors</span>
<b>one for every day</b>
</div>
<div class="rule-line">
<span>One way in</span>
<b>no reliable way back</b>
</div>
</div>
</section>
<section class="calendar-section calendar-mood-board" aria-label="Story atmosphere">
<article>
<i class="fad fa-house-night" aria-hidden="true"></i>
<h3>The house watches</h3>
<p>Corridors turn familiar only after they have betrayed you. Doors remember choices better than people do.</p>
</article>
<article>
<i class="fad fa-users" aria-hidden="true"></i>
<h3>The residents belong</h3>
<p>Warmth and unease sit together here. The people are affectionate, devoted, and impossible to read from the outside.</p>
</article>
<article>
<i class="fad fa-hourglass-half" aria-hidden="true"></i>
<h3>The pattern repeats</h3>
<p>What looks like eccentric tradition may be inheritance, experiment, ritual, or something older than all three.</p>
</article>
</section>
<section class="calendar-section calendar-blurb">
<p>They say the house was built to a pattern.</p>
<p>No one remembers who designed it.</p>
<p>Fifty-two rooms. Four wings. Seven doors.</p>
<p>Each one placed with purpose.</p>
<p>Ellie did not come here by accident.</p>
<p>None of them did.</p>
</section>
@section Meta {
<MetaTag meta-title="The Calendar House by Catherine Lynwood"
meta-description="The Calendar House is a standalone dark psychological mystery about belonging, inherited behaviour, and a house built to a disturbing hidden pattern."
meta-keywords="The Calendar House, Catherine Lynwood, psychological mystery, gothic mystery, dark suspense, standalone novel"
meta-author="Catherine Lynwood"
meta-url="https://www.catherinelynwood.com/the-calendar-house"
meta-image="https://www.catherinelynwood.com/images/calendar-house-hero.png"
meta-image-alt="The Calendar House standing at dusk"
og-site-name="Catherine Lynwood - The Calendar House"
twitter-card-type="summary_large_image"
twitter-site-handle="@@CathLynwood"
twitter-creator-handle="@@CathLynwood" />
}

View File

@ -0,0 +1,55 @@
@{
Layout = "_Layout";
ViewData["Title"] = "The Pattern";
}
<section class="calendar-page-hero pattern-hero">
<p class="calendar-kicker">The pattern</p>
<h1>Once you see it, it has already seen you.</h1>
<p>
The Calendar House is not simply a setting. It is a social system, a memory palace,
and a trap built from habit.
</p>
</section>
<section class="calendar-section pattern-grid">
<article>
<span>01</span>
<h2>Belonging</h2>
<p>Ellie arrives bruised by ordinary rejection and finds a place that answers her loneliness too precisely.</p>
</article>
<article>
<span>02</span>
<h2>Normalisation</h2>
<p>Inside the house, inherited behaviour becomes custom. What outsiders notice first, residents barely name.</p>
</article>
<article>
<span>03</span>
<h2>Inhibition</h2>
<p>The house lowers defences slowly: through ritual, warmth, secrecy, and the comfort of being wanted.</p>
</article>
<article>
<span>04</span>
<h2>Repetition</h2>
<p>Every room has a use. Every season has a pressure. Every invitation is both welcome and warning.</p>
</article>
</section>
<section class="calendar-section calendar-extract-card">
<h2>Working blurb</h2>
<p>They say the house was built to a pattern.</p>
<p>No one remembers who designed it.</p>
<p>At first, it feels like a game.</p>
<p>Corridors that shift. Doors that do not lead back.</p>
<p>Then the house begins to change.</p>
<p>Or perhaps it always was.</p>
</section>
@section Meta {
<MetaTag meta-title="The Pattern - The Calendar House"
meta-description="A spoiler-light look at the ideas behind The Calendar House: belonging, inherited behaviour, intimacy, social unease, and the house as a trap."
meta-author="Catherine Lynwood"
meta-url="https://www.catherinelynwood.com/the-calendar-house/the-pattern"
meta-image="https://www.catherinelynwood.com/images/calendar-house-hero.png"
twitter-card-type="summary_large_image" />
}

View File

@ -0,0 +1,51 @@
@{
var title = ViewData["Title"]?.ToString() ?? "The Calendar House";
}
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@title | Catherine Lynwood</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/fontawesome.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/duotone.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/calendar-house.css" asp-append-version="true" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<meta name="theme-color" content="#11150f">
<link rel="canonical" href="@($"https://www.catherinelynwood.com{Context.Request.Path}")" />
@RenderSection("Meta", required: false)
</head>
<body class="calendar-house-body">
<header class="calendar-house-header">
<a class="calendar-house-brand" asp-controller="CalendarHouse" asp-action="Index">
<span class="brand-mark" aria-hidden="true">52</span>
<span>The Calendar House</span>
</a>
<nav class="calendar-house-nav" aria-label="Calendar House navigation">
<a asp-controller="CalendarHouse" asp-action="Index">Book</a>
<a asp-controller="CalendarHouse" asp-action="HousePlan">House Plan</a>
<a asp-controller="CalendarHouse" asp-action="ThePattern">The Pattern</a>
<a asp-controller="Home" asp-action="Index">Catherine Lynwood</a>
</nav>
</header>
<main>
@RenderBody()
</main>
<footer class="calendar-house-footer">
<div>
<strong>Catherine Lynwood</strong>
<span>The Calendar House is a standalone dark psychological mystery.</span>
</div>
<social-media-share title="@title" url="@Context.Request.Path" class="calendar-house-share"></social-media-share>
</footer>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/calendar-house.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

@ -1,337 +1,10 @@
@model CatherineLynwood.Models.TitlePageViewModel @model CatherineLynwood.Models.TitlePageViewModel
@{ @{
ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters"; ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters";
bool showReviews = Model.Reviews.Items.Any();
} }
<div class="row"> @await Html.PartialAsync("_DiscoveryShowcase", Model)
<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 active" aria-current="page">Discovery</li>
</ol>
</nav>
</div>
</div>
<!-- HERO: Cover + Trailer + Buy Box -->
<section class="mb-4">
<div class="row g-3 align-items-stretch">
<!-- Book Cover -->
<div class="col-lg-5 d-flex d-none d-lg-block">
<div class="card character-card h-100 flex-fill" id="cover-card">
<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 h5 mb-1">The Alpha Flame: <span class="fw-light">Discovery</span></h3>
<p class="card-text mb-0">It's 1983 Birmingham. Maggie, my fiery heroine, standing outside the derelict Rubery Hill Hospital. A story of survival, sisters, and fire.</p>
</div>
</div>
</div>
<!-- Trailer + Buy Box -->
<div class="col-lg-7 d-flex">
<div class="card character-card h-100 flex-fill" id="hero-media-card">
<div class="card-body d-flex flex-column">
<!-- Trailer -->
<div class="trailer-wrapper mb-3">
<!-- Desktop: LANDSCAPE -->
<video id="trailerLandscape"
class="w-100"
playsinline
preload="none"
poster="/images/webp/the-alpha-flame-discovery-trailer-landscape-1400.webp"
controls>
<source src="/videos/the-alpha-flame-discovery-trailer-landscape.mp4" type="video/mp4">
Sorry, your browser doesn't support embedded video.
</video>
<button id="trailerPlayBtn" class="trailer-play-btn">
<i class="fad fa-play"></i>
</button>
</div>
<!-- If JS is off, hide the custom play button so native controls are used -->
<noscript>
<style>
#trailerPlayBtn {
display: none !important
}</style>
</noscript>
@await Component.InvokeAsync("BuyPanel", new { ISO2 = Model.UserIso2, Src = Model.Src, Title = Model.Title })
</div>
</div>
</div>
</div>
</section>
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Audiobook Out Now ★</h3>
<p class="mb-3">
<em>The Alpha Flame: Discovery</em> is now available as a full-length audiobook, narrated to be lived in rather than rushed through.
It’s a slow-burn, character-led story where quiet moments matter, tension builds gradually, and certain scenes land harder when you hear them spoken.
</p>
<p class="mb-3">
Ideal for long drives, late nights, or anyone who prefers to sink into a story through headphones rather than skim it on a screen.
</p>
<a asp-controller="Discovery" asp-action="AudioBook" class="btn btn-dark">
🎧 Explore the audiobook
</a>
</div>
</div>
</section>
<!-- Social proof: show one standout review near the top -->
@if (showReviews)
{
var top = Model.Reviews.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
var fullStars = (int)Math.Floor(top.RatingValue);
var hasHalfStar = top.RatingValue - fullStars >= 0.5;
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
var reviewDate = top.DatePublished.ToString("d MMMM yyyy");
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Reader Praise ★</h3>
<blockquote class="blockquote mb-2">
<span class="mb-2 text-warning d-inline-block">
@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(top.ReviewBody)
<footer>
@top.AuthorName on
<cite title="@top.SiteName">
@if (string.IsNullOrEmpty(top.URL))
{
@top.SiteName
}
else
{
<a href="@top.URL" target="_blank">@top.SiteName</a>
}
</cite>
<span class="text-muted smaller">, @reviewDate</span>
</footer>
</blockquote>
@if (Model.Reviews.Items.Count > 1)
{
<div class="text-end">
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">Read more reviews</a>
</div>
}
</div>
</div>
</section>
}
<!-- Synopsis: open on mobile, collapsible on desktop -->
<section id="synopsis" class="mb-4">
<div class="card character-card text-white" style="background: url('/images/webp/synopsis-background-960.webp'); background-position: center; background-size: cover;">
<div class="card-header">
<h2 class="card-title h1 mb-0">The Alpha Flame: <span class="fw-light">Discovery</span></h2>
<p class="mb-0">Survival, secrets, and shadows in 1983 Birmingham.</p>
</div>
<div class="card-body" id="synopsis-body">
<!-- Audio blurb -->
<div class="row align-items-center mb-3">
<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">
<div class="audio-player text-center">
<audio id="player">
<source src="/audio/the-alpha-flame-discovery-catherine.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<p class="text-center text-white small mb-0">Listen to Catherine talking about the book</p>
</div>
</div>
<!-- Teaser -->
<p class="card-text">
She didn’t go looking for trouble. But when she found Beth, bruised, broken, and terrified, Maggie couldn’t walk away.
</p>
<p class="card-text">
But nothing prepares her for Beth. As she digs deeper into Beth’s world, Maggie finds herself pulled into the shadows, a seedy underworld of secrets, survival, and control, where loyalty is rare and nothing is guaranteed. The more she uncovers, the more she realises this isn’t someone else’s nightmare. It’s her own.
</p>
<p class="card-text">
Set in 1983 Birmingham, nearby Redditch, and Barmouth in Wales, The Alpha Flame: Discovery follows the lives of two young women, Beth and Maggie, as they uncover dark family secrets and fight to survive. Gritty and emotionally charged, it explores the bond between two women who refuse to be broken.
</p>
<!-- Desktop-only: collapse trigger -->
<p class="mb-2 d-none d-md-block">
<a class="btn btn-outline-light btn-sm"
data-bs-toggle="collapse"
href="#fullSynopsis"
role="button"
aria-expanded="false"
aria-controls="fullSynopsis">
Read full synopsis
</a>
</p>
<!-- One copy of the full synopsis -->
<div class="collapse" id="fullSynopsis">
<div class="mt-2">
<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 captures 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 page.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Chapter Previews -->
<section id="chapters" class="mt-4">
<h2>Chapter Previews</h2>
<div class="row">
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter1">
<responsive-image src="beth-stood-in-bathroom.png" class="card-img-top" alt="Beth's Bathroom" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 1: Drowning in Silence — Beth</h3>
<p class="card-text">Beth returns home to find her mother lifeless in the bath...</p>
<div class="text-end"><a asp-action="Chapter1" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter2">
<responsive-image src="maggie-with-her-tr6-2.png" class="fit-image" alt="Maggie With Her TR6" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 2: The Last Lesson — Maggie</h3>
<p class="card-text">On Christmas Eve, Maggie nervously heads out for her driving test not knowing the story that will pan out before her...</p>
<div class="text-end"><a asp-action="Chapter2" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter13">
<responsive-image src="pub-from-chapter-13.png" class="fit-image" alt="Pub from Chapter 13" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 13: A Name She Never Owned — Susie</h3>
<p class="card-text">Susie goes out for a drink with a punter. What on earth could go wrong...</p>
<div class="text-end"><a asp-action="Chapter13" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
</div>
</section>
@section Scripts {
<!-- Plyr for audio -->
<script>
const player = new Plyr('audio');
</script>
<!-- Trailer play/pause via custom button or video click (desktop only) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
const v = document.getElementById("trailerLandscape");
const playBtn = document.getElementById("trailerPlayBtn");
if (!v || !playBtn) return;
// Hide native controls when JS is active; use custom button instead
v.controls = false;
// Start playback
const startPlayback = () => {
v.muted = false;
v.volume = 1.0;
v.play()
.then(() => { playBtn.style.display = "none"; })
.catch(err => console.warn("Video play failed:", err));
};
// Toggle on video click
const togglePlayback = () => {
if (v.paused) {
startPlayback();
} else {
v.pause();
playBtn.style.display = "block";
}
};
// Events
playBtn.addEventListener("click", (e) => { e.preventDefault(); startPlayback(); });
v.addEventListener("click", togglePlayback);
// Keep button state in sync with native events
v.addEventListener("play", () => { playBtn.style.display = "none"; });
v.addEventListener("pause", () => { playBtn.style.display = "block"; });
v.addEventListener("ended", () => { playBtn.style.display = "block"; });
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('fullSynopsis');
if (!el || !window.bootstrap?.Collapse) return;
// Initialise without auto-toggling
const c = new bootstrap.Collapse(el, { toggle: false });
const mq = window.matchMedia('(min-width: 768px)'); // Bootstrap md
function setInitial() {
if (mq.matches) {
// Desktop: keep collapsed
c.hide();
} else {
// Mobile: open by default
c.show();
}
}
setInitial();
// Optional: if user resizes across the breakpoint, adjust state
mq.addEventListener?.('change', setInitial);
});
</script>
}
@section Meta { @section Meta {
<MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood" <MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood"

View File

@ -1,290 +1,10 @@
@model CatherineLynwood.Models.TitlePageViewModel @model CatherineLynwood.Models.TitlePageViewModel
@{ @{
ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters"; ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters";
bool showReviews = Model.Reviews.Items.Any();
} }
<div class="row"> @await Html.PartialAsync("_DiscoveryShowcase", Model)
<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 active" aria-current="page">Discovery</li>
</ol>
</nav>
</div>
</div>
<!-- HERO: Cover + Trailer + Buy Box -->
<section class="mb-4">
<div class="card mb-4">
<div class="card-body">
<h2 class="card-title h1 mb-0">The Alpha Flame: <span class="fw-light">Discovery</span></h2>
<p class="mb-0">Birmingham 1983 - A tale of survival, secrets, and sisterhood.</p>
</div>
</div>
<div class="row g-3 align-items-stretch">
<!-- Trailer + Buy Box -->
<div class="col-12">
<div class="card character-card" id="hero-media-card">
<div class="card-body">
<!-- Trailer -->
<div class="trailer-wrapper mb-3">
<!-- Mobile / tablet: PORTRAIT -->
<video id="trailerPortrait"
class="w-100"
playsinline
preload="none"
poster="/images/webp/the-alpha-flame-discovery-trailer-portrait-400.webp"
controls>
<source src="/videos/the-alpha-flame-discovery-trailer-portrait.mp4" type="video/mp4">
Sorry, your browser doesn't support embedded video.
</video>
<button id="trailerPlayBtn" class="trailer-play-btn">
<i class="fad fa-play"></i>
</button>
</div>
<!-- If JS is off, hide the custom play button so native controls are used -->
<noscript>
<style>
#trailerPlayBtn {
display: none !important
}</style>
</noscript>
@await Component.InvokeAsync("BuyPanel", new { ISO2 = Model.UserIso2, Src = Model.Src })
</div>
</div>
<!-- Sticky mobile buy bar -->
<div id="mobileBuyBar" class="d-md-none fixed-bottom bg-dark text-white py-2 border-top border-3 border-light" style="z-index:1030;">
<div class="container d-flex justify-content-between align-items-center">
<span class="small">The Alpha Flame: Discovery</span>
<a href="#buyBox" class="btn btn-light btn-sm">Buy now</a>
</div>
</div>
</div>
</div>
</section>
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Audiobook Out Now ★</h3>
<p class="mb-3">
<em>The Alpha Flame: Discovery</em> is now available as a full-length audiobook, narrated to be lived in rather than rushed through.
It’s a slow-burn, character-led story where quiet moments matter, tension builds gradually, and certain scenes land harder when you hear them spoken.
</p>
<p class="mb-3">
Ideal for long drives, late nights, or anyone who prefers to sink into a story through headphones rather than skim it on a screen.
</p>
<a asp-controller="Discovery" asp-action="AudioBook" class="btn btn-dark">
🎧 Explore the audiobook
</a>
</div>
</div>
</section>
<!-- Social proof: show one standout review near the top -->
@if (showReviews)
{
var top = Model.Reviews.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
var fullStars = (int)Math.Floor(top.RatingValue);
var hasHalfStar = top.RatingValue - fullStars >= 0.5;
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
var reviewDate = top.DatePublished.ToString("d MMMM yyyy");
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Reader Praise ★</h3>
<blockquote class="blockquote mb-2">
<span class="mb-2 text-warning d-inline-block">
@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(top.ReviewBody)
<footer>
@top.AuthorName on
<cite title="@top.SiteName">
@if (string.IsNullOrEmpty(top.URL))
{
@top.SiteName
}
else
{
<a href="@top.URL" target="_blank">@top.SiteName</a>
}
</cite>
<span class="text-muted smaller">, @reviewDate</span>
</footer>
</blockquote>
@if (Model.Reviews.Items.Count > 1)
{
<div class="text-end">
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">Read more reviews</a>
</div>
}
</div>
</div>
</section>
}
<!-- Synopsis: open on mobile, collapsible on desktop -->
<section id="synopsis" class="mb-4">
<div class="card character-card text-white" style="background: url('/images/webp/synopsis-background-960.webp'); background-position: center; background-size: cover;">
<div class="card-header">
<h2 class="card-title h1 mb-0">The Alpha Flame: <span class="fw-light">Discovery</span></h2>
<p class="mb-0">Birmingham 1983 - A tale of survival, secrets, and sisterhood.</p>
</div>
<div class="card-body" id="synopsis-body">
<!-- Audio blurb -->
<div class="row align-items-center mb-3">
<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">
<div class="audio-player text-center">
<audio id="player">
<source src="/audio/the-alpha-flame-discovery-catherine.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<p class="text-center text-white small mb-0">Listen to Catherine talking about the book</p>
</div>
</div>
<!-- Full synopsis -->
<div>
<div class="mt-2">
<p class="card-text">She didn’t go looking for trouble. But when she found Beth, bruised, broken, and terrified, Maggie couldn’t walk away.</p>
<p class="card-text">But nothing prepares her for Beth. As she digs deeper into Beth’s world, Maggie finds herself pulled into the shadows, a seedy underworld of secrets, survival, and control, where loyalty is rare and nothing is guaranteed. The more she uncovers, the more she realises this isn’t someone else’s nightmare. It’s her own.</p>
<p class="card-text">Set in 1983 Birmingham, nearby Redditch, and Barmouth in Wales, The Alpha Flame: Discovery follows the lives of two young women, Beth and Maggie, as they uncover dark family secrets and fight to survive. Gritty and emotionally charged, it explores the bond between two women who refuse to be broken.</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 captures 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 page.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Chapter Previews -->
<section id="chapters" class="mt-4">
<h2>Chapter Previews</h2>
<div class="row">
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter1">
<responsive-image src="beth-stood-in-bathroom.png" class="card-img-top" alt="Beth's Bathroom" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 1: Drowning in Silence — Beth</h3>
<p class="card-text">Beth returns home to find her mother lifeless in the bath...</p>
<div class="text-end"><a asp-action="Chapter1" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter2">
<responsive-image src="maggie-with-her-tr6-2.png" class="fit-image" alt="Maggie With Her TR6" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 2: The Last Lesson — Maggie</h3>
<p class="card-text">On Christmas Eve, Maggie nervously heads out for her driving test not knowing the story that will pan out before her...</p>
<div class="text-end"><a asp-action="Chapter2" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter13">
<responsive-image src="pub-from-chapter-13.png" class="fit-image" alt="Pub from Chapter 13" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 13: A Name She Never Owned — Susie</h3>
<p class="card-text">Susie goes out for a drink with a punter. What on earth could go wrong...</p>
<div class="text-end"><a asp-action="Chapter13" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
</div>
</section>
@section Scripts {
<!-- Plyr for audio -->
<script>
const player = new Plyr('audio');
</script>
<!-- Trailer play/pause via custom button or video click (single video) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
const v = document.getElementById("trailerPortrait");
const playBtn = document.getElementById("trailerPlayBtn");
if (!v || !playBtn) return;
// Hide native controls when JS is active; use custom button instead
v.controls = false;
// Start playback
const startPlayback = () => {
v.muted = false;
v.volume = 1.0;
v.play()
.then(() => { playBtn.style.display = "none"; })
.catch(err => console.warn("Video play failed:", err));
};
// Toggle on video click
const togglePlayback = () => {
if (v.paused) {
startPlayback();
} else {
v.pause();
playBtn.style.display = "block";
}
};
// Events
playBtn.addEventListener("click", (e) => { e.preventDefault(); startPlayback(); });
v.addEventListener("click", togglePlayback);
// Keep button state in sync with native events
v.addEventListener("play", () => { playBtn.style.display = "none"; });
v.addEventListener("pause", () => { playBtn.style.display = "block"; });
v.addEventListener("ended", () => { playBtn.style.display = "block"; });
});
</script>
}
@section Meta { @section Meta {
<MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood" <MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood"

View File

@ -1,291 +1,10 @@
@model CatherineLynwood.Models.TitlePageViewModel @model CatherineLynwood.Models.TitlePageViewModel
@{ @{
ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters"; ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters";
bool showReviews = Model.Reviews.Items.Any();
} }
<div class="row"> @await Html.PartialAsync("_DiscoveryShowcase", Model)
<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 active" aria-current="page">Discovery</li>
</ol>
</nav>
</div>
</div>
<!-- HERO: Cover + Trailer (no Buy Box here in Version B) -->
<section class="mb-4">
<div class="row g-3 align-items-stretch">
<!-- Trailer only -->
<div class="col-12">
<div class="card character-card" id="hero-media-card">
<div class="card-body">
<!-- Trailer -->
<div class="trailer-wrapper mb-3">
<!-- Mobile / tablet: PORTRAIT -->
<video id="trailerPortrait"
class="w-100"
playsinline
preload="none"
poster="/images/webp/the-alpha-flame-discovery-trailer-portrait-400.webp"
controls>
<source src="/videos/the-alpha-flame-discovery-trailer-portrait.mp4" type="video/mp4">
Sorry, your browser doesn't support embedded video.
</video>
<button id="trailerPlayBtn" class="trailer-play-btn">
<i class="fad fa-play"></i>
</button>
</div>
<!-- If JS is off, hide the custom play button so native controls are used -->
<noscript>
<style>
#trailerPlayBtn {
display: none !important
}</style>
</noscript>
</div>
</div>
</div>
</div>
</section>
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Audiobook Out Now ★</h3>
<p class="mb-3">
<em>The Alpha Flame: Discovery</em> is now available as a full-length audiobook, narrated to be lived in rather than rushed through.
It’s a slow-burn, character-led story where quiet moments matter, tension builds gradually, and certain scenes land harder when you hear them spoken.
</p>
<p class="mb-3">
Ideal for long drives, late nights, or anyone who prefers to sink into a story through headphones rather than skim it on a screen.
</p>
<a asp-controller="Discovery" asp-action="AudioBook" class="btn btn-dark">
🎧 Explore the audiobook
</a>
</div>
</div>
</section>
<!-- Synopsis first (story-first layout) -->
<section id="synopsis" class="mb-4">
<div class="card character-card text-white" style="background: url('/images/webp/synopsis-background-960.webp'); background-position: center; background-size: cover;">
<div class="card-header">
<h2 class="card-title h1 mb-0">The Alpha Flame: <span class="fw-light">Discovery</span></h2>
<p class="mb-0">Survival, secrets, and sisters in 1983 Birmingham.</p>
</div>
<div class="card-body" id="synopsis-body">
<!-- Audio blurb -->
<div class="row align-items-center mb-3">
<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">
<div class="audio-player text-center">
<audio id="player">
<source src="/audio/the-alpha-flame-discovery-catherine.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<p class="text-center text-white small mb-0">Listen to Catherine talking about the book</p>
</div>
</div>
<!-- Full synopsis -->
<div>
<div class="mt-2">
<p class="card-text">She didn’t go looking for trouble. But when she found Beth, bruised, broken, and terrified, Maggie couldn’t walk away.</p>
<p class="card-text">But nothing prepares her for Beth. As she digs deeper into Beth’s world, Maggie finds herself pulled into the shadows, a seedy underworld of secrets, survival, and control, where loyalty is rare and nothing is guaranteed. The more she uncovers, the more she realises this isn’t someone else’s nightmare. It’s her own.</p>
<p class="card-text">Set in 1983 Birmingham, nearby Redditch, and Barmouth in Wales, The Alpha Flame: Discovery follows the lives of two young women, Beth and Maggie, as they uncover dark family secrets and fight to survive. Gritty and emotionally charged, it explores the bond between two women who refuse to be broken.</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 captures 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 page.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Social proof near top -->
@if (showReviews)
{
var top = Model.Reviews.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
var fullStars = (int)Math.Floor(top.RatingValue);
var hasHalfStar = top.RatingValue - fullStars >= 0.5;
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
var reviewDate = top.DatePublished.ToString("d MMMM yyyy");
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Reader Praise ★</h3>
<blockquote class="blockquote mb-2">
<span class="mb-2 text-warning d-inline-block">
@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(top.ReviewBody)
<footer>
@top.AuthorName on
<cite title="@top.SiteName">
@if (string.IsNullOrEmpty(top.URL))
{
@top.SiteName
}
else
{
<a href="@top.URL" target="_blank">@top.SiteName</a>
}
</cite>
<span class="text-muted smaller">, @reviewDate</span>
</footer>
</blockquote>
@if (Model.Reviews.Items.Count > 1)
{
<div class="text-end">
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">Read more reviews</a>
</div>
}
</div>
</div>
</section>
}
<!-- Buy Box now AFTER synopsis/review -->
<section class="mb-4" id="buy">
<div class="row">
<div class="col-12 d-flex">
<div class="card character-card h-100 flex-fill" id="buy-card">
<div class="card-body d-flex flex-column">
@await Component.InvokeAsync("BuyPanel", new { ISO2 = Model.UserIso2, Src = Model.Src })
</div>
</div>
</div>
</div>
</section>
<!-- Sticky mobile buy bar (global, still points to #buyBox inside the partial) -->
<div id="mobileBuyBar" class="d-md-none fixed-bottom bg-dark text-white py-2 border-top border-3 border-light" style="z-index:1030;">
<div class="container d-flex justify-content-between align-items-center">
<span class="small">The Alpha Flame: Discovery</span>
<a href="#buyBox" class="btn btn-light btn-sm">Buy now</a>
</div>
</div>
<!-- Chapter Previews -->
<section id="chapters" class="mt-4">
<h2>Chapter Previews</h2>
<div class="row">
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter1">
<responsive-image src="beth-stood-in-bathroom.png" class="card-img-top" alt="Beth's Bathroom" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 1, Drowning in Silence, Beth</h3>
<p class="card-text">Beth returns home to find her mother lifeless in the bath...</p>
<div class="text-end"><a asp-action="Chapter1" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter2">
<responsive-image src="maggie-with-her-tr6-2.png" class="fit-image" alt="Maggie With Her TR6" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 2, The Last Lesson, Maggie</h3>
<p class="card-text">On Christmas Eve, Maggie nervously heads out for her driving test not knowing the story that will pan out before her...</p>
<div class="text-end"><a asp-action="Chapter2" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter13">
<responsive-image src="pub-from-chapter-13.png" class="fit-image" alt="Pub from Chapter 13" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 13, A Name She Never Owned, Susie</h3>
<p class="card-text">Susie goes out for a drink with a punter. What on earth could go wrong...</p>
<div class="text-end"><a asp-action="Chapter13" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
</div>
</section>
@section Scripts {
<!-- Plyr for audio -->
<script>
const player = new Plyr('audio');
</script>
<!-- Trailer play/pause via custom button or video click (single video) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
const v = document.getElementById("trailerPortrait");
const playBtn = document.getElementById("trailerPlayBtn");
if (!v || !playBtn) return;
// Hide native controls when JS is active; use custom button instead
v.controls = false;
// Start playback
const startPlayback = () => {
v.muted = false;
v.volume = 1.0;
v.play()
.then(() => { playBtn.style.display = "none"; })
.catch(err => console.warn("Video play failed:", err));
};
// Toggle on video click
const togglePlayback = () => {
if (v.paused) {
startPlayback();
} else {
v.pause();
playBtn.style.display = "block";
}
};
// Events
playBtn.addEventListener("click", (e) => { e.preventDefault(); startPlayback(); });
v.addEventListener("click", togglePlayback);
// Keep button state in sync with native events
v.addEventListener("play", () => { playBtn.style.display = "none"; });
v.addEventListener("pause", () => { playBtn.style.display = "block"; });
v.addEventListener("ended", () => { playBtn.style.display = "block"; });
});
</script>
}
@section Meta { @section Meta {
<MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood" <MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood"

View File

@ -21,7 +21,7 @@
<div class="col"> <div class="col">
<p class="h3 text-center" id="status">Loading...</p> <p class="h3 text-center" id="status">Loading...</p>
<ul class="pagination justify-content-center" id="chapter-list"></ul> <ul class="pagination justify-content-center" id="chapter-list"></ul>
<audio id="audio" preload="none"></audio> <audio id="audio" preload="none" data-book-key="Discovery" data-playlist-url="/api/audio/playlist/all?book=Discovery"></audio>
<div class="input-group border border-2 border-dark rounded-3"> <div class="input-group border border-2 border-dark rounded-3">
<!-- Progress Bar (styled to look like an input) --> <!-- Progress Bar (styled to look like an input) -->
<div class="form-control p-0" style="height: 40px;"> <div class="form-control p-0" style="height: 40px;">
@ -53,622 +53,5 @@
</div> </div>
@section Scripts { @section Scripts {
<script> <script src="~/js/audiobook-player.js" asp-append-version="true"></script>
const audioBlobCache = new Map(); // id => Blob
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 = 30 * 60; // 30 minutes
let masterPlaylist = [];
let chapterStartIndices = [];
let currentIndex = 0;
let preloadedAudio = null;
const audio = document.getElementById("audio");
const status = document.getElementById("status");
const toggleButton = document.getElementById("toggle-play");
// Load the playlist
fetch("/api/audio/playlist/all")
.then(res => res.json())
.then(data => {
// ✅ Sort chapters numerically by chapter number
data.sort((a, b) => {
const aNum = parseInt(a.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
const bNum = parseInt(b.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
return aNum - bNum;
});
let flatIndex = 0;
chapterStartIndices = []; // global or higher scoped variable
masterPlaylist = []; // reset in case of reload
data.forEach(chapter => {
chapterStartIndices.push({ index: flatIndex, name: chapter.chapter });
chapter.segments.forEach(segment => {
masterPlaylist.push({
id: segment.id,
display: segment.display,
chapter: chapter.chapter,
duration: parseDuration(segment.display),
text: segment.text || ""
});
flatIndex++;
});
});
// ✅ Determine saved/resume position
const savedIndex = parseInt(localStorage.getItem("lastAudioIndex") || "0", 10);
const currentSeg = masterPlaylist[savedIndex] || {};
const currentChapter = currentSeg.chapter || chapterStartIndices[0].name;
// ✅ Ensure pagination is rendered properly
renderChapterPagination(currentChapter);
status.textContent = `Ready – ${masterPlaylist.length} segments total.`;
// ✅ Preload first segments of each chapter (for snappy chapter switches)
preloadFirstSegments();
// ✅ Preload 10 minutes of sequential audio starting from resume position or beginning
const preloadStart = (savedIndex >= 0 && savedIndex < masterPlaylist.length)
? savedIndex
: 0;
preloadSegmentsAhead(preloadStart, BUFFER_TARGET_SECONDS); // preload 10 mins ahead
// ✅ Resume if index is valid
if (savedIndex >= 0 && savedIndex < masterPlaylist.length) {
const seg = masterPlaylist[savedIndex];
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(savedIndex);
}
});
function fetchBlobWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch blob ${url}:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterPagination(currentChapter) {
let listHtml = '';
const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter);
const totalChapters = chapterStartIndices.length;
// Dynamically determine window size based on screen width
const windowSize = getWindowSize();
const halfWindow = Math.floor(windowSize / 2);
// Previous button
listHtml += `<li class="page-item ${currentPos <= 0 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToPrevChapter(${currentPos})"><i class="fad fa-step-backward"></i></a>
</li>`;
const addPageItem = (i, label = null) => {
const chapterNumber = chapterStartIndices[i].name.match(/Chapter_(\d+)/i)?.[1] || "?";
const isActive = i === currentPos;
listHtml += `<li class="page-item ${isActive ? 'active' : ''}">
<a class="page-link" href="#" onclick="startFrom(${chapterStartIndices[i].index})">${label || chapterNumber}</a>
</li>`;
};
// First button
addPageItem(0);
// Ellipsis after first if needed
if (currentPos - halfWindow > 1) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Sliding window
const start = Math.max(1, currentPos - halfWindow);
const end = Math.min(totalChapters - 2, currentPos + halfWindow);
for (let i = start; i <= end; i++) {
if (i !== 0 && i !== totalChapters - 1) {
addPageItem(i);
}
}
// Ellipsis before last if needed
if (currentPos + halfWindow < totalChapters - 2) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Last button
if (totalChapters > 1) {
addPageItem(totalChapters - 1);
}
// Next button
listHtml += `<li class="page-item ${currentPos >= totalChapters - 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToNextChapter(${currentPos})"><i class="fad fa-step-forward"></i></a>
</li>`;
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;
const newIndex = chapterStartIndices[currentPos - 1].index;
const newChapter = chapterStartIndices[currentPos - 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function goToNextChapter(currentPos) {
if (currentPos >= chapterStartIndices.length - 1) return;
const newIndex = chapterStartIndices[currentPos + 1].index;
const newChapter = chapterStartIndices[currentPos + 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function parseDuration(display) {
const parts = display.split(":").map(Number);
return parts[0] * 60 + (parts[1] || 0);
}
// Call this in startFrom()
function startFrom(index) {
const seg = masterPlaylist[index];
// Ensure autoplay works (helps with Chrome mobile restrictions)
audio.muted = false;
audio.autoplay = true;
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(index);
}
function loadSegment(index) {
if (index < 0 || index >= masterPlaylist.length) return;
currentIndex = index;
updateBufferBar(index);
localStorage.setItem("lastAudioIndex", index.toString());
const seg = masterPlaylist[index];
// ✅ Scroll and highlight current paragraph
document.querySelectorAll(".chapter-text-scroll p").forEach(p => p.classList.remove("active"));
const activePara = document.getElementById(`text-seg-${index}`);
if (activePara) {
activePara.classList.add("active");
const container = document.querySelector(".chapter-text-scroll");
const paraTop = activePara.offsetTop;
const paraHeight = activePara.offsetHeight;
const containerHeight = container.clientHeight;
const scrollTarget = paraTop - container.offsetTop - (containerHeight / 2) + (paraHeight / 2);
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
const tryPlay = async (blob, retryCount = 0) => {
try {
const blobUrl = URL.createObjectURL(blob);
audio.src = blobUrl;
await audio.play();
status.textContent = `Playing: ${seg.chapter.replace(/_/g, ' ')}`;
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} catch (err) {
if (retryCount < 10) {
console.warn(`Retrying audio playback (${retryCount + 1})...`);
setTimeout(() => tryPlay(blob, retryCount + 1), 3000);
} else {
console.error("Playback failed permanently:", err);
status.textContent = "Error loading audio segment.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
};
const fetchAndPlay = async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 5, 2000);
const blob = await fetch(response).then(r => r.blob());
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
console.error("Initial fetch failed, will retry in background:", err);
status.textContent = "Waiting for signal...";
let retries = 0;
const maxRetries = 12;
const retryLoop = setInterval(async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 3, 2000);
const blob = await fetch(response).then(r => r.blob());
clearInterval(retryLoop);
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
retries++;
console.warn(`Retry ${retries}/${maxRetries} failed`);
if (retries >= maxRetries) {
clearInterval(retryLoop);
status.textContent = "Unable to load segment.";
}
}
}, 10000);
}
};
if (audioBlobCache.has(seg.id)) {
tryPlay(audioBlobCache.get(seg.id));
} else {
fetchAndPlay();
}
maybeTriggerPreload();
}
function maybeTriggerPreload() {
if (audio.paused) return; // only preload while playing
const bufferedPercent = getBufferedPercent(currentIndex);
if (bufferedPercent < 90) {
preloadSegmentsAhead(currentIndex + 1, BUFFER_TARGET_SECONDS);
}
}
function getBufferedPercent(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
return Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
}
function preloadSegmentsAhead(startIndex, targetSeconds) {
let totalDuration = 0;
let i = startIndex;
while (i < masterPlaylist.length && totalDuration < targetSeconds) {
const seg = masterPlaylist[i];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn("Preload failed:", err));
}
totalDuration += seg.duration;
i++;
}
}
function fetchWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch ${url} after retries:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterText(chapterName) {
const container = document.getElementById("chapter-text-content");
container.innerHTML = "";
masterPlaylist.forEach((seg, i) => {
if (seg.chapter === chapterName) {
const p = document.createElement("p");
p.textContent = seg.text || "";
p.id = `text-seg-${i}`;
container.appendChild(p);
}
});
}
let currentChapter = "";
let chapterSegments = [];
let chapterTotalDuration = 0;
function initChapterProgress(chapterName) {
currentChapter = chapterName;
chapterSegments = masterPlaylist.filter(s => s.chapter === chapterName);
chapterTotalDuration = chapterSegments.reduce((sum, seg) => sum + seg.duration, 0);
updateProgressUI(0);
renderChapterTicks(); // ✅ Added here
}
function updateProgressUI(elapsedSeconds) {
const percent = (elapsedSeconds / chapterTotalDuration) * 100;
document.getElementById("chapter-progress-bar").style.width = `${percent}%`;
const mins = Math.floor(elapsedSeconds / 60).toString().padStart(2, "0");
const secs = Math.floor(elapsedSeconds % 60).toString().padStart(2, "0");
const totalMins = Math.floor(chapterTotalDuration / 60).toString().padStart(2, "0");
const totalSecs = Math.floor(chapterTotalDuration % 60).toString().padStart(2, "0");
document.getElementById("chapter-timestamp").textContent = `${mins}:${secs} / ${totalMins}:${totalSecs}`;
}
function getElapsedInChapter(currentIdx, audioCurrentTime) {
let sum = 0;
for (let i = 0; i < currentIdx; i++) {
if (masterPlaylist[i].chapter === currentChapter) {
sum += masterPlaylist[i].duration;
}
}
return sum + audioCurrentTime;
}
function renderChapterTicks() {
const ticksContainer = document.getElementById("chapter-ticks");
ticksContainer.innerHTML = "";
let elapsed = 0;
chapterSegments.forEach(seg => {
const percent = (elapsed / chapterTotalDuration) * 100;
const tick = document.createElement("div");
tick.className = "chapter-tick";
tick.style.left = `${percent}%`;
ticksContainer.appendChild(tick);
// const label = document.createElement("div");
// label.className = "chapter-tick-label";
// label.style.left = `${percent}%`;
// label.textContent = seg.display;
// ticksContainer.appendChild(label);
elapsed += seg.duration;
});
}
function updatePaginationHighlight(currentChapter) {
document.querySelectorAll("#chapter-list .page-item").forEach((li, idx) => {
const chapterNum = parseInt(li.textContent);
const currentNum = parseInt(currentChapter.match(/Chapter_(\d+)/i)?.[1]);
if (chapterNum === currentNum) {
li.classList.add("active");
} else {
li.classList.remove("active");
}
});
}
function preloadFirstSegments() {
chapterStartIndices.forEach(({ index }) => {
const seg = masterPlaylist[index];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn(`Preload of chapter start failed (${seg.id}):`, err));
}
});
}
function enforceBlobCacheLimit() {
const iterator = audioBlobCache.keys();
while (totalBlobSize > MAX_BLOB_CACHE_SIZE && audioBlobCache.size > 0) {
const oldestKey = iterator.next().value;
const size = audioBlobSizes.get(oldestKey) || 0;
audioBlobCache.delete(oldestKey);
audioBlobSizes.delete(oldestKey);
totalBlobSize -= size;
console.log(`Evicted ${oldestKey} to maintain cache size. Remaining size: ${totalBlobSize} bytes.`);
}
}
function updateBufferBar(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
const percent = Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
const bar = document.getElementById("buffer-progress-bar");
bar.style.width = `${percent.toFixed(1)}%`;
bar.setAttribute("aria-valuenow", percent.toFixed(1));
// ✅ Set Bootstrap colour classes
bar.classList.remove("bg-primary", "bg-warning", "bg-danger");
if (percent >= 75) {
bar.classList.add("bg-primary");
} else if (percent >= 30) {
bar.classList.add("bg-warning");
} else {
bar.classList.add("bg-danger");
}
}
audio.addEventListener("ended", () => {
if (currentIndex < masterPlaylist.length - 1) {
const nextIndex = currentIndex + 1;
const nextSeg = masterPlaylist[nextIndex];
const currentChapter = masterPlaylist[currentIndex].chapter;
const nextChapter = nextSeg.chapter;
if (nextChapter !== currentChapter) {
renderChapterPagination(nextChapter); // ✅ Update pagination
renderChapterText(nextChapter);
initChapterProgress(nextChapter);
}
loadSegment(nextIndex);
} else {
status.textContent = "Playback complete.";
}
});
// Update during playback
audio.addEventListener("timeupdate", () => {
if (!chapterSegments.length || !audio.duration) return;
const elapsed = getElapsedInChapter(currentIndex, audio.currentTime);
updateProgressUI(elapsed);
});
toggleButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} else {
audio.pause();
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
});
audio.addEventListener("play", () => {
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
});
audio.addEventListener("pause", () => {
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
});
// ✅ These are additional listeners — do not replace existing ones
audio.addEventListener("play", maybeTriggerPreload);
audio.addEventListener("ended", maybeTriggerPreload);
document.getElementById("chapter-progress").addEventListener("click", (e) => {
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickPercent = clickX / rect.width;
const targetTime = clickPercent * chapterTotalDuration;
// Find approximate segment
let cumulative = 0;
for (let i = 0; i < chapterSegments.length; i++) {
const seg = chapterSegments[i];
if (cumulative + seg.duration >= targetTime) {
const globalIndex = masterPlaylist.findIndex(s => s.id === seg.id);
if (globalIndex >= 0) loadSegment(globalIndex);
break;
}
cumulative += seg.duration;
}
});
</script>
} }

View File

@ -0,0 +1,207 @@
@model CatherineLynwood.Models.TitlePageViewModel
@{
bool showReviews = Model.Reviews.Items.Any();
}
<div class="cl-discovery-wow">
<nav aria-label="breadcrumb" class="cl-discovery-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 active" aria-current="page">Discovery</li>
</ol>
</nav>
<section class="cl-discovery-hero">
<div class="cl-discovery-hero__copy">
<p class="cl-kicker mb-3">The Alpha Flame trilogy / Book one</p>
<h1>Discovery</h1>
<p class="cl-discovery-hero__series">The Alpha Flame by Catherine Lynwood</p>
<p class="cl-discovery-hero__lead">
Two young women. One act of courage. A buried truth that turns survival into fire.
</p>
<p class="cl-discovery-hero__truth">
She didn't go looking for trouble. But when Maggie finds Beth bruised, broken, and terrified, walking away is no longer an option.
</p>
<div class="cl-discovery-actions">
<a href="#buyBox" class="btn btn-dark">Buy the book</a>
<a asp-controller="Discovery" asp-action="Trailer" class="btn btn-outline-light">
<i class="fad fa-play"></i>
<span>Watch trailer</span>
</a>
<a href="#story" class="btn btn-outline-light">Enter the story</a>
</div>
</div>
<div class="cl-discovery-hero__art" aria-label="The Alpha Flame: Discovery artwork">
<div class="cl-discovery-cover-stack">
<responsive-image src="the-alpha-flame-discovery-cover.png"
class="cl-discovery-cover"
alt="The Alpha Flame: Discovery by Catherine Lynwood"
display-width-percentage="50"></responsive-image>
<a asp-controller="Discovery" asp-action="Trailer" class="cl-discovery-trailer-card">
<responsive-image src="the-alpha-flame-discovery-trailer-landscape.png"
class="cl-discovery-trailer-card__image"
alt="The Alpha Flame: Discovery trailer still"
display-width-percentage="50"></responsive-image>
<span>
<i class="fad fa-play"></i>
Trailer
</span>
</a>
</div>
</div>
</section>
<section class="cl-discovery-proof-strip" aria-label="Story signals">
<div>
<span>1983</span>
<strong>Birmingham</strong>
</div>
<div>
<span>Gritty</span>
<strong>Women's drama</strong>
</div>
<div>
<span>Audio</span>
<strong>Out now</strong>
</div>
<div>
<span>Core</span>
<strong>Survival, loyalty, fire</strong>
</div>
</section>
<section class="cl-discovery-buy-shell" aria-label="Buy The Alpha Flame: Discovery">
<div class="cl-discovery-buy-intro">
<p class="cl-kicker mb-2">Get the book</p>
<h2>Choose the edition that belongs on your shelf.</h2>
<p>Best options are shown for your location, with direct paperback, collector's hardback, ebook, audiobook, and Amazon routes kept together.</p>
</div>
<div class="cl-discovery-buy-panel">
@await Component.InvokeAsync("BuyPanel", new { ISO2 = Model.UserIso2, Src = Model.Src, Title = Model.Title })
</div>
</section>
<section class="cl-discovery-story" id="story">
<div class="cl-discovery-story__image">
<responsive-image src="rubery-hill-hospital.png"
alt="Rubery Hill Hospital, one of the atmospheric places behind The Alpha Flame: Discovery"
display-width-percentage="50"></responsive-image>
</div>
<div class="cl-discovery-story__copy">
<p class="cl-kicker mb-2">Inside Discovery</p>
<h2>Not a fairy tale. Not a soft landing.</h2>
<p>
Set in 1983 Birmingham, nearby Redditch, and Barmouth in Wales, <em>The Alpha Flame: Discovery</em> follows Beth and Maggie as they uncover dark family secrets and fight to survive.
</p>
<p>
It is character-led, emotionally charged fiction about the bond between two women who refuse to be broken by what the world has done to them.
</p>
</div>
</section>
<section class="cl-discovery-tiles" aria-label="Themes in Discovery">
<article>
<span>01</span>
<h3>The Wound</h3>
<p>Beth's world narrows into loss, danger, and loneliness, with only a fragile ember of defiance left burning.</p>
</article>
<article>
<span>02</span>
<h3>The Spark</h3>
<p>Maggie is fast, fierce, loyal, and impossible to ignore. When she steps in, the story changes shape.</p>
</article>
<article>
<span>03</span>
<h3>The Bond</h3>
<p>Their connection is tender, volatile, protective, and dangerous enough to expose every hidden truth.</p>
</article>
</section>
<section class="cl-discovery-audio">
<div>
<p class="cl-kicker mb-2">Audiobook out now</p>
<h2>Hear it lived in, not rushed through.</h2>
<p>
<em>The Alpha Flame: Discovery</em> is available as a full-length audiobook for long drives, late nights, and readers who want the quiet scenes to land with force.
</p>
</div>
<a asp-controller="Discovery" asp-action="AudioBook" class="btn btn-dark">Explore the audiobook</a>
</section>
@if (showReviews)
{
var top = Model.Reviews.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
var reviewDate = top.DatePublished.ToString("d MMMM yyyy");
<section class="cl-discovery-review">
<div class="cl-discovery-review__stars" aria-label="Five star review">
<i class="fad fa-star"></i>
<i class="fad fa-star"></i>
<i class="fad fa-star"></i>
<i class="fad fa-star"></i>
<i class="fad fa-star"></i>
</div>
<blockquote>
@Html.Raw(top.ReviewBody)
<footer>
@top.AuthorName on
@if (string.IsNullOrEmpty(top.URL))
{
<cite>@top.SiteName</cite>
}
else
{
<cite><a href="@top.URL" target="_blank">@top.SiteName</a></cite>
}
<span>, @reviewDate</span>
</footer>
</blockquote>
<a asp-action="Reviews" class="btn btn-outline-light">Read more reviews</a>
</section>
}
<section class="cl-discovery-chapters">
<div class="cl-discovery-section-head">
<p class="cl-kicker mb-2">Open the door</p>
<h2>Chapter previews</h2>
</div>
<div class="cl-discovery-chapter-grid">
<article>
<a asp-action="Chapter1">
<responsive-image src="beth-stood-in-bathroom.png" alt="Beth's bathroom" display-width-percentage="50"></responsive-image>
</a>
<div>
<span>Chapter 1 / Beth</span>
<h3>Drowning in Silence</h3>
<p>Beth returns home to find her mother lifeless in the bath.</p>
<a asp-action="Chapter1">Read preview</a>
</div>
</article>
<article>
<a asp-action="Chapter2">
<responsive-image src="maggie-with-her-tr6-2.png" alt="Maggie with her Triumph TR6" display-width-percentage="50"></responsive-image>
</a>
<div>
<span>Chapter 2 / Maggie</span>
<h3>The Last Lesson</h3>
<p>On Christmas Eve, Maggie heads out for her driving test, unaware of what is already moving toward her.</p>
<a asp-action="Chapter2">Read preview</a>
</div>
</article>
<article>
<a asp-action="Chapter13">
<responsive-image src="pub-from-chapter-13.png" alt="Pub from Chapter 13" display-width-percentage="50"></responsive-image>
</a>
<div>
<span>Chapter 13 / Susie</span>
<h3>A Name She Never Owned</h3>
<p>Susie goes out for a drink with a punter. What on earth could go wrong?</p>
<a asp-action="Chapter13">Read preview</a>
</div>
</article>
</div>
</section>
</div>

View File

@ -2,36 +2,10 @@
Layout = "/Views/Shared/_Layout.cshtml"; Layout = "/Views/Shared/_Layout.cshtml";
} }
@section CSS {
<style>
.video-overlay {
background-color: rgba(13, 202, 240, 0.2);
}
</style>
}
@section Meta { @section Meta {
@RenderSection("Meta", required: false) @RenderSection("Meta", required: false)
} }
@section BackgroundVideo {
<div id="background-wrapper">
<div class="video-background">
<video id="siteBackgroundVideo"
autoplay
muted
loop
playsinline
preload="none">
<!-- Source will be injected by JS -->
</video>
<div class="video-overlay"></div>
</div>
</div>
}
@RenderBody() @RenderBody()
@section Scripts { @section Scripts {

View File

@ -14,15 +14,19 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="float-md-start w-md-50 p-3"> <div class="cl-editorial-hero">
<p class="cl-kicker mb-2">About the author</p>
<h1 class="display-4 mb-3">Catherine Lynwood</h1>
<p class="lead mb-0">
Birmingham-born author of gritty, emotionally truthful fiction about women, survival, family secrets, and the fire people carry after everything else has been stripped away.
</p>
</div>
<div class="float-md-start w-md-50 p-3 cl-about-portrait">
<figure> <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> <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>Catherine Lynwood</figcaption> <figcaption>Catherine Lynwood</figcaption>
</figure> </figure>
</div> </div>
<header>
<h1>About Catherine Lynwood</h1>
</header>
<section> <section>
<p>Catherine Lynwood was born in Birmingham in 1979, growing up amidst the urban landscapes and industrial heritage of Britain’s second city. Raised in Rubery, Catherine was drawn to the arts from an early age, finding creative inspiration in the sights, sounds, and stories around her. She attended Colmers Farm School, where teachers quickly recognized her talent for drawing and storytelling, often encouraging her to explore her creativity in school projects and art classes.</p> <p>Catherine Lynwood was born in Birmingham in 1979, growing up amidst the urban landscapes and industrial heritage of Britain’s second city. Raised in Rubery, Catherine was drawn to the arts from an early age, finding creative inspiration in the sights, sounds, and stories around her. She attended Colmers Farm School, where teachers quickly recognized her talent for drawing and storytelling, often encouraging her to explore her creativity in school projects and art classes.</p>

View File

@ -17,7 +17,13 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-6"> <div class="col-md-6">
<form asp-action="ContactCatherine" method="post" onsubmit="disableSubmit(this)"> <form asp-action="ContactCatherine" method="post" onsubmit="disableSubmit(this)" class="cl-contact-form">
@Html.AntiForgeryToken()
<input type="hidden" name="ContactStartedAt" value="@DateTimeOffset.UtcNow.ToUnixTimeSeconds()" />
<div class="cl-honeypot" aria-hidden="true">
<label for="Website">Website</label>
<input id="Website" name="Website" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="col-12 col-sm-8 col-md-12"> <div class="col-12 col-sm-8 col-md-12">
<h1>Contact Catherine</h1> <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>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>

View File

@ -12,31 +12,39 @@
</div> </div>
</div> </div>
<div class="cl-editorial-hero">
<p class="cl-kicker mb-2">Gritty British drama by Catherine Lynwood</p>
<h1 class="display-4 mb-3">
Truthful stories about women, survival, and the secrets polite society leaves untouched.
</h1>
<p class="lead mb-0">
Character-led fiction rooted in emotional realism, Birmingham grit, family wounds, loyalty, fire, and the hard-won beauty of refusing to be broken.
</p>
</div>
<div class="row justify-content-center align-items-center mb-5"> <div class="row justify-content-center align-items-center mb-5">
<div class="col-md-5 text-center"> <div class="col-md-5 text-center">
<a asp-controller="Reckoning" asp-action="Index"> <a asp-controller="Reckoning" asp-action="Index">
<div class="hero-video-container"> <div class="cl-cover-feature">
<video id="heroVideo" autoplay muted loop playsinline preload="none" poster="/images/webp/the-alpha-flame-reckoning-400.webp"> <responsive-image src="the-alpha-flame-reckoning-cover.png"
<!-- Source will be injected later --> class="cl-cover-feature__image"
</video> alt="The Alpha Flame: Reckoning by Catherine Lynwood"
display-width-percentage="50"></responsive-image>
<!-- Overlay image remains untouched --> <div class="cl-cover-feature__label">
<responsive-image src="the-alpha-flame-reckoning-overlay.png" <span>The Alpha Flame</span>
class="hero-overlay-image" <strong>Reckoning</strong>
alt="The Alpha Flame: Discovery by Catherine Lynwood" </div>
display-width-percentage="50"
no-index="true">
</responsive-image>
</div> </div>
</a> </a>
</div> </div>
<div class="col-md-7 pt-2 pt-md-0"> <div class="col-md-7 pt-2 pt-md-0">
<h1 class="display-5 fw-bold"> <p class="cl-kicker mb-2">The Alpha Flame trilogy</p>
<h2 class="display-5 fw-bold">
The Alpha Flame The Alpha Flame
<span class="fw-light d-block fs-4">A Trilogy by Catherine Lynwood</span> <span class="fw-light d-block fs-4">A Trilogy by Catherine Lynwood</span>
</h1> </h2>
<p class="lead fst-italic tagline-shadow"> <p class="lead fst-italic tagline-shadow">
Some girls survive. Others set the world on fire. Some girls survive. Others set the world on fire.
@ -105,7 +113,7 @@
} }
<p class="mt-4"> <p class="mt-4">
<h3 class="h6 d-inline"><em>About the Trilogy</em></h3> <h3 class="h6 d-inline"><em>About the Trilogy</em></h3>&nbsp;
Set in 1980s Birmingham, <em>The Alpha Flame</em> blends family drama, emotional suspense, Set in 1980s Birmingham, <em>The Alpha Flame</em> blends family drama, emotional suspense,
and dark secrets rooted in real places and lived experience. and dark secrets rooted in real places and lived experience.
Ideal for readers who value character-driven storytelling, moral complexity, Ideal for readers who value character-driven storytelling, moral complexity,
@ -240,20 +248,4 @@
} }
</script> </script>
<script>
window.addEventListener("load", () => {
setTimeout(() => {
const video = document.getElementById("heroVideo");
const source = document.createElement("source");
source.src = "/videos/the-alpha-flame-reckoning.mp4";
source.type = "video/mp4";
video.appendChild(source);
video.load(); // Triggers actual file load
video.play().catch(err => {
console.warn("Autoplay failed:", err);
});
}, 2000); // Adjust delay (e.g., 1000–3000ms) based on your needs
});
</script>
} }

View File

@ -31,8 +31,8 @@
</section> </section>
<div class="row justify-content-center p-3"> <div class="row justify-content-center p-3">
<div class="col-12 bg-white rounded-5"> <div class="col-12 bg-white rounded-5 cl-audio-panel">
<audio controls> <audio controls preload="metadata" class="w-100">
<source src="/audio/samantha-lynwood.mp3" type="audio/mpeg"> <source src="/audio/samantha-lynwood.mp3" type="audio/mpeg">
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>

View File

@ -5,7 +5,7 @@
<div class="container py-5"> <div class="container py-5">
<div class="row justify-content-center mb-5"> <div class="row justify-content-center mb-5">
<div class="col-12 col-xl-10"> <div class="col-12 col-xl-10">
<div class="bg-dark rounded-4 p-4 p-md-5 shadow-sm border"> <div class="bg-dark rounded-4 p-4 p-md-5 shadow-sm border cl-editorial-hero">
<span class="badge text-bg-dark mb-3">Indie Author Guide</span> <span class="badge text-bg-dark mb-3">Indie Author Guide</span>
<h1 class="display-5 fw-bold mb-3">Indie Author and Self-Publishing</h1> <h1 class="display-5 fw-bold mb-3">Indie Author and Self-Publishing</h1>
<p class="lead mb-3"> <p class="lead mb-3">
@ -27,7 +27,7 @@
<div class="col-12 col-xl-10"> <div class="col-12 col-xl-10">
<div class="row g-4"> <div class="row g-4">
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Traditional vs Self-Publishing</h2> <h2 class="h5 fw-bold mb-3">Traditional vs Self-Publishing</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -42,7 +42,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">ISBNs</h2> <h2 class="h5 fw-bold mb-3">ISBNs</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -57,7 +57,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Editing and Proofreading</h2> <h2 class="h5 fw-bold mb-3">Editing and Proofreading</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -72,7 +72,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Cover Design</h2> <h2 class="h5 fw-bold mb-3">Cover Design</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -87,7 +87,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">KDP vs IngramSpark</h2> <h2 class="h5 fw-bold mb-3">KDP vs IngramSpark</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -102,7 +102,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Publishing on KDP</h2> <h2 class="h5 fw-bold mb-3">Publishing on KDP</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -117,7 +117,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Publishing on IngramSpark</h2> <h2 class="h5 fw-bold mb-3">Publishing on IngramSpark</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -132,7 +132,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Audiobooks and ACX</h2> <h2 class="h5 fw-bold mb-3">Audiobooks and ACX</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -147,7 +147,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Reviews and ARC Readers</h2> <h2 class="h5 fw-bold mb-3">Reviews and ARC Readers</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -162,7 +162,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Amazon Advertising</h2> <h2 class="h5 fw-bold mb-3">Amazon Advertising</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -177,7 +177,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">Author Finances</h2> <h2 class="h5 fw-bold mb-3">Author Finances</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
@ -192,7 +192,7 @@
</div> </div>
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-4"> <div class="card h-100 border-0 shadow-sm rounded-4 cl-feature-card">
<div class="card-body p-4"> <div class="card-body p-4">
<h2 class="h5 fw-bold mb-3">AI for Authors</h2> <h2 class="h5 fw-bold mb-3">AI for Authors</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">

View File

@ -3,6 +3,8 @@
int? accessLevel = Context.Session.GetInt32("BookAccessLevel"); int? accessLevel = Context.Session.GetInt32("BookAccessLevel");
int? accessBook = Context.Session.GetInt32("BookAccessMax"); int? accessBook = Context.Session.GetInt32("BookAccessMax");
// For Reckoning, keep access open for now.
accessLevel = accessLevel ?? 1;
} }
<div class="row"> <div class="row">
@ -10,8 +12,8 @@
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="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="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"><a asp-controller="Reckoning" asp-action="Index">Reckoning</a></li> <li class="breadcrumb-item"><a asp-controller="Reckoning" asp-action="Index">Reckoning</a></li>
<li class="breadcrumb-item active" aria-current="page">Extras</li> <li class="breadcrumb-item active" aria-current="page">Extras</li>
</ol> </ol>
</nav> </nav>
@ -19,54 +21,60 @@
</div> </div>
<div class="container mt-5"> <div class="container mt-5">
<h1 class="extras-header">The Alpha Flame Reckoning Exclusive Extras</h1> <h1 class="extras-header">The Alpha Flame: Reckoning Extras</h1>
<div class="extras-grid mt-4">
@if (accessLevel >= 1)
{
<div class="card extra-card">
<div class="card-body">
<h5 class="card-title">Epilogue</h5>
<p class="card-text">Immerse yourself in the Eplilogue to The Alpha Flame: Discovery. Join the team as they relax for a quite drink at the Barnt Green Inn</p>
<a asp-action="Epilogue" class="btn btn-dark btn-sm">Read or Listen</a>
</div>
</div>
}
@if (accessLevel >= 2)
{
<div class="card extra-card">
<div class="card-body">
<h5 class="card-title">Discovery Scrap Book</h5>
<p class="card-text">Take a look at my image scrapbook for The Alpha Flame: Discovery. View the images I used for inspiration when writing the various scenes within the book.</p>
<a asp-action="ScrapBook" class="btn btn-dark btn-sm">View Scrapbook</a>
</div>
</div>
<div class="card extra-card"> <p class="extras-intro">
<div class="card-body"> You’ve finished the book. Now step a little further into the world behind it.
<h5 class="card-title">Discovery Soundtrack</h5> Some pieces explain nothing. Some pieces explain more than they should.
<p class="card-text">Have a listen to The Alpha Flame soundtrack. A selection of original songs written by me and put to music.</p> </p>
<a asp-action="Soundtrack" class="btn btn-dark btn-sm">Soundtrack</a>
</div> <div class="extras-grid mt-4">
<div class="card extra-card featured-extra">
<div class="card-body">
<h5 class="card-title">Recovered Evidence</h5>
<p class="card-text">
Listen to recovered tapes, examine fragments, and decide who may not be who they appear to be.
Submit your theory before the truth comes out in <em>Redemption</em>.
</p>
<a asp-action="Investigation" class="btn btn-dark btn-sm">Open Case File</a>
</div> </div>
} </div>
@if (accessLevel >= 3)
<div class="card extra-card">
<div class="card-body">
<h5 class="card-title">Reckoning Soundtrack</h5>
<p class="card-text">
Listen to original songs inspired by key moments from <em>The Alpha Flame: Reckoning</em>.
Grief, secrets, fire, and a few things best left in the dark.
</p>
<a asp-action="Soundtrack" class="btn btn-dark btn-sm">Listen to Soundtrack</a>
</div>
</div>
<div class="card extra-card">
<div class="card-body">
<h5 class="card-title">Reckoning Scrapbook</h5>
<p class="card-text">
Browse images, locations, textures, and visual inspiration from the world of <em>Reckoning</em>.
</p>
<a asp-action="ScrapBook" class="btn btn-dark btn-sm">View Scrapbook</a>
</div>
</div>
@* @if (accessLevel >= 3)
{ {
<div class="card extra-card"> <div class="card extra-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Listen to The Alpha Flame: Discovery</h5> <h5 class="card-title">Listen to The Alpha Flame: Reckoning</h5>
<p class="card-text">Because you've purchased a premium physical copy of The Alpha Flame: Discovery, for a limited time this entitles you to listen to the audio version with no extra charge.</p> <p class="card-text">
Listen to the AI-read audio version of <em>The Alpha Flame: Reckoning</em>.
</p>
<a asp-action="Listen" class="btn btn-dark btn-sm">Listen to the Book</a> <a asp-action="Listen" class="btn btn-dark btn-sm">Listen to the Book</a>
</div> </div>
</div> </div>
} *@
<div class="card extra-card">
<div class="card-body">
<h5 class="card-title">Scrapbook: Maggie’s Designs</h5>
<p class="card-text">Flip through Maggie’s sketches, fashion notes, and photos from her original designs – including the infamous red skirt.</p>
<a asp-action="MaggiesDesigns" class="btn btn-dark btn-sm">View Scrapbook</a>
</div>
</div>
}
</div> </div>
</div> </div>
@ -75,18 +83,49 @@
.extras-header { .extras-header {
text-align: center; text-align: center;
margin-top: 3rem; margin-top: 3rem;
margin-bottom: 2rem; margin-bottom: 1rem;
}
.extras-intro {
max-width: 760px;
margin: 0 auto 2rem auto;
text-align: center;
color: #555;
font-size: 1.05rem;
line-height: 1.6;
}
.extras-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
} }
.extra-card { .extra-card {
border: none; border: none;
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 0 15px rgba(0,0,0,0.1); box-shadow: 0 0 15px rgba(0,0,0,0.1);
transition: transform 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.extra-card:hover { .extra-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 0 22px rgba(0,0,0,0.16);
}
.featured-extra {
background: linear-gradient(135deg, #1e1b18, #3a3028);
color: #f7efe2;
}
.featured-extra .card-text {
color: #eadfcd;
}
.featured-extra .btn {
background: #f7efe2;
color: #1e1b18;
border: none;
} }
.card-title { .card-title {
@ -97,17 +136,5 @@
.card-text { .card-text {
font-size: 0.95rem; font-size: 0.95rem;
} }
.extras-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.access-label {
font-size: 0.8rem;
font-style: italic;
color: #888;
}
</style> </style>
} }

View File

@ -54,6 +54,14 @@
<partial name="_ReckoningEvidence" model="Model"></partial> <partial name="_ReckoningEvidence" model="Model"></partial>
</div> </div>
<div class="casefile-panel" data-tab="maps">
<partial name="_ReckoningMaps" model="Model"></partial>
</div>
<div class="casefile-panel" data-tab="chapters">
<partial name="_ReckoningChapters" model="Model"></partial>
</div>
<div class="casefile-panel" data-tab="notes"> <div class="casefile-panel" data-tab="notes">
<partial name="_ReckoningReview" model="Model"></partial> <partial name="_ReckoningReview" model="Model"></partial>
</div> </div>
@ -72,6 +80,8 @@
<button type="button" class="casefile-tab active" data-tab="summary">Summary</button> <button type="button" class="casefile-tab active" data-tab="summary">Summary</button>
<button type="button" class="casefile-tab" data-tab="subjects">Subjects</button> <button type="button" class="casefile-tab" data-tab="subjects">Subjects</button>
<button type="button" class="casefile-tab" data-tab="evidence">Evidence</button> <button type="button" class="casefile-tab" data-tab="evidence">Evidence</button>
<button type="button" class="casefile-tab" data-tab="maps">Maps</button>
<button type="button" class="casefile-tab" data-tab="chapters">Chapters</button>
<button type="button" class="casefile-tab" data-tab="notes">Review</button> <button type="button" class="casefile-tab" data-tab="notes">Review</button>
<button type="button" class="casefile-tab" data-tab="buy">Purchase</button> <button type="button" class="casefile-tab" data-tab="buy">Purchase</button>
<button type="button" class="casefile-tab restricted" data-tab="restricted">Restricted</button> <button type="button" class="casefile-tab restricted" data-tab="restricted">Restricted</button>

View File

@ -77,6 +77,13 @@
</div> </div>
</section> </section>
<section id="evidence" class="reckoning-mobile-section">
<div class="reckoning-mobile-tab reckoning-tab-maps">Maps</div>
<div class="reckoning-mobile-paper">
@await Html.PartialAsync("_ReckoningMaps", Model)
</div>
</section>
<section id="review" class="reckoning-mobile-section"> <section id="review" class="reckoning-mobile-section">
<div class="reckoning-mobile-tab reckoning-tab-review">Review</div> <div class="reckoning-mobile-tab reckoning-tab-review">Review</div>
<div class="reckoning-mobile-paper"> <div class="reckoning-mobile-paper">

View File

@ -0,0 +1,546 @@
@{
ViewData["Title"] = "Recovered Evidence";
}
<div class="container reckoning-evidence-page 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="Reckoning" asp-action="Index">Reckoning</a></li>
<li class="breadcrumb-item"><a asp-controller="Reckoning" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active text-white" aria-current="page">Investigation</li>
</ol>
</nav>
</div>
</div>
<section class="evidence-hero">
<div class="evidence-hero-inner">
<p class="case-label">Restricted Companion Material</p>
<h1 class="evidence-title">Recovered Evidence</h1>
<p class="evidence-lead">
Some things in <em>The Alpha Flame: Reckoning</em> were hidden in plain sight.
A line dismissed too quickly. A voice half-heard. A question no one wanted to answer.
</p>
<p class="evidence-copy">
Below are fragments recovered from the world behind the book. Some are useful.
Some are distractions. Choose the three pieces that tell the truth, then tell me who
you think is not who they appear to be.
</p>
<p class="evidence-warning">
Spoilers ahead. This section is intended for readers who have finished <em>Reckoning</em>.
</p>
</div>
</section>
<div class="evidence-grid">
<div class="evidence-card tape-card" data-evidence-id="tape1">
<h3>Tape One</h3>
<audio controls preload="metadata">
<source src="/audio/investigation/tape-1.mp3" type="audio/mpeg" />
</audio>
<label>
<input type="checkbox" class="evidence-check" value="tape1" />
Select evidence
</label>
</div>
<div class="evidence-card tape-card" data-evidence-id="tape2">
<h3>Tape Two</h3>
<audio controls preload="metadata">
<source src="/audio/investigation/tape-2.mp3" type="audio/mpeg" />
</audio>
<label>
<input type="checkbox" class="evidence-check" value="tape2" />
Select evidence
</label>
</div>
<div class="evidence-card tape-card" data-evidence-id="tape3">
<h3>Tape Three</h3>
<audio controls preload="metadata">
<source src="/audio/investigation/tape-3.mp3" type="audio/mpeg" />
</audio>
<label>
<input type="checkbox" class="evidence-check" value="tape3" />
Select evidence
</label>
</div>
<div class="evidence-card" data-evidence-id="poem">
<h3>Recovered Verse</h3>
<responsive-image src="poem.png" class="evidence-img" alt="Recovered poem" display-width-percentage="50"></responsive-image>
<label>
<input type="checkbox" class="evidence-check" value="poem" />
Select evidence
</label>
</div>
<div class="evidence-card" data-evidence-id="notes">
<h3>Handwritten Notes</h3>
<responsive-image src="hand-written-note.png" class="evidence-img" alt="Hand written note" display-width-percentage="50"></responsive-image>
<label>
<input type="checkbox" class="evidence-check" value="notes" />
Select evidence
</label>
</div>
<div class="evidence-card" data-evidence-id="ledger">
<h3>Ledger Page</h3>
<responsive-image src="ledger.png" class="evidence-img" alt="Ledger" display-width-percentage="50"></responsive-image>
<label>
<input type="checkbox" class="evidence-check" value="ledger" />
Select evidence
</label>
</div>
</div>
<div class="evidence-actions">
<button type="button" id="checkEvidenceBtn" class="btn btn-dark">
Check Evidence
</button>
<p id="evidenceMessage" class="evidence-message"></p>
</div>
<section id="theorySection" class="theory-section d-none">
<h2>Someone is not who they appear to be.</h2>
<p class="theory-hint">
The question was asked once... and no one listened.
</p>
<form asp-action="SubmitTheory" method="post" class="theory-form">
@Html.AntiForgeryToken()
<div class="mb-4">
<label class="form-label">Who do you think it is?</label>
<div class="theory-options">
<label><input type="radio" name="Suspect" value="Barbara" required /> Barbara</label>
<label><input type="radio" name="Suspect" value="Dylan" /> Dylan</label>
<label><input type="radio" name="Suspect" value="Gareth" /> Gareth</label>
<label><input type="radio" name="Suspect" value="Graham" /> Graham</label>
<label><input type="radio" name="Suspect" value="Rebecca" /> Rebecca</label>
<label><input type="radio" name="Suspect" value="Rick" /> Rick</label>
<label><input type="radio" name="Suspect" value="Simon" /> Simon</label>
<label><input type="radio" name="Suspect" value="Sophie" /> Sophie</label>
<label><input type="radio" name="Suspect" value="Yvonne" /> Yvonne</label>
<label><input type="radio" name="Suspect" value="Other" /> Someone else</label>
</div>
</div>
<div class="mb-4">
<label for="Reason" class="form-label">What made you think that?</label>
<textarea id="Reason" name="Reason" class="form-control" rows="5" required></textarea>
</div>
<div class="mb-4">
<label for="EmailAddress" class="form-label">Email address</label>
<input id="EmailAddress" name="EmailAddress" type="email" class="form-control" required />
<small class="form-text">
Needed so I can contact winners when Redemption is released.
</small>
</div>
<div class="form-check mb-4">
<input id="MarketingOptIn" name="MarketingOptIn" type="checkbox" class="form-check-input" value="true" />
<label for="MarketingOptIn" class="form-check-label">
I would like updates about future releases and extras.
</label>
</div>
<button type="submit" class="btn btn-dark">
Submit Theory
</button>
</form>
</section>
</div>
@section Meta {
<style>
.reckoning-evidence-page {
max-width: 1180px;
color: #2b2118;
}
.evidence-title {
text-align: center;
font-weight: 800;
margin-bottom: 1rem;
color: #ffffff;
letter-spacing: .03em;
}
.evidence-intro {
max-width: 760px;
margin: 0 auto 2rem auto;
text-align: center;
color: #ffffff;
font-size: 1.05rem;
line-height: 1.6;
}
.evidence-grid {
display: grid;
grid-template-columns: repeat(3, minmax(260px, 1fr));
gap: 1.5rem;
}
@@media (max-width: 991px) {
.evidence-grid {
grid-template-columns: repeat(2, minmax(240px, 1fr));
}
}
@@media (max-width: 575px) {
.evidence-grid {
grid-template-columns: 1fr;
}
}
.evidence-card {
background: #f3ead8;
color: #2b2118;
border: 1px solid #d3c1a2;
border-radius: .75rem;
padding: 1.25rem;
box-shadow: 0 8px 18px rgba(40, 28, 18, 0.18);
transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
position: relative;
}
.evidence-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: .75rem;
background: linear-gradient(135deg, rgba(255,255,255,.35), transparent 35%), radial-gradient(circle at top right, rgba(92,64,38,.08), transparent 45%);
pointer-events: none;
}
.evidence-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 26px rgba(40, 28, 18, 0.25);
border-color: #aa8152;
}
.evidence-card.selected {
outline: 3px solid #8b5e34;
box-shadow: 0 0 0 5px rgba(139, 94, 52, .18), 0 12px 26px rgba(40, 28, 18, 0.25);
}
.evidence-card h3 {
font-size: 1.15rem;
font-weight: 800;
margin-bottom: 1rem;
color: #3b2a1c;
text-transform: uppercase;
letter-spacing: .04em;
}
.evidence-card audio {
width: 100%;
margin-bottom: 1rem;
}
.evidence-card.tape-card {
min-height: 360px;
background: linear-gradient(rgba(35, 25, 18, .18), rgba(35, 25, 18, .7)), url('/images/cassette-bg.png') center / cover no-repeat;
color: #f7efe2;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.evidence-card.tape-card h3,
.evidence-card.tape-card label {
color: #f7efe2;
text-shadow: 0 1px 2px rgba(0,0,0,.8);
}
.evidence-card.tape-card audio {
margin-top: auto;
margin-bottom: 1rem;
filter: sepia(.25) contrast(1.05);
}
.evidence-card.tape-card::before {
background: radial-gradient(circle at center, rgba(255,255,255,.08), transparent 55%), linear-gradient(135deg, rgba(255,255,255,.12), transparent 45%);
}
.evidence-img {
width: 100%;
height: 230px;
object-fit: cover;
border-radius: .45rem;
margin-bottom: 1rem;
border: 6px solid #efe2c7;
box-shadow: 0 6px 14px rgba(0,0,0,.22);
background: #e6d4b4;
}
.evidence-card label {
display: flex;
gap: .55rem;
align-items: center;
font-weight: 700;
cursor: pointer;
color: #3b2a1c;
position: relative;
z-index: 1;
}
.evidence-card input[type="checkbox"] {
accent-color: #8b5e34;
}
.evidence-actions {
text-align: center;
margin-top: 2rem;
}
.evidence-message {
display: block;
width: fit-content;
max-width: 720px;
margin: 1.25rem auto 0 auto;
padding: .85rem 1.2rem;
font-weight: 800;
color: #fff4dc;
background: rgba(43, 33, 24, 0.95);
border: 1px solid #d3a45f;
border-radius: .6rem;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
text-shadow: 0 1px 2px rgba(0,0,0,.8);
}
.evidence-message {
opacity: 0;
transform: translateY(6px);
transition: all .25s ease;
}
.evidence-message:not(:empty) {
opacity: 1;
transform: translateY(0);
}
.evidence-message:empty {
display: none;
}
.theory-section {
margin-top: 3rem;
padding: 2rem;
border-radius: .75rem;
background: #2b2118;
color: #f3ead8;
border: 1px solid #7d5a39;
box-shadow: 0 14px 35px rgba(0,0,0,0.28);
}
.theory-section h2 {
font-weight: 800;
margin-bottom: 1rem;
color: #fff4dc;
}
.theory-hint {
font-size: 1.1rem;
color: #d9c29e;
margin-bottom: 2rem;
font-style: italic;
}
.theory-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: .75rem;
}
.theory-options label {
background: #3a2b20;
border: 1px solid #7d5a39;
color: #f3ead8;
padding: .75rem;
border-radius: .5rem;
cursor: pointer;
}
.theory-options label:hover {
background: #463426;
}
.theory-options input[type="radio"],
.theory-form input[type="checkbox"] {
accent-color: #d3a45f;
}
.theory-form .form-control {
border-radius: .5rem;
border-color: #c8b18e;
background: #fffaf0;
color: #2b2118;
}
.theory-form .form-text {
color: #d6c7b5;
}
.btn-dark {
background: #2b2118;
border-color: #2b2118;
}
.btn-dark:hover {
background: #5b3a24;
border-color: #5b3a24;
}
.d-none {
display: none !important;
}
.evidence-hero {
max-width: 920px;
margin: 0 auto 2.5rem auto;
padding: 1.5rem;
border-radius: 1.1rem;
background: linear-gradient(rgba(30, 24, 18, .86), rgba(30, 24, 18, .92)), radial-gradient(circle at top left, rgba(211, 164, 95, .25), transparent 45%);
border: 1px solid rgba(211, 164, 95, .55);
box-shadow: 0 18px 45px rgba(0,0,0,.35);
}
.evidence-hero-inner {
padding: 2rem;
border: 1px solid rgba(243, 234, 216, .22);
border-radius: .85rem;
background: rgba(0,0,0,.16);
text-align: center;
}
.case-label {
display: inline-block;
margin-bottom: .85rem;
padding: .35rem .75rem;
border: 1px solid rgba(211, 164, 95, .8);
color: #d3a45f;
text-transform: uppercase;
letter-spacing: .12em;
font-size: .75rem;
font-weight: 800;
}
.evidence-title {
color: #fff4dc;
font-size: clamp(2rem, 4vw, 3.25rem);
margin-bottom: 1rem;
text-shadow: 0 3px 10px rgba(0,0,0,.75);
}
.evidence-lead {
color: #f3ead8;
font-size: 1.18rem;
line-height: 1.65;
max-width: 760px;
margin: 0 auto 1rem auto;
}
.evidence-copy {
color: #dbc8aa;
font-size: 1rem;
line-height: 1.6;
max-width: 760px;
margin: 0 auto 1.25rem auto;
}
.evidence-warning {
display: inline-block;
margin: .5rem auto 0 auto;
padding: .55rem .85rem;
background: rgba(91, 36, 31, .72);
border: 1px solid rgba(255, 210, 170, .35);
border-radius: .5rem;
color: #ffe6cf;
font-size: .92rem;
font-weight: 700;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function () {
const correctEvidence = ["tape1", "poem", "notes"];
const checks = Array.from(document.querySelectorAll(".evidence-check"));
const button = document.getElementById("checkEvidenceBtn");
const message = document.getElementById("evidenceMessage");
const theorySection = document.getElementById("theorySection");
function updateCards() {
checks.forEach(check => {
const card = check.closest(".evidence-card");
card.classList.toggle("selected", check.checked);
});
}
checks.forEach(check => {
check.addEventListener("change", function () {
const selected = checks.filter(c => c.checked);
if (selected.length > 3) {
check.checked = false;
message.textContent = "Choose only three pieces.";
} else {
message.textContent = "";
}
updateCards();
});
});
button.addEventListener("click", function () {
const selected = checks
.filter(c => c.checked)
.map(c => c.value);
if (selected.length !== 3) {
message.textContent = "Choose exactly three pieces of evidence.";
return;
}
const matched = selected.filter(x => correctEvidence.includes(x)).length;
const allCorrect = correctEvidence.every(x => selected.includes(x));
if (allCorrect) {
message.textContent = "Now you’re seeing it the right way.";
button.classList.add("d-none");
setTimeout(() => {
theorySection.classList.remove("d-none");
}, 900);
setTimeout(() => {
theorySection.scrollIntoView({ behavior: "smooth", block: "start" });
}, 1700);
return;
}
if (matched === 2) {
message.textContent = "Close... but something doesn’t belong.";
return;
}
message.textContent = "You’re choosing what stands out... not what matters.";
});
});
</script>
}

View File

@ -0,0 +1,130 @@
@{
ViewData["Title"] = "Theory Submitted";
}
<div class="container confirmation-page my-5">
<div class="confirmation-card">
<div class="confirmation-inner">
<p class="case-label">Submission Received</p>
<h1 class="confirmation-title">
Some saw it early.<br />
Most didn’t.
</h1>
<p class="confirmation-copy">
Your theory has been recorded.
When the truth is revealed in <em>The Alpha Flame: Redemption</em>,
we’ll find out who was paying attention… and who wasn’t.
</p>
<p class="confirmation-sub">
If you’re right, you’ll know.
</p>
<p class="confirmation-sub">
Keep your answer in mind. You’ll want to remember it.
</p>
<div class="confirmation-actions mt-4">
<a asp-controller="Reckoning" asp-action="Extras" class="btn btn-dark">
Back to Extras
</a>
<a asp-controller="Reckoning" asp-action="Index" class="btn btn-outline-light">
Back to Reckoning
</a>
</div>
</div>
</div>
</div>
@section Meta {
<style>
.confirmation-page {
max-width: 900px;
}
.confirmation-card {
border-radius: 1.2rem;
background: linear-gradient(rgba(30, 24, 18, .88), rgba(30, 24, 18, .94)), radial-gradient(circle at top left, rgba(211,164,95,.25), transparent 45%);
border: 1px solid rgba(211,164,95,.6);
box-shadow: 0 20px 50px rgba(0,0,0,.4);
padding: 2rem;
}
.confirmation-inner {
border: 1px solid rgba(243,234,216,.25);
border-radius: .9rem;
padding: 2.5rem;
text-align: center;
background: rgba(0,0,0,.2);
}
.case-label {
display: inline-block;
margin-bottom: 1rem;
padding: .4rem .8rem;
border: 1px solid rgba(211,164,95,.8);
color: #d3a45f;
text-transform: uppercase;
letter-spacing: .12em;
font-size: .75rem;
font-weight: 800;
}
.confirmation-title {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 900;
color: #fff4dc;
margin-bottom: 1.5rem;
text-shadow: 0 3px 12px rgba(0,0,0,.8);
}
.confirmation-copy {
color: #f3ead8;
font-size: 1.1rem;
line-height: 1.7;
max-width: 650px;
margin: 0 auto 1.5rem auto;
}
.confirmation-sub {
color: #d3a45f;
font-size: 1rem;
font-style: italic;
margin-bottom: 2rem;
}
.confirmation-actions {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.btn-dark {
background: #2b2118;
border-color: #2b2118;
}
.btn-dark:hover {
background: #5b3a24;
border-color: #5b3a24;
}
.btn-outline-light {
border-color: #d3a45f;
color: #f3ead8;
}
.btn-outline-light:hover {
background: #d3a45f;
color: #2b2118;
}
</style>
}

View File

@ -21,7 +21,7 @@
<div class="col"> <div class="col">
<p class="h3 text-center" id="status">Loading...</p> <p class="h3 text-center" id="status">Loading...</p>
<ul class="pagination justify-content-center" id="chapter-list"></ul> <ul class="pagination justify-content-center" id="chapter-list"></ul>
<audio id="audio" preload="none"></audio> <audio id="audio" preload="none" data-book-key="Reckoning" data-playlist-url="/api/audio/playlist/all?book=Reckoning"></audio>
<div class="input-group border border-2 border-dark rounded-3"> <div class="input-group border border-2 border-dark rounded-3">
<!-- Progress Bar (styled to look like an input) --> <!-- Progress Bar (styled to look like an input) -->
<div class="form-control p-0" style="height: 40px;"> <div class="form-control p-0" style="height: 40px;">
@ -53,622 +53,5 @@
</div> </div>
@section Scripts { @section Scripts {
<script> <script src="~/js/audiobook-player.js" asp-append-version="true"></script>
const audioBlobCache = new Map(); // id => Blob
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 = 30 * 60; // 30 minutes
let masterPlaylist = [];
let chapterStartIndices = [];
let currentIndex = 0;
let preloadedAudio = null;
const audio = document.getElementById("audio");
const status = document.getElementById("status");
const toggleButton = document.getElementById("toggle-play");
// Load the playlist
fetch("/api/audio/playlist/all")
.then(res => res.json())
.then(data => {
// ✅ Sort chapters numerically by chapter number
data.sort((a, b) => {
const aNum = parseInt(a.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
const bNum = parseInt(b.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
return aNum - bNum;
});
let flatIndex = 0;
chapterStartIndices = []; // global or higher scoped variable
masterPlaylist = []; // reset in case of reload
data.forEach(chapter => {
chapterStartIndices.push({ index: flatIndex, name: chapter.chapter });
chapter.segments.forEach(segment => {
masterPlaylist.push({
id: segment.id,
display: segment.display,
chapter: chapter.chapter,
duration: parseDuration(segment.display),
text: segment.text || ""
});
flatIndex++;
});
});
// ✅ Determine saved/resume position
const savedIndex = parseInt(localStorage.getItem("lastAudioIndex") || "0", 10);
const currentSeg = masterPlaylist[savedIndex] || {};
const currentChapter = currentSeg.chapter || chapterStartIndices[0].name;
// ✅ Ensure pagination is rendered properly
renderChapterPagination(currentChapter);
status.textContent = `Ready – ${masterPlaylist.length} segments total.`;
// ✅ Preload first segments of each chapter (for snappy chapter switches)
preloadFirstSegments();
// ✅ Preload 10 minutes of sequential audio starting from resume position or beginning
const preloadStart = (savedIndex >= 0 && savedIndex < masterPlaylist.length)
? savedIndex
: 0;
preloadSegmentsAhead(preloadStart, BUFFER_TARGET_SECONDS); // preload 10 mins ahead
// ✅ Resume if index is valid
if (savedIndex >= 0 && savedIndex < masterPlaylist.length) {
const seg = masterPlaylist[savedIndex];
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(savedIndex);
}
});
function fetchBlobWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch blob ${url}:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterPagination(currentChapter) {
let listHtml = '';
const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter);
const totalChapters = chapterStartIndices.length;
// Dynamically determine window size based on screen width
const windowSize = getWindowSize();
const halfWindow = Math.floor(windowSize / 2);
// Previous button
listHtml += `<li class="page-item ${currentPos <= 0 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToPrevChapter(${currentPos})"><i class="fad fa-step-backward"></i></a>
</li>`;
const addPageItem = (i, label = null) => {
const chapterNumber = chapterStartIndices[i].name.match(/Chapter_(\d+)/i)?.[1] || "?";
const isActive = i === currentPos;
listHtml += `<li class="page-item ${isActive ? 'active' : ''}">
<a class="page-link" href="#" onclick="startFrom(${chapterStartIndices[i].index})">${label || chapterNumber}</a>
</li>`;
};
// First button
addPageItem(0);
// Ellipsis after first if needed
if (currentPos - halfWindow > 1) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Sliding window
const start = Math.max(1, currentPos - halfWindow);
const end = Math.min(totalChapters - 2, currentPos + halfWindow);
for (let i = start; i <= end; i++) {
if (i !== 0 && i !== totalChapters - 1) {
addPageItem(i);
}
}
// Ellipsis before last if needed
if (currentPos + halfWindow < totalChapters - 2) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Last button
if (totalChapters > 1) {
addPageItem(totalChapters - 1);
}
// Next button
listHtml += `<li class="page-item ${currentPos >= totalChapters - 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToNextChapter(${currentPos})"><i class="fad fa-step-forward"></i></a>
</li>`;
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;
const newIndex = chapterStartIndices[currentPos - 1].index;
const newChapter = chapterStartIndices[currentPos - 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function goToNextChapter(currentPos) {
if (currentPos >= chapterStartIndices.length - 1) return;
const newIndex = chapterStartIndices[currentPos + 1].index;
const newChapter = chapterStartIndices[currentPos + 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function parseDuration(display) {
const parts = display.split(":").map(Number);
return parts[0] * 60 + (parts[1] || 0);
}
// Call this in startFrom()
function startFrom(index) {
const seg = masterPlaylist[index];
// Ensure autoplay works (helps with Chrome mobile restrictions)
audio.muted = false;
audio.autoplay = true;
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(index);
}
function loadSegment(index) {
if (index < 0 || index >= masterPlaylist.length) return;
currentIndex = index;
updateBufferBar(index);
localStorage.setItem("lastAudioIndex", index.toString());
const seg = masterPlaylist[index];
// ✅ Scroll and highlight current paragraph
document.querySelectorAll(".chapter-text-scroll p").forEach(p => p.classList.remove("active"));
const activePara = document.getElementById(`text-seg-${index}`);
if (activePara) {
activePara.classList.add("active");
const container = document.querySelector(".chapter-text-scroll");
const paraTop = activePara.offsetTop;
const paraHeight = activePara.offsetHeight;
const containerHeight = container.clientHeight;
const scrollTarget = paraTop - container.offsetTop - (containerHeight / 2) + (paraHeight / 2);
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
const tryPlay = async (blob, retryCount = 0) => {
try {
const blobUrl = URL.createObjectURL(blob);
audio.src = blobUrl;
await audio.play();
status.textContent = `Playing: ${seg.chapter.replace(/_/g, ' ')}`;
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} catch (err) {
if (retryCount < 10) {
console.warn(`Retrying audio playback (${retryCount + 1})...`);
setTimeout(() => tryPlay(blob, retryCount + 1), 3000);
} else {
console.error("Playback failed permanently:", err);
status.textContent = "Error loading audio segment.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
};
const fetchAndPlay = async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 5, 2000);
const blob = await fetch(response).then(r => r.blob());
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
console.error("Initial fetch failed, will retry in background:", err);
status.textContent = "Waiting for signal...";
let retries = 0;
const maxRetries = 12;
const retryLoop = setInterval(async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 3, 2000);
const blob = await fetch(response).then(r => r.blob());
clearInterval(retryLoop);
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
retries++;
console.warn(`Retry ${retries}/${maxRetries} failed`);
if (retries >= maxRetries) {
clearInterval(retryLoop);
status.textContent = "Unable to load segment.";
}
}
}, 10000);
}
};
if (audioBlobCache.has(seg.id)) {
tryPlay(audioBlobCache.get(seg.id));
} else {
fetchAndPlay();
}
maybeTriggerPreload();
}
function maybeTriggerPreload() {
if (audio.paused) return; // only preload while playing
const bufferedPercent = getBufferedPercent(currentIndex);
if (bufferedPercent < 90) {
preloadSegmentsAhead(currentIndex + 1, BUFFER_TARGET_SECONDS);
}
}
function getBufferedPercent(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
return Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
}
function preloadSegmentsAhead(startIndex, targetSeconds) {
let totalDuration = 0;
let i = startIndex;
while (i < masterPlaylist.length && totalDuration < targetSeconds) {
const seg = masterPlaylist[i];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn("Preload failed:", err));
}
totalDuration += seg.duration;
i++;
}
}
function fetchWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch ${url} after retries:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterText(chapterName) {
const container = document.getElementById("chapter-text-content");
container.innerHTML = "";
masterPlaylist.forEach((seg, i) => {
if (seg.chapter === chapterName) {
const p = document.createElement("p");
p.textContent = seg.text || "";
p.id = `text-seg-${i}`;
container.appendChild(p);
}
});
}
let currentChapter = "";
let chapterSegments = [];
let chapterTotalDuration = 0;
function initChapterProgress(chapterName) {
currentChapter = chapterName;
chapterSegments = masterPlaylist.filter(s => s.chapter === chapterName);
chapterTotalDuration = chapterSegments.reduce((sum, seg) => sum + seg.duration, 0);
updateProgressUI(0);
renderChapterTicks(); // ✅ Added here
}
function updateProgressUI(elapsedSeconds) {
const percent = (elapsedSeconds / chapterTotalDuration) * 100;
document.getElementById("chapter-progress-bar").style.width = `${percent}%`;
const mins = Math.floor(elapsedSeconds / 60).toString().padStart(2, "0");
const secs = Math.floor(elapsedSeconds % 60).toString().padStart(2, "0");
const totalMins = Math.floor(chapterTotalDuration / 60).toString().padStart(2, "0");
const totalSecs = Math.floor(chapterTotalDuration % 60).toString().padStart(2, "0");
document.getElementById("chapter-timestamp").textContent = `${mins}:${secs} / ${totalMins}:${totalSecs}`;
}
function getElapsedInChapter(currentIdx, audioCurrentTime) {
let sum = 0;
for (let i = 0; i < currentIdx; i++) {
if (masterPlaylist[i].chapter === currentChapter) {
sum += masterPlaylist[i].duration;
}
}
return sum + audioCurrentTime;
}
function renderChapterTicks() {
const ticksContainer = document.getElementById("chapter-ticks");
ticksContainer.innerHTML = "";
let elapsed = 0;
chapterSegments.forEach(seg => {
const percent = (elapsed / chapterTotalDuration) * 100;
const tick = document.createElement("div");
tick.className = "chapter-tick";
tick.style.left = `${percent}%`;
ticksContainer.appendChild(tick);
// const label = document.createElement("div");
// label.className = "chapter-tick-label";
// label.style.left = `${percent}%`;
// label.textContent = seg.display;
// ticksContainer.appendChild(label);
elapsed += seg.duration;
});
}
function updatePaginationHighlight(currentChapter) {
document.querySelectorAll("#chapter-list .page-item").forEach((li, idx) => {
const chapterNum = parseInt(li.textContent);
const currentNum = parseInt(currentChapter.match(/Chapter_(\d+)/i)?.[1]);
if (chapterNum === currentNum) {
li.classList.add("active");
} else {
li.classList.remove("active");
}
});
}
function preloadFirstSegments() {
chapterStartIndices.forEach(({ index }) => {
const seg = masterPlaylist[index];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn(`Preload of chapter start failed (${seg.id}):`, err));
}
});
}
function enforceBlobCacheLimit() {
const iterator = audioBlobCache.keys();
while (totalBlobSize > MAX_BLOB_CACHE_SIZE && audioBlobCache.size > 0) {
const oldestKey = iterator.next().value;
const size = audioBlobSizes.get(oldestKey) || 0;
audioBlobCache.delete(oldestKey);
audioBlobSizes.delete(oldestKey);
totalBlobSize -= size;
console.log(`Evicted ${oldestKey} to maintain cache size. Remaining size: ${totalBlobSize} bytes.`);
}
}
function updateBufferBar(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
const percent = Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
const bar = document.getElementById("buffer-progress-bar");
bar.style.width = `${percent.toFixed(1)}%`;
bar.setAttribute("aria-valuenow", percent.toFixed(1));
// ✅ Set Bootstrap colour classes
bar.classList.remove("bg-primary", "bg-warning", "bg-danger");
if (percent >= 75) {
bar.classList.add("bg-primary");
} else if (percent >= 30) {
bar.classList.add("bg-warning");
} else {
bar.classList.add("bg-danger");
}
}
audio.addEventListener("ended", () => {
if (currentIndex < masterPlaylist.length - 1) {
const nextIndex = currentIndex + 1;
const nextSeg = masterPlaylist[nextIndex];
const currentChapter = masterPlaylist[currentIndex].chapter;
const nextChapter = nextSeg.chapter;
if (nextChapter !== currentChapter) {
renderChapterPagination(nextChapter); // ✅ Update pagination
renderChapterText(nextChapter);
initChapterProgress(nextChapter);
}
loadSegment(nextIndex);
} else {
status.textContent = "Playback complete.";
}
});
// Update during playback
audio.addEventListener("timeupdate", () => {
if (!chapterSegments.length || !audio.duration) return;
const elapsed = getElapsedInChapter(currentIndex, audio.currentTime);
updateProgressUI(elapsed);
});
toggleButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} else {
audio.pause();
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
});
audio.addEventListener("play", () => {
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
});
audio.addEventListener("pause", () => {
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
});
// ✅ These are additional listeners — do not replace existing ones
audio.addEventListener("play", maybeTriggerPreload);
audio.addEventListener("ended", maybeTriggerPreload);
document.getElementById("chapter-progress").addEventListener("click", (e) => {
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickPercent = clickX / rect.width;
const targetTime = clickPercent * chapterTotalDuration;
// Find approximate segment
let cumulative = 0;
for (let i = 0; i < chapterSegments.length; i++) {
const seg = chapterSegments[i];
if (cumulative + seg.duration >= targetTime) {
const globalIndex = masterPlaylist.findIndex(s => s.id === seg.id);
if (globalIndex >= 0) loadSegment(globalIndex);
break;
}
cumulative += seg.duration;
}
});
</script>
} }

View File

@ -1,200 +1,257 @@
@{ @{
ViewData["Title"] = "The Alpha Flame: Discovery Scrap Book"; ViewData["Title"] = "The Alpha Flame: Reckoning Scrapbook";
} }
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="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="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="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="Reckoning" asp-action="Index">Reckoning</a></li>
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Extras">Extras</a></li> <li class="breadcrumb-item"><a asp-controller="Reckoning" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active" aria-current="page">Discovery Scrap Book</li> <li class="breadcrumb-item active" aria-current="page">Reckoning Scrapbook</li>
</ol> </ol>
</nav> </nav>
</div> </div>
</div> </div>
<div class="container"> <div class="container scrapbook-page">
<h1 class="mb-5 text-center">Discover Scrapbook</h1> <h1 class="mb-4 text-center">Reckoning Scrapbook</h1>
<p>
On this page I've included some of the images that helped inspire me to write The Alpha Flame: Discovery. <div class="scrapbook-intro">
</p> <p>
<p> Here are some of the images that helped shape <em>The Alpha Flame: Reckoning</em>.
Some of them are fictional, generated by AI, others are real, both recent and archive photos. Some are character impressions, some are locations, and some are simply visual moods that helped me hold a scene in my head while writing.
</p> </p>
<p> <p>
I find that when I'm writing I like to imagine the scene. So having a visual guide helps me tremendously. They are not meant to be exact. They are fragments, glimpses, and visual echoes from the world behind the book.
So I might, for example, ask AI to generate an image of a luxurious restaraunt. The I would look at the result </p>
and tweak it, until what I was looking at matches what was in my imagination. I also find it useful to do the reverse. </div>
I might write a description of a particular location, Beth's flat was one of them, and then feed that into AI to see
if it generated what I'd described. This was really useful because it helped me work out whether there was enough detail.
My thoughts were that if AI can draw what I've described, then I'm sure you as a reader will be able to imagine something
similar. Hopefully it's worked.
</p>
<!-- Section 1 -->
<div class="scrapbook-section"> <div class="scrapbook-section">
<div class="scrapbook-image rotate-1"> <div class="scrapbook-image rotate-1">
<responsive-image src="flyover-at-night.png" class="img-fluid" alt="The Rubery flyover at night time" display-width-percentage="50"></responsive-image> <responsive-image src="maggie-grant-evidence.png" class="img-fluid" alt="Maggie Grant" display-width-percentage="50"></responsive-image>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>The Rubery Flyover</h5> <h5>Maggie Grant</h5>
<p> <p>
A number of scenes in The Alpha Flame: Discovery are set, or at least start here. The Rubery flyover carries the A38 Bristol Road Maggie changes enormously through <em>Reckoning</em>. She starts with confidence, anger, and fire, but the deeper she digs, the more the past begins to pull at her.
down to meet the M5. It's the main road in and out of the city from the South West. Quite why the floyover exists is something of
a mystery to me. Apart from allowing traffic to pass underneath it, past "Beth's Bench", it seems to serve no purpose. It doesn't
bridge any sort of gap in the landscape. Neither does it provide access to a higher ground further along.
</p> </p>
<p> <p>
Many years ago I used to live in this area and travelling underneath the flyover really gave me the creeps. As I describe in the book This image captures something I wanted for her in this book: beauty, yes, but also tension. A woman being watched. A woman trying to work out whether the world around her is admiring her, threatening her, or both.
there were often many unsavoury characters seated on the benches. And thre genuinely were women offering their bodies to whoever
happened to drive past.
</p> </p>
</div> </div>
</div> </div>
<!-- Section 2 -->
<div class="scrapbook-section flex-row-reverse"> <div class="scrapbook-section flex-row-reverse">
<div class="scrapbook-image rotate-2"> <div class="scrapbook-image rotate-2">
<responsive-image src="beth-in-her-flat.png" class="img-fluid" alt="Beth in her flat" display-width-percentage="50"></responsive-image> <responsive-image src="sophie-jones-street-shot.png" class="img-fluid" alt="Sophie Jones" display-width-percentage="50"></responsive-image>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>Beth and Her Flat</h5> <h5>Sophie Jones</h5>
<p> <p>
In the beginning Beth's flat was even more depressing that it is described in the book. The idea of a sterotypical Sophie is one of those characters who should look like she belongs anywhere expensive, dangerous, or badly lit.
early 1980s council flat was too hard to resist. However once I had developed her personality a little further it
became obvious that she would have a lot more pride in where she lived, even if she had no money.
</p> </p>
<p> <p>
In reality the block of flats that I put Beth in are only two storeys on top of a row of shops. But for the I wanted her to have that unsettling quality of someone who knows exactly how she is being seen, and uses it. She does not need to shout. She only needs to stand there long enough for people to understand that she expects the room to move around her.
purpose of the book I made them into one of the taller blocks that can be found all over the area.
</p> </p>
</div> </div>
</div> </div>
<!-- Section 3 -->
<div class="scrapbook-section"> <div class="scrapbook-section">
<div class="scrapbook-image rotate-3"> <div class="scrapbook-image rotate-3">
<responsive-image src="rubery-hill-hospital.png" class="img-fluid" alt="Rubery Hill Hospital" display-width-percentage="50"></responsive-image> <responsive-image src="simon-jones-mug-shot.png" class="img-fluid" alt="Simon Jones" display-width-percentage="50"></responsive-image>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>Rubery Hill Hospital</h5> <h5>Simon Jones</h5>
<p> <p>
Now this place used to genuinely give me the creeps. It was built way back in the 1800s and as I say in the book, Simon needed to feel polished, powerful, and poisonous. A man who has spent years being obeyed, and who expects his money and influence to smooth over every ugly thing beneath the surface.
was origonally known as "The City of Birmingham Lunatic Asylum". It was renamed many times over it's long life </p>
but always had links to what we would call "mental health" these days. From what I have found out the only deviation <p>
from this was during the war, when it was a hospital for injured soldiers. By the time <em>Reckoning</em> reaches Scandals, that mask is beginning to crack. I liked the idea of him looking controlled even when the ground is starting to give way under his feet.
</p> </p>
</div> </div>
</div> </div>
<!-- Section 4 -->
<div class="scrapbook-section flex-row-reverse"> <div class="scrapbook-section flex-row-reverse">
<div class="scrapbook-image rotate-4"> <div class="scrapbook-image rotate-4">
<div class="hero-video-container"> <responsive-image src="Scandals.png" class="img-fluid" alt="Scandals fashion show" display-width-percentage="50"></responsive-image>
<video autoplay muted loop playsinline poster="/images/maggie-in-her-jumpsuit.png">
<source src="/videos/maggie-in-her-jumpsuit.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>Maggie in Her Jumpsuit</h5> <h5>Scandals</h5>
<p> <p>
So, the scene where Maggie goes to Limelight and walks across the dance floor to go and say hello to Zoe and Graham. Is this what you imagined? Scandals is glamour with rot underneath. On the surface, it is fashion, champagne, charity, and beautiful people pretending everything is perfectly civilised.
I doubt it, but it was what was in my mind. The original description of this outfit went on for over a page, and I think you can see why.
Unfortuantely it got severaly cut in the edit. You're probably quite thankful for that!
</p> </p>
<p> <p>
But wouldn't you just love to wear it! But that room is also where power shifts. Maggie steps into the light as Grace, the money starts moving, and the truth begins forcing its way through the polished surface.
</p> </p>
</div> </div>
</div> </div>
<!-- Section 5 -->
<div class="scrapbook-section"> <div class="scrapbook-section">
<div class="scrapbook-image rotate-1"> <div class="scrapbook-image rotate-1">
<responsive-image src="ricardos.png" class="img-fluid" alt="Ricardos" display-width-percentage="50"></responsive-image> <responsive-image src="the-room-at-the-end.png" class="img-fluid" alt="The room at the end" display-width-percentage="50"></responsive-image>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>Ricardo's</h5> <h5>The Room at the End</h5>
<p> <p>
The extremely lavish Riscrod's restaraunt went through many changes, and evloved into something more luxurious every Some rooms in the book are not frightening because of what they show, but because of what they imply.
single time. I'm not sure this image does it justice, but I hope by my descriptions you got the feeling of the sheer
opulance within it.
</p> </p>
<p> <p>
In reality Northfield never had any such restaraunt, it's simply not that sort of place. But it's nice to think that I wanted certain interiors to feel over-dressed, too still, and faintly performative. The sort of room where everything has been arranged to suggest taste, comfort, and control, while hiding something much colder.
it could have.
</p> </p>
</div> </div>
</div> </div>
<!-- Section 6 -->
<div class="scrapbook-section flex-row-reverse"> <div class="scrapbook-section flex-row-reverse">
<div class="scrapbook-image rotate-2"> <div class="scrapbook-image rotate-2">
<responsive-image src="beth-in-her-flat.png" class="img-fluid" alt="Beth in her flat" display-width-percentage="50"></responsive-image> <responsive-image src="the-thinking-spot.png" class="img-fluid" alt="Welsh landscape thinking spot" display-width-percentage="50"></responsive-image>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>Beth and Her Flat</h5> <h5>The Thinking Spot</h5>
<p> <p>
In the beginning Beth's flat was even more depressing that it is described in the book. The idea of a sterotypical Wales gives the story room to breathe. After the pressure of Birmingham, the Welsh sections needed space, salt air, hills, and quiet.
early 1980s council flat was too hard to resist. However once I had developed her personality a little further it
became obvious that she would have a lot more pride in where she lived, even if she had no money.
</p> </p>
<p> <p>
In reality the block of flats that I put Beth in are only two storeys on top of a row of shops. But for the This image has the feeling I wanted for those moments when Beth and Maggie are not chasing anything, not being chased, just trying to understand where they have come from and what their mother left behind.
purpose of the book I made them into one of the taller blocks that can be found all over the area.
</p> </p>
</div> </div>
</div> </div>
<!-- Section 7 -->
<div class="scrapbook-section"> <div class="scrapbook-section">
<div class="scrapbook-image rotate-3"> <div class="scrapbook-image rotate-3">
<responsive-image src="rubery-hill-hospital.png" class="img-fluid" alt="Rubery Hill Hospital" display-width-percentage="50"></responsive-image> <responsive-image src="the-house-that-waited.png" class="img-fluid" alt="The house that waited" display-width-percentage="50"></responsive-image>
</div> </div>
<div class="scrapbook-text"> <div class="scrapbook-text">
<h5>Rubery Hill Hospital</h5> <h5>The House That Waited</h5>
<p> <p>
Now this place used to genuinely give me the creeps. It was built way back in the 1800s and as I say in the book, I loved the idea of a house that had been waiting for the girls without them knowing it.
was origonally known as "The City of Birmingham Lunatic Asylum". It was renamed many times over it's long life </p>
but always had links to what we would call "mental health" these days. From what I have found out the only deviation <p>
from this was during the war, when it was a hospital for injured soldiers. Not haunted exactly, but full of memory. Somewhere that still carries Annie in the walls, in the furniture, in the stories people tell when they finally begin to speak.
</p> </p>
</div> </div>
</div> </div>
<div class="scrapbook-section flex-row-reverse">
<div class="scrapbook-image rotate-4">
<responsive-image src="the-cottage.png" class="img-fluid" alt="Welsh cottage" display-width-percentage="50"></responsive-image>
</div>
<div class="scrapbook-text">
<h5>The Cottage</h5>
<p>
The cottage represents the life that might have been. Quiet, rural, ordinary, and yet completely unreachable for so much of the story.
</p>
<p>
For Beth and Maggie, places like this are not just pretty scenery. They are evidence that their mother had a past, a family, and a version of herself they are only just beginning to discover.
</p>
</div>
</div>
<div class="scrapbook-section">
<div class="scrapbook-image rotate-1">
<responsive-image src="rings-and-rituals.png" class="img-fluid" alt="Ring close-up" display-width-percentage="50"></responsive-image>
</div>
<div class="scrapbook-text">
<h5>Rings and Rituals</h5>
<p>
The rings matter because they are small, personal, and loaded with meaning. They connect the girls to Annie, but they also become part of something far darker.
</p>
<p>
I like objects in stories when they change meaning. A ring can be love, memory, proof, bait, or a weapon, depending on who is holding it.
</p>
</div>
</div>
<div class="scrapbook-section flex-row-reverse">
<div class="scrapbook-image rotate-2">
<responsive-image src="mrs-pattersons-front-room.png" class="img-fluid" alt="Mrs Patterson's front room" display-width-percentage="50"></responsive-image>
</div>
<div class="scrapbook-text">
<h5>Mrs Patterson’s Front Room</h5>
<p>
Mrs Patterson’s house needed to feel ordinary. That was the point. Tea cups, old furniture, soft furnishings, family photographs, and the kind of room where nothing dramatic should happen.
</p>
<p>
Which makes it all the worse when the past walks in. I wanted that contrast between domestic comfort and quiet betrayal.
</p>
</div>
</div>
</div> </div>
@section Meta { @section Meta {
<style> <style>
.scrapbook-page {
color: #2b2118;
}
.scrapbook-page h1 {
color: #fff4dc;
text-shadow: 0 3px 10px rgba(0,0,0,.75);
font-weight: 800;
}
.scrapbook-intro {
max-width: 850px;
margin: 0 auto 3rem auto;
padding: 1.5rem 2rem;
border-radius: 1rem;
background: rgba(43, 33, 24, .88);
border: 1px solid rgba(211, 164, 95, .55);
color: #f3ead8;
box-shadow: 0 14px 35px rgba(0,0,0,.28);
line-height: 1.7;
}
.scrapbook-section { .scrapbook-section {
margin-bottom: 4rem; margin-bottom: 4rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
padding: 1.5rem;
border-radius: 1rem;
background: rgba(255, 250, 240, .88);
box-shadow: 0 12px 30px rgba(0,0,0,.18);
border: 1px solid rgba(211, 193, 162, .8);
} }
.scrapbook-image { .scrapbook-image {
background: white; background: #fffaf0;
padding: 10px 10px 30px 10px; padding: 10px 10px 30px 10px;
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 2px 4px 14px rgba(0, 0, 0, 0.22);
border: 1px solid #ccc; border: 1px solid #d3c1a2;
width: 100%; width: 100%;
max-width: 300px; max-width: 320px;
margin: 1rem; margin: 1rem;
transform: rotate(var(--angle)); transform: rotate(var(--angle));
} }
.scrapbook-image img,
.scrapbook-image picture,
.scrapbook-image video {
width: 100%;
height: auto;
display: block;
}
.scrapbook-text { .scrapbook-text {
flex: 1; flex: 1;
min-width: 250px; min-width: 250px;
margin: 1rem; margin: 1rem;
font-size: 1rem;
line-height: 1.7;
} }
.scrapbook-text h5 {
font-size: 1.35rem;
font-weight: 800;
color: #3b2a1c;
margin-bottom: 1rem;
}
.rotate-1 { .rotate-1 {
--angle: -3deg; --angle: -3deg;
} }
@ -212,12 +269,14 @@
} }
@@media (max-width: 768px) { @@media (max-width: 768px) {
.scrapbook-section { .scrapbook-section,
flex-direction: column; .scrapbook-section.flex-row-reverse {
flex-direction: column !important;
} }
.scrapbook-image { .scrapbook-image {
margin-bottom: 1rem; margin-bottom: 1rem;
transform: rotate(0deg);
} }
} }
</style> </style>

View File

@ -1,12 +1,27 @@
@model List<CatherineLynwood.Models.SoundtrackTrackModel> @model List<CatherineLynwood.Models.SoundtrackTrackModel>
@{ @{
ViewData["Title"] = "Alpha Flame • Soundtrack"; ViewData["Title"] = "Alpha Flame Reckoning • Soundtrack";
} }
<section class="container my-4" id="soundtrack"> <section class="container my-4" id="soundtrack">
<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="Reckoning" asp-action="Index">Reckoning</a></li>
<li class="breadcrumb-item"><a asp-controller="Reckoning" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active" aria-current="page">Soundtrack</li>
</ol>
</nav>
</div>
</div>
<header class="mb-4"> <header class="mb-4">
<h1 class="h2">The Alpha Flame • Soundtrack</h1> <h1 class="h2">The Alpha Flame Reckoning • Soundtrack</h1>
<p class="text-muted mb-0">Eight original tracks inspired by key chapters; listen while you read…</p> <p class="text-white mb-0">Original tracks inspired by key chapters; listen while you read…</p>
</header> </header>
<div class="row gy-4"> <div class="row gy-4">

View File

@ -0,0 +1,253 @@
<section class="chapter-reader">
<header class="chapter-reader__header">
<p class="chapter-reader__eyebrow">The Alpha Flame: Reckoning</p>
<h1 class="chapter-reader__title">Chapter 1, Echoes of the Past</h1>
<p class="chapter-reader__meta">Narrated by Maggie, 28th May 1983</p>
</header>
<div class="chapter-reader__audio">
<div class="chapter-reader__audio-card">
<div class="chapter-reader__audio-content">
<p class="chapter-reader__audio-title">🎙️Chapter Audio</p>
<p class="chapter-reader__audio-subtitle">Narrated by Maggie</p>
<audio controls preload="none" class="chapter-reader__audio-player">
<source src="/audio/the-alpha-flame-reckoning-chapter-1.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
</div>
</div>
<article class="chapter-reader__content">
<p>Beth woke with a gasp, sharp and desperate, like she’d just surfaced from drowning.</p>
<p>I was already half-awake, and I felt her body jolt from her sleep beside me, the sheets pulled tight with panic. She curled in on herself, shivering.</p>
<p>“Hey,” I whispered, rolling towards her, touching her arm gently. “It’s okay. You’re safe. I’m here. It’s me.”</p>
<p>She didn’t speak at first. Just breathed, shallow, half breaths that made her whole frame tremble.</p>
<p>I reached for the bedside lamp, blinking as the warm light spilled across the room. Her eyes were wide, unfocused, like she was still halfway trapped in whatever place she’d just come from.</p>
<p>“The dream?” I queried.</p>
<p>Beth nodded, her voice barely a breath. “It’s always the same,” she whispered.</p>
<p>I knew.</p>
<p>Mine was the same too. Rick, emerging from the dark, all teeth and rage and blood. Beth screaming somewhere behind me. My body frozen, useless. And then… always… the cold grip of his hand just as I tried to run.</p>
<p>“I can’t move,” she murmured. “In the dream, I mean. I can see him, and I know what’s coming, but I can’t… I can’t do anything.”</p>
<p>I swallowed. “I know,” I whispered, pulling her close.</p>
<p>We lay there for a moment in the quiet, the clock ticking gently on the bedside table. The window was cracked open, letting in the soft hush of early summer air. It felt wrong somehow… too peaceful.</p>
<p>“What’s the time?” asked Beth.</p>
<p>“Just gone half seven,” I replied. “Do you want a cup of tea?”</p>
<p>Beth nodded.</p>
<p>As I got up and put on my dressing gown, Beth looked thoughtful.</p>
<p>“Can you pass the memory tin?”</p>
<p>I smiled and took it out of the drawer that had become its home, handing it to her.</p>
<p>I left to make the tea, and on my return, I was greeted to the sight of the entire contents of the tin spread all over the bed.</p>
<p>“Okay…?” I said slowly, grinning at Beth as though she’d lost the plot.</p>
<p>She looked up at me chuckling.</p>
<p>“I was thinking,” she said. “The answer has to be here somewhere… If we’re to get Rick out of our heads, then we need to know why he did what he did. There’s no doubt he did it, but why…? There must be a reason. I mean… I know he’s evil.” Beth shuddered. “But he must have had a reason. He must.”</p>
<p>I thought about it for a while, considering what she’d said.</p>
<p>“I’d like to think so,” I agreed. “But what is it?” Sitting down, looking at the contents strewn all around. “You said everything in the tin was put there by our mum, right?”</p>
<p>“Yes. Pretty much. The only thing I’ve added is her death certificate. Other than that, I’ve added nothing.”</p>
<p>I glanced around the bed.</p>
<p>“Then everything must be there for a reason. If she knew anything, then there must be a clue in here somewhere.”</p>
<p>“I’ve looked through the photos so many times,” Beth replied. “But I’ve never found anything.”</p>
<p>I thought for a while.</p>
<p>“The thing is, it’s not that we don’t know the answer… It’s worse than that,” I said quietly. “We don’t even know the questions.”</p>
<p>Beth nodded, her eyes moving slowly over the contents of the bed, thinking.</p>
<p>“Well, I’d like to know why mum was murdered,” she said at last. “Not just who by. There has to be a reason.”</p>
<p>She fell silent, her gaze drifting to our mum’s wedding photo. She picked it up gently, her thumb brushing the edge.</p>
<p>“And I’d like to know if this is our dad,” she added. “He looks nice.”</p>
<p>I stared at the black and white picture. A crowd gathered outside a small stone church, confetti caught mid-air. Our mum looked radiant, no older than we were now.</p>
<p>Beth turned it over. On the back, written in biro, were the words: <span class="chapter-reader__artifact">St David’s Church, 16th June 1961</span>.</p>
<p>“Wow,” I murmured. “Mum had only just turned sixteen.”</p>
<p>I frowned at the date again. Sixteen. I knew it wasn’t unheard of back then, but it still felt… young.</p>
<p>“Maybe the church is a clue,” I said slowly.</p>
<p>A quick check of the phone book confirmed there were two churches called St David’s nearby. One in Shenley Green, the other in Highgate.</p>
<p>“It’s somewhere to start,” I said.</p>
<p>Beth nodded. “Yes. It is.”</p>
<p>I made a note of the addresses on a piece of paper.</p>
<p>Looking around the bed, for the first time I noticed a small square inconspicuous brown box.</p>
<p>“What’s that?”</p>
<p>Beth picked the box up, smiling. The lid was hinged on one side. It was an old-fashioned ring box. She opened it and held the contents up for me to extract.</p>
<p>Inside were two gold rings. The first, an engagement ring. Dainty, with a thin dark gold band, encrusted with a single diamond set in a heart shaped mount. As she held it up to the light it sparkled and glistened. I pulled the curtains open to let the sunshine in and the ring caught the sunlight, scattering it in tiny flashes across the room.</p>
<p>“It’s beautiful.”</p>
<p>“Try it on,” suggested Beth.</p>
<p>“Oh… I can’t,” I objected, a lump tightening in my throat. “It doesn’t feel right. Like it belongs to you, not me.”</p>
<p>“Why not? She was your mum too,” said Beth, softly, gentle.</p>
<p>I hesitated, my fingers trembling as I slid the ring onto my index finger, testing it, as though I had no right to wear it properly. Beth shook her head, took the ring from me and slipped it onto my wedding finger instead. The cool metal settled against my skin as if it had always been meant for me. That thought made me feel like a fraud, yet the fit was undeniable.</p>
<p>Beth smiled, a glint of pride in her eyes.</p>
<p>“It’s a little loose on me,” she said. “You’ve got fatter fingers,” she added with a laugh, grounding the moment.</p>
<p>I laughed too, grateful for the release. “You’re catching me up. It’s all those sausage sandwiches.”</p>
<p>She laughed again and reached for the other ring in the box.</p>
<p>The second ring was a simple gold band, a wedding ring. Once more, quite dainty, certainly designed for a woman. As I inspected it, I noticed an engraving along the inside of the ring, <span class="chapter-reader__artifact">‘Love is Flame.’</span></p>
<blockquote class="chapter-reader__poem">
<p>
One came with thunder, one with flame,<br>
The years between, not quite the same.<br>
From me to you, two paths unfold,<br>
One marked in silver, one in gold.
</p>
<p>
Some things we carry, some things we hear,<br>
A whisper of truth through crackle and fear.<br>
Where music still plays and memories stay,<br>
Look not at the sound, but what lies in its sway.
</p>
<p>
The key you seek lies not in this rhyme,<br>
But inked where the ledger remembers time.<br>
Letters may hide in numbers proud,<br>
But not every sum needs naming aloud.
</p>
<p>
If ever you're lost or chasing a thread,<br>
Let this guide you to what lies ahead.<br>
The second will guide you, the first may mislead,<br>
Count not the years, but the time in between.
</p>
</blockquote>
<p>“That’s beautiful,” I said.</p>
<p>Beth smiled, that lovely warm smile she gave when remembering her most precious moments. Then, as I watched, her face changed.</p>
<p>“Oh my God!” she exclaimed. “I’ve just realised, that poem… it’s about us!”</p>
<p>I looked at the piece of paper again.</p>
<p>“That first bit, it’s you and me,” said Beth, her voice full of excitement. “It’s only just dawned on me. I’ve read that so many times, but I never knew,” a huge smile lighting up her face.</p>
<p>She took the piece of paper off me carefully and read it once more.</p>
<p>“I’ve no idea what the rest of it means, but now I love it even more.”</p>
<p>We drank our tea, and Beth tidied up the bed, carefully putting everything back into the tin and placing it lovingly back in the drawer.</p>
<p>“Listen,” I said. “How about we get it framed. Then we can put it on the wall and see it every day?”</p>
<p>Beth smiled, but not fully.</p>
<p>“Not a good idea?” I queried.</p>
<p>Beth’s smile widened a little, though it didn’t quite reach her eyes.</p>
<p>“Yes… I’d like that.”</p>
<p>“But…?”</p>
<p>“It doesn’t feel right taking it out of the tin,” she insisted.</p>
<p>I nodded. I understood entirely. This tin had been Beth’s life, her salvation, the one safe place she’d ever had. Splitting it up wouldn’t feel right.</p>
<p>“Well, how about we get some nice paper and write it out, and frame that?” I suggested. “That way we can see the words every day but not disturb the tin.”</p>
<p>Beth’s eyes brightened. “Yes. I like that.”</p>
<p>After a quick chat we decided to go down the village and buy a frame. We dressed and went through to the living room. Mum was surprised to see us.</p>
<p>“Oh my word. What on earth are you two doing up at this time of day, let alone dressed?”</p>
<p>We told her our plans as we put our shoes on.</p>
<p>It wasn’t long before we were in the village, walking along New Road, the main street lined with rows of shops on either side. Even though it was early, there were shoppers everywhere.</p>
<p>We stopped outside Roy’s Toys.</p>
<p>“I used to love going in there as a kid,” said Beth.</p>
<p>I stared at her, surprised. “Me too. Every Saturday nearly. How did we never meet?”</p>
<p>Beth grinned. “Come on, let’s go in.”</p>
<p>The bell above the door jingled as we stepped inside. The air smelled faintly of dust and cardboard, the same as it had when I was little.</p>
<p>“I always wanted one of these,” Beth said, lifting a boxed train set from the shelf.</p>
<p>“Same here,” I laughed. “Mum always said they were for boys.”</p>
<p>We lingered over a display of miniature fashion dolls, each wearing bright, slightly tacky dresses.</p>
<p>“You’d have redesigned all of these,” Beth teased.</p>
<p>“Of course. They’re a crime against fashion.”</p>
<p>When we stepped back out into the sunlight, still smiling, we strolled on.</p>
<p>Two doors down, we passed the only vacant shop on the street.</p>
<p>“Shame,” I murmured, peering in. “It’s been empty for a couple of years now. I always thought it would make a great dress shop… sell my designs and a few other bits.”</p>
<p>We passed the Village Kitchen café, and the air changed instantly. The comforting, unmistakable smell of proper breakfasts rolled out every time the door opened, bacon, coffee, toast and something buttery I couldn’t quite place.</p>
<p>We moved on to the framing shop, its windows full of neatly displayed pictures in every size and style. Inside, the smell of fresh-cut wood and paper hung in the air.</p>
<p>Eventually we chose one with a warm oak finish that would suit the poem perfectly.</p>
<p>“They’ve got art papers too,” Beth pointed out, leading me to a rack where thick sheets in every shade and texture were stacked.</p>
<p>At the counter, the woman wrapped our purchases in brown paper and string.</p>
<p>We made our way back home slowly, the bag swinging between us, talking idly about where the poem might hang, and what colour ink would look best against the cream paper.</p>
<p class="chapter-reader__closing">Little did I know how significant this morning would be.</p>
</article>
</section>

View File

@ -16,25 +16,6 @@
@RenderSection("BackgroundVideo", required: false) @RenderSection("BackgroundVideo", required: false)
} }
} }
else
{
@section BackgroundVideo {
<div id="background-wrapper">
<div class="video-background">
<video id="siteBackgroundVideo"
autoplay
muted
loop
playsinline
preload="none">
<!-- Source will be injected by JS -->
</video>
<div class="video-overlay"></div>
</div>
</div>
}
}
@RenderBody() @RenderBody()
@section Scripts { @section Scripts {

View File

@ -0,0 +1,3 @@
@model CatherineLynwood.Models.TitlePageViewModel
<partial name="_Chapter1" />

View File

@ -0,0 +1,47 @@
@model CatherineLynwood.Models.TitlePageViewModel
<h2>Map Reference</h2>
<p class="casefile-evidence-intro">
Evidence and events occured in the locations shown below.
</p>
<div class="casefile-evidence-list">
<section class="casefile-evidence-item">
<div class="casefile-evidence-item-header">
<div class="casefile-evidence-item-id">Rubery Area</div>
</div>
<div class="casefile-evidence-media bg-white">
<responsive-image src="rubery-map.png"
class="casefile-evidence-image"
alt="A not to scale map of Rubery and the surrounding area."
display-width-percentage="50"></responsive-image>
</div>
<p>
Although much of the observed activity appears to have occured in the Rubery area, there is evidence to suggest activity on Solihull and Birmingham.
</p>
<p>
Officers continue to keep surveilance on all areas of concern.
</p>
</section>
<section class="casefile-evidence-item">
<div class="casefile-evidence-item-header">
<div class="casefile-evidence-item-id">Barmouth / Harlech Area</div>
</div>
<div class="casefile-evidence-paperclip bg-white">
<responsive-image src="wales-map.png"
class="casefile-evidence-image"
alt="A not to scale map of Barmouth and the surrounding area."
display-width-percentage="50"></responsive-image>
</div>
<p>
The Barmouth police have notified us of certain events that occured around Barmouth and the surrounding areas.
</p>
</section>
</div>

View File

@ -43,7 +43,7 @@
} }
<form asp-action="Comment" class="row" id="comment" onsubmit="disableSubmit(this)"> <form asp-action="Comment" class="row cl-comment-form" id="comment" onsubmit="disableSubmit(this)">
<div class="col-12 pb-3"> <div class="col-12 pb-3">
<h2>Leave a Comment</h2> <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. 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.

View File

@ -1,4 +1,22 @@
<!DOCTYPE html> @{
var requestPath = Context.Request.Path.Value?.ToLowerInvariant() ?? "/";
var pageTone = requestPath.Contains("/the-alpha-flame/discovery")
? "cl-page-discovery"
: requestPath.Contains("/reckoning")
? "cl-page-reckoning"
: requestPath.Contains("/indie-author")
? "cl-page-guide"
: requestPath.Contains("/the-alpha-flame/blog")
? "cl-page-journal"
: requestPath.Contains("/about-catherine-lynwood")
? "cl-page-about"
: requestPath.Contains("/verostic-genre")
? "cl-page-verostic"
: requestPath == "/" || requestPath == ""
? "cl-page-home"
: "cl-page-general";
}
<!DOCTYPE html>
<html lang="en-GB"> <html lang="en-GB">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -43,9 +61,9 @@
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png"> <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<meta name="msapplication-TileColor" content="#17C8EB"> <meta name="msapplication-TileColor" content="#4B243A">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png"> <meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#17C8EB"> <meta name="theme-color" content="#181514">
@ -57,7 +75,7 @@
@RenderSection("Meta", required: false) @RenderSection("Meta", required: false)
</head> </head>
<body class="bg-primary text-white"> <body class="cl-site-shell @pageTone bg-primary text-white">
@if (IsSectionDefined("BackgroundVideo")) @if (IsSectionDefined("BackgroundVideo"))
{ {
@RenderSection("BackgroundVideo", required: false) @RenderSection("BackgroundVideo", required: false)
@ -76,7 +94,7 @@
<div class="content"> <div class="content">
<!-- Content wrapper to keep everything on top of the background --> <!-- Content wrapper to keep everything on top of the background -->
<header> <header>
<nav class="navbar fixed-top navbar-expand-sm navbar-toggleable-sm navbar-dark border-bottom border-2 border-primary box-shadow mb-3 bg-dark"> <nav class="navbar fixed-top navbar-expand-sm navbar-toggleable-sm navbar-dark border-bottom border-2 border-primary box-shadow mb-3 bg-dark cl-navbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand pe-0 me-0" asp-area="" asp-controller="Home" asp-action="Index"> <a class="navbar-brand pe-0 me-0" asp-area="" asp-controller="Home" asp-action="Index">
<img src="~/images/catherine-lynwood-banner-logo-small.png" alt="Catherine Lynwood Logo Banner" class="img-fluid" style="height: 45px; width: 265px;" /> <img src="~/images/catherine-lynwood-banner-logo-small.png" alt="Catherine Lynwood Logo Banner" class="img-fluid" style="height: 45px; width: 265px;" />
@ -145,7 +163,7 @@
</div> </div>
</nav> </nav>
</header> </header>
<div class="container-lg" style="margin-top: 70px;"> <div class="container-lg cl-main-container" style="margin-top: 70px;">
<main role="main" class="p-0"> <main role="main" class="p-0">
@RenderBody() @RenderBody()
</main> </main>
@ -158,7 +176,7 @@
</svg> </svg>
</div> </div>
</div> <!-- End of content wrapper --> </div> <!-- End of content wrapper -->
<footer class="border-top border-2 border-primary footer bg-dark py-3" id="site-footer"> <footer class="border-top border-2 border-primary footer bg-dark py-3 cl-footer" id="site-footer">
<div class="container"> <div class="container">
<div class="row justify-content-center align-items-center" > <div class="row justify-content-center align-items-center" >
<div class="col-md-6 text-center order-md-2"> <div class="col-md-6 text-center order-md-2">
@ -227,74 +245,6 @@
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
<script>
(function () {
const mediaMap = {
discovery: {
video: "/videos/background-discovery.mp4",
poster: "/images/webp/the-alpha-flame-discovery-blank-400.webp"
},
reckoning: {
video: "/videos/background-reckoning.mp4",
poster: "/images/webp/the-alpha-flame-reckoning-blank-400.webp"
},
redemption: {
video: "/videos/background-redemption.mp4",
poster: "/images/webp/the-alpha-flame-redemption-blank-400.webp"
},
default: {
video: "/videos/background-reckoning.mp4",
poster: "/images/webp/the-alpha-flame-reckoning-blank-400.webp"
}
};
function pickMedia(pathname) {
const path = (pathname || "/").toLowerCase();
if (path.includes("/discovery")) return mediaMap.discovery;
if (path.includes("/reckoning")) return mediaMap.reckoning;
if (path.includes("/redemption")) return mediaMap.redemption;
return mediaMap.default;
}
function initBackgroundVideo() {
const video = document.getElementById("siteBackgroundVideo");
if (!video) return;
const chosen = pickMedia(window.location.pathname);
// Stop any eager autoplay behaviour while we swap assets
try { video.pause(); } catch {}
// Ensure poster matches route BEFORE loading anything
video.poster = chosen.poster;
// Clear any existing sources to avoid weird caching/duplication issues
video.querySelectorAll("source").forEach(s => s.remove());
const source = document.createElement("source");
source.src = chosen.video;
source.type = "video/mp4";
video.appendChild(source);
// Now load and play
video.load();
video.play().catch(err => {
console.warn("Background video autoplay failed:", err);
});
}
// Run ASAP once the DOM exists
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initBackgroundVideo);
} else {
initBackgroundVideo();
}
})();
</script>
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', function () { window.addEventListener('load', function () {

View File

@ -2,36 +2,10 @@
Layout = "/Views/Shared/_Layout.cshtml"; Layout = "/Views/Shared/_Layout.cshtml";
} }
@section CSS{
<style>
.video-overlay {
background-color: rgba(13, 202, 240, 0.2);
}
</style>
}
@section Meta { @section Meta {
@RenderSection("Meta", required: false) @RenderSection("Meta", required: false)
} }
@section BackgroundVideo {
<div id="background-wrapper">
<div class="video-background">
<video id="siteBackgroundVideo"
autoplay
muted
loop
playsinline
preload="none">
<!-- Source will be injected by JS -->
</video>
<div class="video-overlay"></div>
</div>
</div>
}
@RenderBody() @RenderBody()
@section Scripts { @section Scripts {

View File

@ -6,22 +6,6 @@
@RenderSection("Meta", required: false) @RenderSection("Meta", required: false)
} }
@section BackgroundVideo {
<div id="background-wrapper">
<div class="video-background">
<video id="siteBackgroundVideo"
autoplay
muted
loop
playsinline
preload="none">
<!-- Source will be injected by JS -->
</video>
<div class="video-overlay"></div>
</div>
</div>
}
@RenderBody() @RenderBody()
@section Scripts { @section Scripts {

View File

@ -33,18 +33,19 @@
</div> </div>
<div class="row my-4"> <div class="row my-4">
<div class="col-12 text-center"> <div class="col-12 text-center cl-editorial-hero">
<p class="cl-kicker mb-2">Author journal</p>
<h1 class="display-4"> <h1 class="display-4">
<i class="fad fa-coffee" style="--fa-primary-color: #ffffff; --fa-secondary-color: #042830;"></i> <i class="fad fa-coffee" style="--fa-primary-color: #ffffff; --fa-secondary-color: #042830;"></i>
A Cuppa With Catherine A Cuppa With Catherine
</h1> </h1>
<p class="lead mt-3 px-3"> <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. Pop the kettle on and settle in for truthful notes from the writing desk: book news, behind-the-scenes reflections, and the lived-in worlds Catherine brings to life.
</p> </p>
</div> </div>
</div> </div>
<form 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 cl-journal-filter">
<div class="col-12 pt-3"> <div class="col-12 pt-3">
<div class="row justify-content-center"> <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="col-md-2 col-md-10 col-lg-9 col-xl-8 col-xxl-7">
@ -149,7 +150,7 @@ else
@if (!string.IsNullOrEmpty(Model.NextBlog.Title)) @if (!string.IsNullOrEmpty(Model.NextBlog.Title))
{ {
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="card h-100 shadow-lg border-3 border-dark"> <div class="card h-100 shadow-lg border-3 border-dark cl-feature-card">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-1"> <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> <responsive-image src="@Model.NextBlog.ImageUrl" class="img-fluid rounded-start-3" alt="@Model.NextBlog.ImageAlt" display-width-percentage="30"></responsive-image>
@ -169,7 +170,7 @@ else
{ {
<div class="col-sm-6 col-md-4 mb-4 d-flex"> <div class="col-sm-6 col-md-4 mb-4 d-flex">
<div class="card shadow-sm"> <div class="card shadow-sm cl-feature-card">
<div class="card-header text-center"> <div class="card-header text-center">
<h2 class="h5 card-title">@item.Title</h2> <h2 class="h5 card-title">@item.Title</h2>
<small class="text-muted">@item.SubTitle</small> <small class="text-muted">@item.SubTitle</small>
@ -208,7 +209,7 @@ else
} }
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="card shadow-sm"> <div class="card shadow-sm cl-feature-card">
<div class="card-body"> <div class="card-body">
<p class="h5">Got an idea for a future blog post?</p> <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> <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>

View File

@ -54,8 +54,6 @@
<div class="col-12"> <div class="col-12">
@if (!string.IsNullOrWhiteSpace(Model.ImageUrl) && !string.IsNullOrWhiteSpace(Model.ContentTop)) @if (!string.IsNullOrWhiteSpace(Model.ImageUrl) && !string.IsNullOrWhiteSpace(Model.ContentTop))
{ {
var posterImage = $"/images/webp/{Model.DefaultWebpImage}";
<responsive-image src="@Model.ImageUrl" <responsive-image src="@Model.ImageUrl"
class="d-none" class="d-none"
alt="The Alpha Flame: Discovery by Catherine Lynwood" alt="The Alpha Flame: Discovery by Catherine Lynwood"
@ -74,12 +72,10 @@
} }
else else
{ {
<div class="hero-video-container"> <figure>
<video id="heroVideo" autoplay muted loop playsinline preload="none" poster="@posterImage"> <responsive-image src="@Model.ImageUrl" alt="@Model.ImageAlt" class="img-fluid rounded-5 border border-3 border-dark shadow-lg" display-width-percentage="50"></responsive-image>
<!-- Source will be injected later --> <figcaption>@Html.Raw(Model.ImageDescription)</figcaption>
</video> </figure>
</div>
@Html.Raw(Model.ImageDescription)
} }
</div> </div>
@ -96,12 +92,10 @@
} }
else else
{ {
<div class="hero-video-container"> <figure>
<video id="heroVideo" autoplay muted loop playsinline preload="none" poster="@posterImage"> <responsive-image src="@Model.ImageUrl" alt="@Model.ImageAlt" class="img-fluid rounded-5 border border-3 border-dark shadow-lg" display-width-percentage="50"></responsive-image>
<!-- Source will be injected later --> <figcaption>@Html.Raw(Model.ImageDescription)</figcaption>
</video> </figure>
</div>
@Html.Raw(Model.ImageDescription)
} }
</div> </div>
@ -166,25 +160,6 @@
const player = new Plyr('audio'); const player = new Plyr('audio');
</script> </script>
@if (!string.IsNullOrEmpty(Model.VideoUrl))
{
<script>
window.addEventListener("load", () => {
setTimeout(() => {
const video = document.getElementById("heroVideo");
const source = document.createElement("source");
source.src = "/videos/@Model.VideoUrl";
source.type = "video/mp4";
video.appendChild(source);
video.load(); // Triggers actual file load
video.play().catch(err => {
console.warn("Autoplay failed:", err);
});
}, 2000); // Adjust delay (e.g., 1000–3000ms) based on your needs
});
</script>
}
<script> <script>
function disableSubmit(form) { function disableSubmit(form) {
const button = form.querySelector('button[type="submit"]'); const button = form.querySelector('button[type="submit"]');
@ -209,12 +184,9 @@
og-site-name="Catherine Lynwood - The Alpha Flame" og-site-name="Catherine Lynwood - The Alpha Flame"
article-published-time="@Model.PublishDate" article-published-time="@Model.PublishDate"
article-modified-time="@DateTime.UtcNow" article-modified-time="@DateTime.UtcNow"
twitter-card-type="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? "summary_large_image" : "player")" twitter-card-type="summary_large_image"
twitter-site-handle="@@CathLynwood" twitter-site-handle="@@CathLynwood"
twitter-creator-handle="@@CathLynwood" twitter-creator-handle="@@CathLynwood" />
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)" />
<script type="application/ld+json"> <script type="application/ld+json">

View File

@ -14,7 +14,8 @@
</div> </div>
<div class="container py-2"> <div class="container py-2">
<div class="text-center mb-5"> <div class="text-center mb-5 cl-editorial-hero">
<p class="cl-kicker mb-2">A Verostic trilogy</p>
<h1 class="display-5 fw-bold">The Alpha Flame Trilogy</h1> <h1 class="display-5 fw-bold">The Alpha Flame Trilogy</h1>
<p class="lead"> <p class="lead">
A powerful series of love, trauma, discovery and transformation. A powerful series of love, trauma, discovery and transformation.
@ -26,7 +27,7 @@
<!-- Book 1: Full-width Featured --> <!-- Book 1: Full-width Featured -->
<div class="row mb-5"> <div class="row mb-5">
<div class="col-12"> <div class="col-12">
<div class="card h-100 shadow-lg border-3 border-dark"> <div class="card h-100 shadow-lg border-3 border-dark cl-feature-card">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-3 d-none d-md-block border-end border-dark border-3"> <div class="col-md-3 d-none d-md-block border-end border-dark border-3">
<a asp-controller="Discovery" asp-action="Index"> <a asp-controller="Discovery" asp-action="Index">
@ -69,7 +70,7 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card h-100 shadow-sm border-start border-3 border-dark position-relative"> <div class="card h-100 shadow-sm border-start border-3 border-dark position-relative cl-feature-card">
<span class="badge bg-warning text-dark position-absolute top-0 start-0 m-2">Coming 1st April 2026</span> <span class="badge bg-warning text-dark position-absolute top-0 start-0 m-2">Coming 1st April 2026</span>
<div class="row g-0"> <div class="row g-0">
<div class="col-md-3 d-none d-md-block border-end border-dark border-3"> <div class="col-md-3 d-none d-md-block border-end border-dark border-3">
@ -116,7 +117,7 @@
@* @*
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card h-100 shadow-sm border-start border-3 border-dark position-relative"> <div class="card h-100 shadow-sm border-start border-3 border-dark position-relative cl-feature-card">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-8 d-none d-md-block"> <div class="col-md-8 d-none d-md-block">
<a asp-action="Characters"> <a asp-action="Characters">

View File

@ -147,24 +147,6 @@
const player = new Plyr('audio'); const player = new Plyr('audio');
</script> </script>
@if (!string.IsNullOrEmpty(Model.VideoUrl))
{
<script>
window.addEventListener("load", () => {
setTimeout(() => {
const video = document.getElementById("heroVideo");
const source = document.createElement("source");
source.src = "/videos/@Model.VideoUrl";
source.type = "video/mp4";
video.appendChild(source);
video.load(); // Triggers actual file load
video.play().catch(err => {
console.warn("Autoplay failed:", err);
});
}, 2000); // Adjust delay (e.g., 1000–3000ms) based on your needs
});
</script>
}
} }
@section Meta { @section Meta {
@ -178,12 +160,9 @@
og-site-name="Catherine Lynwood - The Alpha Flame" og-site-name="Catherine Lynwood - The Alpha Flame"
article-published-time="@Model.PublishDate" article-published-time="@Model.PublishDate"
article-modified-time="@DateTime.UtcNow" article-modified-time="@DateTime.UtcNow"
twitter-card-type="@(string.IsNullOrWhiteSpace(Model.VideoUrl) ? "summary_large_image" : "player")" twitter-card-type="summary_large_image"
twitter-site-handle="@@CathLynwood" twitter-site-handle="@@CathLynwood"
twitter-creator-handle="@@CathLynwood" twitter-creator-handle="@@CathLynwood" />
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)" />
<script type="application/ld+json"> <script type="application/ld+json">

View File

@ -1,11 +1,11 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=CatherineLynwood;User Id=web;Password=ryaN9982?;MultipleActiveResultSets=true;Encrypt=false;TrustServerCertificate=true;" "DefaultConnection": "Server=localhost;Database=CatherineLynwood;User Id=catherine;Password=ryaN9982?;MultipleActiveResultSets=true;Encrypt=false;TrustServerCertificate=true;"
}, },
"Smtp": { "Smtp": {
"Host": "smtpout.secureserver.net", "Host": "smtpout.secureserver.net",
"Port": "587", "Port": "587",
"Sender": "your-email@catherinelynwood.com", "Sender": "catherine@catherinelynwood.com",
"Username": "catherine@catherinelynwood.com", "Username": "catherine@catherinelynwood.com",
"Password": "ryaN9982?" "Password": "ryaN9982?"
}, },

View File

@ -0,0 +1,617 @@
:root {
--calendar-ink: #f3ead7;
--calendar-muted: #c7bda7;
--calendar-paper: #eadfc8;
--calendar-brass: #c79a4d;
--calendar-blood: #7e2f2a;
--calendar-moss: #51604a;
--calendar-night: #11150f;
--calendar-black: #070806;
}
* {
box-sizing: border-box;
}
.calendar-house-body {
margin: 0;
color: var(--calendar-ink);
background:
radial-gradient(circle at 15% 15%, rgba(126, 47, 42, 0.18), transparent 28rem),
linear-gradient(135deg, #10150f 0%, #171611 45%, #070806 100%);
font-family: Georgia, "Times New Roman", serif;
min-height: 100vh;
}
.calendar-house-body a {
color: inherit;
}
.calendar-house-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem clamp(1rem, 3vw, 2.5rem);
background: rgba(7, 8, 6, 0.88);
border-bottom: 1px solid rgba(199, 154, 77, 0.35);
backdrop-filter: blur(16px);
}
.calendar-house-brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.08rem;
font-size: 0.9rem;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 2.4rem;
height: 2.4rem;
border: 1px solid var(--calendar-brass);
color: var(--calendar-brass);
}
.calendar-house-nav {
display: flex;
align-items: center;
gap: clamp(0.7rem, 2vw, 1.4rem);
font-family: Arial, sans-serif;
font-size: 0.88rem;
}
.calendar-house-nav a {
color: var(--calendar-muted);
text-decoration: none;
}
.calendar-house-nav a:hover,
.calendar-house-nav a:focus {
color: var(--calendar-ink);
}
.calendar-hero {
position: relative;
min-height: min(860px, calc(100vh - 3.9rem));
display: grid;
align-items: end;
overflow: hidden;
border-bottom: 1px solid rgba(199, 154, 77, 0.28);
}
.calendar-hero-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.calendar-hero-shade {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(7, 8, 6, 0.9), rgba(7, 8, 6, 0.35) 52%, rgba(7, 8, 6, 0.78)),
linear-gradient(0deg, rgba(7, 8, 6, 0.96), transparent 58%);
}
.calendar-hero-content {
position: relative;
z-index: 2;
width: min(760px, 100%);
padding: clamp(7rem, 13vw, 12rem) clamp(1rem, 6vw, 5rem) clamp(7rem, 12vw, 10rem);
}
.calendar-kicker {
margin: 0 0 0.75rem;
color: var(--calendar-brass);
font-family: Arial, sans-serif;
text-transform: uppercase;
letter-spacing: 0.16rem;
font-size: 0.78rem;
}
.calendar-hero h1,
.calendar-page-hero h1 {
margin: 0;
max-width: 12ch;
font-size: clamp(3.6rem, 12vw, 9rem);
line-height: 0.86;
font-weight: 500;
}
.calendar-hero-copy,
.calendar-page-hero p {
max-width: 42rem;
color: var(--calendar-muted);
font-size: clamp(1.05rem, 2vw, 1.35rem);
line-height: 1.6;
}
.calendar-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 2rem;
}
.calendar-button {
display: inline-flex;
align-items: center;
gap: 0.6rem;
min-height: 3rem;
padding: 0.8rem 1rem;
background: var(--calendar-brass);
color: var(--calendar-black);
border: 1px solid var(--calendar-brass);
text-decoration: none;
font-family: Arial, sans-serif;
font-weight: 700;
}
.calendar-button:hover,
.calendar-button:focus {
color: var(--calendar-black);
background: #d8b06c;
}
.calendar-button-ghost {
color: var(--calendar-ink);
background: rgba(7, 8, 6, 0.52);
}
.calendar-button-ghost:hover,
.calendar-button-ghost:focus {
color: var(--calendar-ink);
background: rgba(81, 96, 74, 0.7);
}
.calendar-hero-numbers {
position: absolute;
right: clamp(1rem, 4vw, 3rem);
bottom: 1.25rem;
z-index: 3;
display: grid;
grid-template-columns: repeat(4, minmax(5.5rem, 1fr));
gap: 0.6rem;
width: min(620px, calc(100% - 2rem));
}
.calendar-hero-numbers span,
.calendar-house-stats div {
min-height: 5rem;
padding: 0.75rem;
background: rgba(7, 8, 6, 0.72);
border: 1px solid rgba(199, 154, 77, 0.38);
color: var(--calendar-muted);
font-family: Arial, sans-serif;
text-transform: uppercase;
font-size: 0.72rem;
}
.calendar-hero-numbers strong,
.calendar-house-stats strong {
display: block;
color: var(--calendar-ink);
font-family: Georgia, "Times New Roman", serif;
font-size: 2rem;
line-height: 1;
font-weight: 500;
}
.calendar-section,
.calendar-page-hero {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
}
.calendar-section {
padding: clamp(3rem, 7vw, 6rem) 0;
}
.calendar-page-hero {
padding: clamp(6rem, 10vw, 9rem) 0 clamp(2.5rem, 6vw, 5rem);
}
.calendar-page-hero h1 {
max-width: 13ch;
font-size: clamp(3rem, 8vw, 7rem);
}
.calendar-intro-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.7fr);
gap: clamp(2rem, 6vw, 5rem);
align-items: center;
}
.calendar-intro-grid h2,
.calendar-note h2,
.calendar-extract-card h2 {
margin: 0 0 1rem;
font-size: clamp(2rem, 5vw, 4rem);
line-height: 1;
}
.calendar-intro-grid p,
.calendar-note p,
.pattern-grid p,
.calendar-extract-card p {
color: var(--calendar-muted);
font-size: 1.08rem;
line-height: 1.75;
}
.calendar-rule-panel,
.calendar-extract-card {
padding: clamp(1rem, 4vw, 2rem);
color: #251c12;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.18), transparent),
var(--calendar-paper);
box-shadow: 0 1.2rem 4rem rgba(0, 0, 0, 0.42);
}
.rule-line {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid rgba(37, 28, 18, 0.24);
font-family: Arial, sans-serif;
}
.rule-line:last-child {
border-bottom: 0;
}
.rule-line span {
text-transform: uppercase;
font-size: 0.78rem;
letter-spacing: 0.08rem;
}
.rule-line b {
text-align: right;
}
.calendar-mood-board,
.pattern-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.calendar-mood-board article,
.pattern-grid article,
.calendar-note,
.plan-board {
padding: clamp(1.2rem, 3vw, 2rem);
background: rgba(7, 8, 6, 0.45);
border: 1px solid rgba(199, 154, 77, 0.25);
}
.calendar-mood-board i {
color: var(--calendar-brass);
font-size: 2rem;
}
.calendar-mood-board h3,
.pattern-grid h2 {
margin: 1rem 0 0.6rem;
font-size: 1.45rem;
}
.calendar-mood-board p {
color: var(--calendar-muted);
line-height: 1.65;
}
.calendar-blurb {
width: min(820px, calc(100% - 2rem));
text-align: center;
}
.calendar-blurb p {
margin: 0.45rem 0;
font-size: clamp(1.35rem, 3vw, 2.4rem);
line-height: 1.25;
}
.calendar-house-stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.75rem;
padding-top: 0;
}
.calendar-house-stats div {
text-align: center;
}
.plan-board {
padding-top: 1.4rem;
}
.plan-controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1.4rem;
}
.plan-filter {
min-height: 2.6rem;
padding: 0.55rem 0.85rem;
color: var(--calendar-ink);
background: rgba(7, 8, 6, 0.7);
border: 1px solid rgba(199, 154, 77, 0.32);
font-family: Arial, sans-serif;
}
.plan-filter.active {
color: var(--calendar-black);
background: var(--calendar-brass);
}
.house-diagram {
position: relative;
display: grid;
grid-template-columns: 1fr 1.3fr 1fr;
grid-template-rows: 9rem 9rem 9rem;
gap: 0.8rem;
min-height: 29rem;
margin-bottom: 1.5rem;
}
.wing,
.central-hall {
display: grid;
place-items: center;
padding: 1rem;
border: 1px solid rgba(234, 223, 200, 0.25);
background: rgba(234, 223, 200, 0.08);
color: var(--calendar-muted);
font-family: Arial, sans-serif;
text-transform: uppercase;
letter-spacing: 0.08rem;
}
.wing-spring {
grid-column: 2;
grid-row: 1;
background: rgba(81, 96, 74, 0.32);
}
.wing-summer {
grid-column: 3;
grid-row: 2;
background: rgba(199, 154, 77, 0.2);
}
.wing-autumn {
grid-column: 1;
grid-row: 2;
background: rgba(126, 47, 42, 0.3);
}
.wing-winter {
grid-column: 2;
grid-row: 3;
background: rgba(108, 122, 128, 0.22);
transform: translateX(1.4rem);
}
.central-hall {
grid-column: 2;
grid-row: 2;
color: var(--calendar-ink);
border-color: var(--calendar-brass);
background: rgba(7, 8, 6, 0.72);
}
.chimneys,
.doors {
position: absolute;
inset: 0;
pointer-events: none;
}
.chimneys span,
.doors span {
position: absolute;
display: block;
background: var(--calendar-brass);
}
.chimneys span {
width: 0.55rem;
height: 2rem;
top: -0.4rem;
}
.chimneys span:nth-child(1) { left: 12%; }
.chimneys span:nth-child(2) { left: 19%; }
.chimneys span:nth-child(3) { left: 28%; }
.chimneys span:nth-child(4) { left: 36%; }
.chimneys span:nth-child(5) { left: 43%; }
.chimneys span:nth-child(6) { left: 51%; }
.chimneys span:nth-child(7) { left: 58%; }
.chimneys span:nth-child(8) { left: 65%; }
.chimneys span:nth-child(9) { left: 73%; }
.chimneys span:nth-child(10) { left: 81%; }
.chimneys span:nth-child(11) { left: 88%; }
.chimneys span:nth-child(12) { left: 94%; }
.doors span {
width: 1.8rem;
height: 0.38rem;
bottom: 0;
background: var(--calendar-ink);
}
.doors span:nth-child(1) { left: 9%; }
.doors span:nth-child(2) { left: 22%; }
.doors span:nth-child(3) { left: 36%; }
.doors span:nth-child(4) { left: 50%; }
.doors span:nth-child(5) { left: 63%; }
.doors span:nth-child(6) { left: 76%; }
.doors span:nth-child(7) { left: 90%; }
.room-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
}
.room-tile {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 0.65rem;
min-height: 6.2rem;
padding: 0.75rem;
color: var(--calendar-ink);
background: rgba(17, 21, 15, 0.92);
border: 1px solid rgba(199, 154, 77, 0.22);
text-align: left;
}
.room-tile:hover,
.room-tile:focus {
border-color: var(--calendar-brass);
transform: translateY(-2px);
}
.room-tile span {
grid-row: span 2;
color: var(--calendar-brass);
font-size: 1.4rem;
}
.room-tile strong {
font-weight: 500;
}
.room-tile em {
color: var(--calendar-muted);
font-family: Arial, sans-serif;
font-size: 0.78rem;
font-style: normal;
}
.room-tile.is-hidden {
display: none;
}
.pattern-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.pattern-grid article span {
color: var(--calendar-brass);
font-size: 2rem;
}
.calendar-extract-card {
width: min(820px, calc(100% - 2rem));
}
.calendar-extract-card p {
margin: 0.4rem 0;
color: #251c12;
font-size: clamp(1.2rem, 2.4vw, 2rem);
}
.calendar-house-footer {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem clamp(1rem, 3vw, 2.5rem);
color: var(--calendar-muted);
border-top: 1px solid rgba(199, 154, 77, 0.28);
background: rgba(7, 8, 6, 0.72);
font-family: Arial, sans-serif;
}
.calendar-house-footer div {
display: grid;
gap: 0.2rem;
}
@media (max-width: 980px) {
.calendar-intro-grid,
.calendar-mood-board,
.pattern-grid {
grid-template-columns: 1fr;
}
.calendar-house-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.room-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.calendar-house-header,
.calendar-house-footer {
align-items: flex-start;
flex-direction: column;
}
.calendar-house-nav {
flex-wrap: wrap;
}
.calendar-hero {
min-height: 760px;
}
.calendar-hero-content {
padding-top: 5rem;
padding-bottom: 12rem;
}
.calendar-hero-numbers {
grid-template-columns: repeat(2, minmax(0, 1fr));
left: 1rem;
right: 1rem;
width: auto;
}
.calendar-house-stats,
.room-grid {
grid-template-columns: 1fr;
}
.house-diagram {
grid-template-columns: 1fr;
grid-template-rows: repeat(5, 6.5rem);
min-height: auto;
}
.wing-spring,
.wing-summer,
.wing-autumn,
.wing-winter,
.central-hall {
grid-column: 1;
transform: none;
}
.wing-spring { grid-row: 1; }
.wing-summer { grid-row: 2; }
.central-hall { grid-row: 3; }
.wing-autumn { grid-row: 4; }
.wing-winter { grid-row: 5; }
}

View File

@ -221,6 +221,10 @@
background: #e0b0b0; background: #e0b0b0;
} }
.reckoning-tab-maps {
background: #d6cca0; /* slightly more beige */
}
.reckoning-mobile-paper { .reckoning-mobile-paper {
background: #f7f1e4; background: #f7f1e4;
border: 1px solid rgba(90, 66, 42, 0.16); border: 1px solid rgba(90, 66, 42, 0.16);

View File

@ -463,31 +463,46 @@
.casefile-tab:nth-child(1) { .casefile-tab:nth-child(1) {
background: #f6d96b; background: #f6d96b;
z-index: 6; z-index: 9;
} }
.casefile-tab:nth-child(2) { .casefile-tab:nth-child(2) {
background: #f4a6c1; background: #f4a6c1;
z-index: 5; z-index: 8;
} }
.casefile-tab:nth-child(3) { .casefile-tab:nth-child(3) {
background: #9ed8f5; background: #9ed8f5;
z-index: 4; z-index: 7;
} }
.casefile-tab:nth-child(4) { .casefile-tab:nth-child(4) {
background: #b9e3a1; background: #b9e3a1;
z-index: 3; z-index: 6;
} }
.casefile-tab:nth-child(5) { .casefile-tab:nth-child(5) {
background: #f7b26a; background: #f7b26a;
z-index: 2; z-index: 5;
} }
.casefile-tab:nth-child(6) { .casefile-tab:nth-child(6) {
background: #c7b5f0; background: #c7b5f0;
z-index: 4;
}
.casefile-tab:nth-child(7) {
background: #f28b82; /* soft red */
z-index: 3;
}
.casefile-tab:nth-child(8) {
background: #a0e7e5; /* mint */
z-index: 2;
}
.casefile-tab:nth-child(9) {
background: #e6c79c; /* manila / paper tone */
z-index: 1; z-index: 1;
} }
@ -505,12 +520,12 @@
} }
.casefile-tab.restricted { .casefile-tab.restricted {
background: #c7b5f0; background: #e6c79c;
color: #5c4631; color: #5c4631;
} }
.casefile-tab.restricted.active { .casefile-tab.restricted.active {
background: #c7b5f0; background: #e6c79c;
color: #5c4631; color: #5c4631;
} }
@ -980,3 +995,316 @@
/* Optional: soften edges slightly */ /* Optional: soften edges slightly */
border-radius: 2px; border-radius: 2px;
} }
.chapter-reader {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem;
background: rgba(248, 244, 236, 0.92);
border: 1px solid rgba(107, 84, 58, 0.18);
border-radius: 18px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
color: #35291f;
position: relative;
}
.chapter-reader::before {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgba(107, 84, 58, 0.12);
border-radius: 12px;
pointer-events: none;
}
.chapter-reader__header {
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(107, 84, 58, 0.2);
text-align: center;
}
.chapter-reader__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #8a5a44;
}
.chapter-reader__title {
margin: 0;
font-size: clamp(1rem, 1vw, 1.5rem);
line-height: 1.1;
font-weight: 200;
color: #5d2f2f;
}
.chapter-reader__meta {
margin: 0.85rem 0 0;
font-size: 0.98rem;
font-style: italic;
color: #6a5746;
}
.chapter-reader__content {
font-size: 1.08rem;
line-height: 1.9;
}
.chapter-reader__content p {
margin: 0 0 1.35rem;
text-wrap: pretty;
}
.chapter-reader__content p + p {
text-indent: 0;
}
.chapter-reader__artifact {
display: inline-block;
padding: 0.08rem 0.35rem;
background: rgba(201, 168, 112, 0.16);
border-radius: 4px;
font-style: italic;
color: #5a4330;
}
.chapter-reader__poem {
margin: 2rem 0;
padding: 1.5rem 1.75rem;
background: rgba(255, 250, 240, 0.9);
border-left: 4px solid #b7895b;
border-radius: 12px;
box-shadow: inset 0 0 0 1px rgba(107, 84, 58, 0.08);
}
.chapter-reader__poem p {
margin-bottom: 1.2rem;
font-style: italic;
line-height: 1.8;
color: #4e3a2b;
}
.chapter-reader__poem p:last-child {
margin-bottom: 0;
}
.chapter-reader__closing {
margin-top: 2rem;
padding-top: 1rem;
font-weight: 600;
color: #5d2f2f;
}
@media (max-width: 640px) {
.chapter-reader {
padding: 1.4rem 1rem;
border-radius: 14px;
}
.chapter-reader__content {
font-size: 1rem;
line-height: 1.8;
}
.chapter-reader__poem {
padding: 1.1rem 1rem;
}
}
.chapter-reader__audio {
margin: 1.75rem 0 2.25rem;
}
.chapter-reader__audio-label {
margin-bottom: 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8a5a44;
font-weight: 600;
}
.chapter-reader__audio-player {
width: 100%;
border-radius: 8px;
}
/* Styled version */
.chapter-reader__audio-card {
display: flex;
gap: 1rem;
align-items: center;
padding: 1rem 1.2rem;
background: rgba(255, 250, 240, 0.9);
border: 1px solid rgba(107, 84, 58, 0.2);
border-radius: 12px;
box-shadow: 0 6px 18px rgba(0,0,0,0.05);
}
.chapter-reader__audio-icon {
font-size: 1.8rem;
opacity: 0.8;
}
.chapter-reader__audio-content {
flex: 1;
}
.chapter-reader__audio-title {
margin: 0;
font-weight: 700;
color: #5d2f2f;
}
.chapter-reader__audio-subtitle {
margin: 0 0 0.6rem;
font-size: 0.9rem;
color: #6a5746;
}
/* Keep the Reckoning case-file theme isolated from the site-wide facelift. */
.cl-page-reckoning .casefile-content,
.cl-page-reckoning .casefile-paper-stack-layer,
.cl-page-reckoning .casefile-panel {
color: #2e241c !important;
}
.cl-page-reckoning .casefile-content {
background-color: #fbf8ef !important;
background-image: repeating-linear-gradient(
to bottom,
rgba(120, 140, 170, 0.14) 0px,
rgba(120, 140, 170, 0.14) 1px,
transparent 1px,
transparent 34px
) !important;
}
.cl-page-reckoning .casefile-paper-stack-layer {
background: #f6f1e5 !important;
}
.cl-page-reckoning .casefile-panel h1,
.cl-page-reckoning .casefile-panel h2,
.cl-page-reckoning .casefile-panel h3,
.cl-page-reckoning .casefile-panel h4,
.cl-page-reckoning .casefile-panel h5,
.cl-page-reckoning .casefile-panel h6,
.cl-page-reckoning .casefile-section-heading,
.cl-page-reckoning .buybox-casefile-page-header h2,
.cl-page-reckoning .buybox-casefile-group-title,
.cl-page-reckoning .reckoning-subject-entry h3 {
color: #1f1813 !important;
}
.cl-page-reckoning .casefile-panel p,
.cl-page-reckoning .casefile-panel li,
.cl-page-reckoning .casefile-panel dd,
.cl-page-reckoning .casefile-panel dt,
.cl-page-reckoning .casefile-panel strong,
.cl-page-reckoning .casefile-evidence-item,
.cl-page-reckoning .casefile-evidence-item p,
.cl-page-reckoning .casefile-evidence-intro,
.cl-page-reckoning .casefile-review-body,
.cl-page-reckoning .casefile-review-body p,
.cl-page-reckoning .casefile-review-source,
.cl-page-reckoning .casefile-notes-meta,
.cl-page-reckoning .casefile-notes-margin,
.cl-page-reckoning .buybox-casefile-page,
.cl-page-reckoning .buybox-casefile-page p,
.cl-page-reckoning .buybox-casefile-page span,
.cl-page-reckoning .buybox-casefile-page small,
.cl-page-reckoning .buybox-casefile-country,
.cl-page-reckoning .buybox-casefile-group-message,
.cl-page-reckoning .buybox-casefile-link,
.cl-page-reckoning .buybox-casefile-price {
color: #2e241c !important;
}
.cl-page-reckoning .casefile-evidence-media,
.cl-page-reckoning .casefile-evidence-paperclip,
.cl-page-reckoning .casefile-panel .bg-white,
.cl-page-reckoning .casefile-panel .bg-light {
color: #2e241c !important;
background: #fffaf0 !important;
border-color: rgba(70, 50, 35, 0.22) !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18), 0 8px 14px rgba(0, 0, 0, 0.12) !important;
}
.cl-page-reckoning .casefile-evidence-image,
.cl-page-reckoning .casefile-panel .img-fluid,
.cl-page-reckoning .casefile-panel .fit-image {
filter: none !important;
}
.cl-page-reckoning .casefile-review-body {
border-left: 4px solid rgba(90, 74, 61, 0.35) !important;
padding-left: 1rem;
}
.cl-page-reckoning .casefile-inline-link,
.cl-page-reckoning .casefile-review-source a,
.cl-page-reckoning .buybox-casefile-link:hover,
.cl-page-reckoning .buybox-casefile-link:focus {
color: #5d2f2f !important;
}
.cl-page-reckoning #buyBox {
color: #2e241c !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
.cl-page-reckoning #buyBox,
.cl-page-reckoning #buyBox p,
.cl-page-reckoning #buyBox li,
.cl-page-reckoning #buyBox span,
.cl-page-reckoning #buyBox small,
.cl-page-reckoning #buyBox h1,
.cl-page-reckoning #buyBox h2,
.cl-page-reckoning #buyBox h3,
.cl-page-reckoning #buyBox h4,
.cl-page-reckoning #buyBox h5,
.cl-page-reckoning #buyBox h6 {
color: #2e241c !important;
}
.cl-page-reckoning #buyBox .alert {
color: #3f341c !important;
background: rgba(246, 226, 122, 0.35) !important;
border-color: rgba(80, 65, 20, 0.24) !important;
}
.cl-page-reckoning #buyBox .btn {
color: #fff8eb !important;
background: #2f2619 !important;
border-color: #2f2619 !important;
border-radius: 4px !important;
box-shadow: none !important;
}
.cl-page-reckoning #buyBox .btn:hover,
.cl-page-reckoning #buyBox .btn:focus {
color: #fff8eb !important;
background: #5d2f2f !important;
border-color: #5d2f2f !important;
}
.cl-page-reckoning #buyBox .btn span,
.cl-page-reckoning #buyBox .btn i {
color: #fff8eb !important;
}
.cl-page-reckoning #buyBox .price-chip,
.cl-page-reckoning #buyBox .price-chip span {
color: #2e241c !important;
background: #e6c79c !important;
border-color: rgba(70, 50, 35, 0.24) !important;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Some files were not shown because too many files have changed in this diff Show More