From d8015f54efb0f9fbc9447211b358461013a92ab2 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 6 Mar 2026 15:33:49 -0700 Subject: [PATCH 1/4] Add blog management: migration, API, service, and admin UI Blog posts stored in PostgreSQL with markdown rendering via Markdig. Public API for reads, admin-only writes with dual auth (JWT + session). Admin page for creating, editing, and deleting posts. Co-Authored-By: Claude Opus 4.6 --- Media.JoshHeaps.Net/Api/BlogApi.cs | 156 +++++++++++ .../Database/029_blog_posts.sql | 30 ++ .../Media.JoshHeaps.Net.csproj | 1 + Media.JoshHeaps.Net/Models/BlogPost.cs | 16 ++ Media.JoshHeaps.Net/Pages/Admin.cshtml | 1 + Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml | 82 ++++++ Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml.cs | 19 ++ Media.JoshHeaps.Net/Program.cs | 1 + Media.JoshHeaps.Net/Services/BlogService.cs | 204 ++++++++++++++ .../wwwroot/css/blog-admin.css | 261 ++++++++++++++++++ Media.JoshHeaps.Net/wwwroot/js/blog-admin.js | 150 ++++++++++ 11 files changed, 921 insertions(+) create mode 100644 Media.JoshHeaps.Net/Api/BlogApi.cs create mode 100644 Media.JoshHeaps.Net/Database/029_blog_posts.sql create mode 100644 Media.JoshHeaps.Net/Models/BlogPost.cs create mode 100644 Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml.cs create mode 100644 Media.JoshHeaps.Net/Services/BlogService.cs create mode 100644 Media.JoshHeaps.Net/wwwroot/css/blog-admin.css create mode 100644 Media.JoshHeaps.Net/wwwroot/js/blog-admin.js diff --git a/Media.JoshHeaps.Net/Api/BlogApi.cs b/Media.JoshHeaps.Net/Api/BlogApi.cs new file mode 100644 index 0000000..46b5f23 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/BlogApi.cs @@ -0,0 +1,156 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/blog")] +public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : ControllerBase +{ + [HttpGet("posts")] + public async Task GetPosts() + { + var posts = await blogService.GetAllPostsAsync(); + return Ok(posts.Select(MapToPublicDto)); + } + + [HttpGet("posts/{slug}")] + public async Task GetPostBySlug(string slug) + { + var post = await blogService.GetPostBySlugAsync(slug); + if (post is null) return NotFound(); + return Ok(MapToPublicDto(post)); + } + + [HttpGet("posts/tags/{tag}")] + public async Task GetPostsByTag(string tag) + { + var posts = await blogService.GetPostsByTagAsync(tag); + return Ok(posts.Select(MapToPublicDto)); + } + + [HttpGet("posts/admin/{id}")] + public async Task GetPostForAdmin(long id) + { + var userId = GetUserIdFromAuth(); + if (userId is null) return Unauthorized(); + if (!await IsAdmin(userId.Value)) return Forbid(); + + var post = await blogService.GetPostByIdAsync(id); + if (post is null) return NotFound(); + return Ok(MapToAdminDto(post)); + } + + [HttpPost("posts")] + public async Task CreatePost([FromBody] CreateBlogPostRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId is null) return Unauthorized(); + if (!await IsAdmin(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Title)) + return BadRequest(new { error = "Title is required" }); + if (string.IsNullOrWhiteSpace(request.MarkdownContent)) + return BadRequest(new { error = "Content is required" }); + + var post = await blogService.CreatePostAsync( + request.Title.Trim(), + request.Summary?.Trim() ?? "", + request.MarkdownContent, + request.Tags ?? [], + userId.Value, + request.PublishedDate ?? DateTime.UtcNow); + + if (post is null) + return StatusCode(500, new { error = "Failed to create post" }); + + return Ok(MapToAdminDto(post)); + } + + [HttpPut("posts/{id}")] + public async Task UpdatePost(long id, [FromBody] UpdateBlogPostRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId is null) return Unauthorized(); + if (!await IsAdmin(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Title)) + return BadRequest(new { error = "Title is required" }); + if (string.IsNullOrWhiteSpace(request.MarkdownContent)) + return BadRequest(new { error = "Content is required" }); + + var post = await blogService.UpdatePostAsync( + id, + request.Title.Trim(), + request.Summary?.Trim() ?? "", + request.MarkdownContent, + request.Tags ?? [], + request.PublishedDate ?? DateTime.UtcNow); + + if (post is null) + return NotFound(new { error = "Post not found" }); + + return Ok(MapToAdminDto(post)); + } + + [HttpDelete("posts/{id}")] + public async Task DeletePost(long id) + { + var userId = GetUserIdFromAuth(); + if (userId is null) return Unauthorized(); + if (!await IsAdmin(userId.Value)) return Forbid(); + + var deleted = await blogService.DeletePostAsync(id); + if (!deleted) return NotFound(new { error = "Post not found" }); + + return Ok(new { success = true }); + } + + private static object MapToPublicDto(Models.BlogPost post) => new + { + post.Id, + post.Slug, + post.Title, + post.Summary, + post.Tags, + post.PublishedDate, + post.HtmlContent + }; + + private static object MapToAdminDto(Models.BlogPost post) => new + { + post.Id, + post.Slug, + post.Title, + post.Summary, + post.MarkdownContent, + post.Tags, + post.PublishedDate, + post.CreatedAt, + post.UpdatedAt + }; + + private async Task IsAdmin(long userId) + { + return await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')", + new { UserId = userId }); + } + + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + return jwtUserId; + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + return sessionUserId; + + return null; + } +} + +public record CreateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List? Tags, DateTime? PublishedDate); +public record UpdateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List? Tags, DateTime? PublishedDate); diff --git a/Media.JoshHeaps.Net/Database/029_blog_posts.sql b/Media.JoshHeaps.Net/Database/029_blog_posts.sql new file mode 100644 index 0000000..a5933cf --- /dev/null +++ b/Media.JoshHeaps.Net/Database/029_blog_posts.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS app.blog_posts ( + id BIGSERIAL PRIMARY KEY, + slug VARCHAR(200) UNIQUE NOT NULL, + title VARCHAR(500) NOT NULL, + summary TEXT NOT NULL DEFAULT '', + markdown_content TEXT NOT NULL, + html_content TEXT NOT NULL, + tags TEXT[] NOT NULL DEFAULT '{}', + author_id BIGINT NOT NULL REFERENCES app.users(id), + published_date TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON app.blog_posts(slug); +CREATE INDEX IF NOT EXISTS idx_blog_posts_published_date ON app.blog_posts(published_date DESC); + +-- Seed existing blog post +INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date) +SELECT + 'my-first-post', + 'My First Post', + 'Welcome to my blog! Here I''ll share thoughts on software development, projects I''m working on, and things I find interesting.', + E'# Welcome to My Blog\n\nI''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.\n\n## What to Expect\n\nI plan to write about:\n\n- **Projects** I''m working on, like this website and the other things on my portfolio\n- **Software development** tips and patterns I find useful\n- **Problem solving** approaches that have helped me grow as a developer\n\n## Why a Blog?\n\nBuilding things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.\n\nStay tuned for more posts!', + E'

