diff --git a/InterlinedList/Services/InterlinedApiClient.Blog.cs b/InterlinedList/Services/InterlinedApiClient.Blog.cs new file mode 100644 index 0000000..e910611 --- /dev/null +++ b/InterlinedList/Services/InterlinedApiClient.Blog.cs @@ -0,0 +1,68 @@ +using System.Net.Http; + +namespace InterlinedList.Services; + +/// +/// Blog newsletter subscription. +/// +/// +/// +/// There is no public blog-read endpoint. Verified live 2026-09-16: +/// /api/blog, /api/blog/posts and /api/blog/list all +/// return 404, and the only blog routes in the spec besides these +/// subscription ones are /api/admin/blog* — admin-only and +/// cookie-authed. So reading the blog is a browser handoff (#121); there is +/// nothing to fetch, and scraping the website's HTML is not a substitute for +/// an API. +/// +/// +/// Unsubscribe is token-based, not email-based. Both +/// GET and POST /api/blog/unsubscribe take a ?token= +/// query parameter and nothing else — the token comes from the email footer +/// (the POST form is the RFC-8058 one-click target). A client that only +/// knows the user's address therefore cannot unsubscribe them, so that +/// is a handoff too rather than a button that can't work. +/// +/// +public sealed partial class InterlinedApiClient +{ + /// + /// Subscribe an address to the blog newsletter. Double opt-in — a + /// confirmation email follows, and the subscription isn't live until its + /// link is clicked. + /// + /// + /// Request shape is {email}. The server validates it: + /// an invalid address returns + /// 400 {"error":"A valid email is required","code":"bad_request"} + /// (verified live with not-an-email — no message was sent to anyone). + /// The success envelope is not verified: confirming it would mean + /// mailing a real address, so nothing is parsed from it. + /// + public Task SubscribeToBlogAsync(string email, CancellationToken ct = default) + => SendVoidAsync(HttpMethod.Post, "api/blog/subscribe", new { email }, ct); + + /// + /// Complete a double opt-in confirmation from an emailed token. + /// + /// + /// Included for completeness; in practice the confirmation link is clicked + /// in the browser from the email, which is where it lands anyway. + /// + public Task ConfirmBlogSubscriptionAsync(string token, CancellationToken ct = default) + => SendVoidAsync(HttpMethod.Get, $"api/blog/subscribe/confirm?token={Uri.EscapeDataString(token)}", null, ct); + + /// + /// Unsubscribe using a token from an email footer. + /// + /// + /// Only usable if the caller actually has the token — see the type-level + /// remarks. Do not surface this as "unsubscribe me"; the app has no way to + /// obtain the token. + /// + public Task UnsubscribeFromBlogAsync(string token, CancellationToken ct = default) + => SendVoidAsync(HttpMethod.Post, $"api/blog/unsubscribe?token={Uri.EscapeDataString(token)}", new { }, ct); + + /// The blog's web address, for the browser handoff. + public static string BlogUrl => $"{ApiConfig.BaseUrl}blog"; +} diff --git a/InterlinedList/ViewModels/BlogPanelViewModel.cs b/InterlinedList/ViewModels/BlogPanelViewModel.cs new file mode 100644 index 0000000..7ad7e36 --- /dev/null +++ b/InterlinedList/ViewModels/BlogPanelViewModel.cs @@ -0,0 +1,94 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// Blog: read it in the browser, and subscribe to the newsletter. +/// +/// +/// Reading is a handoff because there is no public blog-read endpoint (#121) — +/// /api/blog, /api/blog/posts and /api/blog/list all 404, +/// and authoring lives under admin-only cookie-authed routes. Unsubscribe is +/// also a handoff, because the endpoint needs a token that only the email +/// footer carries. +/// +public partial class BlogPanelViewModel : ObservableObject +{ + private readonly SessionService _session; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))] + private string email = ""; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))] + private bool isBusy; + + [ObservableProperty] + private string? statusMessage; + + [ObservableProperty] + private string? errorMessage; + + public BlogPanelViewModel(SessionService session) + { + _session = session; + // Pre-fill with the account address — the overwhelmingly likely choice. + email = session.CurrentUser?.Email ?? ""; + } + + private bool CanSubscribe() => !IsBusy && LooksLikeEmail(Email); + + // Cheap client-side shape check only. The server is authoritative and + // returns 400 "A valid email is required"; this just avoids an obviously + // pointless round trip. + private static bool LooksLikeEmail(string value) + { + var trimmed = value.Trim(); + var at = trimmed.IndexOf('@'); + return at > 0 + && at < trimmed.Length - 1 + && trimmed.IndexOf('.', at) > at + 1 + && !trimmed.Contains(' '); + } + + [RelayCommand(CanExecute = nameof(CanSubscribe))] + private async Task SubscribeAsync() + { + IsBusy = true; + StatusMessage = null; + ErrorMessage = null; + try + { + await _session.Api.SubscribeToBlogAsync(Email.Trim()); + // Double opt-in: say so, or the user will assume they're done. + StatusMessage = $"Check {Email.Trim()} for a confirmation link — " + + "the subscription isn't active until you click it."; + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsBusy = false; + } + } + + /// Open the blog in the OS browser — there is no read API to render. + [RelayCommand] + private void OpenBlog() => OpenInBrowser(InterlinedApiClient.BlogUrl); + + private static void OpenInBrowser(string url) + { + // UseShellExecute is required for .NET Core+ to hand a URL to the OS + // default browser — same pattern as the OAuth handoff. + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = url, + UseShellExecute = true, + }); + } +} diff --git a/InterlinedList/Views/BlogPanel.xaml b/InterlinedList/Views/BlogPanel.xaml new file mode 100644 index 0000000..0941fd7 --- /dev/null +++ b/InterlinedList/Views/BlogPanel.xaml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + +