Compare commits

..

No commits in common. "a2c2db053ee54bf42afc643474f66ad9653068ef" and "8ffa4a39fec0e8811ee63ad03bebdff2c6b7f64b" have entirely different histories.

17 changed files with 58 additions and 457 deletions

View File

@ -5,11 +5,9 @@ namespace FastBlog.Core.Abstractions.Repositories.Blogs;
public interface IBlogMetaRepository
{
Task<BlogMeta?> GetPublished(string? slug);
Task<BlogMeta?> Get(string? slug);
Task<BlogMeta?> GetForEdit(int id);
Task<bool> Delete(int id);
Task<Result<BlogMeta, BusinessError>> Add(BlogMeta meta);
Task<Result<BlogMeta, BusinessError>> Update(BlogMeta meta);
Task<PagedResponse<BlogMeta>> List(BlogFilter filter, PagedRequest request);
}

View File

@ -17,7 +17,6 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Dapper.SqlBuilder" Version="2.0.78" />
<PackageReference Include="FluentMigrator" Version="5.2.0" />
<PackageReference Include="FluentMigrator.Runner.SQLite" Version="5.2.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.8" />

View File

@ -1,3 +0,0 @@
namespace FastBlog.Core.Models.Blogs;
public sealed record BlogFilter(DateTime? From, DateTime? To, string? Search);

View File

@ -1,5 +1,4 @@
using System.Text;
using Dapper;
using Dapper;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Db;
using FastBlog.Core.Models;
@ -10,7 +9,7 @@ namespace FastBlog.Core.Repositories;
public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory) : IBlogMetaRepository
{
public async Task<BlogMeta?> GetPublished(string? slug)
public async Task<BlogMeta?> Get(string? slug)
{
using var connection = connectionFactory.Create();
@ -31,27 +30,6 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { slug });
}
public async Task<BlogMeta?> Get(string? slug)
{
using var connection = connectionFactory.Create();
const string sqlNull = """
select * from Blogs
where slug is null
""";
const string sql = """
select * from Blogs
where (slug = @slug or id = cast(@slug as integer))
""";
if (slug is null)
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sqlNull);
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { slug });
}
public async Task<BlogMeta?> GetForEdit(int id)
{
using var connection = connectionFactory.Create();
@ -120,46 +98,4 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
throw;
}
}
public async Task<PagedResponse<BlogMeta>> List(BlogFilter filter, PagedRequest request)
{
using var connection = connectionFactory.Create();
var parameters = new DynamicParameters();
parameters.Add("@Amount", request.Amount);
parameters.Add("@Offset", request.Offset);
var sql = new StringBuilder("""
select COUNT(*) from Files;
select * from Blogs ""
""");
if (filter.To.HasValue)
{
sql.AppendLine("where CreatedAt >= @To");
parameters.Add("@To", filter.To.Value);
}
if (filter.From.HasValue)
{
sql.AppendLine("where CreatedAt <= @From");
parameters.Add("@From", filter.From.Value);
}
if (filter.Search is not null)
{
sql.AppendLine("where Title like @Search");
parameters.Add("@Search", $"%{filter.Search}%");
}
sql.AppendLine("order by CreatedAt desc");
sql.AppendLine("limit @Amount offset @Offset;");
await using var multi = await connection.QueryMultipleAsync(sql.ToString(), parameters);
var total = await multi.ReadFirstAsync<long>();
var result = (await multi.ReadAsync<BlogMeta>()).ToArray();
return PagedResponse<BlogMeta>.Create(total, request.Amount, request.Offset, result);
}
}

View File

