Compare commits

...
Author SHA1 Message Date
60c10781f0
deploy in pre and prod : bug fixes
All checks were successful
Dotnet build and test / build (pull_request) Successful in 16m30s
2026-09-21 12:49:38 +01:00
625e062b4b
Fix white-on-white comment blocks in quartz theme
Quartz sets white body text on a coloured page background, but
--bs-tertiary-bg is near-white in its light variant. The .blogcomment
background now uses a semi-transparent black overlay so white text
stays legible, matching the dropdown hover fix.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:29:33 +01:00
1455ba5187
Restore tree-structured comments in Blog Details
The $.psc.blogcomment widget (from the removed wwwroot/js/comment.js)
provided reply-to-comment nesting. Rewrite Details.cshtml to drive the
same /api/v1/blogcomments endpoint with ParentId, delegating reply
clicks so they work across the whole nested tree.

Render comment metadata server-side in the Comment view component
template (the widget used to add it client-side) and Include Author so
nested comments get their author instead of relying on the request-scoped
identity map.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:21:33 +01:00
605d885917
Rewrite Blogspot Index view with skip/take pagination
Renders Prev/Next pager from the Index(string id, int skip, int take)
call profile, drops dead .sametitle JS, and replaces the redundant
ReadPermission branch and Minutes>0 modifié check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:12:20 +01:00
5 changed files with 384 additions and 167 deletions

View file

@ -7,8 +7,14 @@ APP_INSTALL_PREFIX=preprod-
include .$(APP_INSTALL_ENV).env
generated/:
@mkdir -p $@
reinstall: copy-binaries
@for project in $(APP_PROJECT_NAMES); do \
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
@echo "💯 backend started"
reinstall-prod:
make APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
@ -62,7 +68,6 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
@sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
@sync
$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service
sudo cp $^ $@
sudo chown root:root $@
@ -81,21 +86,11 @@ install: copy-binaries copy-services
sudo systemctl start yavsc$${project} ; \
done
reinstall: copy-binaries
@for project in $(APP_PROJECT_NAMES); do \
sudo systemctl start yavsc$${project} ; done
reinstall-prod:
make APP_INSTALL_ENV= APP_INSTALL_PREFIX=
reinstall-pre: copy-binaries
@for project in $(APP_PROJECT_NAMES); do \
sudo systemctl start yavscPre$${project} ; done
stop-services:
@for project in $(APP_PROJECT_NAMES); \
do \
sudo systemctl stop yavsc$${project} ; \
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
@ -118,3 +113,5 @@ showBlogsLogs:
clean:
@rm -rf generated
generated/:
@mkdir -p $@

View file

@ -18,7 +18,10 @@ namespace Yavsc.ViewComponents
}
public async Task<IViewComponentResult> InvokeAsync(long id)
{
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id);
var comment = await context.Comment
.Include(c => c.Children)
.Include(c => c.Author)
.FirstOrDefaultAsync(c => c.Id==id);
if (comment == null)
throw new InvalidOperationException();
ViewBag.apictlr = "/api/v1/blogcomments";

View file

