| @@ -107,6 +107,142 @@ Class AdminController_Class | |||
| Response.Redirect "/admin/posts" | |||
| End Sub | |||
| '--------------------------------------------------------------- | |||
| ' Action: GenerateAIContent | |||
| '--------------------------------------------------------------- | |||
| Public Sub GenerateAIContent(ByVal id) | |||
| Dim post | |||
| On Error Resume Next | |||
| Set post = PostsRepository().FindByID(id) | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| Flash().AddError "Post not found." | |||
| Response.Redirect "/admin/posts" | |||
| Exit Sub | |||
| End If | |||
| On Error GoTo 0 | |||
| Dim generatedSummary, generatedBody | |||
| On Error Resume Next | |||
| GeneratePostContentFromAI post, generatedSummary, generatedBody | |||
| If Err.Number <> 0 Then | |||
| Flash().AddError "AI content generation failed: " & Err.Description | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| Response.Redirect PostEditUrl(post.PostID) | |||
| Exit Sub | |||
| End If | |||
| On Error GoTo 0 | |||
| If Len(Trim(generatedSummary)) = 0 Or Len(Trim(generatedBody)) = 0 Then | |||
| Flash().AddError "AI content generation returned empty content." | |||
| Response.Redirect PostEditUrl(post.PostID) | |||
| Exit Sub | |||
| End If | |||
| post.Summary = generatedSummary | |||
| post.Body = generatedBody | |||
| post.UpdatedDate = Now() | |||
| PostsRepository().Update post | |||
| Flash().Success = "AI content generated." | |||
| Response.Redirect PostEditUrl(post.PostID) | |||
| End Sub | |||
| Private Sub GeneratePostContentFromAI(ByRef post, ByRef generatedSummary, ByRef generatedBody) | |||
| Dim apiKey : apiKey = Trim(CStr(GetAppSetting("AbacusApiKey"))) | |||
| If Len(apiKey) = 0 Or LCase(apiKey) = "nothing" Then | |||
| Err.Raise 1, "AdminController.GeneratePostContentFromAI", "Abacus API key is not configured." | |||
| End If | |||
| Dim baseUrl : baseUrl = Trim(CStr(GetAppSetting("AbacusApiBaseUrl"))) | |||
| If Len(baseUrl) = 0 Or LCase(baseUrl) = "nothing" Then baseUrl = "https://routellm.abacus.ai/v1" | |||
| If Right(baseUrl, 1) = "/" Then baseUrl = Left(baseUrl, Len(baseUrl) - 1) | |||
| Dim modelName : modelName = Trim(CStr(GetAppSetting("AbacusModel"))) | |||
| If Len(modelName) = 0 Or LCase(modelName) = "nothing" Then modelName = "route-llm" | |||
| Dim systemPrompt, userPrompt, payload, responseText, parsed, choices, choice, message, content, contentJson | |||
| systemPrompt = "You write clear, engaging blog post content for a classic ASP blog. Return only valid JSON with two keys: summary and body. Summary must be 1 to 2 sentences. Body must be 3 to 5 short paragraphs separated by blank lines. Do not use markdown fences, bullets, or code blocks." | |||
| userPrompt = "Create blog content for this post title: " & SafeText(post.Title) & vbCrLf & _ | |||
| "Existing summary: " & SafeText(post.Summary) & vbCrLf & _ | |||
| "Existing body: " & SafeText(post.Body) & vbCrLf & _ | |||
| "Keep the title unchanged. Make the content readable and helpful for a general audience." | |||
| payload = "{""model"":""" & JsonEscape(modelName) & """,""messages"":[{""role"":""system"",""content"":""" & JsonEscape(systemPrompt) & """},{""role"":""user"",""content"":""" & JsonEscape(userPrompt) & """}],""temperature"":0.7}" | |||
| responseText = HttpPostJson(baseUrl & "/chat/completions", apiKey, payload) | |||
| Set parsed = json() | |||
| parsed.loadJSON responseText | |||
| If Not parsed.data.Exists("choices") Then | |||
| Err.Raise 1, "AdminController.GeneratePostContentFromAI", "Abacus API response did not include choices." | |||
| End If | |||
| Set choices = parsed.data.Item("choices") | |||
| If choices.Count = 0 Then | |||
| Err.Raise 1, "AdminController.GeneratePostContentFromAI", "Abacus API returned no choices." | |||
| End If | |||
| Set choice = choices.Item(0) | |||
| If Not choice.Exists("message") Then | |||
| Err.Raise 1, "AdminController.GeneratePostContentFromAI", "Abacus API response did not include a message." | |||
| End If | |||
| Set message = choice.Item("message") | |||
| If Not message.Exists("content") Then | |||
| Err.Raise 1, "AdminController.GeneratePostContentFromAI", "Abacus API response did not include content." | |||
| End If | |||
| content = Trim(CStr(message.Item("content"))) | |||
| contentJson = ExtractJsonObject(content) | |||
| Set parsed = json() | |||
| parsed.loadJSON contentJson | |||
| If parsed.data.Exists("summary") Then generatedSummary = Trim(CStr(parsed.data.Item("summary"))) | |||
| If parsed.data.Exists("body") Then generatedBody = Trim(CStr(parsed.data.Item("body"))) | |||
| End Sub | |||
| Private Function HttpPostJson(ByVal url, ByVal apiKey, ByVal payload) | |||
| Dim http | |||
| Set http = Server.CreateObject("Msxml2.ServerXMLHTTP.6.0") | |||
| http.setTimeouts 10000, 10000, 10000, 30000 | |||
| http.open "POST", url, False | |||
| http.setRequestHeader "Content-Type", "application/json" | |||
| http.setRequestHeader "Authorization", "Bearer " & apiKey | |||
| http.send payload | |||
| If http.status < 200 Or http.status >= 300 Then | |||
| Err.Raise IIf(http.status > 0, http.status, 1), "AdminController.HttpPostJson", "Abacus API returned HTTP " & http.status & ": " & Left(CStr(http.responseText), 500) | |||
| End If | |||
| HttpPostJson = http.responseText | |||
| End Function | |||
| Private Function ExtractJsonObject(ByVal text) | |||
| Dim startPos, endPos | |||
| startPos = InStr(text, "{") | |||
| endPos = InStrRev(text, "}") | |||
| If startPos > 0 And endPos > startPos Then | |||
| ExtractJsonObject = Mid(text, startPos, endPos - startPos + 1) | |||
| Else | |||
| ExtractJsonObject = text | |||
| End If | |||
| End Function | |||
| Private Function SafeText(ByVal value) | |||
| If IsNull(value) Or IsEmpty(value) Then | |||
| SafeText = "" | |||
| Else | |||
| SafeText = CStr(value) | |||
| End If | |||
| End Function | |||
| End Class | |||
| Dim AdminController_Class__Singleton | |||
| @@ -48,27 +48,27 @@ Class PostsController_Class | |||
| '--------------------------------------------------------------- | |||
| ' Action: Show | |||
| '--------------------------------------------------------------- | |||
| Public Sub Show(ByVal slug) | |||
| Dim matches | |||
| Set matches = PostsRepository().Find(Array("Slug", slug, "IsPublished", 1), Empty) | |||
| If matches.Count = 0 Then | |||
| Response.Status = "404 Not Found" | |||
| %> | |||
| <!--#include file="../views/Error/NotFound.asp" --> | |||
| Public Sub Show(ByVal slug) | |||
| Dim matches | |||
| Set matches = PostsRepository().Find(Array("Slug", slug, "IsPublished", 1), Empty) | |||
| If matches.Count = 0 Then | |||
| Response.Status = "404 Not Found" | |||
| %> | |||
| <!--#include file="../views/Error/NotFound.asp" --> | |||
| <% | |||
| Exit Sub | |||
| End If | |||
| Dim post | |||
| Set post = matches.Front() | |||
| Dim comments | |||
| Set comments = CommentsRepository().Find(Array("PostID", post.PostID, "IsApproved", 1), Array("CreatedDate")) | |||
| %> | |||
| <!--#include file="../views/Posts/show.asp" --> | |||
| <% | |||
| End Sub | |||
| End If | |||
| Dim post | |||
| Set post = matches.Front() | |||
| Dim comments | |||
| Set comments = CommentsRepository().Find(Array("PostID", post.PostID, "IsApproved", 1), "CreatedDate") | |||
| %> | |||
| <!--#include file="../views/Posts/show.asp" --> | |||
| <% | |||
| End Sub | |||
| '--------------------------------------------------------------- | |||
| ' Action: New | |||
| @@ -27,29 +27,29 @@ Class CommentsRepository_Class | |||
| Set GetAll = Find(Empty, orderBy) | |||
| End Function | |||
| Public Function Find(where_kvarray, order_string_or_array) | |||
| Dim sql : sql = "Select [AuthorEmail], [AuthorName], [Body], [CommentID], [CreatedDate], [IsApproved], [PostID] FROM [Comments]" | |||
| Dim where_keys, where_values, i | |||
| If Not IsEmpty(where_kvarray) Then | |||
| KVUnzip where_kvarray, where_keys, where_values | |||
| If Not IsEmpty(where_keys) Then | |||
| sql = sql & " WHERE " | |||
| For i = 0 To UBound(where_keys) | |||
| If i > 0 Then sql = sql & " AND " | |||
| sql = sql & " " & QI(where_keys(i)) & " = ?" | |||
| Next | |||
| End If | |||
| End If | |||
| sql = sql & BuildOrderBy(order_string_or_array, "[CommentID]") | |||
| Dim rs : Set rs = DAL.Query(sql, where_values) | |||
| Dim list : Set list = new LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_Comments") | |||
| rs.MoveNext | |||
| Loop | |||
| Set Find = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function Find(where_kvarray, order_string_or_array) | |||
| Dim sql : sql = "Select [AuthorEmail], [AuthorName], [Body], [CommentID], [CreatedDate], [IsApproved], [PostID] FROM [Comments]" | |||
| Dim where_keys, where_values, i | |||
| If Not IsEmpty(where_kvarray) Then | |||
| KVUnzip where_kvarray, where_keys, where_values | |||
| If Not IsEmpty(where_keys) Then | |||
| sql = sql & " WHERE " | |||
| For i = 0 To UBound(where_keys) | |||
| If i > 0 Then sql = sql & " AND " | |||
| sql = sql & " " & QI(where_keys(i)) & " = ?" | |||
| Next | |||
| End If | |||
| End If | |||
| sql = sql & BuildOrderBy(order_string_or_array, "[CommentID]") | |||
| Dim rs : Set rs = DAL.Query(sql, where_values) | |||
| Dim list : Set list = new LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_Comments") | |||
| rs.MoveNext | |||
| Loop | |||
| Set Find = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function FindPaged(where_kvarray, order_string_or_array, per_page, page_num, ByRef page_count, ByRef record_count) | |||
| Dim sql : sql = "Select [AuthorEmail], [AuthorName], [Body], [CommentID], [CreatedDate], [IsApproved], [PostID] FROM [Comments]" | |||
| @@ -3,11 +3,11 @@ | |||
| <h1 class="h3 mb-1">Manage Categories</h1> | |||
| <p class="text-muted mb-0">All categories — edit or delete.</p> | |||
| </div> | |||
| <a class="btn btn-primary" href="/categories/new">New Category</a> | |||
| <a class="btn btn-primary" href="<%= CategoryNewUrl() %>">New Category</a> | |||
| </div> | |||
| <% If categories.Count = 0 Then %> | |||
| <div class="alert alert-secondary">No categories yet. <a href="/categories/new">Create the first one.</a></div> | |||
| <div class="alert alert-secondary">No categories yet. <a href="<%= CategoryNewUrl() %>">Create the first one.</a></div> | |||
| <% Else %> | |||
| <div class="table-responsive"> | |||
| <table class="table table-hover align-middle"> | |||
| @@ -27,14 +27,14 @@ | |||
| %> | |||
| <tr> | |||
| <td> | |||
| <a href="/categories/<%= Server.URLEncode(CStr(adminCatItem.CategoryID)) %>" class="text-decoration-none fw-semibold"> | |||
| <a href="<%= CategoryUrl(adminCatItem.CategoryID) %>" class="text-decoration-none fw-semibold"> | |||
| <%= H(adminCatItem.Name) %> | |||
| </a> | |||
| </td> | |||
| <td class="text-muted small"><%= H(adminCatItem.Description) %></td> | |||
| <td class="text-end text-nowrap"> | |||
| <a class="btn btn-sm btn-outline-secondary" href="/categories/<%= Server.URLEncode(CStr(adminCatItem.CategoryID)) %>/edit">Edit</a> | |||
| <form class="d-inline" method="post" action="/categories/<%= H(adminCatItem.CategoryID) %>/delete"> | |||
| <a class="btn btn-sm btn-outline-secondary" href="<%= CategoryEditUrl(adminCatItem.CategoryID) %>">Edit</a> | |||
| <form class="d-inline" method="post" action="<%= CategoryDeleteUrl(adminCatItem.CategoryID) %>"> | |||
| <button class="btn btn-sm btn-outline-danger" type="submit" onclick="return confirm('Delete this category?')">Delete</button> | |||
| </form> | |||
| </td> | |||
| @@ -7,7 +7,7 @@ | |||
| <div class="row gy-3"> | |||
| <div class="col-md-6"> | |||
| <a class="card shadow-sm text-decoration-none h-100" href="/admin/posts"> | |||
| <a class="card shadow-sm text-decoration-none h-100" href="<%= AdminUrl() %>/posts"> | |||
| <div class="card-body"> | |||
| <h2 class="h5 mb-1">Posts</h2> | |||
| <p class="text-muted mb-0">Create, publish, and manage blog posts.</p> | |||
| @@ -15,7 +15,7 @@ | |||
| </a> | |||
| </div> | |||
| <div class="col-md-6"> | |||
| <a class="card shadow-sm text-decoration-none h-100" href="/admin/categories"> | |||
| <a class="card shadow-sm text-decoration-none h-100" href="<%= AdminUrl() %>/categories"> | |||
| <div class="card-body"> | |||
| <h2 class="h5 mb-1">Categories</h2> | |||
| <p class="text-muted mb-0">Organize posts into categories.</p> | |||
| @@ -3,11 +3,11 @@ | |||
| <h1 class="h3 mb-1">Manage Posts</h1> | |||
| <p class="text-muted mb-0">All posts — publish, edit, or delete.</p> | |||
| </div> | |||
| <a class="btn btn-primary" href="/posts/new">New Post</a> | |||
| <a class="btn btn-primary" href="<%= PostNewUrl() %>">New Post</a> | |||
| </div> | |||
| <% If posts.Count = 0 Then %> | |||
| <div class="alert alert-secondary">No posts yet. <a href="/posts/new">Create the first one.</a></div> | |||
| <div class="alert alert-secondary">No posts yet. <a href="<%= PostNewUrl() %>">Create the first one.</a></div> | |||
| <% Else %> | |||
| <div class="table-responsive"> | |||
| <table class="table table-hover align-middle"> | |||
| @@ -44,17 +44,20 @@ | |||
| <%= H(FormatDateTime(adminPostItem.CreatedDate, vbShortDate)) %> | |||
| </td> | |||
| <td class="text-end text-nowrap"> | |||
| <a class="btn btn-sm btn-outline-secondary" href="/posts/<%= Server.URLEncode(CStr(adminPostItem.PostID)) %>/edit">Edit</a> | |||
| <a class="btn btn-sm btn-outline-secondary" href="<%= PostEditUrl(adminPostItem.PostID) %>">Edit</a> | |||
| <form class="d-inline" method="post" action="<%= AdminPostAIUrl(adminPostItem.PostID) %>"> | |||
| <button class="btn btn-sm btn-outline-info" type="submit">AI Content</button> | |||
| </form> | |||
| <% If adminPostItem.IsPublished = 1 Then %> | |||
| <form class="d-inline" method="post" action="/admin/posts/<%= H(adminPostItem.PostID) %>/unpublish"> | |||
| <form class="d-inline" method="post" action="<%= AdminPostUnpublishUrl(adminPostItem.PostID) %>"> | |||
| <button class="btn btn-sm btn-outline-warning" type="submit">Unpublish</button> | |||
| </form> | |||
| <% Else %> | |||
| <form class="d-inline" method="post" action="/admin/posts/<%= H(adminPostItem.PostID) %>/publish"> | |||
| <form class="d-inline" method="post" action="<%= AdminPostPublishUrl(adminPostItem.PostID) %>"> | |||
| <button class="btn btn-sm btn-success" type="submit">Publish</button> | |||
| </form> | |||
| <% End If %> | |||
| <form class="d-inline" method="post" action="/posts/<%= H(adminPostItem.PostID) %>/delete"> | |||
| <form class="d-inline" method="post" action="<%= PostDeleteUrl(adminPostItem.PostID) %>"> | |||
| <button class="btn btn-sm btn-outline-danger" type="submit" onclick="return confirm('Delete this post?')">Delete</button> | |||
| </form> | |||
| </td> | |||
| @@ -4,7 +4,7 @@ | |||
| <div class="card-body"> | |||
| <h1 class="h3 mb-4">Edit Category</h1> | |||
| <form method="post" action="/categories/<%= H(category.CategoryID) %>"> | |||
| <form method="post" action="<%= CategoryUrl(category.CategoryID) %>"> | |||
| <div class="mb-3"> | |||
| <label class="form-label" for="Name">Name</label> | |||
| <input class="form-control" type="text" id="Name" name="Name" value="<%= H(category.Name) %>" required> | |||
| @@ -22,11 +22,11 @@ | |||
| <div class="d-flex flex-wrap gap-2"> | |||
| <button class="btn btn-primary" type="submit">Update Category</button> | |||
| <a class="btn btn-outline-secondary" href="/categories">Cancel</a> | |||
| <a class="btn btn-outline-secondary" href="<%= CategoriesUrl() %>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| <form method="post" action="/categories/<%= H(category.CategoryID) %>/delete" class="mt-3"> | |||
| <form method="post" action="<%= CategoryDeleteUrl(category.CategoryID) %>" class="mt-3"> | |||
| <button class="btn btn-outline-danger" type="submit">Delete Category</button> | |||
| </form> | |||
| </div> | |||
| @@ -3,7 +3,7 @@ | |||
| <h1 class="h3 mb-1">Categories</h1> | |||
| <p class="text-muted mb-0">Browse post categories from ASPBlogBrainOrdure.</p> | |||
| </div> | |||
| <a class="btn btn-primary" href="/categories/new">New Category</a> | |||
| <a class="btn btn-primary" href="<%= CategoryNewUrl() %>">New Category</a> | |||
| </div> | |||
| <% | |||
| @@ -25,12 +25,12 @@ Else | |||
| <article class="card shadow-sm"> | |||
| <div class="card-body"> | |||
| <h2 class="h5 mb-2"> | |||
| <a href="/categories/<%= Server.URLEncode(CStr(categoryItem.CategoryID)) %>" class="text-decoration-none"> | |||
| <a href="<%= CategoryUrl(categoryItem.CategoryID) %>" class="text-decoration-none"> | |||
| <%= H(categoryItem.Name) %> | |||
| </a> | |||
| </h2> | |||
| <p class="text-muted mb-3"><%= H(categoryItem.Description) %></p> | |||
| <a class="btn btn-sm btn-outline-primary" href="/categories/<%= Server.URLEncode(CStr(categoryItem.CategoryID)) %>">View</a> | |||
| <a class="btn btn-sm btn-outline-primary" href="<%= CategoryUrl(categoryItem.CategoryID) %>">View</a> | |||
| </div> | |||
| </article> | |||
| </div> | |||
| @@ -4,7 +4,7 @@ | |||
| <div class="card-body"> | |||
| <h1 class="h3 mb-4">New Category</h1> | |||
| <form method="post" action="/categories"> | |||
| <form method="post" action="<%= CategoriesUrl() %>"> | |||
| <div class="mb-3"> | |||
| <label class="form-label" for="Name">Name</label> | |||
| <input class="form-control" type="text" id="Name" name="Name" required> | |||
| @@ -22,7 +22,7 @@ | |||
| <div class="d-flex gap-2"> | |||
| <button class="btn btn-primary" type="submit">Create Category</button> | |||
| <a class="btn btn-outline-secondary" href="/categories">Cancel</a> | |||
| <a class="btn btn-outline-secondary" href="<%= CategoriesUrl() %>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -1,15 +1,15 @@ | |||
| <article class="card shadow-sm mb-4"> | |||
| <div class="card-body"> | |||
| <div class="mb-3"> | |||
| <a href="/categories" class="small text-decoration-none">← Back to categories</a> | |||
| <a href="<%= CategoriesUrl() %>" class="small text-decoration-none">← Back to categories</a> | |||
| </div> | |||
| <h1 class="h2 mb-3"><%= H(category.Name) %></h1> | |||
| <p class="fs-5 text-muted mb-4"><%= H(category.Description) %></p> | |||
| <div class="d-flex flex-wrap gap-2"> | |||
| <a class="btn btn-outline-primary" href="/categories/<%= Server.URLEncode(CStr(category.CategoryID)) %>/edit">Edit Category</a> | |||
| <form method="post" action="/categories/<%= H(category.CategoryID) %>/delete"> | |||
| <a class="btn btn-outline-primary" href="<%= CategoryEditUrl(category.CategoryID) %>">Edit Category</a> | |||
| <form method="post" action="<%= CategoryDeleteUrl(category.CategoryID) %>"> | |||
| <button class="btn btn-outline-danger" type="submit">Delete Category</button> | |||
| </form> | |||
| </div> | |||
| @@ -32,14 +32,14 @@ | |||
| <article class="card shadow-sm"> | |||
| <div class="card-body"> | |||
| <h3 class="h5 mb-1"> | |||
| <a href="/posts/<%= Server.URLEncode(CStr(catPostItem.Slug)) %>" class="text-decoration-none"> | |||
| <a href="<%= PostUrl(catPostItem.Slug) %>" class="text-decoration-none"> | |||
| <%= H(catPostItem.Title) %> | |||
| </a> | |||
| </h3> | |||
| <% If Len(Trim(CStr(catPostItem.Summary))) > 0 Then %> | |||
| <p class="text-muted mb-2 small"><%= H(catPostItem.Summary) %></p> | |||
| <% End If %> | |||
| <a class="btn btn-sm btn-outline-primary" href="/posts/<%= Server.URLEncode(CStr(catPostItem.Slug)) %>">Read</a> | |||
| <a class="btn btn-sm btn-outline-primary" href="<%= PostUrl(catPostItem.Slug) %>">Read</a> | |||
| </div> | |||
| </article> | |||
| </div> | |||
| @@ -2,7 +2,7 @@ | |||
| <h1 class="display-5 fw-bold mb-2">BrainOrdure</h1> | |||
| <p class="lead text-muted mb-4">Thoughts, notes, and things worth writing down.</p> | |||
| <div class="d-flex gap-2"> | |||
| <a href="/posts" class="btn btn-dark px-4">Read Posts</a> | |||
| <a href="/categories" class="btn btn-outline-secondary px-4">Browse Categories</a> | |||
| <a href="<%= PostsUrl() %>" class="btn btn-dark px-4">Read Posts</a> | |||
| <a href="<%= CategoriesUrl() %>" class="btn btn-outline-secondary px-4">Browse Categories</a> | |||
| </div> | |||
| </div> | |||
| @@ -4,7 +4,7 @@ | |||
| <div class="card-body"> | |||
| <h1 class="h3 mb-4">Edit Post</h1> | |||
| <form method="post" action="/posts/<%= H(post.PostID) %>"> | |||
| <form method="post" action="<%= PostEditUrl(post.PostID) %>"> | |||
| <div class="mb-3"> | |||
| <label class="form-label" for="Title">Title</label> | |||
| <input class="form-control" type="text" id="Title" name="Title" value="<%= H(post.Title) %>" required> | |||
| @@ -25,13 +25,14 @@ | |||
| <input class="form-control" type="number" id="CategoryID" name="CategoryID" min="0" value="<%= H(post.CategoryID) %>"> | |||
| </div> | |||
| <div class="d-flex flex-wrap gap-2"> | |||
| <button class="btn btn-primary" type="submit">Update Post</button> | |||
| <a class="btn btn-outline-secondary" href="/posts">Cancel</a> | |||
| </div> | |||
| </form> | |||
| <div class="d-flex flex-wrap gap-2"> | |||
| <button class="btn btn-primary" type="submit">Update Post</button> | |||
| <button class="btn btn-outline-info" type="submit" formaction="<%= AdminPostAIUrl(post.PostID) %>" formmethod="post">AI Content</button> | |||
| <a class="btn btn-outline-secondary" href="<%= PostsUrl() %>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| <form method="post" action="/posts/<%= H(post.PostID) %>/delete" class="mt-3"> | |||
| <form method="post" action="<%= PostDeleteUrl(post.PostID) %>" class="mt-3"> | |||
| <button class="btn btn-outline-danger" type="submit">Delete Post</button> | |||
| </form> | |||
| </div> | |||
| @@ -3,7 +3,7 @@ | |||
| <h1 class="h3 mb-1">Posts</h1> | |||
| <p class="text-muted mb-0">Published articles from ASPBlogBrainOrdure.</p> | |||
| </div> | |||
| <a class="btn btn-primary" href="/posts/new">New Post</a> | |||
| <a class="btn btn-primary" href="<%= PostNewUrl() %>">New Post</a> | |||
| </div> | |||
| <% | |||
| @@ -28,9 +28,9 @@ Else | |||
| <div class="card-body"> | |||
| <div class="d-flex flex-column flex-md-row justify-content-between gap-2"> | |||
| <h2 class="h5 mb-1"> | |||
| <a href="/posts/<%= Server.URLEncode(CStr(postItem.Slug)) %>" class="text-decoration-none"> | |||
| <%= H(postItem.Title) %> | |||
| </a> | |||
| <a href="<%= PostUrl(postItem.Slug) %>" class="text-decoration-none"> | |||
| <%= H(postItem.Title) %> | |||
| </a> | |||
| </h2> | |||
| <% | |||
| Dim publishedText | |||
| @@ -49,10 +49,10 @@ Else | |||
| %> | |||
| </div> | |||
| <p class="text-muted mb-3"><%= H(postItem.Summary) %></p> | |||
| <a class="btn btn-sm btn-outline-primary" href="/posts/<%= Server.URLEncode(CStr(postItem.Slug)) %>">Read</a> | |||
| </div> | |||
| </article> | |||
| </div> | |||
| <a class="btn btn-sm btn-outline-primary" href="<%= PostUrl(postItem.Slug) %>">Read</a> | |||
| </div> | |||
| </article> | |||
| </div> | |||
| <% | |||
| Loop | |||
| %> | |||
| @@ -4,7 +4,7 @@ | |||
| <div class="card-body"> | |||
| <h1 class="h3 mb-4">New Post</h1> | |||
| <form method="post" action="/posts"> | |||
| <form method="post" action="<%= PostsUrl() %>"> | |||
| <div class="mb-3"> | |||
| <label class="form-label" for="Title">Title</label> | |||
| <input class="form-control" type="text" id="Title" name="Title" required> | |||
| @@ -27,7 +27,7 @@ | |||
| <div class="d-flex gap-2"> | |||
| <button class="btn btn-primary" type="submit">Create Post</button> | |||
| <a class="btn btn-outline-secondary" href="/posts">Cancel</a> | |||
| <a class="btn btn-outline-secondary" href="<%= PostsUrl() %>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -1,7 +1,7 @@ | |||
| <article class="card shadow-sm mb-4"> | |||
| <div class="card-body"> | |||
| <div class="mb-3"> | |||
| <a href="/posts" class="small text-decoration-none">← Back to posts</a> | |||
| <a href="<%= PostsUrl() %>" class="small text-decoration-none">← Back to posts</a> | |||
| </div> | |||
| <h1 class="h2 mb-2"><%= H(post.Title) %></h1> | |||
| @@ -34,22 +34,58 @@ | |||
| <!-- Comments --> | |||
| <section class="mt-2"> | |||
| <h2 class="h4 mb-3">Comments (<%= comments.Count %>)</h2> | |||
| <% | |||
| Dim commentsCount, commentsLoadFailed | |||
| commentsCount = 0 | |||
| commentsLoadFailed = False | |||
| <% If comments.Count = 0 Then %> | |||
| On Error Resume Next | |||
| If IsObject(comments) Then commentsCount = comments.Count | |||
| If Err.Number <> 0 Then | |||
| commentsLoadFailed = True | |||
| commentsCount = 0 | |||
| Err.Clear | |||
| End If | |||
| On Error GoTo 0 | |||
| %> | |||
| <h2 class="h4 mb-3">Comments (<%= commentsCount %>)</h2> | |||
| <% If commentsLoadFailed Then %> | |||
| <div class="alert alert-warning mb-4">Comments are temporarily unavailable.</div> | |||
| <% ElseIf commentsCount = 0 Then %> | |||
| <p class="text-muted mb-4">No comments yet. Be the first to leave one below.</p> | |||
| <% Else %> | |||
| <% | |||
| Dim commentIter, commentItem | |||
| Dim commentsIterFailed | |||
| commentsIterFailed = False | |||
| On Error Resume Next | |||
| Set commentIter = comments.Iterator() | |||
| Do While commentIter.HasNext | |||
| If Err.Number <> 0 Then | |||
| commentsIterFailed = True | |||
| Err.Clear | |||
| End If | |||
| Do While Not commentsIterFailed And commentIter.HasNext | |||
| Set commentItem = commentIter.GetNext() | |||
| If Err.Number <> 0 Then | |||
| commentsIterFailed = True | |||
| Err.Clear | |||
| Exit Do | |||
| End If | |||
| Dim commentDateText | |||
| commentDateText = "" | |||
| If IsDate(commentItem.CreatedDate) Then | |||
| commentDateText = FormatDateTime(commentItem.CreatedDate, vbLongDate) | |||
| End If | |||
| %> | |||
| <div class="card shadow-sm mb-3"> | |||
| <div class="card-body"> | |||
| <div class="d-flex justify-content-between mb-2"> | |||
| <strong class="small"><%= H(commentItem.AuthorName) %></strong> | |||
| <span class="small text-muted"><%= H(FormatDateTime(commentItem.CreatedDate, vbLongDate)) %></span> | |||
| <span class="small text-muted"><%= H(commentDateText) %></span> | |||
| </div> | |||
| <% | |||
| Dim commentBody | |||
| @@ -63,6 +99,17 @@ | |||
| </div> | |||
| <% | |||
| Loop | |||
| If Err.Number <> 0 Then | |||
| commentsIterFailed = True | |||
| Err.Clear | |||
| End If | |||
| On Error GoTo 0 | |||
| If commentsIterFailed Then | |||
| %> | |||
| <div class="alert alert-warning mb-4">Some comments could not be displayed.</div> | |||
| <% | |||
| End If | |||
| %> | |||
| <% End If %> | |||
| @@ -70,7 +117,7 @@ | |||
| <div class="card shadow-sm mt-4"> | |||
| <div class="card-body"> | |||
| <h3 class="h5 mb-3">Leave a Comment</h3> | |||
| <form method="post" action="/comments"> | |||
| <form method="post" action="<%= CommentsUrl() %>"> | |||
| <input type="hidden" name="PostID" value="<%= H(post.PostID) %>"> | |||
| <input type="hidden" name="PostSlug" value="<%= H(post.Slug) %>"> | |||
| <div class="mb-3"> | |||
| @@ -62,13 +62,13 @@ End If | |||
| <a class="<%= hdr_navHome %>" href="/">Home</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="<%= hdr_navPosts %>" href="/posts">Posts</a> | |||
| <a class="<%= hdr_navPosts %>" href="<%= PostsUrl() %>">Posts</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="<%= hdr_navCats %>" href="/categories">Categories</a> | |||
| <a class="<%= hdr_navCats %>" href="<%= CategoriesUrl() %>">Categories</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="<%= hdr_navAdmin %>" href="/admin">Admin</a> | |||
| <a class="<%= hdr_navAdmin %>" href="<%= AdminUrl() %>">Admin</a> | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| @@ -187,6 +187,71 @@ Function H(s) | |||
| End If | |||
| End Function | |||
| '------------------------------------------------------------------------------ | |||
| ' Canonical application URL helpers | |||
| ' - Categories use numeric IDs | |||
| ' - Posts use slug permalinks for public links and numeric IDs for admin actions | |||
| '------------------------------------------------------------------------------ | |||
| Function CategoryUrl(ByVal categoryId) | |||
| CategoryUrl = "/categories/" & Server.URLEncode(CStr(categoryId)) | |||
| End Function | |||
| Function CategoriesUrl() | |||
| CategoriesUrl = "/categories" | |||
| End Function | |||
| Function CategoryNewUrl() | |||
| CategoryNewUrl = "/categories/new" | |||
| End Function | |||
| Function CategoryEditUrl(ByVal categoryId) | |||
| CategoryEditUrl = CategoryUrl(categoryId) & "/edit" | |||
| End Function | |||
| Function CategoryDeleteUrl(ByVal categoryId) | |||
| CategoryDeleteUrl = CategoryUrl(categoryId) & "/delete" | |||
| End Function | |||
| Function PostUrl(ByVal slug) | |||
| PostUrl = "/posts/" & Server.URLEncode(CStr(slug)) | |||
| End Function | |||
| Function PostsUrl() | |||
| PostsUrl = "/posts" | |||
| End Function | |||
| Function PostNewUrl() | |||
| PostNewUrl = "/posts/new" | |||
| End Function | |||
| Function PostEditUrl(ByVal postId) | |||
| PostEditUrl = "/posts/" & Server.URLEncode(CStr(postId)) & "/edit" | |||
| End Function | |||
| Function PostDeleteUrl(ByVal postId) | |||
| PostDeleteUrl = "/posts/" & Server.URLEncode(CStr(postId)) & "/delete" | |||
| End Function | |||
| Function AdminPostPublishUrl(ByVal postId) | |||
| AdminPostPublishUrl = "/admin/posts/" & Server.URLEncode(CStr(postId)) & "/publish" | |||
| End Function | |||
| Function AdminPostUnpublishUrl(ByVal postId) | |||
| AdminPostUnpublishUrl = "/admin/posts/" & Server.URLEncode(CStr(postId)) & "/unpublish" | |||
| End Function | |||
| Function AdminPostAIUrl(ByVal postId) | |||
| AdminPostAIUrl = "/admin/posts/" & Server.URLEncode(CStr(postId)) & "/ai" | |||
| End Function | |||
| Function AdminUrl() | |||
| AdminUrl = "/admin" | |||
| End Function | |||
| Function CommentsUrl() | |||
| CommentsUrl = "/comments" | |||
| End Function | |||
| '======================================================================================================================= | |||
| ' Adapted from Tolerable library | |||
| @@ -533,4 +598,4 @@ Function FormatDateForSql(vbDate) | |||
| FormatDateForSql = "'" & yyyy & "-" & mm & "-" & dd & " " & hh & ":" & nn & ":" & ss & "'" | |||
| End Function | |||
| %> | |||
| %> | |||
| @@ -65,7 +65,7 @@ Public Sub AddRoute(method, path, controller, action) | |||
| End If | |||
| Dim routeKey | |||
| routeKey = methodUpper & ":" & LCase(Trim(path)) | |||
| routeKey = methodUpper & ":" & NormalizePath(path) | |||
| If Not routes.Exists(routeKey) Then | |||
| routes.Add routeKey, Array(Trim(controller), Trim(action)) | |||
| @@ -77,7 +77,7 @@ End Sub | |||
| '------------------------------------------------------------ | |||
| Public Function Resolve(method, path) | |||
| Dim routeKey, routeValue, values | |||
| routeKey = UCase(method) & ":" & LCase(path) | |||
| routeKey = UCase(method) & ":" & NormalizePath(path) | |||
| ' Always return a params array (empty by default) | |||
| Dim emptyParams() : ReDim emptyParams(-1) | |||
| @@ -113,6 +113,9 @@ End Function | |||
| '------------------------------------------------------------ | |||
| Private Function IsMatch(requestPath, routePattern, values) | |||
| Dim reqParts, routeParts, i, paramCount | |||
| requestPath = NormalizePath(requestPath) | |||
| routePattern = NormalizePath(routePattern) | |||
| reqParts = Split(requestPath, "/") | |||
| routeParts = Split(routePattern, "/") | |||
| @@ -123,7 +126,7 @@ Private Function IsMatch(requestPath, routePattern, values) | |||
| paramCount = 0 : ReDim values(-1) | |||
| For i = 0 To UBound(reqParts) | |||
| If Left(routeParts(i), 1) = ":" Then | |||
| If IsDynamicSegment(routeParts(i)) Then | |||
| ReDim Preserve values(paramCount) | |||
| values(paramCount) = reqParts(i) | |||
| paramCount = paramCount + 1 | |||
| @@ -136,6 +139,51 @@ Private Function IsMatch(requestPath, routePattern, values) | |||
| IsMatch = True | |||
| End Function | |||
| '------------------------------------------------------------ | |||
| ' INTERNAL NormalizePath(path) | |||
| ' Removes query strings, trims whitespace, and normalizes | |||
| ' leading/trailing slashes so "/admin" and "/admin/" match. | |||
| '------------------------------------------------------------ | |||
| Private Function NormalizePath(path) | |||
| Dim cleaned | |||
| cleaned = LCase(Trim(CStr(path))) | |||
| If Len(cleaned) = 0 Then | |||
| NormalizePath = "" | |||
| Exit Function | |||
| End If | |||
| If InStr(cleaned, "?") > 0 Then | |||
| cleaned = Left(cleaned, InStr(cleaned, "?") - 1) | |||
| End If | |||
| Do While Left(cleaned, 1) = "/" | |||
| cleaned = Mid(cleaned, 2) | |||
| Loop | |||
| Do While Right(cleaned, 1) = "/" And Len(cleaned) > 0 | |||
| cleaned = Left(cleaned, Len(cleaned) - 1) | |||
| Loop | |||
| NormalizePath = cleaned | |||
| End Function | |||
| '------------------------------------------------------------ | |||
| ' INTERNAL IsDynamicSegment(segment) | |||
| ' Supports both ":id" and "{id}" route tokens. | |||
| '------------------------------------------------------------ | |||
| Private Function IsDynamicSegment(segment) | |||
| If Len(segment) = 0 Then | |||
| IsDynamicSegment = False | |||
| ElseIf Left(segment, 1) = ":" Then | |||
| IsDynamicSegment = True | |||
| ElseIf Left(segment, 1) = "{" And Right(segment, 1) = "}" Then | |||
| IsDynamicSegment = True | |||
| Else | |||
| IsDynamicSegment = False | |||
| End If | |||
| End Function | |||
| '------------------------------------------------------------ | |||
| ' Optional lifecycle hooks | |||
| '------------------------------------------------------------ | |||
| @@ -35,6 +35,7 @@ | |||
| router.AddRoute "GET", "/admin/categories", "AdminController", "Categories" | |||
| router.AddRoute "POST", "/admin/posts/{id}/publish", "AdminController", "PublishPost" | |||
| router.AddRoute "POST", "/admin/posts/{id}/unpublish", "AdminController", "UnpublishPost" | |||
| router.AddRoute "POST", "/admin/posts/{id}/ai", "AdminController", "GenerateAIContent" | |||
| ' Dispatch the request (resolves route and executes controller action) | |||
| MVC.DispatchRequest Request.ServerVariables("REQUEST_METHOD"), _ | |||
| @@ -39,6 +39,15 @@ | |||
| <!-- Cache-bust parameter name (default: "v") --> | |||
| <add key="CacheBustParamName" value="v" /> | |||
| <!-- Abacus RouteLLM endpoint for AI-generated post content --> | |||
| <add key="AbacusApiBaseUrl" value="https://routellm.abacus.ai/v1" /> | |||
| <!-- Abacus API key (populate locally / via deployment secret) --> | |||
| <add key="AbacusApiKey" value="" /> | |||
| <!-- Model name to send to the Abacus RouteLLM API --> | |||
| <add key="AbacusModel" value="route-llm" /> | |||
| </appSettings> | |||
| <system.webServer> | |||
Powered by TurnKey Linux.