diff --git a/AGENTS.md b/AGENTS.md index 450ea3f7f..2b53cd2d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,12 +115,14 @@ The solution file is `src/Moonglade.slnx`. The root `README.md` is the main depl - Public and admin pages are primarily Razor Pages under `src/Moonglade.Web/Pages`. - Admin JSON operations are primarily API controllers under `src/Moonglade.Web/Controllers`. - Frontend code is built around the existing Razor layouts, Bootstrap, Alpine.js, TinyMCE, Monaco editor, and Tagify. Do not add a new frontend framework unless explicitly requested. +- Code block language support has two UI surfaces: the public post renderer and the admin TinyMCE code sample dialog. When adding a highlight.js language, register the language before `hljs.highlightElement` in `src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs` and also add the language to `codesample_languages` in `src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs`, otherwise authors cannot select it from the editor. - The TinyMCE language folder README says language packs should not be translated directly; use Crowdin instead. - Server-rendered UI text should consider resource files. Supported cultures are currently `en-US`, `zh-Hans`, `zh-Hant`, `de-DE`, and `ja-JP`. - Localization uses shared resources under `src/Moonglade.Web/Resources/Program.*.resx`. Razor pages inject `IStringLocalizer` as `SharedLocalizer`, and DataAnnotations display names are configured to use the same `Program` resource. When adding or renaming any `SharedLocalizer["..."]` key or `[Display(Name = "...")]` text, update all non-English resource files: `Program.zh-Hans.resx`, `Program.zh-Hant.resx`, `Program.de-DE.resx`, and `Program.ja-JP.resx`. ### Documentation And Licenses +- Repository content must be written in English unless the file is a localization resource, such as `src/Moonglade.Web/Resources/Program.*.resx` or third-party language pack files. Unit test data may also contain non-English values when the behavior under test requires them. Do not add non-English text to Markdown files, source code, comments, configuration, or documentation outside localization resources. - The README states that this blogging system must not be used to serve users in mainland China or to publish content prohibited by Chinese law or any applicable regulations. - The repository license is GPL-3.0. TinyMCE includes a GPL-2.0-or-later license notice. Do not remove or rewrite third-party license files casually. - Do not add license or copyright headers unless explicitly requested. diff --git a/FEATURE-ROADMAP.md b/FEATURE-ROADMAP.md new file mode 100644 index 000000000..83562478c --- /dev/null +++ b/FEATURE-ROADMAP.md @@ -0,0 +1,280 @@ +# Moonglade Feature Research And Roadmap + +Prepared on: 2026-06-08 + +This document records the current feature research findings for Moonglade and provides a trackable roadmap for future enhancement work. + +## Current Feature Overview + +Moonglade is already a mature personal blogging platform for developers. Its existing capabilities include: + +- Posts: publishing, drafts, scheduled publishing, schedule cancellation, unpublishing, recycle bin, featured posts, outdated markers, and AI-assisted content markers. +- Pages: standalone pages, page-specific CSS, publish state, and recycle bin. +- Content organization: categories, tags, archives, featured lists, tag pages, and category lists. +- Editors: TinyMCE HTML editor, Monaco Markdown editor, drag-and-drop or pasted image upload, syntax highlighting, and LaTeX rendering. +- Comments: built-in comments, third-party comment HTML, review and approval, replies, closing comments on old posts, Gravatar, captcha, and word filtering. +- Social and protocol support: Webmention, RSS, Atom, OPML, OpenSearch, FOAF, Sitemap, and IndexNow. +- SEO: canonical URLs, meta description, keywords, Dublin Core, JSON-LD BlogPosting, and basic OpenGraph metadata. +- Images: local file system and Azure Blob Storage providers, image watermarking, original image retention, and CDN URL replacement. +- Admin portal: posts, pages, categories, tags, comments, menus, widgets, mentions, activity logs, and settings. +- Configuration: runtime blog settings, general settings, content settings, comment settings, notification settings, subscription settings, image settings, advanced settings, appearance settings, and custom menus. +- Authentication: local account authentication and Microsoft Entra ID. +- Operations: Docker, Azure deployment assets, health checks, startup initialization, automatic database migration, update checks, and background services. +- Databases: SQL Server and PostgreSQL. +- Tests: coverage across Features, Web, Auth, Configuration, ImageStorage, Syndication, Theme, Webmention, Moderation, BackgroundServices, and related projects. + +## Features Worth Enhancing + +### Editing Experience + +Moonglade already has HTML and Markdown editors, but the writing and publishing workflow can be more robust. + +- [x] Add draft autosave. +- [ ] Add live Markdown preview. +- [ ] Add SEO preview for title, abstract, canonical URL, and keywords. +- [ ] Add social sharing preview for OpenGraph and Twitter Card metadata. + +### Media Management + +Image upload and watermarking are already strong, but there is no full admin media library yet. + +- [ ] Add an admin media library page. +- [ ] Support image search, preview, and URL copying. +- [ ] Support deleting unused images. +- [ ] Detect orphaned images. +- [ ] Detect duplicate images. +- [ ] Support compression, thumbnails, and WebP or AVIF conversion. +- [ ] Make the upload size limit configurable. + +### Comment And Webmention Anti-Spam + +Moonglade already has captcha, review, word filtering, and Webmention validation. The next step is stronger moderation tooling. + +- [ ] Add an IP blacklist. +- [ ] Add an email blacklist. +- [ ] Add a domain blacklist. +- [ ] Add comment rate limiting. +- [x] Add Webmention source rate limiting. +- [ ] Add trusted and blocked domain rules for Webmention. +- [ ] Improve batch approval and batch deletion workflows. +- [ ] Record review reasons and blocking reasons. + +### Analytics + +The current view count and request count features are useful, but they are closer to counters than analytics. + +- [ ] Add an admin analytics dashboard. +- [ ] Add popular post rankings. +- [ ] Add recent traffic trends. +- [ ] Add referrer statistics. +- [ ] Add search term statistics. +- [ ] Add bot filtering effectiveness statistics. +- [ ] Implement a multi-instance-safe counting strategy that does not rely on single-process locks. + +### Data Export And Migration + +The current export functionality mainly covers posts and pages. It should become a complete backup and migration capability. + +- [ ] Export comments. +- [ ] Export categories. +- [ ] Export tags. +- [ ] Export menus. +- [ ] Export widgets. +- [ ] Export configuration. +- [ ] Export themes and custom CSS. +- [ ] Export an image index. +- [ ] Add import and restore workflows. +- [ ] Add import validation and conflict handling. + +### Admin Diagnostics + +Moonglade already has an About page and `/health`. A more useful system status page would help production operations. + +- [ ] Check database connectivity. +- [ ] Check image storage. +- [ ] Check email delivery configuration. +- [ ] Check IndexNow configuration. +- [ ] Check Webmention send and receive capability. +- [ ] Check content moderation service health. +- [ ] Check background service state. +- [ ] Check cache state. +- [ ] Show update check status. + +### SEO And Sharing Quality + +Moonglade has a good SEO foundation. The next step is improving search engine and social platform presentation. + +- [ ] Support `og:image` for posts. +- [ ] Add Twitter Card metadata. +- [ ] Improve SEO metadata for category and tag pages. +- [ ] Add category-level and tag-level sitemap or feed support. +- [ ] Add redirect management for slug changes. +- [ ] Add structured data validation hints. + +### Security Hardening + +The security foundation is good. Admin protection and configuration risk detection can still be improved. + +- [ ] Add login failure rate limiting. +- [ ] Add administrator 2FA. +- [ ] Warn about default accounts and weak configuration. +- [ ] Add a UI for CSP and security response headers. +- [ ] Add stricter validation or a safe mode for custom Head and Foot scripts. +- [ ] Add secondary confirmation for sensitive admin operations. + +## New Features Worth Adding + +### Post Revision History + +- [ ] Save a snapshot for each post change. +- [ ] Support version diff. +- [ ] Support restoring historical versions. +- [ ] Record editor, edit time, and publish state. + +### Content Series + +- [ ] Add a Series entity. +- [ ] Allow posts to belong to one or more series. +- [ ] Add a series detail page. +- [ ] Add previous and next navigation within a series on post pages. +- [ ] Add a series progress navigation component. + +### Related Posts + +- [ ] Recommend posts based on tags. +- [ ] Recommend posts based on categories. +- [ ] Recommend posts based on keywords. +- [ ] Exclude the current post and unpublished posts. +- [ ] Make the recommendation count configurable. + +### Email Subscription And Newsletter + +- [ ] Add a subscriber entity. +- [ ] Send subscription confirmation email. +- [ ] Add unsubscribe links. +- [ ] Send post summaries after publishing. +- [ ] Add subscriber management in the admin portal. +- [ ] Record email delivery failures. + +### Social Publishing And Webhooks + +- [ ] Add generic webhook support. +- [ ] Trigger webhooks after publishing a post. +- [ ] Support retries and failure records. +- [ ] Optionally support Mastodon, Bluesky, X, LinkedIn, Discord, and Slack. + +### AI-Assisted Writing + +Moonglade currently has an AI-assisted content marker. Optional AI-assisted authoring features can build on that foundation. + +- [ ] Generate abstracts. +- [ ] Suggest titles. +- [ ] Suggest SEO keywords. +- [ ] Translate content. +- [ ] Check spelling and grammar. +- [ ] Keep every AI feature configurable, optional, and testable. + +### Full Backup And Restore + +- [ ] Add manual backup. +- [ ] Add scheduled backup. +- [ ] Back up to the local file system. +- [ ] Back up to Azure Blob Storage. +- [ ] Validate before restore. +- [ ] Add restore dry-run mode. + +### Multiple Administrators And Roles + +This is a larger architectural change and should be deferred until higher-priority work is complete. + +- [ ] Add user entities and admin management pages. +- [ ] Add Author, Editor, and Administrator roles. +- [ ] Bind posts to authors. +- [ ] Authorize admin pages by role. +- [ ] Track activity logs by user. + +## Suggested Execution Batches + +### Batch 1: Low Risk, High Value + +Goal: improve daily use and create a stronger foundation for future features. + +- [x] Enhance site search. +- [ ] Expand complete export coverage. +- [ ] Add an admin system diagnostics page. +- [ ] Clean up hard-coded localization text. +- [ ] Add Features and Web tests. + +Acceptance criteria: + +- [x] Search results are more complete and support pagination, filtering, and sorting. +- [ ] Exported data covers the main business entities. +- [ ] Admin users can inspect the health of critical dependencies. +- [ ] New or changed UI text is synchronized to non-English resource files. +- [ ] Affected test projects pass. + +### Batch 2: Content Production Efficiency + +Goal: make writing, editing, and publishing more reliable. + +- [x] Add draft autosave. +- [ ] Add draft recovery. +- [ ] Add post revision history. +- [ ] Add Markdown preview. +- [ ] Add SEO and social sharing preview. +- [ ] Add a pre-publish checklist. +- [ ] Add the first version of the media library. + +Acceptance criteria: + +- [ ] Editing work is not lost after accidental refresh. +- [ ] Users can view and restore historical versions. +- [ ] The pre-publish flow can catch obvious content quality issues. +- [ ] Images can be managed from the admin portal. + +### Batch 3: Interaction And Growth + +Goal: improve reader interaction, subscription, and internal content discovery. + +- [ ] Enhance comment anti-spam. +- [ ] Enhance Webmention management. +- [ ] Add related posts. +- [ ] Add content series. +- [ ] Add newsletter subscription. +- [ ] Add publishing webhooks. + +Acceptance criteria: + +- [ ] Admin users can moderate comments and Webmentions more efficiently. +- [ ] Post pages guide readers to additional relevant content. +- [ ] Readers can subscribe to email updates. +- [ ] Publishing events can integrate with external services. + +### Batch 4: Production Resilience And Platform Capability + +Goal: improve production stability, security, and extensibility. + +- [ ] Make view counting safe for multi-instance deployments. +- [ ] Add scheduled backup and restore. +- [ ] Harden login security. +- [ ] Add configuration risk warnings. +- [ ] Add security response header configuration. +- [ ] Add optional AI-assisted writing. +- [ ] Evaluate multiple administrators and role-based authorization. + +Acceptance criteria: + +- [ ] Statistics do not obviously lose updates or conflict in multi-instance deployments. +- [ ] Main data can be backed up and restored reliably. +- [ ] Admin login security is stronger. +- [ ] AI features are disabled by default and have clear configuration, logging, and test boundaries. + +## Implementation Notes + +- Put business logic in the owning project such as `src/Moonglade.Features`, `src/Moonglade.Configuration`, or `src/Moonglade.Data`, and keep the Web layer thin. +- Consider both SQL Server and PostgreSQL for every database-related change. +- Treat post URLs, `RouteLink`, publish dates, slugs, feeds, sitemap, IndexNow, Webmention, and cache invalidation as high-risk boundaries. +- Follow the `IBlogSettings` pattern for new settings, and update defaults, initialization, settings pages, and resource files. +- Make new external service calls configurable, testable, logged, and preferably event-driven or backgrounded. +- When adding or renaming UI text, update the non-English resource files. +- Add or update tests in the matching test project for behavioral changes. At minimum, run the affected test project or the Web project build. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b4c66f26e..ca6142f60 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,8 +3,8 @@ Edi Wang edi.wang (C) 2026 edi.wang@outlook.com - 15.16.1 - 15.16.1 - 15.16.1 + 15.17.0 + 15.17.0 + 15.17.0 \ No newline at end of file diff --git a/src/Moonglade.Data.PostgreSql/Moonglade.Data.PostgreSql.csproj b/src/Moonglade.Data.PostgreSql/Moonglade.Data.PostgreSql.csproj index c1ec2df8f..f90a08f08 100644 --- a/src/Moonglade.Data.PostgreSql/Moonglade.Data.PostgreSql.csproj +++ b/src/Moonglade.Data.PostgreSql/Moonglade.Data.PostgreSql.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/src/Moonglade.Data.SqlServer/Moonglade.Data.SqlServer.csproj b/src/Moonglade.Data.SqlServer/Moonglade.Data.SqlServer.csproj index f2f374e56..e16a3f85f 100644 --- a/src/Moonglade.Data.SqlServer/Moonglade.Data.SqlServer.csproj +++ b/src/Moonglade.Data.SqlServer/Moonglade.Data.SqlServer.csproj @@ -10,7 +10,7 @@ enable - + diff --git a/src/Moonglade.Data/Moonglade.Data.csproj b/src/Moonglade.Data/Moonglade.Data.csproj index 14713b9c3..45fb0dbdc 100644 --- a/src/Moonglade.Data/Moonglade.Data.csproj +++ b/src/Moonglade.Data/Moonglade.Data.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Moonglade.Email.Client/Moonglade.Email.Client.csproj b/src/Moonglade.Email.Client/Moonglade.Email.Client.csproj index bf9ba8a51..618fbabfe 100644 --- a/src/Moonglade.Email.Client/Moonglade.Email.Client.csproj +++ b/src/Moonglade.Email.Client/Moonglade.Email.Client.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Moonglade.Features/Post/SearchPostQuery.cs b/src/Moonglade.Features/Post/SearchPostQuery.cs index 347e60927..c71cf9846 100644 --- a/src/Moonglade.Features/Post/SearchPostQuery.cs +++ b/src/Moonglade.Features/Post/SearchPostQuery.cs @@ -4,43 +4,136 @@ namespace Moonglade.Features.Post; -public record SearchPostQuery(string Keyword) : IQuery>; +public enum SearchPostSort +{ + Newest = 0, + Oldest = 1, + TitleAscending = 2, + TitleDescending = 3 +} + +public record SearchPostQuery( + string Keyword, + int PageSize = 10, + int PageIndex = 1, + string CategorySlug = null, + string Tag = null, + string LanguageCode = null, + DateTime? StartDateUtc = null, + DateTime? EndDateUtc = null, + SearchPostSort Sort = SearchPostSort.Newest) : IQuery; -public class SearchPostQueryHandler(BlogDbContext db) : IQueryHandler> +public record SearchPostQueryResult(List Posts, int TotalRows); + +public class SearchPostQueryHandler(BlogDbContext db) : IQueryHandler { - public async Task> HandleAsync(SearchPostQuery request, CancellationToken ct) + public async Task HandleAsync(SearchPostQuery request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Keyword)) { throw new ArgumentException("Keyword must not be null or whitespace.", nameof(request.Keyword)); } - var normalized = Regex.Replace(request.Keyword.Trim(), @"\s+", " "); - var words = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (request.PageSize < 1) + { + throw new ArgumentOutOfRangeException(nameof(request.PageSize), "Page size must be greater than 0."); + } + + if (request.PageIndex < 1) + { + throw new ArgumentOutOfRangeException(nameof(request.PageIndex), "Page index must be greater than 0."); + } + + var words = NormalizeKeyword(request.Keyword); IQueryable query = db.Post .AsNoTracking() .Where(p => !p.IsDeleted && p.PostStatus == PostStatus.Published); - if (words.Length > 1) + query = ApplyKeywordFilter(query, words); + query = ApplyFacetFilters(query, request); + + var totalRows = await query.CountAsync(ct); + var posts = await ApplySorting(query, request.Sort) + .Skip((request.PageIndex - 1) * request.PageSize) + .Take(request.PageSize) + .SelectToDigest() + .ToListAsync(ct); + + return new(posts, totalRows); + } + + private static string[] NormalizeKeyword(string keyword) + { + var normalized = Regex.Replace(keyword.Trim(), @"\s+", " "); + return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + + private static IQueryable ApplyKeywordFilter(IQueryable query, IEnumerable words) + { + foreach (var word in words) { - foreach (var word in words) - { - query = query.Where(p => EF.Functions.Like(p.Title, "%" + word + "%")); - } + var keyword = word; + query = query.Where(p => + p.Title.Contains(keyword) || + (p.ContentAbstract != null && p.ContentAbstract.Contains(keyword)) || + (p.Keywords != null && p.Keywords.Contains(keyword)) || + p.Tags.Any(t => t.DisplayName.Contains(keyword) || t.NormalizedName.Contains(keyword)) || + p.PostCategory.Any(pc => pc.Category != null && (pc.Category.DisplayName.Contains(keyword) || pc.Category.Slug.Contains(keyword)))); } - else + + return query; + } + + private static IQueryable ApplyFacetFilters(IQueryable query, SearchPostQuery request) + { + if (!string.IsNullOrWhiteSpace(request.CategorySlug)) { - var word = words[0]; - query = query.Where(p => - p.Title.Contains(word) || - p.Tags.Any(t => t.DisplayName.Contains(word))); + var categorySlug = request.CategorySlug.Trim().ToLower(); + query = query.Where(p => p.PostCategory.Any(pc => pc.Category.Slug == categorySlug)); } - var results = await query - .SelectToDigest() - .ToListAsync(ct); + if (!string.IsNullOrWhiteSpace(request.Tag)) + { + var tag = request.Tag.Trim().ToLower(); + query = query.Where(p => p.Tags.Any(t => t.NormalizedName == tag)); + } + + if (!string.IsNullOrWhiteSpace(request.LanguageCode)) + { + var languageCode = request.LanguageCode.Trim().ToLower(); + query = query.Where(p => p.ContentLanguageCode == languageCode); + } + + if (request.StartDateUtc.HasValue) + { + var startDateUtc = request.StartDateUtc.Value.Date; + query = query.Where(p => p.PubDateUtc >= startDateUtc); + } - return results; + if (request.EndDateUtc.HasValue) + { + var endDateUtc = request.EndDateUtc.Value.Date.AddDays(1); + query = query.Where(p => p.PubDateUtc < endDateUtc); + } + + return query; } + + private static IOrderedQueryable ApplySorting(IQueryable query, SearchPostSort sort) => + sort switch + { + SearchPostSort.Oldest => query + .OrderBy(p => p.PubDateUtc) + .ThenBy(p => p.Title), + SearchPostSort.TitleAscending => query + .OrderBy(p => p.Title) + .ThenByDescending(p => p.PubDateUtc), + SearchPostSort.TitleDescending => query + .OrderByDescending(p => p.Title) + .ThenByDescending(p => p.PubDateUtc), + _ => query + .OrderByDescending(p => p.PubDateUtc) + .ThenBy(p => p.Title) + }; } diff --git a/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj b/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj index f0274b82d..21fc05278 100644 --- a/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj +++ b/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj @@ -10,6 +10,6 @@ - + \ No newline at end of file diff --git a/src/Moonglade.IndexNow.Client/Moonglade.IndexNow.Client.csproj b/src/Moonglade.IndexNow.Client/Moonglade.IndexNow.Client.csproj index 37295bc64..ec0d5aa0d 100644 --- a/src/Moonglade.IndexNow.Client/Moonglade.IndexNow.Client.csproj +++ b/src/Moonglade.IndexNow.Client/Moonglade.IndexNow.Client.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Moonglade.Moderation/Moonglade.Moderation.csproj b/src/Moonglade.Moderation/Moonglade.Moderation.csproj index 9842fed39..d18e3bfc9 100644 --- a/src/Moonglade.Moderation/Moonglade.Moderation.csproj +++ b/src/Moonglade.Moderation/Moonglade.Moderation.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Moonglade.Web/Commands/PostManagementCommands.cs b/src/Moonglade.Web/Commands/PostManagementCommands.cs index b6025bb1a..23b8ee3cd 100644 --- a/src/Moonglade.Web/Commands/PostManagementCommands.cs +++ b/src/Moonglade.Web/Commands/PostManagementCommands.cs @@ -14,11 +14,11 @@ public record PostOperationContext( string UserAgent, string RootUrl); -public record PostOperationResult(bool Succeeded, Guid PostId, string ErrorMessage) +public record PostOperationResult(bool Succeeded, Guid PostId, DateTime? LastModifiedUtc, string ErrorMessage) { - public static PostOperationResult Success(Guid postId) => new(true, postId, string.Empty); + public static PostOperationResult Success(Guid postId, DateTime? lastModifiedUtc) => new(true, postId, lastModifiedUtc, string.Empty); - public static PostOperationResult Conflict(string errorMessage) => new(false, Guid.Empty, errorMessage); + public static PostOperationResult Conflict(string errorMessage) => new(false, Guid.Empty, null, errorMessage); } public record SavePostCommand(PostEditModel Payload, PostOperationContext Context) : ICommand; @@ -67,7 +67,7 @@ await PostActivityLogger.LogAsync( ProcessPublishedPost(model.LastModifiedUtc, postEntity, request.Context); } - return PostOperationResult.Success(postEntity.Id); + return PostOperationResult.Success(postEntity.Id, postEntity.LastModifiedUtc); } catch (Exception ex) { @@ -237,4 +237,4 @@ await commandMediator.SendAsync(new CreateActivityLogCommand( context.IpAddress, context.UserAgent), ct); } -} \ No newline at end of file +} diff --git a/src/Moonglade.Web/Controllers/MentionController.cs b/src/Moonglade.Web/Controllers/MentionController.cs index 405527b09..2b6520be6 100644 --- a/src/Moonglade.Web/Controllers/MentionController.cs +++ b/src/Moonglade.Web/Controllers/MentionController.cs @@ -44,6 +44,7 @@ public async Task ReceiveWebmention( WebmentionStatus.SpamDetectedFakeNotFound => NotFound("The requested resource was not found."), WebmentionStatus.ErrorTargetUriNotExist => Conflict("Cannot retrieve post ID and title for the target URL."), WebmentionStatus.ErrorWebmentionAlreadyRegistered => Conflict("Webmention already registered."), + WebmentionStatus.SourceRateLimitExceeded => StatusCode(StatusCodes.Status429TooManyRequests, "Webmention source rate limit exceeded."), WebmentionStatus.GenericError => StatusCode(StatusCodes.Status500InternalServerError, "An internal server error occurred."), _ => StatusCode(StatusCodes.Status500InternalServerError, "An unknown error occurred.") }; @@ -102,4 +103,4 @@ public async Task Clear() await commandMediator.SendAsync(new ClearMentionsCommand()); return NoContent(); } -} \ No newline at end of file +} diff --git a/src/Moonglade.Web/Controllers/PostController.cs b/src/Moonglade.Web/Controllers/PostController.cs index 2f524a007..6b11b1c9f 100644 --- a/src/Moonglade.Web/Controllers/PostController.cs +++ b/src/Moonglade.Web/Controllers/PostController.cs @@ -43,7 +43,11 @@ public async Task CreateOrEdit(PostEditModel model) return Conflict(result.ErrorMessage); } - return Ok(new { result.PostId }); + return Ok(new + { + result.PostId, + LastModifiedUtc = result.LastModifiedUtc?.ToString("u") + }); } [TypeFilter(typeof(ClearBlogCache), Arguments = diff --git a/src/Moonglade.Web/Extensions/ServiceCollectionExtensions.cs b/src/Moonglade.Web/Extensions/ServiceCollectionExtensions.cs index d3baf8b9c..beb762b95 100644 --- a/src/Moonglade.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/Moonglade.Web/Extensions/ServiceCollectionExtensions.cs @@ -209,7 +209,7 @@ private static IServiceCollection AddMoongladeProblemDetails(this IServiceCollec private static IServiceCollection AddMoongladeCoreServices(this IServiceCollection services, IConfiguration configuration) { - services.AddWebmention(); + services.AddWebmention(configuration); services.AddSyndication() .AddInMemoryCacheAside() @@ -270,4 +270,4 @@ private static IServiceCollection AddMoongladeInitializers(this IServiceCollecti return services; } -} \ No newline at end of file +} diff --git a/src/Moonglade.Web/Moonglade.Web.csproj b/src/Moonglade.Web/Moonglade.Web.csproj index 8289ba254..0f4b767bd 100644 --- a/src/Moonglade.Web/Moonglade.Web.csproj +++ b/src/Moonglade.Web/Moonglade.Web.csproj @@ -41,8 +41,8 @@ - - + + diff --git a/src/Moonglade.Web/Pages/Admin/Comments.cshtml b/src/Moonglade.Web/Pages/Admin/Comments.cshtml index 924476a62..d8c5dd7d9 100644 --- a/src/Moonglade.Web/Pages/Admin/Comments.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Comments.cshtml @@ -8,7 +8,9 @@ data-comment-deleted="@SharedLocalizer["Comment deleted"]" data-comments-deleted="@SharedLocalizer["Comments deleted"]" data-confirm-delete="@SharedLocalizer["Are you sure you want to delete this comment?"]" - data-confirm-delete-selected="@SharedLocalizer["Are you sure you want to delete selected comments?"]"> + data-confirm-delete-selected="@SharedLocalizer["Are you sure you want to delete selected comments?"]" + data-create-time="@SharedLocalizer["Create Time"]" + data-reply-posted="@SharedLocalizer["Reply posted"]"> @Html.AntiForgeryToken() @@ -199,7 +201,7 @@
@SharedLocalizer["Filter"]
- +
diff --git a/src/Moonglade.Web/Pages/Admin/Draft.cshtml b/src/Moonglade.Web/Pages/Admin/Draft.cshtml index 56a2c6623..1a880998d 100644 --- a/src/Moonglade.Web/Pages/Admin/Draft.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Draft.cshtml @@ -23,7 +23,7 @@ class="text-muted mb-3" :class="{ 'd-flex align-items-center': isLoading }">
- Loading drafts... + @SharedLocalizer["Loading drafts..."]
@@ -58,4 +58,4 @@
- \ No newline at end of file + diff --git a/src/Moonglade.Web/Pages/Admin/EditPage.cshtml b/src/Moonglade.Web/Pages/Admin/EditPage.cshtml index 867ab954b..13b4aec29 100644 --- a/src/Moonglade.Web/Pages/Admin/EditPage.cshtml +++ b/src/Moonglade.Web/Pages/Admin/EditPage.cshtml @@ -1,7 +1,7 @@ @page "/admin/page/edit/{id:guid?}" @using Microsoft.AspNetCore.Mvc.TagHelpers @{ - ViewBag.Title = "Edit Page"; + ViewBag.Title = SharedLocalizer["Edit Page"]; ViewBag.XData = "pageEditor"; ViewBag.HideAdminSidebar = true; ViewBag.AdminBackUrl = "/admin/page"; @@ -12,6 +12,10 @@ } + + @section navbarActions { @@ -115,7 +119,7 @@ x-bind:disabled="isSaving" x-on:click="isPreview = false"> @SharedLocalizer["Save"] - Saving... + @SharedLocalizer["Saving..."] +
@@ -141,26 +145,26 @@ x-model="formData.hideSidebar" class="form-check-input" id="hideSidebar"> - +
- +
- +
- \ No newline at end of file + diff --git a/src/Moonglade.Web/Pages/Admin/EditPost.cshtml b/src/Moonglade.Web/Pages/Admin/EditPost.cshtml index 697e452b0..b8f408d55 100644 --- a/src/Moonglade.Web/Pages/Admin/EditPost.cshtml +++ b/src/Moonglade.Web/Pages/Admin/EditPost.cshtml @@ -1,7 +1,7 @@ @page "/admin/post/edit/{id:guid?}" @{ - ViewBag.Title = "Edit Post"; + ViewBag.Title = SharedLocalizer["Edit Post"]; ViewBag.XData = "postEditor"; ViewBag.HideAdminSidebar = true; ViewBag.AdminBackUrl = "javascript:history.go(-1);"; @@ -12,15 +12,49 @@ } +@section navbarActions { + +} + @section head { } @Html.AntiForgeryToken() + +
- Loading post data... + @SharedLocalizer["Loading post data..."]
@@ -43,7 +77,7 @@
- Drag & Drop / Paste image here to upload. + @SharedLocalizer["Drag & Drop / Paste image here to upload."]
@@ -71,7 +105,7 @@ Modify + class="btn btn-warning btn-sm">@SharedLocalizer["Modify"]
@@ -122,21 +156,21 @@ x-model="formData.enableComment" class="form-check-input" id="enableComment"> - +
- +
- +
- +
@@ -234,7 +268,7 @@
- +
@if (string.IsNullOrWhiteSpace(Configuration["IndexNow:ApiKey"])) { - Not configured + @SharedLocalizer["Not configured"] } else { @@ -196,9 +196,9 @@
- * Global script that will be injected into every page's head section.
+ @Html.Raw(string.Format(SharedLocalizer["* Global script that will be injected into every page's {0} section."].Value, "head"))
- * Please use valid <script>...</script> code. + @Html.Raw(string.Format(SharedLocalizer["* Please use valid {0} code."].Value, "<script>...</script>"))
@@ -211,9 +211,9 @@
- * Global script that will be injected into every page's footer, before the ending of body tag. Typically used for third party analytics services.
+ @Html.Raw(string.Format(SharedLocalizer["* Global script that will be injected into every page's footer, before the ending of {0} tag. Typically used for third party analytics services."].Value, "body"))
- * Please use valid <script>...</script> code. + @Html.Raw(string.Format(SharedLocalizer["* Please use valid {0} code."].Value, "<script>...</script>"))
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml index 35500fd28..314d6e144 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml @@ -8,7 +8,8 @@ } @section head { diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml index d2dc68354..bad00b65c 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml @@ -172,24 +172,26 @@
@if (ModeratorOptions.Value.Provider.ToLowerInvariant() == "local") { - Using local provider + @SharedLocalizer["Using local provider"] } else if (ModeratorOptions.Value.Provider.ToLowerInvariant() == "remote") { if (!string.IsNullOrWhiteSpace(ModeratorOptions.Value.ApiEndpoint)) { - Using Azure provider at @ModeratorOptions.Value.ApiEndpoint + @Html.Raw(string.Format( + SharedLocalizer["Using Azure provider at {0}"].Value, + $"{System.Net.WebUtility.HtmlEncode(ModeratorOptions.Value.ApiEndpoint)}")) } else { - - Please follow instruction to setup content security API - + @Html.Raw(string.Format( + SharedLocalizer["Please follow {0} to setup content security API"].Value, + "instruction")) } } else { - Unknown provider, this feature may not be working. + @SharedLocalizer["Unknown provider, this feature may not be working."] }
@SharedLocalizer["Word filter only applies when comment review is disabled."]
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml index 8ba28a1c1..519e2aecc 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml @@ -27,7 +27,11 @@ } @section admintoolbar { @@ -83,7 +87,7 @@
- +
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml index cf742f33d..223d29e2f 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml @@ -41,7 +41,7 @@
-
e.g. https://cdn.edi.wang/ediwang-images
+
@SharedLocalizer["e.g. https://cdn.edi.wang/ediwang-images"]
@@ -49,7 +49,11 @@
-
* Recommend to use Azure Front Door and CDN
+
+ @Html.Raw(string.Format( + SharedLocalizer["* Recommend to use {0}"].Value, + "Azure Front Door and CDN")) +

@@ -126,7 +130,7 @@

- +
@SharedLocalizer["Transparency value (0-255)."]
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml index 3b056948a..3c8acf0a6 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml @@ -5,7 +5,8 @@ } @{ @@ -27,7 +28,9 @@ string.IsNullOrWhiteSpace(Configuration["Email:ApiKey"])) {
- Please follow instructions to setup email API. + @Html.Raw(string.Format( + SharedLocalizer["Please follow {0} to setup email API."].Value, + "instructions"))
} else @@ -37,7 +40,7 @@
- +
@@ -66,7 +69,7 @@
diff --git a/src/Moonglade.Web/Pages/Admin/Widgets.cshtml b/src/Moonglade.Web/Pages/Admin/Widgets.cshtml index 0cb3605be..1a590804b 100644 --- a/src/Moonglade.Web/Pages/Admin/Widgets.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Widgets.cshtml @@ -19,7 +19,17 @@ data-image-url-required="@SharedLocalizer["Image URL is required"]" data-text-url-required="@SharedLocalizer["Button text and URL are required"]" data-max-buttons="@SharedLocalizer["You can add up to 3 buttons"]" - data-remove-button="@SharedLocalizer["Are you sure you want to remove this button?"]"> + data-remove-button="@SharedLocalizer["Are you sure you want to remove this button?"]" + data-display-order-range="@SharedLocalizer["Display Order must be between -30 and 999."]" + data-unnamed-link="@SharedLocalizer["Unnamed Link"]" + data-unnamed-button="@SharedLocalizer["Unnamed Button"]" + data-open-in-new-tab="@SharedLocalizer["Open in new tab"]" + data-open-in-same-tab="@SharedLocalizer["Opens in same tab"]" + data-add-new-link="@SharedLocalizer["Add New Link"]" + data-edit-link="@SharedLocalizer["Edit Link"]" + data-add-new-button="@SharedLocalizer["Add New Button"]" + data-edit-button="@SharedLocalizer["Edit Button"]" + data-button="@SharedLocalizer["Button"]"> @section admintoolbar { @@ -52,7 +62,7 @@
- Disabled + @SharedLocalizer["Disabled"]

@@ -60,7 +70,7 @@