@ -6,26 +6,6 @@ namespace FastBlog.Core.Services;
public sealed class BlogService(IBlogMetaRepository metaRepository, IBlogFileRepository blogFileRepository)
{
public async Task<Blog?> GetPublished(string? slug)
{
slug = slug?.ToLower();
var metaResult = await metaRepository.GetPublished(slug);
if (metaResult is null)
return null;
var sourceResult = await blogFileRepository.Read(metaResult.SourceLocation);
if (sourceResult is null)
{
throw new FileNotFoundException("File with metadata cannot be found");
}
return new Blog
{
Metadata = metaResult,
Text = sourceResult
};
}
public async Task<Blog?> Get(string? slug)
{
slug = slug?.ToLower();
@ -46,8 +26,6 @@ public sealed class BlogService(IBlogMetaRepository metaRepository, IBlogFileRep
};
}
public Task<PagedResponse<BlogMeta>> ListMetas(BlogFilter filter, PagedRequest request) => metaRepository.List(filter, request);
public async Task<Blog?> GetForEdit(int id)
{
var metaResult = await metaRepository.GetForEdit(id);

View File

@ -1,4 +1,5 @@
using FastBlog.Core.Models;
using System.Diagnostics;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Models.Blogs;
using FastBlog.Core.Services;
using FastBlog.Web.Models;
@ -17,7 +18,7 @@ public class BlogsController(BlogService service) : Controller
if (string.IsNullOrWhiteSpace(slug))
slug = null;
var blog = await service.GetPublished(slug);
var blog = await service.Get(slug);
if (blog is null)
return NotFound();
@ -29,7 +30,7 @@ public class BlogsController(BlogService service) : Controller
[Route("")]
public async Task<IActionResult> Index()
{
var blog = await service.GetPublished(null);
var blog = await service.Get(null);
if (blog is null)
return NotFound();
@ -37,40 +38,6 @@ public class BlogsController(BlogService service) : Controller
return View(blog);
}
[HttpGet]
[Route("unpublished/{*slug}")]
public async Task<IActionResult> Unpublished(string? slug)
{
if (string.IsNullOrWhiteSpace(slug))
slug = null;
var blog = await service.Get(slug);
if (blog is null)
return NotFound();
return View(blog);
}
[HttpGet("list/edit")]
public async Task<IActionResult> ListEdit(
[FromQuery(Name = "amount")] int amount = 25,
[FromQuery(Name = "offset")] int offset = 0,
[FromQuery(Name = "query")] string? query = null,
[FromQuery(Name = "from")] DateTime? from = null,
[FromQuery(Name = "to")] DateTime? to = null)
{
if(amount is < 1 or > 100)
return BadRequest("Amount must be between 1 and 100");
if(offset < 0)
return BadRequest("Offset must be greater than 0");
return View(await service.ListMetas(new BlogFilter(from,to, query), new PagedRequest(amount, offset)));
}
[HttpGet]
[Route("edit/{id:int?}")]
public async ValueTask<IActionResult> Edit(int? id)
@ -81,15 +48,15 @@ public class BlogsController(BlogService service) : Controller
return View(
new EditBlog
{
Text = "# My new blog",
Title = "Blog from " + DateTime.UtcNow.ToString("g"),
SourceLocation = $"{date:yyyy-MM-dd-HH-mm}_blog.md",
CreatedAt = date,
ModifiedAt = date,
FullWidth = false,
ShowDetails = true,
Slug = "blog-" + date.ToString("yyyy-MM-dd-HH-mm"),
Visible = false
Text = "# My new blog",
Title = "Blog from " + DateTime.UtcNow.ToString("g"),
SourceLocation = $"{date:yyyy-MM-dd-HH-mm}_blog.md",
CreatedAt = date,
ModifiedAt = date,
FullWidth = false,
ShowDetails = true,
Slug = "blog-" + date.ToString("yyyy-MM-dd-HH-mm"),
Visible = false
}
);
}

View File

@ -1,6 +1,4 @@
using System;
namespace FastBlog.Web.Models;
namespace FastBlog.Web.Models;
public sealed class EditBlog
{

View File

@ -3,6 +3,7 @@ using FastBlog.Web;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development)
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
else
@ -13,9 +14,11 @@ builder.Services.Configure<DisplayOptions>(builder.Configuration.GetSection("Dis
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
}
app.UseHttpsRedirection();

View File

@ -4,11 +4,31 @@
ViewBag.Title = Model.Metadata.Title;
}
@await Html.PartialAsync("Blogs/Content", Model)
@await Html.PartialAsync("Blogs/BlogContent", Model)
<br>
<div class="container">
<hr/>
@await Html.PartialAsync("Blogs/ManageCard", Model.Metadata)
<article>
<header>
<h3>Editor's controls</h3>
</header>
<body>
<div class="grid">
<div>
<a href="/edit/@Model.Metadata.Id">
<button class="btn-fw">
Edit
</button>
</a>
</div>
<div>
<button class="btn-fw secondary" hx-delete="/blogs/@Model.Metadata.Id">
Delete
</button>
</div>
</div>
</body>
</article>
</div>

View File

@ -1,123 +0,0 @@
@using FastBlog.Web.Helpers
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Blogs.BlogMeta>
@{
ViewBag.Title = "Blogs";
}
<div class="container" id="file-list">
<br/>
<article>
<header>
<a href="/edit">
<button class="btn-fw secondary">
New
</button>
</a>
<hr/>
<h3>List of Blogs</h3>
</header>
<body>
@if (Model.Data.Length is 0)
{
<h4>No blogs have been found</h4>
}
<div class="grid">
<div style="display: flex; font-size: 16px">
<p>Name</p>
</div>
<div style="display: flex; font-size: 16px">
<p>Published</p>
</div>
<div style="display: flex; font-size: 16px">
<p>Visible</p>
</div>
<div style="display: flex; font-size: 16px">
<p>Actions</p>
</div>
</div>
@foreach (var blog in Model.Data)
{
<div class="grid">
<div style="display: flex; font-size: 16px">
<p class="no-margin" style="margin-right: 10px; font-weight: bold">
@if (blog.CreatedAt < DateTime.UtcNow)
{
<a target="_blank" href="/@blog.Slug">@blog.Title</a>
}
else
{
<a class="pico-color-purple-600" target="_blank" href="/unpublished/@blog.Slug">@blog.Title</a>
}
</p>
</div>
<div>
<p class="no-margin">
@if (blog.CreatedAt < DateTime.UtcNow)
{
@blog.CreatedAt.ToString("yy-MM-dd HH:mm:ss")
;
}
else
{
<span class="pico-color-purple-400">@blog.CreatedAt.ToString("yy-MM-dd HH:mm:ss")</span>
;
}
</p>
</div>
<div>
<p class="no-margin">
<strong>
@if (blog.Visible)
{
<spans>Yes</spans>
}
else
{
<span class="pico-color-yellow">No</span>
}
</strong>
</p>
</div>
<div>
<p class="no-margin">
<a class="pico-color-red" hx-delete="/blogs/@blog.Id"
hx-swap="outerHTML"
hx-target="#file-list">
[ Delete ]
</a>
<a class="pico-color-blue-100" target="_blank" href="/edit/@blog.Id">[ Edit ]</a>
</p>
</div>
</div>
<hr/>
}
</body>
<footer>
<div>
<button
hx-get="/edit-list?offset=@(Model.Offset - 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
Previous
</button>
<button
hx-get="/edit-list?offset=@(Model.Offset + 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>
</footer>
</article>
</div>

View File

@ -1,123 +0,0 @@
@using FastBlog.Web.Helpers
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Blogs.BlogMeta>
@{
ViewBag.Title = "Blogs";
}
<div class="container" id="file-list">
<br/>
<article>
<header>
<a href="/edit">
<button class="btn-fw secondary">
New
</button>
</a>
<hr/>
<h3>List of Blogs</h3>
</header>
<body>
@if (Model.Data.Length is 0)
{
<h4>No blogs have been found</h4>
}
<div class="grid">
<div style="display: flex; font-size: 16px">
<p>Name</p>
</div>
<div style="display: flex; font-size: 16px">
<p>Published</p>
</div>
<div style="display: flex; font-size: 16px">
<p>Visible</p>
</div>
<div style="display: flex; font-size: 16px">
<p>Actions</p>
</div>
</div>
@foreach (var blog in Model.Data)
{
<div class="grid">
<div style="display: flex; font-size: 16px">
<p class="no-margin" style="margin-right: 10px; font-weight: bold">
@if (blog.CreatedAt < DateTime.UtcNow)
{
<a target="_blank" href="/@blog.Slug">@blog.Title</a>
}
else
{
<a class="pico-color-purple-600" target="_blank" href="/unpublished/@blog.Slug">@blog.Title</a>
}
</p>
</div>
<div>
<p class="no-margin">
@if (blog.CreatedAt < DateTime.UtcNow)
{
@blog.CreatedAt.ToString("yy-MM-dd HH:mm:ss")
;
}
else
{
<span class="pico-color-purple-400">@blog.CreatedAt.ToString("yy-MM-dd HH:mm:ss")</span>
;
}
</p>
</div>
<div>
<p class="no-margin">
<strong>
@if (blog.Visible)
{
<spans>Yes</spans>
}
else
{
<span class="pico-color-yellow">No</span>
}
</strong>
</p>
</div>
<div>
<p class="no-margin">
<a class="pico-color-red" hx-delete="/blogs/@blog.Id"
hx-swap="outerHTML"
hx-target="#file-list">
[ Delete ]
</a>
<a class="pico-color-blue-100" target="_blank" href="/edit/@blog.Id">[ Edit ]</a>
</p>
</div>
</div>
<hr/>
}
</body>
<footer>
<div>
<button
hx-get="/edit-list?offset=@(Model.Offset - 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
Previous
</button>
<button
hx-get="/edit-list?offset=@(Model.Offset + 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>
</footer>
</article>
</div>

View File

@ -1,6 +1,6 @@
@model FastBlog.Core.Models.Blogs.Blog
@await Html.PartialAsync("Blogs/Content", Model)
@await Html.PartialAsync("Blogs/BlogContent", Model)
<hr/>

View File

@ -1,22 +0,0 @@
@model FastBlog.Core.Models.Blogs.Blog
@{
ViewBag.Title = Model.Metadata.Title;
}
@if(DateTime.UtcNow < Model.Metadata.CreatedAt)
{
<div class="alert alert-important">
<p class="alert-title">Unpublished blog</p>
<p>This blog would not be visible and accessible until @Model.Metadata.CreatedAt.ToString("g")</p>
</div>
}
@await Html.PartialAsync("Blogs/Content", Model)
<br>
<div class="container">
<hr/>
@await Html.PartialAsync("Blogs/ManageCard", Model.Metadata)
</div>

View File

@ -1,23 +0,0 @@
@model FastBlog.Core.Models.Blogs.BlogMeta
<article>
<header>
<h3>Editor's controls</h3>
</header>
<body>
<div class="grid">
<div>
<a href="/edit/@Model.Id">
<button class="btn-fw">
Edit
</button>
</a>
</div>
<div>
<button class="btn-fw secondary" hx-delete="/blogs/@Model.Id">
Delete
</button>
</div>
</div>
</body>
</article>

View File

@ -30,7 +30,7 @@ return;
<nav>
<ul>
<li><a href="~/"><h1>@options.Value.Title</h1></a></li>
<li><a href="~/blogs/list">Blog</a></li>
<li><a href="~/blog/list">Blog</a></li>
<li><a href="~/edit">New</a></li>
<li><a href="~/files">Files</a></li>
</ul>

View File

@ -63,10 +63,6 @@ h1, h2, h3, h4, h5, h6 {
--pico-font-family: 'Fira Code', Roboto, sans-serif;
}
hr {
border-color: var(--pico-secondary-background);
}
a {
color: inherit;
text-decoration-color: inherit;
@ -135,7 +131,7 @@ body {
margin-top: 35px;
}
.markdown-alert, .alert {
.markdown-alert {
color: inherit;
background-color: inherit;
border-radius: var(--pico-border-radius);
@ -145,41 +141,41 @@ body {
margin-bottom: 10px;
}
.markdown-alert p, .alert p {
.markdown-alert p {
margin: 0;
}
.markdown-alert-title, .alert-title {
.markdown-alert-title {
font-family: 'Fira Code', Roboto, sans-serif;
font-size: 16px;
font-weight: bold;
}
.markdown-alert-note, .alert-note {
.markdown-alert-note {
color: var(--pico-primary);
background: var(--pico-primary-background);
border-color: var(--pico-primary-background);
}
.markdown-alert-tip, .alert-tip {
.markdown-alert-tip {
color: var(--pico-tip);
background: var(--pico-tip-background);
border-color: var(--pico-tip-background);
}
.markdown-alert-important, .alert-important {
.markdown-alert-important {
color: var(--pico-important);
background: var(--pico-important-background);
border-color: var(--pico-important-background);
}
.markdown-alert-warning, .alert-warning {
.markdown-alert-warning {
color: var(--pico-warning);
background: var(--pico-warning-background);
border-color: var(--pico-warning-background);
}
.markdown-alert-caution, .alert-caution {
.markdown-alert-caution {
color: var(--pico-danger);
background: var(--pico-danger-background);
border-color: var(--pico-danger-background);