feat: add individual post page (#4)

* chore: add markdown lint to ignore no h1 heading in markdown files

* chore: update example settings

* chore: update example development env file

* fix: remove fixed 100% height on container

* feat: add individual post page

* chore: update README.md

* feat: add links to headings in post and add copy button to code blocks

* tests: add tests for get post async method

* tests: remove heading for test blogs

* fix: only add links to headings in markdown body

* tests: add tests for post page

* tests: add test to check for blog content

* docs: add jsdoc comments

* chore: fix incorrect env variable format in example

* fix: don't render anything if blog post is null

* fix: don't render anything until posts are fetched

* chore: add docker compose file for prod
This commit is contained in:
Stevan Freeborn
2024-04-11 00:58:22 -05:00
committed by GitHub
parent 75831e5f45
commit dd7e7e1783
19 changed files with 1774 additions and 13 deletions
+82
View File
@@ -0,0 +1,82 @@
@page "/{slug:nonfile}"
@implements IAsyncDisposable
@inject IPostService PostService
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
@inject ILogger<Post> Logger
@if (BlogPost is null)
{
return;
}
else
{
<PageTitle>@BlogPost.Title - journal</PageTitle>
<HeadContent>
<meta name="description" content="@BlogPost.Lead">
<link rel="stylesheet" href="markdown.css">
<link rel="stylesheet" href="prism.css">
<script src="prism.js"></script>
<script src="https://cdn.jsdelivr.net/npm/anchor-js/anchor.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
</HeadContent>
<article>
<div class="heading-container">
<h2>@BlogPost.Title</h2>
<p>@BlogPost.PublishedAt.ToString("MMMM d, yyyy")</p>
</div>
<div class="markdown-body">
@((MarkupString)BlogPost.Content)
</div>
</article>
}
@code {
[Parameter]
public string Slug { get; set; } = string.Empty;
private PostWithContent? BlogPost;
private IJSObjectReference? module;
protected override async Task OnInitializedAsync()
{
var post = await PostService.GetPostAsync(Slug);
if (post is null)
{
NavigationManager.NavigateTo("/Error/404");
return;
}
BlogPost = post;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./Components/Pages/Post.razor.js");
await module.InvokeVoidAsync("addAnchors");
await module.InvokeVoidAsync("addClipboard");
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
try
{
if (module is not null)
{
await module.DisposeAsync();
}
}
catch (Exception ex) when (ex is JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error disposing of the module");
}
}
}
+42
View File
@@ -0,0 +1,42 @@
article {
display: flex;
flex-direction: column;
gap: 2rem;
& .heading-container {
& h2 {
font-size: 2.5rem;
font-weight: 900;
}
& p {
font-size: 0.85rem;
}
}
.markdown-body {
& pre {
position: relative;
& .copy-button {
position: absolute;
top: 0;
right: 0;
cursor: pointer;
color: var(--primary-white);
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
transition-property: color;
transition-duration: 0.3s;
transition-timing-function: ease-in-out;
& svg {
width: 1rem;
height: 1rem;
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @summary Adds anchors to each heading in the post
* so that users can link directly to them.
*/
export function addAnchors() {
const selectors = [
'.markdown-body h2',
'.markdown-body h3',
'.markdown-body h4',
'.markdown-body h5',
'.markdown-body h6'
];
// relies on AnchorJS library
// being loaded in Post.razor
anchors.add(selectors.join(','));
}
/**
* @summary Adds a button to each code block that
* allows users to copy the code to their clipboard.
*/
export function addClipboard() {
const codeBlocks = document.querySelectorAll('pre');
codeBlocks.forEach((block) => {
const button = document.createElement('button');
button.className = 'copy-button';
button.type = 'button';
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor">
<path d="M384 336H192c-8.8 0-16-7.2-16-16V64c0-8.8 7.2-16 16-16l140.1 0L400 115.9V320c0 8.8-7.2 16-16 16zM192 384H384c35.3 0 64-28.7 64-64V115.9c0-12.7-5.1-24.9-14.1-33.9L366.1 14.1c-9-9-21.2-14.1-33.9-14.1H192c-35.3 0-64 28.7-64 64V320c0 35.3 28.7 64 64 64zM64 128c-35.3 0-64 28.7-64 64V448c0 35.3 28.7 64 64 64H256c35.3 0 64-28.7 64-64V416H272v32c0 8.8-7.2 16-16 16H64c-8.8 0-16-7.2-16-16V192c0-8.8 7.2-16 16-16H96V128H64z"/>
</svg>
`
button.onclick = () => {
button.style.color = 'green';
setTimeout(() => {
button.style.color = '';
}, 1000);
};
const buttonText = document.createElement('span');
buttonText.className = 'sr-only';
buttonText.textContent = 'Copy';
button.appendChild(buttonText);
block.appendChild(button);
});
// relies on ClipboardJS library
// being loaded in Post.razor
var clipboard = new ClipboardJS('.copy-button', {
target: (trigger) => {
return trigger.previousElementSibling;
}
});
clipboard.on('success', function(e) {
e.clearSelection();
});
}