Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f5b843a4 | ||
|
|
b2d0c72541 | ||
|
|
941759f7c3 | ||
|
|
2443b92c23 | ||
|
|
d8015f54ef | ||
|
|
d1eb772e89 | ||
|
|
caad111457 | ||
|
|
dda6ac68dc | ||
|
|
af309ca8d6 |
@@ -0,0 +1,77 @@
|
|||||||
|
# .gitea/workflows/deploy.yml
|
||||||
|
name: Deploy Media Server
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-media-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install toolchain
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends rsync openssh-client
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: dotnet publish -c Release -o ./publish
|
||||||
|
|
||||||
|
- name: Prepare SSH
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.MEDIA_SSH_KEY }}
|
||||||
|
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Staged through /tmp because the deploy user can't write /var/www directly.
|
||||||
|
# /usr/local/sbin/deploy-media (root-owned, the only command in deploy's
|
||||||
|
# sudoers) moves it into place with --exclude=App_Data, so the uploaded
|
||||||
|
# media and its encrypted store are never touched by --delete.
|
||||||
|
- name: Stage publish output
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.MEDIA_SSH_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
SSH_OPTS="-p $PORT -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes"
|
||||||
|
ssh $SSH_OPTS "$SSH_USER@$SSH_HOST" \
|
||||||
|
"rm -rf /tmp/media-app-stage && mkdir -p /tmp/media-app-stage"
|
||||||
|
rsync -az --delete -e "ssh $SSH_OPTS" \
|
||||||
|
publish/ "$SSH_USER@$SSH_HOST:/tmp/media-app-stage/"
|
||||||
|
|
||||||
|
- name: Install and restart
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.MEDIA_SSH_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
ssh -p "$PORT" -i "$HOME/.ssh/deploy_key" "$SSH_USER@$SSH_HOST" \
|
||||||
|
"sudo -n /usr/local/sbin/deploy-media && rm -rf /tmp/media-app-stage"
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<BlogApi> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("posts")]
|
||||||
|
public async Task<IActionResult> GetPosts()
|
||||||
|
{
|
||||||
|
var posts = await blogService.GetAllPostsAsync();
|
||||||
|
return Ok(posts.Select(MapToPublicDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("posts/{slug}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> GetPostsByTag(string tag)
|
||||||
|
{
|
||||||
|
var posts = await blogService.GetPostsByTagAsync(tag);
|
||||||
|
return Ok(posts.Select(MapToPublicDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("posts/admin/{id}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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" });
|
||||||
|
|
||||||
|
_ = InvalidateFrontendCacheAsync();
|
||||||
|
return Ok(MapToAdminDto(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("posts/{id}")]
|
||||||
|
public async Task<IActionResult> 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" });
|
||||||
|
|
||||||
|
_ = InvalidateFrontendCacheAsync();
|
||||||
|
return Ok(MapToAdminDto(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("posts/{id}")]
|
||||||
|
public async Task<IActionResult> 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" });
|
||||||
|
|
||||||
|
_ = InvalidateFrontendCacheAsync();
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("images")]
|
||||||
|
public async Task<IActionResult> 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,
|
||||||
|
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 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<bool> IsAdmin(long userId)
|
||||||
|
{
|
||||||
|
return await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"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<string>? Tags, DateTime? PublishedDate);
|
||||||
|
public record UpdateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
|
||||||
@@ -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'<h1>Welcome to My Blog</h1>\n<p>I''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.</p>\n<h2>What to Expect</h2>\n<p>I plan to write about:</p>\n<ul>\n<li><strong>Projects</strong> I''m working on, like this website and the other things on my portfolio</li>\n<li><strong>Software development</strong> tips and patterns I find useful</li>\n<li><strong>Problem solving</strong> approaches that have helped me grow as a developer</li>\n</ul>\n<h2>Why a Blog?</h2>\n<p>Building things is great, but explaining <em>how</em> and <em>why</em> you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.</p>\n<p>Stay tuned for more posts!</p>',
|
||||||
|
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');
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||||
|
<PackageReference Include="Markdig" Version="1.1.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
<PackageReference Include="Npgsql" Version="9.0.4" />
|
<PackageReference Include="Npgsql" Version="9.0.4" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||||
|
|||||||
@@ -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<string> Tags { get; set; } = [];
|
||||||
|
public long AuthorId { get; set; }
|
||||||
|
public DateTime PublishedDate { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
@page
|
||||||
|
@model Media.JoshHeaps.Net.Pages.BlogAdminModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Blog Admin";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="~/css/blog-admin.css" asp-append-version="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<div class="welcome-section">
|
||||||
|
<div class="welcome-left">
|
||||||
|
<a href="/Admin" class="back-button" title="Back to Admin">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||||
|
<polyline points="12 19 5 12 12 5"></polyline>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1>Blog Admin</h1>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button id="newPostBtn" class="btn btn-primary">New Post</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="postsList" class="admin-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Posts</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Tags</th>
|
||||||
|
<th>Published</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="postsTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="postEditor" class="admin-section" style="display: none;">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 id="editorTitle">New Post</h2>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="editPostId" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postTitle">Title</label>
|
||||||
|
<input type="text" id="postTitle" class="form-input" placeholder="Post title" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postSummary">Summary</label>
|
||||||
|
<input type="text" id="postSummary" class="form-input" placeholder="Brief summary" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postTags">Tags (comma-separated)</label>
|
||||||
|
<input type="text" id="postTags" class="form-input" placeholder="dev, personal" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postDate">Published Date</label>
|
||||||
|
<input type="date" id="postDate" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postContent">Markdown Content</label>
|
||||||
|
<div class="content-toolbar">
|
||||||
|
<button type="button" id="uploadImageBtn" class="btn btn-secondary btn-sm">Upload Image</button>
|
||||||
|
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;" />
|
||||||
|
<span id="uploadStatus" class="upload-status"></span>
|
||||||
|
</div>
|
||||||
|
<textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button id="savePostBtn" class="btn btn-primary">Save</button>
|
||||||
|
<button id="cancelEditBtn" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/blog-admin.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
@@ -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<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
RequireAuthentication();
|
||||||
|
LoadUserSession();
|
||||||
|
|
||||||
|
var denied = await RequireRole("admin", _dbExecutor);
|
||||||
|
if (denied != null) return denied;
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/BlogAdmin" class="landing-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-icon-wrapper">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9"></path>
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-text">
|
||||||
|
<h2>Blog</h2>
|
||||||
|
<p class="card-description">Create and manage blog posts for your website</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="card-link-text">Open workspace</span>
|
||||||
|
<div class="card-arrow">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
<polyline points="12 5 19 12 12 19"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (Model.HasMedicalRole)
|
@if (Model.HasMedicalRole)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ builder.Services.AddScoped<FolderService>();
|
|||||||
builder.Services.AddScoped<GraphService>();
|
builder.Services.AddScoped<GraphService>();
|
||||||
builder.Services.AddScoped<MedicalDocsService>();
|
builder.Services.AddScoped<MedicalDocsService>();
|
||||||
builder.Services.AddSingleton<MedicalAiService>();
|
builder.Services.AddSingleton<MedicalAiService>();
|
||||||
|
builder.Services.AddScoped<BlogService>();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// Add session support
|
// Add session support
|
||||||
builder.Services.AddDistributedMemoryCache();
|
builder.Services.AddDistributedMemoryCache();
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Markdig;
|
||||||
|
using Media.JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace Media.JoshHeaps.Net.Services;
|
||||||
|
|
||||||
|
public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger<BlogService> logger)
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> 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();
|
||||||
|
|
||||||
|
public async Task<List<BlogPost>> 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<BlogPost?> 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<BlogPost?> 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<List<BlogPost>> 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<BlogPost?> CreatePostAsync(string title, string summary, string markdownContent, List<string> 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<BlogPost?> UpdatePostAsync(long id, string title, string summary, string markdownContent, List<string> 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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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<string[]>(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();
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
"FromName": "Media App",
|
"FromName": "Media App",
|
||||||
"EnableSsl": "true"
|
"EnableSsl": "true"
|
||||||
},
|
},
|
||||||
|
"BlogCache": {
|
||||||
|
"InvalidateUrl": "https://joshheaps.net/api/blog/invalidate",
|
||||||
|
"InvalidateKey": "CHANGE_ME"
|
||||||
|
},
|
||||||
"FileUpload": {
|
"FileUpload": {
|
||||||
"MaxFileSizeMB": 10,
|
"MaxFileSizeMB": 10,
|
||||||
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
(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);
|
||||||
|
|
||||||
|
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 = ``;
|
||||||
|
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() {
|
||||||
|
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 = '<tr><td colspan="5" class="empty-message">No posts yet. Create your first post!</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = posts.map(post => {
|
||||||
|
const tags = post.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('');
|
||||||
|
const date = new Date(post.publishedDate).toLocaleDateString();
|
||||||
|
return `<tr>
|
||||||
|
<td>${escapeHtml(post.title)}</td>
|
||||||
|
<td><code>${escapeHtml(post.slug)}</code></td>
|
||||||
|
<td><div class="tag-list">${tags}</div></td>
|
||||||
|
<td>${date}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="blogEditPost(${post.id})">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="blogDeletePost(${post.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).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();
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user