@ -1,102 +1,235 @@
@model BlogPost
@{
ViewBag.Title=Model.Title;
ViewBag.Title = Model.Title;
}
@section scripts {
<script src="~/js/comment.js" asp-append-version="true"></script>
<script>
$.psc.blogcomment.prototype.options.lang = '@System.Globalization.CultureInfo.CurrentUICulture.Name';
$.psc.blogcomment.prototype.options.apictrlr = '/api/v1/blogcomments';
$.psc.blogcomment.prototype.options.authorId = '@User.GetUserId()';
$.psc.blogcomment.prototype.options.authorName = '@User.GetUserName()';
$(document).ready(function() {
$('#cmtBtn').click(function() {
$.ajax({
async: true,
cache: false,
type: 'POST',
method: 'POST',
contentType: "application/json",
data: JSON.stringify({
Article: $('#Comment').val(),
ReceiverId: @Model.Id
}),
error: function(xhr,data) {
if (xhr.status === 400)
{
if (xhr.responseJSON)
{
$('#commentValidation').html(
xhr.responseJSON.Article);
} else {
$('#commentValidation').html(
"Une erreur est survenue : "+xhr.status+"<br/>"+
"<code><pre>"+xhr.responseText+"</pre></code>"
)
}
}
},
success: function (data) {
var comment = $('#Comment').val();
$('#Comment').val('');
$('#commentValidation').empty();
var nnode = '<div data-type="blogcomment" data-id="'+data.id+'" data-allow-edit="True" data-allow-moderate="@ViewBag.moderatoFlag" data-date="'+data.dateCreated+'" data-username="@User.GetUserName()">'+comment+'</div>';
$('#comments').append($(nnode).blogcomment())
},
url:'/api/v1/blogcomments'
});
});
})
</script>
@section header {
<style>
.avatar, .commentmeta, .blogcomment p , .subcomments {
display: inline-block;
.blog-details .post {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: start;
margin-bottom: 1.5rem;
}
.blog-details .post-photo {
width: 128px;
height: 128px;
object-fit: cover;
border-radius: .375rem;
}
.blog-details .post-title {
margin: 0 0 .25rem;
}
.blog-details .post-meta {
font-size: .85rem;
color: var(--bs-secondary-color, #6c757d);
margin-bottom: .5rem;
}
.blog-details .post-article {
grid-column: 1 / -1;
}
#comments {
margin-top: 1.5rem;
}
.blogcomment {
display: inline-block;
margin:.3em;
margin: .3em 0;
padding: .5rem .75rem;
border-left: 3px solid var(--bs-border-color, #dee2e6);
/* Quartz ships white body text on a coloured page bg, so
use a semi-transparent black overlay rather than
--bs-tertiary-bg (which is near-white in the quartz
light variant and would render white-on-white). */
background: rgba(0, 0, 0, 0.15);
border-radius: .25rem;
}
.blogcomment .comment-body > :last-child {
margin-bottom: 0;
}
.blogcomment .commentmeta {
display: block;
font-size: .75rem;
color: var(--bs-secondary-color, #6c757d);
margin-top: .25rem;
}
.blogcomment .reply-btn {
padding: 0;
font-size: .75rem;
}
.subcomments {
margin-left: 1.25rem;
margin-top: .5rem;
}
.reply-form {
display: flex;
align-items: center;
gap: .5rem;
margin: .5rem 0;
max-width: 40rem;
}
.reply-form .form-control {
flex: 1;
}
.comment-form .input-group {
max-width: 40rem;
}
#commentValidation,
.reply-validation {
color: var(--bs-danger, #dc3545);
font-size: .8rem;
}
</style>
}
@section scripts {
<script>
$(document).ready(function () {
var api = '/api/v1/blogcomments';
var receiverId = @Model.Id;
var userName = @Json.Serialize(User.GetUserName());
// Reply buttons are rendered server-side only when the
// user is authenticated, so a non-empty userName here
// means freshly appended nodes may carry one too.
var canReply = !!userName;
<div class="post">
<div class="float-left">
<img class="photo" src="@Model.Photo" >
// Build a plain, escaped comment node from a just-posted
// comment. The server-side Comment view component is what
// markdown-renders and decorates persisted comments; a
// newly created one is plain text until the page reloads.
function buildCommentNode(text, data, parentId) {
var $node = $('<div class="blogcomment"></div>')
.attr('data-type', 'blogcomment')
.attr('data-id', data.id)
.attr('data-receiver-id', receiverId);
if (parentId != null) $node.attr('data-parent-id', parentId);
$node.append($('<div class="comment-body"></div>').append($('<p></p>').text(text)));
$node.append($('<span class="commentmeta"></span>')
.text('— ' + (userName || 'Vous') + ', posté à ' + new Date(data.dateCreated).toLocaleTimeString()));
if (canReply) {
$node.append($('<button type="button" class="btn btn-link btn-sm reply-btn">Répondre</button>'));
}
$node.append($('<div class="subcomments"></div>'));
return $node;
}
function postComment(text, parentId, $validation) {
return $.ajax({
type: 'POST',
contentType: 'application/json',
url: api,
data: JSON.stringify({
Article: text,
ReceiverId: receiverId,
ParentId: parentId
})
}).fail(function (xhr) {
var msg;
if (xhr.status === 400 && xhr.responseJSON) {
msg = xhr.responseJSON.Article || xhr.responseJSON.title || 'Validation error';
} else {
msg = 'Une erreur est survenue : ' + xhr.status;
}
($validation || $('#commentValidation')).text(msg);
});
}
// Top-level comment form.
$('#cmtBtn').click(function () {
var text = $('#Comment').val();
if (!text) return;
$('#commentValidation').empty();
postComment(text, null, $('#commentValidation')).done(function (data) {
$('#comments').append(buildCommentNode(text, data, null));
$('#Comment').val('');
});
});
// Tree replies — delegated so it works for every nested
// comment, including ones appended at runtime.
$('#comments').on('click', '.reply-btn', function () {
var $comment = $(this).closest('[data-type="blogcomment"]');
// Toggle: if a reply form is already open for this
// comment, close it instead of opening a second one.
var $existing = $comment.children('.reply-form');
if ($existing.length) { $existing.remove(); return; }
var $form = $('<div class="reply-form"></div>');
$form.append('<input class="form-control reply-input" placeholder="..." />');
$form.append('<button type="button" class="btn btn-secondary reply-submit">Envoyer</button>');
$form.append('<span class="reply-validation"></span>');
// Insert before this comment's .subcomments so nested
// replies land in the right place.
$comment.children('.subcomments').before($form);
$form.find('.reply-input').focus();
});
$('#comments').on('click', '.reply-submit', function () {
var $form = $(this).closest('.reply-form');
var $comment = $form.closest('[data-type="blogcomment"]');
var parentId = parseInt($comment.attr('data-id'), 10);
var text = $form.find('.reply-input').val();
if (!text) return;
var $val = $form.find('.reply-validation');
$val.empty();
postComment(text, parentId, $val).done(function (data) {
var $sub = $comment.children('.subcomments');
$sub.append(buildCommentNode(text, data, parentId));
$form.remove();
});
});
});
</script>
}
<div class="blog-details">
<div class="post card">
@if (!string.IsNullOrEmpty(Model.Photo))
{
<img class="post-photo" src="@Model.Photo" alt="@Model.Title" />
}
<div>
<h1 class="post-title">@Model.Title</h1>
<div class="post-meta">
@Html.DisplayFor(m => m.Author)
@if (Model.DateModified > Model.DateCreated)
{
<span> · modifié le @Model.DateModified.ToString("dddd d MMM yyyy à H:mm")</span>
}
else
{
<span> · posté le @Model.DateCreated.ToString("dddd d MMM yyyy à H:mm")</span>
}
</div>
</div>
<div class="post-body post-article">
<markdown>@Model.Article</markdown>
</div>
<h1 ismarkdown>@Model.Title</h1>
@Html.DisplayFor(m=>m.Author)
<markdown>@Html.DisplayFor(m=>m.Article)</markdown>
</div>
<div id="comments">
@if (Model.Comments!=null) {
foreach (var comment in Model.Comments.Where(c=>c.ParentId==null)) {
@if (Model.Comments != null)
{
foreach (var comment in Model.Comments.Where(c => c.ParentId == null))
{
@await Component.InvokeAsync("Comment", new { id = comment.Id })
}
}
</div>
@if (User.GetUserId()!=null) {
<div class="form-horizontal">
<div class="input-group" >
<input name="Comment" id="Comment" class="form-control" placeholder="..."/>
@if (User.GetUserId() != null)
{
<div class="comment-form form-horizontal">
<div class="input-group">
<input name="Comment" id="Comment" class="form-control" placeholder="..." />
<span class="input-group-btn">
<span id="commentValidation"></span>
<input type="button" value="Comment" class="btn btn-secondary"
data-receiverId="@Model.Id" id="cmtBtn"
/>
<input type="button" value="Comment" class="btn btn-secondary" id="cmtBtn" />
</span>
</div>
<span asp-validation-for="Article" class="text-danger" ></span>
</div>
}
else {
<p><i>Vous devez être identifié pour commenter.</i> </p>
else
{
<p><i>Vous devez être identifié pour commenter.</i></p>
}
<a asp-action="Index" class="btn btn-link">Back to List</a>
</div>

View file

@ -1,85 +1,155 @@
@model IEnumerable<IBlogPost>
@{
ViewBag.Title = "Blogs, l'index";
// Pagination reflects the Index(string id, int skip, int take)
// call profile. The service applies Skip/Take server-side, so
// the view only needs the current page params to build Prev/Next
// links — it does not receive a total count.
int currentSkip = Context.Request.Query.TryGetValue("skip", out var skipVal) && int.TryParse(skipVal, out var s) ? s : 0;
int currentTake = Context.Request.Query.TryGetValue("take", out var takeVal) && int.TryParse(takeVal, out var t) ? t : 25;
var posts = Model.ToList();
bool hasPrev = currentSkip > 0;
bool hasNext = posts.Count >= currentTake;
int prevSkip = Math.Max(0, currentSkip - currentTake);
int nextSkip = currentSkip + currentTake;
int maxSummaryLen = 256;
}
@section header {
<style>
.collapsed {
height: 1em;
.blog-index {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sametitlegrip {
text-decoration: underline;
cursor: pointer;
.blog-index .post {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: start;
}
.sametitle {
overflow: hidden;
transition: height 1s;
.blog-index .post-photo {
width: 96px;
height: 96px;
object-fit: cover;
border-radius: .375rem;
}
td {
transition: height 1s;
.blog-index .post-body {
min-width: 0;
}
div.row {
border-bottom: dashed black 1px;
.blog-index .index-post-title {
margin: 0 0 .25rem;
font-size: 1.15rem;
}
.blog-index .post-meta {
font-size: .8rem;
color: var(--bs-secondary-color, #6c757d);
}
.blog-index .post-author img {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
vertical-align: middle;
margin-right: .25rem;
}
.blog-index .actiongroup {
margin-top: .5rem;
display: flex;
gap: .5rem;
}
.blog-index .post-summary {
margin: .25rem 0;
}
.blog-index .post-summary > p:last-child {
margin-bottom: 0;
}
.blog-pager {
display: flex;
justify-content: space-between;
margin-top: 1.5rem;
}
</style>
}
@section scripts {
<script>
$(document).ready(function () {
$(".sametitle").addClass("collapsed")
.on("mouseover",function(){
$(this).removeClass("collapsed")
}).on("mouseout",function(){
$(this).addClass("collapsed")
});
}
)
</script>
}
<h2>@ViewBag.Title</h2>
<p class="text-success">@ViewBag.StatusMessage</p>
<div class="blog-index">
@{
int maxTextLen = 75;
foreach (var post in Model) {
<div class="post card">
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink" >
<div class="float-left"><img class="photo card-photo" src="@post.Photo" ></div>
<h3 class="index-post-title">@post.Title</h3>
@if (!posts.Any())
{
<p>Néant</p>
}
else
{
<div class="blog-index">
@foreach (var post in posts)
{
var canEdit = (await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded;
<article class="post card">
@if (!string.IsNullOrEmpty(post.Photo))
{
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink">
<img class="post-photo" src="@post.Photo" alt="@post.Title" />
</a>
<div>
<a asp-action="Details" asp-route-id="@post.Id">
<markdown summary="@maxTextLen">@post.Article</markdown></a>
<span style="font-size:x-small;">@Html.DisplayFor(m => post.Author)</span>
<span style="font-size:xx-small;">
posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")
@if ((post.DateModified - post.DateCreated).Minutes > 0){
@:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")
}
<div class="post-body">
<h3 class="index-post-title">
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink">@post.Title</a>
</h3>
<div class="post-summary">
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink text-decoration-none">
<markdown summary="@maxSummaryLen">@post.Article</markdown>
</a>
</div>
<div class="post-meta">
@if (post.Author != null)
{
<span class="post-author">
@if (!string.IsNullOrEmpty(post.Author.Avatar))
{
<img src="@post.Author.Avatar" alt="" />
}
@@@post.Author.UserName
</span>
<span> · </span>
}
<span>posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")</span>
@if (post.DateModified > post.DateCreated)
{
<span> · modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")</span>
}
</div>
<div class="actiongroup">
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
{
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
@if (canEdit)
{
<a asp-action="Delete" asp-route-id="@post.Id" class="btn btn-danger">Delete</a>
}
</div>
</div>
</article>
}
</div>
<nav class="blog-pager" aria-label="Pagination">
@if (hasPrev)
{
<a asp-action="Index"
asp-route-skip="@prevSkip"
asp-route-take="@currentTake"
class="btn btn-outline-secondary">← Précédent</a>
}
else
{
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
<span></span>
}
@if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
@if (hasNext)
{
<a asp-action="Delete" asp-route-id="@post.Id" class="btn btn-danger">Delete</a>
}
</div>
</div>
<a asp-action="Index"
asp-route-skip="@nextSkip"
asp-route-take="@currentTake"
class="btn btn-outline-secondary">Suivant →</a>
}
</nav>
}
</div>
@if(Model.Count()==0){<p>Néant</p>}

View file

@ -1,16 +1,30 @@
@model Comment
<div data-type="blogcomment" data-id="@Model.Id" data-receiver-id="@Model.ReceiverId" data-allow-edit="@(User.GetUserId()==Model.AuthorId?"true":"false")"
data-allow-moderate="@ViewBag.moderatoFlag" data-date="@Model.DateCreated.ToString("yyyy-MM-dd hh:mm:ss")" data-username="@Model.Author.UserName" >
<div class="blogcomment" data-type="blogcomment"
data-id="@Model.Id"
data-receiver-id="@Model.ReceiverId"
data-allow-edit="@(User.GetUserId()==Model.AuthorId?"true":"false")"
data-allow-moderate="@ViewBag.moderatoFlag"
data-date="@Model.DateCreated.ToString("yyyy-MM-dd hh:mm:ss")"
data-username="@(Model.Author?.UserName)">
<div class="comment-body">
<markdown>@Model.Article</markdown>
@if (Model.Children!=null) {
<div class="subcomments">
@foreach (var comment in Model.Children)
</div>
<span class="commentmeta">
— @(Model.Author?.UserName ?? "Anonyme"),
posté le @Model.DateCreated.ToString("dddd d MMM yyyy à H:mm")
</span>
@if (User.GetUserId() != null)
{
@await this.Component.InvokeAsync("Comment", new { id = comment.Id});
<button type="button" class="btn btn-link btn-sm reply-btn">Répondre</button>
}
<div class="subcomments">
@if (Model.Children != null)
{
foreach (var comment in Model.Children)
{
@await this.Component.InvokeAsync("Comment", new { id = comment.Id })
}
}
</div>
}
</div>