Compare commits

...

3 Commits

Author SHA1 Message Date
the1mason a2c2db053e Smol stuff v2 2024-10-21 22:02:27 +05:00
the1mason ddefe25081 Smol stuff.. 2024-10-21 22:02:16 +05:00
the1mason fbd7b1c7d3 Added blog list for edit 2024-10-21 21:49:51 +05:00
17 changed files with 457 additions and 58 deletions

View File

@ -5,9 +5,11 @@ 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,6 +17,7 @@
<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

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

View File

@ -1,4 +1,5 @@
using Dapper;
using System.Text;
using Dapper;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Db;
using FastBlog.Core.Models;
@ -9,7 +10,7 @@ namespace FastBlog.Core.Repositories;
public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory) : IBlogMetaRepository
{
public async Task<BlogMeta?> Get(string? slug)
public async Task<BlogMeta?> GetPublished(string? slug)
{
using var connection = connectionFactory.Create();
@ -29,6 +30,27 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sqlNull);
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)
{
@ -98,4 +120,46 @@ 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,6 +6,26 @@ 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();
@ -25,6 +45,8 @@ public sealed class BlogService(IBlogMetaRepository metaRepository, IBlogFileRep
Text = sourceResult
};
}
public Task<PagedResponse<BlogMeta>> ListMetas(BlogFilter filter, PagedRequest request) => metaRepository.List(filter, request);
public async Task<Blog?> GetForEdit(int id)
{

View File

@ -1,5 +1,4 @@
using System.Diagnostics;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Models;
using FastBlog.Core.Models.Blogs;
using FastBlog.Core.Services;
using FastBlog.Web.Models;
@ -18,7 +17,7 @@ public class BlogsController(BlogService service) : Controller
if (string.IsNullOrWhiteSpace(slug))
slug = null;
var blog = await service.Get(slug);
var blog = await service.GetPublished(slug);
if (blog is null)
return NotFound();
@ -30,7 +29,7 @@ public class BlogsController(BlogService service) : Controller
[Route("")]
public async Task<IActionResult> Index()
{
var blog = await service.Get(null);
var blog = await service.GetPublished(null);
if (blog is null)
return NotFound();
@ -38,6 +37,40 @@ 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)
@ -48,21 +81,21 @@ 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
}
);
}
var blog = await service.GetForEdit(id.Value);
if (blog is null)
return NotFound();
@ -118,7 +151,7 @@ public class BlogsController(BlogService service) : Controller
return Redirect($"/blogs/{editBlog.Slug}");
}
[HttpPost]
[Route("preview")]
public IActionResult Preview([FromForm] EditBlog editBlog)
@ -141,16 +174,16 @@ public class BlogsController(BlogService service) : Controller
}
});
}
[HttpDelete]
[Route("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var result = await service.Delete(id);
if (!result)
return NotFound();
HttpContext.Response.Headers.Append("Hx-Redirect", "/");
return Ok();
}

View File

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

View File

@ -3,7 +3,6 @@ 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
@ -14,11 +13,9 @@ 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,31 +4,11 @@
ViewBag.Title = Model.Metadata.Title;
}
@await Html.PartialAsync("Blogs/BlogContent", Model)
@await Html.PartialAsync("Blogs/Content", Model)
<br>
<div class="container">
<hr/>
<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>
@await Html.PartialAsync("Blogs/ManageCard", Model.Metadata)
</div>

View File

@ -0,0 +1,123 @@
@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

@ -0,0 +1,123 @@
@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/BlogContent", Model)
@await Html.PartialAsync("Blogs/Content", Model)
<hr/>

View File

@ -0,0 +1,22 @@
@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

@ -0,0 +1,23 @@
@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="~/blog/list">Blog</a></li>
<li><a href="~/blogs/list">Blog</a></li>
<li><a href="~/edit">New</a></li>
<li><a href="~/files">Files</a></li>
</ul>

View File

@ -63,6 +63,10 @@ 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;
@ -131,7 +135,7 @@ body {
margin-top: 35px;
}
.markdown-alert {
.markdown-alert, .alert {
color: inherit;
background-color: inherit;
border-radius: var(--pico-border-radius);
@ -141,41 +145,41 @@ body {
margin-bottom: 10px;
}
.markdown-alert p {
.markdown-alert p, .alert p {
margin: 0;
}
.markdown-alert-title {
.markdown-alert-title, .alert-title {
font-family: 'Fira Code', Roboto, sans-serif;
font-size: 16px;
font-weight: bold;
}
.markdown-alert-note {
.markdown-alert-note, .alert-note {
color: var(--pico-primary);
background: var(--pico-primary-background);
border-color: var(--pico-primary-background);
}
.markdown-alert-tip {
.markdown-alert-tip, .alert-tip {
color: var(--pico-tip);
background: var(--pico-tip-background);
border-color: var(--pico-tip-background);
}
.markdown-alert-important {
.markdown-alert-important, .alert-important {
color: var(--pico-important);
background: var(--pico-important-background);
border-color: var(--pico-important-background);
}
.markdown-alert-warning {
.markdown-alert-warning, .alert-warning {
color: var(--pico-warning);
background: var(--pico-warning-background);
border-color: var(--pico-warning-background);
}
.markdown-alert-caution {
.markdown-alert-caution, .alert-caution {
color: var(--pico-danger);
background: var(--pico-danger-background);
border-color: var(--pico-danger-background);