Welcome to My Blog

\n

I''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.

\n

What to Expect

\n

I plan to write about:

\n
    \n
  • Projects I''m working on, like this website and the other things on my portfolio
  • \n
  • Software development tips and patterns I find useful
  • \n
  • Problem solving approaches that have helped me grow as a developer
  • \n
\n

Why a Blog?

\n

Building things is great, but explaining how and why you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.

\n

Stay tuned for more posts!

', + ARRAY['dev', 'personal'], + (SELECT id FROM app.users WHERE id = 1), + '2026-03-06T00:00:00Z' +WHERE EXISTS (SELECT 1 FROM app.users WHERE id = 1) +AND NOT EXISTS (SELECT 1 FROM app.blog_posts WHERE slug = 'my-first-post'); diff --git a/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj b/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj index 5c5215a..9423bdf 100644 --- a/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj +++ b/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj @@ -10,6 +10,7 @@ + diff --git a/Media.JoshHeaps.Net/Models/BlogPost.cs b/Media.JoshHeaps.Net/Models/BlogPost.cs new file mode 100644 index 0000000..897a2de --- /dev/null +++ b/Media.JoshHeaps.Net/Models/BlogPost.cs @@ -0,0 +1,16 @@ +namespace Media.JoshHeaps.Net.Models; + +public class BlogPost +{ + public long Id { get; set; } + public string Slug { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; + public string MarkdownContent { get; set; } = string.Empty; + public string HtmlContent { get; set; } = string.Empty; + public List Tags { get; set; } = []; + public long AuthorId { get; set; } + public DateTime PublishedDate { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/Admin.cshtml b/Media.JoshHeaps.Net/Pages/Admin.cshtml index 48ecbde..25bf4e5 100644 --- a/Media.JoshHeaps.Net/Pages/Admin.cshtml +++ b/Media.JoshHeaps.Net/Pages/Admin.cshtml @@ -21,6 +21,7 @@

Admin

diff --git a/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml new file mode 100644 index 0000000..c1495ee --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml @@ -0,0 +1,82 @@ +@page +@model Media.JoshHeaps.Net.Pages.BlogAdminModel +@{ + ViewData["Title"] = "Blog Admin"; + Layout = "_Layout"; +} + +@section Styles { + +} + +
+
+
+ + + + + + +

Blog Admin

+
+
+ +
+
+ +
+
+

Posts

+
+
+ + + + + + + + + + + +
TitleSlugTagsPublishedActions
+
+
+ + +
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml.cs b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml.cs new file mode 100644 index 0000000..53f0a85 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages; + +public class BlogAdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel +{ + private readonly DbExecutor _dbExecutor = dbExecutor; + + public async Task OnGetAsync() + { + RequireAuthentication(); + LoadUserSession(); + + var denied = await RequireRole("admin", _dbExecutor); + if (denied != null) return denied; + + return Page(); + } +} diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index ee5d36a..ce24eb8 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add session support builder.Services.AddDistributedMemoryCache(); diff --git a/Media.JoshHeaps.Net/Services/BlogService.cs b/Media.JoshHeaps.Net/Services/BlogService.cs new file mode 100644 index 0000000..260f481 --- /dev/null +++ b/Media.JoshHeaps.Net/Services/BlogService.cs @@ -0,0 +1,204 @@ +using System.Text.RegularExpressions; +using Markdig; +using Media.JoshHeaps.Net.Models; + +namespace Media.JoshHeaps.Net.Services; + +public partial class BlogService(DbExecutor db, ILogger logger) +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + public async Task> GetAllPostsAsync() + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at + FROM app.blog_posts + ORDER BY published_date DESC", + MapBlogPost); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get all blog posts"); + return []; + } + } + + public async Task GetPostByIdAsync(long id) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at + FROM app.blog_posts + WHERE id = @Id", + MapBlogPost, + new { Id = id }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get blog post by id {Id}", id); + return null; + } + } + + public async Task GetPostBySlugAsync(string slug) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at + FROM app.blog_posts + WHERE slug = @Slug", + MapBlogPost, + new { Slug = slug }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get blog post by slug {Slug}", slug); + return null; + } + } + + public async Task> GetPostsByTagAsync(string tag) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at + FROM app.blog_posts + WHERE @Tag = ANY(tags) + ORDER BY published_date DESC", + MapBlogPost, + new { Tag = tag }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get blog posts by tag {Tag}", tag); + return []; + } + } + + public async Task CreatePostAsync(string title, string summary, string markdownContent, List tags, long authorId, DateTime publishedDate) + { + try + { + var slug = GenerateSlug(title); + var htmlContent = Markdown.ToHtml(markdownContent, Pipeline); + var now = DateTime.UtcNow; + + return await db.ExecuteReaderAsync( + @"INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at) + VALUES (@Slug, @Title, @Summary, @MarkdownContent, @HtmlContent, @Tags, @AuthorId, @PublishedDate, @CreatedAt, @UpdatedAt) + RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at", + MapBlogPost, + new + { + Slug = slug, + Title = title, + Summary = summary, + MarkdownContent = markdownContent, + HtmlContent = htmlContent, + Tags = tags.ToArray(), + AuthorId = authorId, + PublishedDate = publishedDate, + CreatedAt = now, + UpdatedAt = now + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create blog post '{Title}'", title); + return null; + } + } + + public async Task UpdatePostAsync(long id, string title, string summary, string markdownContent, List tags, DateTime publishedDate) + { + try + { + var slug = GenerateSlug(title); + var htmlContent = Markdown.ToHtml(markdownContent, Pipeline); + + return await db.ExecuteReaderAsync( + @"UPDATE app.blog_posts + SET slug = @Slug, title = @Title, summary = @Summary, markdown_content = @MarkdownContent, + html_content = @HtmlContent, tags = @Tags, published_date = @PublishedDate, updated_at = @UpdatedAt + WHERE id = @Id + RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at", + MapBlogPost, + new + { + Id = id, + Slug = slug, + Title = title, + Summary = summary, + MarkdownContent = markdownContent, + HtmlContent = htmlContent, + Tags = tags.ToArray(), + PublishedDate = publishedDate, + UpdatedAt = DateTime.UtcNow + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update blog post {Id}", id); + return null; + } + } + + public async Task DeletePostAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + "DELETE FROM app.blog_posts WHERE id = @Id", + new { Id = id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete blog post {Id}", id); + return false; + } + } + + private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader) + { + return new BlogPost + { + Id = reader.GetInt64(0), + Slug = reader.GetString(1), + Title = reader.GetString(2), + Summary = reader.GetString(3), + MarkdownContent = reader.GetString(4), + HtmlContent = reader.GetString(5), + Tags = reader.GetFieldValue(6).ToList(), + AuthorId = reader.GetInt64(7), + PublishedDate = reader.GetDateTime(8), + CreatedAt = reader.GetDateTime(9), + UpdatedAt = reader.GetDateTime(10) + }; + } + + private static string GenerateSlug(string title) + { + var slug = title.ToLowerInvariant(); + slug = SlugInvalidChars().Replace(slug, ""); + slug = SlugWhitespace().Replace(slug, "-"); + slug = SlugMultipleDashes().Replace(slug, "-"); + return slug.Trim('-'); + } + + [GeneratedRegex(@"[^a-z0-9\s-]")] + private static partial Regex SlugInvalidChars(); + + [GeneratedRegex(@"\s+")] + private static partial Regex SlugWhitespace(); + + [GeneratedRegex(@"-{2,}")] + private static partial Regex SlugMultipleDashes(); +} diff --git a/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css b/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css new file mode 100644 index 0000000..ba0bf99 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css @@ -0,0 +1,261 @@ +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +.welcome-section { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + color: var(--text-primary); + padding: 32px 40px; + border-radius: 8px; + margin-bottom: 32px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.welcome-left { + display: flex; + align-items: center; + gap: 16px; +} + +.back-button { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.back-button:hover { + background: var(--bg-hover); + border-color: var(--accent-primary); + color: var(--accent-primary); + transform: translateX(-2px); +} + +.back-button svg { + width: 20px; + height: 20px; +} + +.welcome-section h1 { + margin: 0; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.5px; +} + +.quick-actions { + display: flex; + gap: 12px; +} + +.btn { + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +.btn-primary { + background: var(--accent-primary); + color: #fff; + border-color: var(--accent-primary); +} + +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-primary); +} + +.btn-secondary:hover { + background: var(--bg-hover); + border-color: var(--accent-primary); +} + +.btn-danger { + background: var(--danger); + color: #fff; + border-color: var(--danger); +} + +.btn-danger:hover { + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +.admin-section { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 24px; + margin-bottom: 24px; +} + +.section-header { + margin-bottom: 16px; +} + +.section-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +.table-container { + overflow-x: auto; +} + +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--border-secondary); +} + +.admin-table th { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + background: var(--bg-tertiary); +} + +.admin-table td { + font-size: 14px; + color: var(--text-primary); +} + +.admin-table tbody tr:hover { + background: var(--bg-hover); +} + +.admin-table code { + font-size: 12px; + padding: 2px 6px; + background: var(--bg-tertiary); + border-radius: 4px; + color: var(--text-secondary); +} + +.empty-message { + text-align: center; + color: var(--text-secondary); + font-style: italic; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.tag-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +.action-cell { + display: flex; + gap: 6px; +} + +/* Editor form */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.form-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; +} + +.form-input:focus { + border-color: var(--accent-primary); +} + +.form-textarea { + min-height: 400px; + resize: vertical; + font-family: monospace; + line-height: 1.5; +} + +.editor-actions { + display: flex; + gap: 8px; + margin-top: 20px; +} + +@media (max-width: 768px) { + .welcome-section { + flex-direction: column; + gap: 16px; + padding: 20px; + align-items: flex-start; + } + + .admin-table th, + .admin-table td { + padding: 8px 10px; + } + + .action-cell { + flex-direction: column; + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js b/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js new file mode 100644 index 0000000..2ed21a9 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js @@ -0,0 +1,150 @@ +(function () { + let posts = []; + + document.addEventListener('DOMContentLoaded', init); + + async function init() { + await loadPosts(); + + document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor); + document.getElementById('savePostBtn').addEventListener('click', savePost); + document.getElementById('cancelEditBtn').addEventListener('click', hideEditor); + } + + async function loadPosts() { + const res = await fetch('/api/blog/posts'); + if (!res.ok) return; + posts = await res.json(); + renderPostsTable(); + } + + function renderPostsTable() { + const tbody = document.getElementById('postsTableBody'); + if (posts.length === 0) { + tbody.innerHTML = 'No posts yet. Create your first post!'; + return; + } + tbody.innerHTML = posts.map(post => { + const tags = post.tags.map(t => `${escapeHtml(t)}`).join(''); + const date = new Date(post.publishedDate).toLocaleDateString(); + return ` + ${escapeHtml(post.title)} + ${escapeHtml(post.slug)} +
${tags}
+ ${date} + + + + + `; + }).join(''); + } + + function showNewPostEditor() { + document.getElementById('editorTitle').textContent = 'New Post'; + document.getElementById('editPostId').value = ''; + document.getElementById('postTitle').value = ''; + document.getElementById('postSummary').value = ''; + document.getElementById('postTags').value = ''; + document.getElementById('postContent').value = ''; + document.getElementById('postDate').value = new Date().toISOString().split('T')[0]; + document.getElementById('postsList').style.display = 'none'; + document.getElementById('postEditor').style.display = ''; + } + + function hideEditor() { + document.getElementById('postEditor').style.display = 'none'; + document.getElementById('postsList').style.display = ''; + } + + async function savePost() { + const id = document.getElementById('editPostId').value; + const title = document.getElementById('postTitle').value.trim(); + const summary = document.getElementById('postSummary').value.trim(); + const tagsStr = document.getElementById('postTags').value.trim(); + const markdownContent = document.getElementById('postContent').value; + const dateStr = document.getElementById('postDate').value; + + if (!title) { alert('Title is required'); return; } + if (!markdownContent) { alert('Content is required'); return; } + + const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : []; + const publishedDate = dateStr ? new Date(dateStr + 'T00:00:00Z').toISOString() : null; + + const body = { title, summary, markdownContent, tags, publishedDate }; + const url = id ? `/api/blog/posts/${id}` : '/api/blog/posts'; + const method = id ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to save post'); + return; + } + + hideEditor(); + await loadPosts(); + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + window.blogEditPost = async function (id) { + const res = await fetch(`/api/blog/posts`); + if (!res.ok) return; + const allPosts = await res.json(); + + // Need markdown content, so fetch the admin-specific data + // The public API doesn't return markdownContent, so we fetch all and find by id + // Actually, we need to get it from the create/update response pattern + // For editing, we'll fetch via a dedicated admin endpoint or use stored data + // Since the public GET returns htmlContent but not markdownContent, + // let's make a specific request. But our API only has public routes returning htmlContent. + // We need the admin to get markdownContent too. + + // Workaround: find in local posts array (which has htmlContent), but we need markdown. + // The cleanest fix is to have the GET endpoints also return markdownContent for admins, + // but for now let's add an admin-specific detail endpoint. + // Actually, looking at the API, the GET /posts/{slug} returns the public DTO. + // Let me use a different approach: store markdownContent in memory from the list. + + // For now, we need to get the post with markdownContent. + // Let's fetch by id from the admin-aware endpoint. + const detailRes = await fetch(`/api/blog/posts/admin/${id}`); + if (!detailRes.ok) { + alert('Failed to load post for editing'); + return; + } + const post = await detailRes.json(); + + document.getElementById('editorTitle').textContent = 'Edit Post'; + document.getElementById('editPostId').value = post.id; + document.getElementById('postTitle').value = post.title; + document.getElementById('postSummary').value = post.summary || ''; + document.getElementById('postTags').value = (post.tags || []).join(', '); + document.getElementById('postContent').value = post.markdownContent || ''; + document.getElementById('postDate').value = new Date(post.publishedDate).toISOString().split('T')[0]; + document.getElementById('postsList').style.display = 'none'; + document.getElementById('postEditor').style.display = ''; + }; + + window.blogDeletePost = async function (id) { + if (!confirm('Are you sure you want to delete this post?')) return; + + const res = await fetch(`/api/blog/posts/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to delete post'); + return; + } + await loadPosts(); + }; +})(); From 2443b92c23fe439cb497f0813702c4645edcb239 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 6 Mar 2026 16:12:58 -0700 Subject: [PATCH 2/4] Update blog button --- Media.JoshHeaps.Net/Pages/Admin.cshtml | 1 - Media.JoshHeaps.Net/Pages/Landing.cshtml | 25 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Media.JoshHeaps.Net/Pages/Admin.cshtml b/Media.JoshHeaps.Net/Pages/Admin.cshtml index 25bf4e5..48ecbde 100644 --- a/Media.JoshHeaps.Net/Pages/Admin.cshtml +++ b/Media.JoshHeaps.Net/Pages/Admin.cshtml @@ -21,7 +21,6 @@

Admin

diff --git a/Media.JoshHeaps.Net/Pages/Landing.cshtml b/Media.JoshHeaps.Net/Pages/Landing.cshtml index 1d72a2e..6052410 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml @@ -120,6 +120,31 @@ + +
+
+
+ + + + +
+
+
+

Blog

+

Create and manage blog posts for your website

+
+
+ +
} @if (Model.HasMedicalRole) From 941759f7c3a449820be4cd28d2e8c8b9a4db38ce Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 6 Mar 2026 16:31:20 -0700 Subject: [PATCH 3/4] Add image upload capabilities --- Media.JoshHeaps.Net/Api/BlogApi.cs | 27 +++++++++ Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml | 5 ++ Media.JoshHeaps.Net/Services/BlogService.cs | 56 ++++++++++++++++++- .../wwwroot/css/blog-admin.css | 20 +++++++ Media.JoshHeaps.Net/wwwroot/js/blog-admin.js | 45 +++++++++++++++ 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Media.JoshHeaps.Net/Api/BlogApi.cs b/Media.JoshHeaps.Net/Api/BlogApi.cs index 46b5f23..f40ae8a 100644 --- a/Media.JoshHeaps.Net/Api/BlogApi.cs +++ b/Media.JoshHeaps.Net/Api/BlogApi.cs @@ -107,6 +107,33 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle return Ok(new { success = true }); } + [HttpPost("images")] + public async Task UploadImage(IFormFile file) + { + var userId = GetUserIdFromAuth(); + if (userId is null) return Unauthorized(); + if (!await IsAdmin(userId.Value)) return Forbid(); + + if (file is null || file.Length == 0) + return BadRequest(new { error = "No file provided" }); + + var (url, error) = await blogService.SaveImageAsync(file); + if (url is null) + return BadRequest(new { error }); + + return Ok(new { url }); + } + + [HttpGet("images/{fileName}")] + public IActionResult GetImage(string fileName) + { + var (filePath, mimeType) = blogService.GetImagePath(fileName); + if (filePath is null) + return NotFound(); + + return PhysicalFile(filePath, mimeType!); + } + private static object MapToPublicDto(Models.BlogPost post) => new { post.Id, diff --git a/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml index c1495ee..ff97963 100644 --- a/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml +++ b/Media.JoshHeaps.Net/Pages/BlogAdmin.cshtml @@ -68,6 +68,11 @@
+
+ + + +
diff --git a/Media.JoshHeaps.Net/Services/BlogService.cs b/Media.JoshHeaps.Net/Services/BlogService.cs index 260f481..871ad8b 100644 --- a/Media.JoshHeaps.Net/Services/BlogService.cs +++ b/Media.JoshHeaps.Net/Services/BlogService.cs @@ -4,8 +4,10 @@ using Media.JoshHeaps.Net.Models; namespace Media.JoshHeaps.Net.Services; -public partial class BlogService(DbExecutor db, ILogger logger) +public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger logger) { + private static readonly HashSet AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + private const long MaxImageSize = 10 * 1024 * 1024; private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .Build(); @@ -166,6 +168,58 @@ public partial class BlogService(DbExecutor db, ILogger logger) } } + public async Task<(string? url, string? error)> SaveImageAsync(IFormFile file) + { + if (!AllowedMimeTypes.Contains(file.ContentType)) + return (null, "Only JPEG, PNG, GIF, and WEBP images are allowed"); + + if (file.Length > MaxImageSize) + return (null, "Image must be under 10MB"); + + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) ext = ".jpg"; + + var fileName = $"{Guid.NewGuid()}{ext}"; + var folder = Path.Combine(environment.ContentRootPath, "App_Data", "blog"); + Directory.CreateDirectory(folder); + + var filePath = Path.Combine(folder, fileName); + try + { + await using var stream = new FileStream(filePath, FileMode.Create); + await file.CopyToAsync(stream); + return ($"/api/blog/images/{fileName}", null); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save blog image"); + if (File.Exists(filePath)) File.Delete(filePath); + return (null, "Failed to save image"); + } + } + + public (string? filePath, string? mimeType) GetImagePath(string fileName) + { + if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\')) + return (null, null); + + var filePath = Path.Combine(environment.ContentRootPath, "App_Data", "blog", fileName); + if (!File.Exists(filePath)) + return (null, null); + + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + var mimeType = ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + + return (filePath, mimeType); + } + private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader) { return new BlogPost diff --git a/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css b/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css index ba0bf99..40bf6c1 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css +++ b/Media.JoshHeaps.Net/wwwroot/css/blog-admin.css @@ -229,6 +229,26 @@ border-color: var(--accent-primary); } +.content-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.upload-status { + font-size: 13px; + color: var(--text-secondary); +} + +.upload-error { + color: var(--danger); +} + +.upload-success { + color: var(--success, #22c55e); +} + .form-textarea { min-height: 400px; resize: vertical; diff --git a/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js b/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js index 2ed21a9..8052fae 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js +++ b/Media.JoshHeaps.Net/wwwroot/js/blog-admin.js @@ -9,6 +9,51 @@ document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor); document.getElementById('savePostBtn').addEventListener('click', savePost); document.getElementById('cancelEditBtn').addEventListener('click', hideEditor); + + const imageInput = document.getElementById('imageFileInput'); + document.getElementById('uploadImageBtn').addEventListener('click', () => imageInput.click()); + imageInput.addEventListener('change', uploadImage); + } + + async function uploadImage() { + const input = document.getElementById('imageFileInput'); + const status = document.getElementById('uploadStatus'); + if (!input.files.length) return; + + const file = input.files[0]; + const formData = new FormData(); + formData.append('file', file); + + status.textContent = 'Uploading...'; + status.className = 'upload-status'; + + try { + const res = await fetch('/api/blog/images', { method: 'POST', body: formData }); + if (!res.ok) { + const err = await res.json(); + status.textContent = err.error || 'Upload failed'; + status.className = 'upload-status upload-error'; + return; + } + const { url } = await res.json(); + const textarea = document.getElementById('postContent'); + const markdown = `![${file.name}](${url})`; + const pos = textarea.selectionStart; + const before = textarea.value.substring(0, pos); + const after = textarea.value.substring(pos); + const needsNewline = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; + textarea.value = before + needsNewline + markdown + '\n' + after; + textarea.focus(); + textarea.selectionStart = textarea.selectionEnd = pos + needsNewline.length + markdown.length + 1; + status.textContent = 'Uploaded!'; + status.className = 'upload-status upload-success'; + setTimeout(() => { status.textContent = ''; }, 2000); + } catch { + status.textContent = 'Upload failed'; + status.className = 'upload-status upload-error'; + } finally { + input.value = ''; + } } async function loadPosts() { From b2d0c725418745e608266f015d7021f0c6643d96 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 6 Mar 2026 17:05:36 -0700 Subject: [PATCH 4/4] add cache invalidation --- Media.JoshHeaps.Net/Api/BlogApi.cs | 25 ++++++++++++++++++++++++- Media.JoshHeaps.Net/Program.cs | 1 + Media.JoshHeaps.Net/appsettings.json | 4 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Media.JoshHeaps.Net/Api/BlogApi.cs b/Media.JoshHeaps.Net/Api/BlogApi.cs index f40ae8a..55c203f 100644 --- a/Media.JoshHeaps.Net/Api/BlogApi.cs +++ b/Media.JoshHeaps.Net/Api/BlogApi.cs @@ -6,7 +6,7 @@ namespace Media.JoshHeaps.Net.Api; [ApiController] [Route("api/blog")] -public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : ControllerBase +public class BlogApi(BlogService blogService, DbExecutor dbExecutor, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) : ControllerBase { [HttpGet("posts")] public async Task GetPosts() @@ -65,6 +65,7 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle if (post is null) return StatusCode(500, new { error = "Failed to create post" }); + _ = InvalidateFrontendCacheAsync(); return Ok(MapToAdminDto(post)); } @@ -91,6 +92,7 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle if (post is null) return NotFound(new { error = "Post not found" }); + _ = InvalidateFrontendCacheAsync(); return Ok(MapToAdminDto(post)); } @@ -104,6 +106,7 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle var deleted = await blogService.DeletePostAsync(id); if (!deleted) return NotFound(new { error = "Post not found" }); + _ = InvalidateFrontendCacheAsync(); return Ok(new { success = true }); } @@ -158,6 +161,26 @@ public class BlogApi(BlogService blogService, DbExecutor dbExecutor) : Controlle post.UpdatedAt }; + private async Task InvalidateFrontendCacheAsync() + { + try + { + var url = configuration["BlogCache:InvalidateUrl"]; + var key = configuration["BlogCache:InvalidateKey"]; + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) return; + + var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(5); + var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Add("X-Invalidate-Key", key); + await client.SendAsync(request); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to invalidate frontend blog cache"); + } + } + private async Task IsAdmin(long userId) { return await dbExecutor.ExecuteAsync( diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index ce24eb8..dab4ad8 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddHttpClient(); // Add session support builder.Services.AddDistributedMemoryCache(); diff --git a/Media.JoshHeaps.Net/appsettings.json b/Media.JoshHeaps.Net/appsettings.json index b05998d..852491e 100644 --- a/Media.JoshHeaps.Net/appsettings.json +++ b/Media.JoshHeaps.Net/appsettings.json @@ -20,6 +20,10 @@ "FromName": "Media App", "EnableSsl": "true" }, + "BlogCache": { + "InvalidateUrl": "https://joshheaps.net/api/blog/invalidate", + "InvalidateKey": "CHANGE_ME" + }, "FileUpload": { "MaxFileSizeMB": 10, "AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],