diff --git a/CHANGELOG.md b/CHANGELOG.md index ac258ff6..c855b249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,111 +16,15 @@ Cette convention est partagée avec le dépôt [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) pour la production des paquets `.deb`. -## [1.0.7] - preview +## [Unreleased] ### Added -- Per-post ACL in PostIt: a new “Manage ACL” page, opened from the ACL - button on a selected post, lets the post author grant or revoke - grants for individuals or circles. The server scopes each grant - operation to `caller == post.AuthorId` and returns `404` (not `403`) - for posts the caller does not own, so the existence of another - user's post is not leaked. -- Circle membership API + UI: three new REST endpoints under - `/api/circle/{id}/members` (`GET` list, `POST` add, `DELETE` - remove) and a new “Members” column on the *My Circles* page with an - “Add a member” button that opens a search modal. The search modal - reuses `IUserDirectory` (introduced by the `IContactService` split - in this same release) — exactly the use case the abstraction was - carved out for. -- Publish toggle for blog posts: a new `PUT /api/BlogApi/{id}/publish` - endpoint, and a `Published` checkbox in the post toolbar that - toggles a `BlogSpotPublication` row for the post. The publish - signal flows through the pre-existing `PermissionHandler.IsPublic` - path, so no new column was needed and the server-side authorisation - logic is unchanged. -- `UserSearchApiController` in `Yavsc.Blogs`: - `GET /api/user-search?q=...&e=...&take=...`. Any-authenticated- - caller endpoint that exposes the user's email under a closed- - community assumption (documented in the controller's XML doc). - Wired to the PostIt Desktop address book so the user search modal - picks it up. -- `IYavscApiClient` abstraction in `Yavsc.Api.Client`. The transport - for the blog/circle/blog-acl/user-search clients is now accessed - through this interface, so `PostIt.Tests` can stub the HTTP layer - without spinning up a real WebAPI host. -- Forgejo Actions release workflow: a `.forgejo/workflows/release.yml` - pipeline that builds and publishes a release with the PostIt APK - on tag push. Written in pure bash (the runner image has no Node), - uses `jq` for JSON body construction and response parsing, uses the - runner-provided `GITHUB_TOKEN` (no repo-level secret needed), - validates the CHANGELOG section heading before allowing the tag - to ship. -- `make release V=` target: creates a `release/` branch - from `main`, bumps the `` property in every `.csproj` via - `dotnet-gitversion /updateprojectfiles`, commits the bump on the - release branch, and pushes to `origin`. Fails fast if the working - tree is dirty or if `HEAD` is not on `main`. -- Forgejo status badges in the README. ### Changed -- The new Publish toggle replaces the “Visibility enum” approach - originally drafted in this branch: the existing `BlogSpotPublication` - table already carried enough information to expose a publish - switch, so no schema change was needed. The original `feat(blog): - add Visibility { Private, Public }` commit and its EF migration - were reverted in favour of the endpoint-only toggle. -- `BlogPost` DTO and `IBlogPost` moved from `PostIt.Models` to - `Yavsc.Abstract.Blogspot`, the shared assembly where the server-side - entity and the wire DTO both live. Renamed `Yavsc.Blogspot.BlogPost` - to `BlogPostDto` to make the wire/entity distinction explicit. -- `BlogAclApiController` and `CircleApiController` moved from - `Yavsc.Api` (not yet enabled in production) to `Yavsc.Blogs`, where - they belong next to the `BlogSpotService` they depend on. -- `IContactService` split from `IUserDirectory`: the two interfaces - previously conflated the local address-book access (mobile-only, - via `Contacts.Default`) and the Yavsc user-search access - (Desktop-only, via `/api/user-search`) behind a single facade. The - split restores the `ContactDto.Emails` multi-value shape that was - being silently flattened to a single string before. -- CI: the Forgejo Actions build now compiles `.csproj` projects - directly inside the runner container (which ships the .NET SDK + - Android workload), instead of relying on a separate Docker build - step. Node-based third-party actions were replaced with bash + curl - + `jq`. The validate-release job parses the CHANGELOG section - heading to derive the channel (`stable` / `preview` / `unstable`) - rather than the patch-version parity alone. ### Fixed -- `CircleApiController` used to read the caller's user id via - `FindFirstValue(ClaimTypes.NameIdentifier)`, which does not match - when JWT Bearer middleware has `MapInboundClaims = false`. Switched - to `User.GetUserId()` (tries `sub` first, then - `ClaimTypes.NameIdentifier`, then `nameid`). This was a latent - bug visible in tests but easy to ship to production if a host - ever disabled the remap. -- `CircleApiController` and `BlogAclApiController` reads and writes - were not always scoped to the caller's own data. Tightened the - authorisation checks: cross-user reads now return `404`, not the - raw record. -- `validate-release` CHANGELOG channel check used to parse the - patch-version parity only, which disagreed with the channel - suffix in the section heading (e.g. `## [1.0.7] - preview` - would be flagged as `stable` from the parity alone). The job now - inspects the heading line and trusts the suffix when present. -- `.forgejo/workflows/release.yml`: the asset-upload URL now carries - the asset name as a query-string parameter instead of a `curl` - positional argument. The previous shape triggered Forgejo's - “Missing `name` parameter” 400 in some cases. ### Removed -- The `## [Unreleased]` block has been moved into this section. -- The abandoned `Visibility { Private, Public }` enum and its EF - migration, reverted in this release. The publish toggle covers - the same user-visible switch without a schema change. - -[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD -[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 ## [1.0.6] - stable @@ -154,4 +58,5 @@ pour la production des paquets `.deb`. actual release id. Switched to `jq` for both body construction and field extraction. +[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD [1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 diff --git a/README.md b/README.md index f3ca0e46..d1ed912a 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,6 @@ C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. -# Statut actuel des actions Forgejo - -[![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml) - -[![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)]( -https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml -) - -[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest) - - # Statut actuel des actions GitHub * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs index fbccb606..68fa514e 100644 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt.Tests/BearerScopeTests.cs @@ -8,9 +8,6 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using PostIt.Services; using PostIt.Services; using Xunit; @@ -97,7 +94,7 @@ public class BearerScopeTests // CapturingHttpHandler is the assertion point. It // records the first request's Authorization header and // returns 200 with an empty array (BlogApiClient - // deserialises to List). + // deserialises to List). var captured = new CapturingHttpHandler(); var client = new YavscApiClient( settings, @@ -122,7 +119,7 @@ public class BearerScopeTests // Resolve a BlogApiClient on top. We don't need real // posts; we just need the outbound HTTP request to be // the one we capture. - var blog = new BlogApiClient(subClient, "http://localhost/"); + var blog = new BlogApiClient(subClient); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs index 4b541e42..755ce105 100644 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -1,4 +1,4 @@ -using Yavsc.Blogspot; +using PostIt.Models; using PostIt.Services; using PostIt.ViewModels; using Yavsc.Models; @@ -18,7 +18,7 @@ internal sealed class CallRecorder /// Test fake that records every CallAsync invocation /// and answers them with a canned sequence: the first call gets -/// a server-issued BlogPostDto (Id=42), the second call gets a +/// a server-issued BlogPost (Id=42), the second call gets a /// single-element list containing that post. Used by the ViewModel /// tests and the headless UI test to capture exactly what the /// Save button posts to the server. @@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { _recorder.Calls.Add((method, path, body)); - // BlogPostDto? boxes to BlogPostDto at runtime, so we test the - // non-nullable type — typeof(BlogPostDto?) is a C# error + // BlogPost? boxes to BlogPost at runtime, so we test the + // non-nullable type — typeof(BlogPost?) is a C# error // (CS8639: "typeof cannot be used on a nullable reference // type"). - if (typeof(T) == typeof(BlogPostDto)) - return Task.FromResult((T)(object)new BlogPostDto + if (typeof(T) == typeof(BlogPost)) + return Task.FromResult((T)(object)new BlogPost { Id = 42, Title = "Mon premier billet", AuthorId = "tester", Article = "Contenu du billet de test.", }); - if (typeof(T) == typeof(List)) - return Task.FromResult((T)(object)new List + if (typeof(T) == typeof(List)) + return Task.FromResult((T)(object)new List { new() { Id = 42, Title = "Mon premier billet" } }); diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index b6bf963a..c76115d7 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -2,8 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using PostIt.Models; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; @@ -25,7 +24,7 @@ namespace PostIt.Tests; /// in which a brand-new post can be created), the binding has /// no target and the user's keystrokes are silently dropped. /// Clicking "Save" then routes to the VM branch -/// if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } } +/// if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } } /// which the controller rejects with 400 "The Title field is /// required." This test fails on that branch today and will /// pass once the VM owns a dedicated Title/Article @@ -41,7 +40,7 @@ public class MainPageSaveTests // not a Control, so it needs a navigation host). var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api, "http://localhost/"); + var blog = new BlogApiClient(api); var viewModel = new MainPageViewModel(blog); var page = new MainPage { DataContext = viewModel }; @@ -77,14 +76,14 @@ public class MainPageSaveTests // we inspect the recorder. await Task.Delay(200); - // Assert: the first POST to "blog" carried a BlogPostDto + // Assert: the first POST to "blog" carried a BlogPost // whose Title is exactly what the user typed. The bug // fails this assertion with Title == string.Empty. Assert.NotEmpty(recorder.Calls); var (method, path, body) = recorder.FirstCall; Assert.Equal(HttpMethod.Post, method); Assert.Equal("blog", path); - var sent = Assert.IsType(body); + var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } } diff --git a/src/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt.Tests/PostIt.Tests.csproj index 54c40e8c..3d35d827 100644 --- a/src/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt.Tests/PostIt.Tests.csproj @@ -6,10 +6,10 @@ false PostIt.Tests true - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada - 1.1.0-beta.1 + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index 2dee4604..48569915 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,5 +1,4 @@ -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using PostIt.Models; using PostIt.Services; using PostIt.ViewModels; @@ -15,12 +14,12 @@ public class PostItViewModelTests // default; tests construct one with a fake YavscApiClient that // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); - var blog = new BlogApiClient(fakeApi, "http://localhost/"); + var blog = new BlogApiClient(fakeApi); var viewModel = new MainPageViewModel(blog); - viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); - viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); - viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); + viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); + viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); + viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); viewModel.SearchText = "search"; viewModel.SearchCommand.Execute(null); @@ -41,13 +40,13 @@ public class PostItViewModelTests // The new BlogApiClient delegates transport to YavscApiClient. // We feed it a fake YavscApiClient that returns the expected // list straight from CallAsync. - var expected = new List + var expected = new List { new() { Id = 1, Title = "Hello" }, new() { Id = 2, Title = "World" } }; var api = new StubYavscApiClient(expected); - var blog = new BlogApiClient(api, "http://localhost/"); + var blog = new BlogApiClient(api); var posts = await blog.GetPostsAsync(); @@ -77,8 +76,8 @@ public class PostItViewModelTests /// Test fake that hands back a canned list of posts from any CallAsync. private sealed class StubYavscApiClient : YavscApiClient { - private readonly List _posts; - public StubYavscApiClient(List posts) + private readonly List _posts; + public StubYavscApiClient(List posts) : base( new Settings { @@ -98,7 +97,7 @@ public class PostItViewModelTests { // The canned fake only knows about a list of posts; the // BlogApiClient test asserts on that list directly. - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) return Task.FromResult((T)(object)_posts); return Task.FromResult(default(T)!); } diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index e54bc541..c020fec9 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -8,9 +8,6 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using PostIt.Services; using System.Threading.Tasks; using IdentityModel.OidcClient; using IdentityModel.OidcClient.Browser; diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 62b3a343..900f1428 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -14,7 +14,6 @@ - diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index de4e3801..3820bf49 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -12,10 +12,10 @@ apk false android-arm;android-arm64;android-x86;android-x64 - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada - 1.1.0-beta.1 + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 7a0a9ba3..a339b9f0 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -4,10 +4,10 @@ Exe true enable - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada - 1.1.0-beta.1 + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 6ab6fdac..c543c550 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -5,10 +5,10 @@ See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.--> net10.0 enable - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada - 1.1.0-beta.1 + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 app.manifest diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 6f93edf9..b5740f2f 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -7,7 +7,6 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; using PostIt.Services; -using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; @@ -56,12 +55,7 @@ public partial class App : Application "PostIt", "tokens.json")); var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(); - var userDirectory = new UserDirectory(userSearchClient); + var client = new BlogApiClient(api); var services = new ServiceCollection(); @@ -81,22 +75,14 @@ public partial class App : Application services.AddSingleton(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // ViewModels services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(api); + services.AddSingleton(api); services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); - services.AddSingleton(userDirectory); services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/PostIt/PostIt/Models/BlogPost.cs b/src/PostIt/PostIt/Models/BlogPost.cs new file mode 100644 index 00000000..e62fcea2 --- /dev/null +++ b/src/PostIt/PostIt/Models/BlogPost.cs @@ -0,0 +1,37 @@ +using System; +using Yavsc.Abstract.Identity; +using Yavsc.Abstract.Identity.Security; +using Yavsc.Blogspot; + +namespace PostIt.Models; + +public class BlogPost : IBlogPost +{ + public string AuthorId { get; set; } + + public IApplicationUser Author { get; set; } + + public string Article { get; set ; } + public string Photo { get; set ; } + public long Id { get; set ; } + public DateTime DateCreated { get; set ; } + public string UserCreated { get; set ; } + public DateTime DateModified { get; set ; } + public string UserModified { get; set ; } + public string Title { get; set ; } + + public bool AuthorizeCircle(long circleId) + { + throw new NotImplementedException(); + } + + public ICircleAuthorization[] GetACL() + { + throw new NotImplementedException(); + } + + public string[] GetTags() + { + throw new NotImplementedException(); + } +} diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index c63771b8..d9cf96d3 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -4,10 +4,10 @@ enable latest true - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada - 1.1.0-beta.1 + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 @@ -25,7 +25,6 @@ - diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs similarity index 51% rename from src/Yavsc.Api.Client/BlogApiClient.cs rename to src/PostIt/PostIt/Services/BlogApiClient.cs index cbc82358..5e927b97 100644 --- a/src/Yavsc.Api.Client/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -3,18 +3,17 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Yavsc.Blogspot; +using PostIt.Models; -namespace Yavsc.Api.Client; +namespace PostIt.Services; /// /// High-level client for the Blog subsystem of the Yavsc API /// (deployed at https://blogs.pschneider.fr). All transport /// concerns — base URL, JSON serialisation, Bearer auth, silent /// refresh on 401, request body shaping — are delegated to -/// , which lives in the consuming -/// application (PostIt). This class is a thin DTO↔path mapper, -/// nothing more. +/// . This class is a thin DTO↔path +/// mapper, nothing more. /// /// URL convention. 's /// BaseAddress already terminates with /api/v1/ @@ -35,50 +34,35 @@ public sealed class BlogApiClient { private const string DefaultPathPrefix = "blog"; - private readonly IYavscApiClient _api; - private readonly Uri _baseAddress; + private readonly YavscApiClient _api; private readonly string _pathPrefix; - public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix) + public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix) { _api = api ?? throw new ArgumentNullException(nameof(api)); - if (string.IsNullOrEmpty(blogsBaseAddress)) - throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); - // e.g. "https://blogs.pschneider.fr/api/v1/" — keep the + // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the // trailing slash so relative paths ("posts") resolve correctly. - _baseAddress = new Uri(blogsBaseAddress); - api.Http.BaseAddress = _baseAddress; + api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl); _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; } - public Task> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) - => _api.CallAsync>( + public Task> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) + => _api.CallAsync>( HttpMethod.Get, $"{_pathPrefix}?start={start}&take={take}", ct: ct); - public Task GetPostAsync(long id, CancellationToken ct = default) - => _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); + public Task GetPostAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); - public Task CreatePostAsync(BlogPostDto post, CancellationToken ct = default) - => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct); + public Task CreatePostAsync(BlogPost post, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct); - public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default) + public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default) => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct); public Task DeletePostAsync(long id, CancellationToken ct = default) => _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct); - - /// - /// Set a post's publication state. true publishes - /// it (visible to anonymous readers via - /// PermissionHandler.IsPublic); false takes - /// it back to draft. Idempotent: the resulting state - /// matches the call, regardless of the previous state. - /// - public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default) - => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish", - body: new { publish }, ct: ct); } diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs deleted file mode 100644 index fa7d37f6..00000000 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ /dev/null @@ -1,36 +0,0 @@ -#if !ANDROID && !IOS -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace PostIt.Services; - -/// -/// Desktop stub for . -/// -/// The desktop has no equivalent of the mobile address -/// book (no Contacts.Default, no CardDAV out of the -/// box). Rather than synthesise a list from a different -/// source, this provider returns an empty list and lets the -/// UI render an honest "no local contacts on this platform" -/// message. -/// -/// If desktop users want to invite people who aren't -/// Yavsc members, that flow goes through a separate path -/// (manual email entry + invitation endpoint) — not through -/// . Finding existing Yavsc -/// members is 's job, not this -/// one's. -/// -/// Future CardDAV / Google Contacts / Exchange -/// providers can plug in here as additional -/// implementations selected -/// from DI by configuration. -/// -public sealed class ContactService : IContactService -{ - public Task> GetDeviceContactsAsync(CancellationToken ct = default) - => Task.FromResult>(Array.Empty()); -} -#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs deleted file mode 100644 index 8dbd134d..00000000 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ /dev/null @@ -1,82 +0,0 @@ -#if ANDROID || IOS -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Maui.ApplicationModel.Communication; -using Microsoft.Maui.ApplicationModel; -using Microsoft.Maui.Devices; - -namespace PostIt.Services; - -/// -/// Mobile implementation backed by MAUI Essentials -/// Contacts.Default. -/// -/// Compiled only for ANDROID and IOS. On desktop targets, -/// see ContactService.Desktop.cs (the stub that wins at -/// compile time). -/// -/// Note: at runtime, this class throws -/// NotImplementedInReferenceAssemblyException unless -/// the host application project also references the -/// platform-specific Microsoft.Maui.Essentials implementation -/// (typically PostIt.Android). On iOS the same is -/// required via PostIt.iOS. On desktop the stub is used -/// and this file is excluded. -/// -public sealed class ContactService : IContactService -{ - public async Task> GetDeviceContactsAsync(CancellationToken ct = default) - { - if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) - return Array.Empty(); - - try - { - var status = await Permissions.RequestAsync(); - if (status != PermissionStatus.Granted) - return Array.Empty(); - - var contacts = await Contacts.Default.GetAllAsync(); - if (contacts is null) return Array.Empty(); - - // Carry the per-contact email list as-is. A real - // device contact can carry several addresses (home / - // work / other); the UI use case ("invite / add to a - // circle") can then decide which address to use, or - // let the user pick. The platform-neutral ContactDto - // shape is intentionally richer than the Yavsc - // directory's single-Email shape — the two flows - // answer different questions. - var result = new List(contacts.Count); - foreach (var c in contacts) - { - var emails = ExtractEmails(c.Emails); - result.Add(new ContactDto( - c.Id, - c.DisplayName ?? string.Empty, - emails)); - } - return result; - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}"); - return Array.Empty(); - } - } - - private static IReadOnlyList ExtractEmails(IEnumerable? emails) - { - if (emails is null) return Array.Empty(); - var list = new List(); - foreach (var e in emails) - { - if (!string.IsNullOrEmpty(e.EmailAddress)) - list.Add(e.EmailAddress); - } - return list; - } -} -#endif diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs deleted file mode 100644 index 49ca3064..00000000 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace PostIt.Services; - -/// -/// Abstraction over the device-local address book. Used by -/// the "invite someone" flow to enumerate people the user -/// already has in their phone — including people who have -/// never heard of Yavsc. -/// -/// Distinct from , which -/// reads the central Yavsc user table. A device contact may -/// not have a Yavsc account; a directory entry always does. -/// The two are exposed as separate interfaces so a UI that -/// needs both can take both by constructor injection and -/// present them under separate sections (e.g. "Contacts from -/// your phone" vs "Yavsc members"). -/// -/// Implementations live next to this file in -/// platform-conditional source files: -/// ContactService.Mobile.cs (ANDROID/IOS) and -/// ContactService.Desktop.cs (everything else). On -/// desktop the implementation is a stub that returns an -/// empty list: the desktop has no equivalent of the mobile -/// address book, and inviting from a desktop is a separate -/// flow. -/// -public interface IContactService -{ - /// - /// Read the device address book. Returns the contacts - /// known to the local provider; on desktop (no local - /// provider) this is always an empty list. - /// - Task> GetDeviceContactsAsync(CancellationToken ct = default); -} - -/// -/// Platform-neutral contact DTO. Source-of-truth shape for -/// the UI layer; concrete providers (MAUI Essentials on -/// mobile) map to this type. -/// -/// Emails is a list on purpose: a real device -/// contact may carry several addresses (home / work / other). -/// The UI use case ("invite / add to a circle") can then -/// decide which address to use, or let the user pick. This -/// is intentionally richer than the Yavsc directory's -/// single-Email shape — the two flows answer different -/// questions and shouldn't be flattened onto the same -/// wire. -/// -public sealed record ContactDto( - string Id, - string DisplayName, - IReadOnlyList Emails); diff --git a/src/PostIt/PostIt/Services/IUserDirectory.cs b/src/PostIt/PostIt/Services/IUserDirectory.cs deleted file mode 100644 index 7d4c1eb4..00000000 --- a/src/PostIt/PostIt/Services/IUserDirectory.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace PostIt.Services; - -/// -/// Abstraction over the central Yavsc user directory. Used by -/// the "add to a circle" flow to find Yavsc users by display -/// name or email. -/// -/// Distinct from , which -/// reads the device-local address book. A Yavsc user -/// directory entry is always a registered account; a device -/// contact may be anyone in the user's phone — including -/// people who have never heard of Yavsc. -/// -/// Implementations live next to this file in -/// platform-conditional source files: -/// UserDirectory.Desktop.cs and -/// UserDirectory.Mobile.cs. Both currently delegate to -/// UserSearchClient (the central /api/user-search -/// endpoint); the split exists so future platform-specific -/// sources (offline cache, directory-scoped providers) can be -/// plugged in without disturbing the consumer. -/// -public interface IUserDirectory -{ - /// - /// Search the directory by display name (substring) and/or - /// email (exact). - /// - /// Substring filter on the user's - /// display name. Empty or whitespace short-circuits to an - /// empty list (matches the client UX of "type to search", - /// not "show me a directory"). - /// Cancellation token. - /// A flat list of matching directory entries. - /// Never null; may be empty. - Task> SearchAsync(string query, CancellationToken ct = default); -} - -/// -/// Platform-neutral summary of a Yavsc directory entry. Mirrors -/// the wire shape of /api/user-search (see -/// UserSearchResultDto) but expressed in terms that -/// don't leak transport concerns. -/// -/// Kept as a record on purpose: directory entries are -/// immutable snapshots from the server, so structural equality -/// makes "did the user already pick this one?" trivial. -/// -public sealed record UserSummary( - string Id, - string UserName, - string? FullName, - string? Avatar, - string? Email) -{ - /// - /// Convenience for "what to show in a picker". Falls back - /// to when - /// is null or empty. - /// - public string DisplayName => - string.IsNullOrWhiteSpace(FullName) ? UserName : FullName; -} diff --git a/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs deleted file mode 100644 index c821bb87..00000000 --- a/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs +++ /dev/null @@ -1,52 +0,0 @@ -#if !ANDROID && !IOS -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Yavsc.Api.Client; - -namespace PostIt.Services; - -/// -/// Desktop implementation of . -/// Delegates to the central /api/user-search endpoint -/// via . -/// -/// The desktop has no device-local address book, so the -/// "add to a circle" flow on desktop is Yavsc-users-only. -/// Inviting someone who doesn't have a Yavsc account from -/// desktop is a separate feature (manual email entry + -/// invitation endpoint) and lives outside this interface. -/// -public sealed class UserDirectory : IUserDirectory -{ - private readonly UserSearchClient _client; - - public UserDirectory(UserSearchClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - - public async Task> SearchAsync( - string query, CancellationToken ct = default) - { - // UserSearchClient already short-circuits on empty - // queries, but do it here too so the contract is - // obvious to anyone reading IUserDirectory alone - // without having to chase the client wrapper. - if (string.IsNullOrWhiteSpace(query)) - return Array.Empty(); - - var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); - if (results is null) return Array.Empty(); - - return results.Select(u => new UserSummary( - Id: u.Id, - UserName: u.UserName, - FullName: u.FullName, - Avatar: u.Avatar, - Email: u.Email)).ToList(); - } -} -#endif diff --git a/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs deleted file mode 100644 index 5cba6e4a..00000000 --- a/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs +++ /dev/null @@ -1,49 +0,0 @@ -#if ANDROID || IOS -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Yavsc.Api.Client; - -namespace PostIt.Services; - -/// -/// Mobile implementation of . -/// Same backing as the desktop provider (the central -/// /api/user-search endpoint via -/// ) — mobile devices have the -/// network too, and "add to a circle" needs the same directory -/// regardless of platform. -/// -/// The split exists so a future mobile-only provider -/// (offline cache, device-local mirror of the user's own -/// circles) can be plugged in without touching consumers. -/// -public sealed class UserDirectory : IUserDirectory -{ - private readonly UserSearchClient _client; - - public UserDirectory(UserSearchClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - - public async Task> SearchAsync( - string query, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(query)) - return Array.Empty(); - - var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); - if (results is null) return Array.Empty(); - - return results.Select(u => new UserSummary( - Id: u.Id, - UserName: u.UserName, - FullName: u.FullName, - Avatar: u.Avatar, - Email: u.Email)).ToList(); - } -} -#endif diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index b611fe02..9ae1453b 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -9,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; using IdentityModel.OidcClient; using PostIt.ViewModels; -using Yavsc.Api.Client; namespace PostIt.Services; @@ -25,7 +24,7 @@ namespace PostIt.Services; /// only refreshes once even if many /// concurrent requests are in flight. /// -public class YavscApiClient : IYavscApiClient, IAsyncDisposable +public class YavscApiClient : IAsyncDisposable { // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. diff --git a/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs deleted file mode 100644 index a721d738..00000000 --- a/src/PostIt/PostIt/ViewModels/AddCircleMemberDialogViewModel.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Threading; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using PostIt.Services; -using Yavsc.Api.Client; - -namespace PostIt.ViewModels; - -/// -/// View model for the "add a Yavsc user to a circle" modal. -/// -/// Resolves users through -/// (which delegates to /api/user-search); the caller -/// (CirclesPage) decides whether to add the picked user to -/// the circle by calling -/// -/// (which is bound to the dialog's "Ajouter" button). -/// -/// The dialog itself doesn't know the target -/// CircleId: that's set by the caller via the -/// constructor and the dialog only triggers -/// against the -/// string. The "Add" command -/// returns the picked via the -/// event, and the hosting -/// CirclesPage then calls -/// . -/// -public partial class AddCircleMemberDialogViewModel : ViewModelBase -{ - private readonly IUserDirectory _directory; - - [ObservableProperty] - public partial string SearchQuery { get; set; } = string.Empty; - - [ObservableProperty] - public partial ObservableCollection Results { get; set; } = new(); - - [ObservableProperty] - public partial UserSummary? Selected { get; set; } - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } = string.Empty; - - /// - /// Raised when the user confirms a selection. The hosting - /// CirclesPage subscribes to this event and calls - /// CircleApiClient.AddMemberAsync with the target - /// circle id + the picked user's id. The dialog itself - /// does not know the circle id by design: separation of - /// concerns — the modal is a user picker, not a - /// "circle joiner" form. - /// - public event EventHandler? Confirmed; - - public AddCircleMemberDialogViewModel(IUserDirectory directory) - { - _directory = directory ?? throw new ArgumentNullException(nameof(directory)); - } - - public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - - /// - /// Search the directory for users matching the current - /// . Triggered explicitly via the - /// "Rechercher" button — no debouncing, so the caller - /// stays in control of how often the network is hit. - /// - [RelayCommand] - public async Task SearchAsync() - { - if (string.IsNullOrWhiteSpace(SearchQuery)) - { - Results.Clear(); - StatusMessage = "Tapez un nom ou un email"; - return; - } - - IsBusy = true; - try - { - var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true); - Results = new ObservableCollection(hits ?? Array.Empty()); - StatusMessage = $"{Results.Count} résultat(s)"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - /// - /// Raise for the currently selected - /// user. No-op when no selection has been made — keeps the - /// UI from firing an event with a null payload. - /// - [RelayCommand] - public void Add() - { - if (Selected is null) - { - StatusMessage = "Sélectionnez un utilisateur"; - return; - } - Confirmed?.Invoke(this, Selected); - } -} diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs deleted file mode 100644 index c017c426..00000000 --- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs +++ /dev/null @@ -1,310 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using PostIt.Services; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; - -namespace PostIt.ViewModels; - -/// -/// View model for the "Mes cercles" page. CRUD on the caller's own -/// circles (the server scopes every endpoint to the caller's uid -/// since the BlogAcl fix on this branch), plus membership -/// management on the currently selected circle. -/// -/// The view lists circles in , supports -/// create / edit via , and exposes -/// per-item Delete and per-item edit commands. -/// drives a progress overlay during API calls; -/// surfaces success / error feedback in the view footer. -/// -/// When the user selects a circle in the list, -/// fetches its members into -/// . The "Add a member" command -/// () is a UI event the view -/// raises to open AddCircleMemberDialog; the dialog -/// raises a Confirmed event back, which the page's -/// code-behind forwards here via -/// . The "remove" -/// command is per-row and runs inline. -/// -public partial class CirclesPageViewModel : ViewModelBase -{ - private readonly CircleApiClient _client; - - [ObservableProperty] - public partial ObservableCollection Circles { get; set; } = new(); - - [ObservableProperty] - public partial CircleDto? SelectedCircle { get; set; } - - /// Editor buffer for the new / edited circle's name. - [ObservableProperty] - public partial string DraftName { get; set; } = string.Empty; - - /// Editor buffer for the new / edited circle's visibility flag. - [ObservableProperty] - public partial bool DraftPublic { get; set; } - - /// Members of the currently selected circle. Empty - /// when no circle is selected or after a refresh that - /// produced an empty list. Updated by - /// . - [ObservableProperty] - public partial ObservableCollection Members { get; set; } = new(); - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } = string.Empty; - - /// - /// Raised when the user wants to add a member to the - /// currently selected circle. The view listens to this - /// event and opens AddCircleMemberDialog. - /// - public event EventHandler? AddMemberRequested; - - public CirclesPageViewModel(CircleApiClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - - public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - - /// - /// Partial property setter: when the selected circle - /// changes, refresh the members list. The setter is - /// invoked by the [ObservableProperty] source generator - /// for both user selections and programmatic resets. - /// - partial void OnSelectedCircleChanged(CircleDto? value) - { - Members = new ObservableCollection(); - if (value is not null) - { - // Fire-and-forget: load members in the background. - // Errors are routed to StatusMessage inside - // LoadMembersAsync. - _ = LoadMembersAsync(value.Id); - } - } - - [RelayCommand] - public async Task RefreshAsync() - { - IsBusy = true; - try - { - var list = await _client.GetMyCirclesAsync(); - Circles = new ObservableCollection(list ?? new()); - StatusMessage = $"{Circles.Count} cercle(s)"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - /// - /// Load the members of one of the caller's circles. The - /// server scopes the endpoint with a 404 when the circle - /// doesn't belong to the caller (mirroring the rest of the - /// circle API); that case flattens to an empty list here. - /// - [RelayCommand] - public async Task LoadMembersAsync(long circleId) - { - IsBusy = true; - try - { - var list = await _client.GetMembersAsync(circleId); - Members = new ObservableCollection(list ?? new()); - StatusMessage = $"{Members.Count} membre(s)"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - Members = new ObservableCollection(); - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public void StartCreate() - { - SelectedCircle = null; - DraftName = string.Empty; - DraftPublic = false; - StatusMessage = "Nouveau cercle"; - } - - [RelayCommand] - public void StartEdit(CircleDto? circle) - { - if (circle is null) return; - SelectedCircle = circle; - DraftName = circle.Name; - DraftPublic = circle.Public; - StatusMessage = $"Édition de « {circle.Name} »"; - } - - [RelayCommand] - public async Task SaveAsync() - { - if (string.IsNullOrWhiteSpace(DraftName)) - { - StatusMessage = "Le nom est obligatoire"; - return; - } - - IsBusy = true; - try - { - if (SelectedCircle is null) - { - var created = await _client.CreateCircleAsync(new CircleDto - { - Name = DraftName.Trim(), - Public = DraftPublic, - }); - StatusMessage = created is null - ? "Création échouée" - : $"Cercle « {created.Name} » créé"; - } - else - { - SelectedCircle.Name = DraftName.Trim(); - SelectedCircle.Public = DraftPublic; - await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle); - StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour"; - } - await RefreshAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public async Task DeleteAsync(CircleDto? circle) - { - if (circle is null) return; - IsBusy = true; - try - { - await _client.DeleteCircleAsync(circle.Id); - StatusMessage = $"Cercle « {circle.Name} » supprimé"; - // If the deleted circle was the selected one, - // clear the selection so the Members view goes - // empty too (the partial setter on - // SelectedCircle will reset Members). - if (SelectedCircle?.Id == circle.Id) - SelectedCircle = null; - await RefreshAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - /// - /// Fire the event so - /// the view opens AddCircleMemberDialog. The view - /// forwards the dialog's Confirmed event back to - /// . - /// - [RelayCommand] - public void OpenAddMember() - { - if (SelectedCircle is null) - { - StatusMessage = "Sélectionnez d'abord un cercle"; - return; - } - AddMemberRequested?.Invoke(this, EventArgs.Empty); - } - - /// - /// Called by the view when the dialog confirms a - /// selection. Adds the picked user to the currently - /// selected circle and refreshes the members list. - /// - public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked) - { - if (SelectedCircle is null || picked is null) return; - IsBusy = true; - try - { - await _client.AddMemberAsync(SelectedCircle.Id, picked.Id); - StatusMessage = $"« {picked.DisplayName} » ajouté au cercle"; - await LoadMembersAsync(SelectedCircle.Id); - } - catch (Exception ex) - { - // 409 (already a member) is a likely race — surface - // it as a friendly status, not an error. The - // server returns 409 for "already a member"; - // YavscApiClient surfaces that as an exception - // today; future refactors could route 409 into a - // typed result, but for now the message string is - // distinctive enough. - var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict") - ? "Déjà membre du cercle" - : $"Erreur: {ex.Message}"; - StatusMessage = msg; - } - finally - { - IsBusy = false; - } - } - - /// - /// Per-row "remove" command. Updates the local - /// collection in place so the UI doesn't flash. - /// - [RelayCommand] - public async Task RemoveMemberAsync(CircleMemberDto? member) - { - if (member is null || SelectedCircle is null) return; - IsBusy = true; - try - { - await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id); - Members.Remove(member); - StatusMessage = $"« {member.UserName} » retiré du cercle"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index d1d16306..e7ea26a0 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -4,8 +4,7 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using PostIt.Models; using PostIt.Services; namespace PostIt.ViewModels; @@ -25,7 +24,7 @@ public partial class MainPageViewModel : ViewModelBase /// previous "{Binding SelectedPost.Title}" binding, the user's /// keystrokes were silently dropped whenever /// SelectedPost was null, which made the editor a trap - /// and caused Save to POST a BlogPostDto with an empty + /// and caused Save to POST a BlogPost with an empty /// title — hence the 400 "The Title field is required". [ObservableProperty] public partial string DraftTitle { get; set; } @@ -35,18 +34,6 @@ public partial class MainPageViewModel : ViewModelBase [ObservableProperty] public partial string DraftArticle { get; set; } - /// Editor buffer for the post's publication state. - /// Reflects the server-side IsPublished flag (the - /// existence of a row in BlogSpotPublication) and - /// is pushed to the server via - /// on explicit - /// toggle — it is NOT included in the regular Save - /// payload, mirroring the wire contract where - /// BlogPostDto doesn't carry Publish as a - /// mutable field. Toggling is its own action. - [ObservableProperty] - public partial bool DraftIsPublished { get; set; } - [ObservableProperty] public partial ViewModelBase? CurrentViewModel { get; set; } @@ -59,13 +46,13 @@ public partial class MainPageViewModel : ViewModelBase public partial string SearchText { get; set; } [ObservableProperty] - public partial ObservableCollection Posts { get; set; } + public partial ObservableCollection Posts { get; set; } [ObservableProperty] - public partial ObservableCollection FilteredPosts { get; set; } + public partial ObservableCollection FilteredPosts { get; set; } [ObservableProperty] - public partial BlogPostDto? SelectedPost { get; set; } + public partial BlogPost? SelectedPost { get; set; } [ObservableProperty] public partial bool IsBusy { get; set; } @@ -95,8 +82,8 @@ public partial class MainPageViewModel : ViewModelBase private void Init(Settings? settings) { SearchText = string.Empty; - Posts = new ObservableCollection(); - FilteredPosts = new ObservableCollection(); + Posts = new ObservableCollection(); + FilteredPosts = new ObservableCollection(); SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; @@ -114,7 +101,6 @@ public partial class MainPageViewModel : ViewModelBase WindowTitle = "PostIt"; DraftTitle = string.Empty; DraftArticle = string.Empty; - DraftIsPublished = false; CurrentViewModel = this; } @@ -133,7 +119,7 @@ public partial class MainPageViewModel : ViewModelBase partial void OnSearchTextChanged(string value) => ApplyFilter(); - partial void OnSelectedPostChanged(BlogPostDto? value) + partial void OnSelectedPostChanged(BlogPost? value) { // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content @@ -144,9 +130,6 @@ public partial class MainPageViewModel : ViewModelBase // doesn't show stale content. DraftTitle = value?.Title ?? string.Empty; DraftArticle = value?.Article ?? string.Empty; - // Mirror publication state too. Defaults to false on - // null selection so a fresh draft starts unpublished. - DraftIsPublished = value?.IsPublished ?? false; UpdateCommandStates(); } @@ -193,7 +176,7 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { - // Build a fresh BlogPostDto from the editor buffer on + // Build a fresh BlogPost from the editor buffer on // every Save — we no longer mutate SelectedPost in // place. The previous behaviour copied the buffer // (which was a no-op when SelectedPost was null) @@ -205,7 +188,7 @@ public partial class MainPageViewModel : ViewModelBase // the update path. if (SelectedPost is null || SelectedPost.Id == 0) { - var draft = new BlogPostDto + var draft = new BlogPost { Title = DraftTitle, Article = DraftArticle ?? string.Empty, @@ -221,7 +204,7 @@ public partial class MainPageViewModel : ViewModelBase } else { - var update = new BlogPostDto + var update = new BlogPost { Id = SelectedPost.Id, AuthorId = SelectedPost.AuthorId, @@ -257,46 +240,6 @@ public partial class MainPageViewModel : ViewModelBase }); } - /// - /// Toggle the publication state of the currently selected - /// post. Pushes the new state to - /// PUT /api/BlogApi/{id}/publish and reflects it - /// locally in + the - /// selected post so the UI updates without a full - /// refresh. - /// - /// The toggle is its own action — separate from Save - /// — because Publish is not part of the - /// BlogPostDto payload. Bundling it into Save - /// would require a wire-shape change and a second server - /// overload; the dedicated endpoint keeps the wire - /// contract clean. - /// - [RelayCommand] - internal async Task TogglePublish() - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; - return; - } - - await ExecuteAsync(async () => - { - var desired = !DraftIsPublished; - await BlogClient.SetPublishAsync(SelectedPost.Id, desired); - DraftIsPublished = desired; - // Mirror into the selected post so a subsequent - // RefreshPostsAsync() doesn't blow away the - // locally flipped state until the round-trip - // re-hydrates it. - SelectedPost.IsPublished = desired; - StatusMessage = desired - ? $"Billet {SelectedPost.Id} publié." - : $"Billet {SelectedPost.Id} remis en brouillon."; - }); - } - [RelayCommand] internal void OpenSettings() { @@ -373,32 +316,4 @@ public partial class MainPageViewModel : ViewModelBase /// forced the buggy "draft with empty title" branch. private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - - /// - /// Raised when the user asks to open the "manage ACL" dialog for - /// the currently selected post. The MainPage code-behind - /// listens to this event and pushes a PostAclDialog on the - /// navigation stack. The VM itself can't navigate directly - /// because the navigation surface (NavigationPage) lives - /// in the View layer. - /// - public event EventHandler? ManageAclRequested; - - [RelayCommand(CanExecute = nameof(CanManageAcl))] - public void ManageAcl() - { - if (SelectedPost is null) return; - ManageAclRequested?.Invoke(this, SelectedPost); - } - - /// - /// Raised when the user asks to open the circles page (full - /// CRUD on their own circles). Same routing as - /// . - /// - public event EventHandler? OpenCirclesRequested; - - [RelayCommand] - public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs deleted file mode 100644 index 68b96b7c..00000000 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Yavsc.Blogspot; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; - -namespace PostIt.ViewModels; - -/// -/// View model for the "Gérer l'ACL" modal of a single blog post. -/// -/// Loads the caller's circles once on construct (the dropdown -/// only shows circles the user owns), then keeps an in-memory list -/// of the ACL entries for the post. / -/// are the only mutating verbs; both -/// refresh the list afterwards so the UI stays in sync with the -/// server. -/// -/// The server is the source of truth: it scopes every -/// endpoint to the caller's uid and rejects ACL grants on posts -/// the caller doesn't own. This VM does not re-validate that — -/// any 403 / 404 will surface as an exception caught by the -/// command and routed to . -/// -public partial class PostAclDialogViewModel : ViewModelBase -{ - private readonly BlogAclApiClient _aclClient; - private readonly CircleApiClient _circleClient; - - /// The post whose ACL is being edited. Set by the - /// caller (MainPage) when opening the dialog. - public BlogPostDto Post { get; } - - [ObservableProperty] - public partial ObservableCollection MyCircles { get; set; } = new(); - - [ObservableProperty] - public partial ObservableCollection AclEntries { get; set; } = new(); - - [ObservableProperty] - public partial CircleDto? SelectedCircleToAdd { get; set; } - - [ObservableProperty] - public partial bool IsBusy { get; set; } - - [ObservableProperty] - public partial string StatusMessage { get; set; } = string.Empty; - - public PostAclDialogViewModel( - BlogPostDto post, - BlogAclApiClient aclClient, - CircleApiClient circleClient) - { - Post = post ?? throw new ArgumentNullException(nameof(post)); - _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); - _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); - } - - public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - - [RelayCommand] - public async Task LoadAsync() - { - IsBusy = true; - try - { - // Load circles and ACL entries in parallel — both are - // independent reads on the same host. The caller's uid - // is implicit in both endpoints. - var circlesTask = _circleClient.GetMyCirclesAsync(); - var aclTask = _aclClient.GetMyAclAsync(); - await Task.WhenAll(circlesTask, aclTask); - - var circles = circlesTask.Result ?? new List(); - MyCircles = new ObservableCollection(circles); - - var allAcl = aclTask.Result ?? new List(); - AclEntries = new ObservableCollection( - allAcl.Where(a => a.BlogPostId == Post.Id)); - - StatusMessage = $"{AclEntries.Count} autorisation(s)"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public async Task AddAsync() - { - if (SelectedCircleToAdd is null) - { - StatusMessage = "Sélectionnez un cercle à ajouter"; - return; - } - - IsBusy = true; - try - { - var created = await _aclClient.GrantAsync(new CircleAuthorizationDto - { - CircleId = SelectedCircleToAdd.Id, - BlogPostId = Post.Id, - Comment = false, - }); - if (created is not null) - { - AclEntries.Add(created); - StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; - } - else - { - StatusMessage = "Autorisation refusée par le serveur"; - } - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - [RelayCommand] - public async Task RevokeAsync(CircleAuthorizationDto? acl) - { - if (acl is null) return; - IsBusy = true; - try - { - await _aclClient.RevokeAsync(acl.CircleId); - AclEntries.Remove(acl); - StatusMessage = "Autorisation révoquée"; - } - catch (Exception ex) - { - StatusMessage = $"Erreur: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } -} diff --git a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml b/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml deleted file mode 100644 index 2c13e99c..00000000 --- a/src/PostIt/PostIt/Views/AddCircleMemberDialog.axaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - -