From a3c90ad06eb29dd316b8ed4e95d15bdd2780520c Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 6 Mar 2026 15:33:49 -0700 Subject: [PATCH 1/2] 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 0ea72d0..e76f8c6 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.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 9f3f610025f669422a0eccc92a5a3e065fa37d13 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 6 Mar 2026 16:12:58 -0700 Subject: [PATCH 2/2] 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)