Compare commits

..
Author SHA1 Message Date
988de7c366 version update
All checks were successful
Forgejo Release / release (push) Successful in 14m46s
2026-09-22 18:39:19 +01:00
07dd9d4434 CHANGELOG: add 1.0.8-rc16 section
Describes the estimate signature + TeX/PDF generation release impact:
signature pad on validation, role-aware accept/reject with signatures,
ProAccepted/ClientAccepted split, estimate.tex/estimate.pdf endpoints
in Yavsc.Api, vector TikZ signatures, PostIt PDF/TeX download buttons,
list refresh + title renames, the lualatex stdin runtime, and the
Emergency stop / log-write / RenderViewToString / Layout fixes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:35:48 +01:00
7e13161f27 Fix lualatex stdin PDF gen + unit test for signed estimate PDF
GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex
treats stdin as a regular input file instead of the terminal — without
it, lualatex emergency-stops after the first line of a multi-line
document (the real root cause of the production "Emergency stop", not
bera/luaotfload which were separate host-package gaps already fixed).

EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the
returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is
extracted into its own file under ViewModels/FrontOffice.

Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector
draws of both signatures) and GenerateEstimatePdf on a devis signed by
both parties — asserts the PDF exists at the expected DestDir path, is
non-empty, and that only the PDF persists (aux/log cleaned). Skips
cleanly when lualatex is absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:33:47 +01:00
82d1d6f6d6 Draw estimate signatures as vector TikZ instead of PNG
The estimate foot now renders the provider and client signatures as
TikZ \draw/\fill paths straight from the captured stroke data, in two
side-by-side minipages with a \useasboundingbox so the boxes align
regardless of stroke coverage. A side that hasn't signed shows a
signature rule.

- TeXHelpers.SignatureToTikz walks the PostIt wire format
  ([k, x0,y0, ...] per stroke, coords in [0, CoordinateMax], y-down)
  and emits one \draw per stroke (round caps/joins, 1.5pt), flipping y
  so the signature is upright on paper; a single-point tap becomes a
  filled dot, mirroring the Avalonia round-capped Polyline.
- FrontOfficeApiController.LoadEstimateForRenderAsync includes
  Estimate.Signatures so SignaturePro/SignatureClient resolve.
- Estimate_tex.cshtml drops the \includegraphics of a PNG (which
  failed the build when the file was absent) and adds \usepackage{tikz}.
- README: document texlive-pictures (tikz) and texlive-fonts-extra
  (bera) as runtime deps; bera.sty missing is the likely cause of the
  remaining host Emergency stop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:45:05 +01:00
b76ce164f5 corrige la génération du Pdf 2026-09-22 16:34:51 +01:00
3f01a3ee74 PDF gen: capture lualatex stdout/stderr into the error message
The detailed LaTeX error (missing .sty, missing \includegraphics
target, …) was being lost: we only redirected stdin, and the engine
writes its diagnostics to the .log file we delete in cleanup. So a
generation failure surfaced only as the terse "Emergency stop" in the
journal, with no clue what actually failed.

Redirect stdout and stderr, and on non-zero exit include them in
GenerationErrorMessage so the caller sees the real engine diagnostic.
Keep the .log file on failure (only clean it on success) so it can be
inspected alongside.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:31:10 +01:00
2a30746a32 README: document lualatex runtime deps for PDF generation
texlive-binaries (lualatex) + texlive-luatex (luaotfload) are required
on the API host for estimate/bill PDF generation. Note that luaotfload
moved from texlive-latex-extra into texlive-luatex on recent Debian.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:28:52 +01:00
3ddfed5fe0 Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on
  ProviderValidationDate so a provider's signed estimate leaves their
  list; PostIt refreshes the list on navigation pop-back.
- Title renames: "Devis en attente de signature fournisseur/client"
  on both the provider and client sides (HomePage, VM, tests).
- Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into
  Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent,
  Bill/Estimate templates, _ViewImports). Program.cs wires
  AddControllersWithViews + SiteSettings; FrontOfficeApiController
  injects IRazorViewEngine (cast to IViewEngine) with optional deps
  and exposes estimate.tex/estimate.pdf endpoints keyed by query id.
- PDF generation drives through the Estimate_pdf view (side-effect
  render) and compiles via the system lualatex, fed TeX on stdin (no
  intermediate .tex), PDF to the bills dir, aux artifacts cleaned so
  only the .pdf persists. Resolve the bills dir to an absolute path so
  lualatex's -output-directory and working directory agree regardless
  of the host cwd (fixes "I can't write on file estimate-1.log").
- PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync;
  Télécharger PDF/TeX buttons on the validation and command-detail
  pages via a StorageProvider save helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:22:01 +01:00
05f60a72b2 CLI options : -c configurqtion-file.json 2026-09-21 18:53:17 +01:00
064be13388 Reopen estimate: carry signature strokes on the estimate payload
Reopening an already-validated estimate showed an empty pad: the
strokes were stored server-side but never sent back. Embed them in the
estimate payload and load them straight into the pad, in the same wire
format PostIt captured — no separate fetch.

- Server: Signature inverse navs get STJ [JsonIgnore] (cycle-safe
  under System.Text.Json, matching ApplicationUser); the four estimate
  read endpoints Include(e => e.Signatures) so strokes ride the payload
- Client: EstimateDto.Signatures with EstimateSignatureDto (Strokes,
  Type=Pro/0|Client/1, CoordinateMax, CapturedAtUtc)
- PostIt: SignaturePadControl.Load(int[]) replaces the buffer and
  raises RedrawRequested; EstimateValidationPageViewModel.LoadExisting
  Signature() picks the perspective's side off the estimate and loads
  it on Attach
- Tests: server asserts GET estimate/{id} carries the strokes; PostIt
  VM tests cover load + side-by-perspective

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 18:03:08 +01:00
725fd0c728 Split QueryStatus.Accepted into ProAccepted/ClientAccepted
Distinguish the two parties' acceptances: ProAccepted (provider) and
ClientAccepted (client) replace the single Accepted value for new
acceptances. Accepted=2 is retained as a legacy value so existing DB rows
keep their integer mapping.

- FrontOfficeApiController.AcceptQuery sets status by ActorRole
- BillingController provider-ongoing filters match the new values
- FrontOfficeController (MVC) dashboard counts use ProAccepted||Accepted
- PostIt: IsOngoingStatus, GetStatusRank (client-accepted ranks first for
  the pro), and status-badge switches (P/C glyphs) handle the new values
- Tests assert ProAccepted (provider) vs ClientAccepted (client)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:32 +01:00
35c5a7f611 PostIt: validate/refuse an estimate with a signature (pro or client)
Adds the client-side ability to open an estimate from the list, draw a
signature on a reusable SignaturePadView control, and accept or reject it
against /api/v1/front/query/accept|reject. The server resolves the actor
role (provider vs client) from the caller's identity, so the same page works
for either party.

- Yavsc.Api.Client: QueryAcceptanceDto + FrontOfficeApiClient (accept/reject)
- PostIt: extract SignaturePadView UserControl from SignaturePage; new
  EstimateValidationPage (VM + view) with Valider/Refuser/Effacer/Retour;
  wire OpenEstimate entry point from EstimateListPage; register client/page
  in DI + ViewLocator
- Tests: FrontOfficeApiClient + EstimateValidationPageViewModel unit tests;
  update EstimateListPageViewModelTests ctor

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:18 +01:00
806eb05bcc Add missing Account/Delete confirmation page
The POST Delete(UnregisterViewModel) action and the GET Delete() action
both existed in AccountController, but Views/Account/Delete.cshtml was
missing — so GET /Account/Delete had no page to render. Add the
confirmation view (posts back to Delete with the user's UserId, with a
"Confirmer la suppression" button and an "Annuler" cancel link).

Add a smoke test: an authenticated GET /Account/Delete returns 200 and
the confirmation page body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:31 +01:00
eb560045b9 Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00
682c0a658d make reinstall, better 2026-09-21 14:58:40 +01:00
e6ded21bce asciidoc => markdown 2026-09-21 14:39:25 +01:00
caea7463b1 Hide inserted estimates from client list; redirect anon to /signin
Estimates with status Inserted (provider hasn't worked on them yet)
were showing up in the client's "Mes devis à valider" list. The
asclient endpoint now requires ProviderValidationDate to be set, so
only estimates the provider has actually established and submitted
are visible to the client. The asprovider endpoint is unchanged
(providers still need to see incoming unprocessed requests).

Also fix the anonymous-access redirect on Yavsc.Org: the application
cookie's LoginPath defaulted to /Account/Login, which has no
controller. Anonymous hits to a restricted page now redirect to the
real /signin endpoint (with /signout and /Account/AccessDenied set
to match the existing controller routes).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:38:49 +01:00
0e98f5732c deploy in pre and prod : bug fixes 2026-09-21 12:49:38 +01:00
595c7e0ff5 Fix white-on-white comment blocks in quartz theme
Quartz sets white body text on a coloured page background, but
--bs-tertiary-bg is near-white in its light variant. The .blogcomment
background now uses a semi-transparent black overlay so white text
stays legible, matching the dropdown hover fix.

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:12:20 +01:00
f521d6e159 The blogspot iis now in the PostIt app,
no more creation/edition from here
2026-09-21 10:40:49 +01:00
61d7f99ba0 accessibility 2026-09-21 10:38:09 +01:00
9494ac2067 reinstall-prod target 2026-09-21 10:37:39 +01:00
1b92b76fc9 code cleaup 2026-09-20 21:06:02 +01:00
4b34d67b3b reprod deployment 2026-09-20 18:35:27 +01:00
7f20ded14e --stop-on-fail 2026-09-20 14:12:08 +01:00
e80ad6ac92 test is ialready skipped when adb is not available 2026-09-20 13:42:45 +01:00
350dd1446d nuget referencezs updated 2026-09-20 01:53:20 +01:00
bc72d46bb3 Use AvaloniaEdit 2026-09-20 00:21:07 +01:00
2b2dca2a38 Publish, bug fix 2026-09-19 22:20:52 +01:00
1f849af886 refacto publication 2026-09-19 21:46:08 +01:00
6329309e68 Blog Post Id 2026-09-19 21:08:10 +01:00
dec9c6eb03 Updated 2026-09-19 18:09:02 +01:00
3632ed0ceb Org Markdown : Markdig 2026-09-19 18:00:09 +01:00
4bca36906e optimisation of workflows : Skiasharp deps already installed
All checks were successful
Forgejo Release / release (push) Successful in 5m35s
2026-09-19 16:37:57 +01:00
4b7ac2dec8 test(blogs): configure SiteSettings.Blog in MappedClaimsBlogsWebServerFixture
BlogSpotService's constructor throws InvalidOperationException
("SiteSettings.Blog is not configured.") when IOptions<SiteSettings>.Blog
is null, so every request to BlogApiController 500'd resolving the
service — all 3 BlogApiMappedClaimsTests were failing with
InternalServerError.

The mapped-claims fixture never configured SiteSettings (the main
BlogsWebServerFixture does, pointing Blog at a per-run temp dir).
Mirror that: Configure<SiteSettings>(s => s.Blog = <temp dir>)
before AddScoped<BlogSpotService>(). These tests are JSON-only
(AttachFiles short-circuits), but the service must be constructible.

Verified: BlogApiMappedClaimsTests 3/3 green; full Yavsc.Blogs.Tests
37/37 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 14:14:28 +01:00
92af3c0428 build: disable StaticWebAssets cache to stop MSB4018 race
DefineStaticWebAssets.ReadOrCreateCache opens the *.dswa.cache.json
files under obj/.../ via File.OpenRead with no try/catch, so under
parallel solution builds a sharing-violation / TOCTOU race on the cache
file escapes as MSB4018 and fails the whole build (seen on
Yavsc.Blogs during the rc15 solution build).

Set StaticWebAssetsCacheDefineStaticWebAssetsEnabled=false so
CacheManifestPath is empty and ReadOrCreateCache short-circuits to a
no-file-I/O path. The cache is a pure build-speed optimization; no
effect on output correctness, only on incremental rebuild speed of the
wwwroot-light web projects.

Verified: Yavsc.Api (transitive web deps) and Yavsc.Blogs.Tests build
clean, and zero *.dswa.cache.json files are produced.

Refs:
  https://source.dot.net/Microsoft.NET.Sdk.StaticWebAssets.Tasks/DefineStaticWebAssets.Cache.cs.html
  https://github.com/dotnet/sdk/issues/49039

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 13:53:25 +01:00
5470924cdb changelog: add 1.0.8-rc15 entry
Cover the 60 commits since 1.0.8-rc14: blog attachments + Markdown
preview, the Estimate pipeline (list/edition/validation + API + EF
migrations), provider ongoing requests, file-ACL and genericEstimate
migrations, POSTIT_SETTINGS_JSON, billing filtered by user, shared
YavscMessageSender, Gitleaks CI, and the test stabilization wave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 12:56:02 +01:00
289626bd89 preview the Markdown 2026-09-18 19:45:35 +01:00
f038c08276 +MarkdownViewer 2026-09-18 13:58:31 +01:00
98be57f798 post the file 2026-09-18 13:38:42 +01:00
64df506f3d corrige la création du lien 2026-09-18 12:13:00 +01:00
6a1bd23ca5 test: cover BlogApiClient.CreateMultipartContent shape + server round-trip
Expose CreateMultipartContent as public so the blogs backend tests can
assert on its wire shape directly and round-trip it through the live
BlogsWebServerFixture host. New CreateMultipartContentTests:
- shape: blog part is camelCase JSON with WhenWritingNull, one file
  part per attachment (filename, Content-Type, octet-stream fallback);
- round-trip: POST the built body to /api/v1/blogspot, assert 201
  Created + attachment row + file landed under BlogFilesRoot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-18 11:39:45 +01:00
74cfa10b6b -Config.Temp 2026-09-18 11:13:44 +01:00
3160eada93 refacto site settings 2026-09-17 23:54:36 +01:00
af5c1a3e92 BlogAttachmentLinkTests 2026-09-17 00:57:52 +01:00
4d17a39258 Supprime un faux message d'erreur 2026-09-16 19:48:13 +01:00
6ae814ef8a better indications 2026-09-16 18:53:17 +01:00
2787939ad7 new defaults 2026-09-16 18:23:52 +01:00
07a2986769 code cleanup 2026-09-16 18:23:42 +01:00
8bf6f20048 fixes the upload 2026-09-16 18:23:27 +01:00
eb22e4a5ab Estimate lists 2026-09-15 00:19:23 +01:00
6e92c57c1d install native dependencies 2026-09-14 10:07:10 +01:00
cdd963e8b7 fixes the pipeline 2026-09-14 00:47:28 +01:00
812a3301e7 removes the ItemGroup.Using Include=Xunit 2026-09-14 00:40:30 +01:00
c6d75f1d77 build before testing (sunit not found by MSBuild task) 2026-09-14 00:02:54 +01:00
d172f00c5b Merge remote-tracking branch 'origin/main' into feat/files-control 2026-09-13 23:39:41 +01:00
99a8707504 The Estimate is validated 2026-09-13 23:29:06 +01:00
027c0e1a03 Estimate UI 2026-09-13 18:54:27 +01:00
9550f9fe2d better and simpler UI 2026-09-13 17:51:42 +01:00
3cb98f6603 POSTIT_SETTINGS_JSON env var 2026-09-13 13:34:39 +01:00
37300206fb list only my billing codes 2026-09-13 03:49:07 +01:00
3003e6fe93 take the scanning in account 2026-09-13 03:25:09 +01:00
ee373f81dc use Sqlite 2026-09-13 03:22:22 +01:00
9a77209304 got saved a RdvQuery 2026-09-13 03:09:12 +01:00
9cae5b1f55 tests en vert 2026-09-12 17:31:18 +01:00
128735a237 fixe tests 2026-09-12 16:49:36 +01:00
9f55ef84da GetOpenIdConfiguration_returns_ok against testing host 2026-09-12 14:30:50 +01:00
80f9040ac3 Use a shared YavscMessageSender 2026-09-12 13:57:25 +01:00
bcf36709d7 Add provider ongoing requests flow and sort persistence 2026-09-12 13:29:49 +01:00
9d3e4c8b78 generic estimate 2026-09-12 12:59:44 +01:00
4b2e2a1c08 fixes the tests 2026-09-11 18:37:28 +01:00
4a28bc63f9 MainPage => BlogsPage 2026-09-11 18:37:26 +01:00
15317207c6 Bad test, fix the test 2026-09-11 18:37:25 +01:00
1e0dc07a2c Test the release, don't ignore test return code 2026-09-11 18:37:24 +01:00
69d404bc49 Bad test, fix the test 2026-09-11 13:42:29 +01:00
2cd5c6b68a Test the release, don't ignore test return code 2026-09-11 13:25:32 +01:00
2ee8b3dbec implements a dico 2026-09-11 12:45:13 +01:00
7bd169bf61 We don't need these "release" branches 2026-09-11 11:01:46 +01:00
78721a34de user the CommandBar 2026-09-07 20:53:23 +01:00
d3392f1e08 db testing 2026-09-07 20:02:56 +01:00
b356c17a2c Removes an abusive assertion, that went wrong 2026-09-07 19:16:23 +01:00
7b4538b03a Post Files 2026-09-07 15:41:33 +01:00
cf5c2f4b39 code reorg 2026-09-07 14:39:53 +01:00
17fd68b474 code format 2026-09-07 14:12:34 +01:00
82b5d06230 scan the commit 2026-09-07 13:20:50 +01:00
ed456ab3d1 Gitleaks integration 2026-09-07 12:56:48 +01:00
867d756a26 cleanup 2026-09-07 12:28:24 +01:00
f079165212 test 2026-09-07 12:20:55 +01:00
e877a59606 picket scan 2026-09-07 12:08:57 +01:00
5bd0e998a2 Secret scan 2026-09-07 11:57:00 +01:00
e2d1953faf CircleAuthorizationToFile EF migration 2026-09-07 11:02:41 +01:00
31a723f5fa unique email handling 2026-09-07 10:58:20 +01:00
92aa68c03c code reorg 2026-09-07 10:30:45 +01:00
281 changed files with 1124 additions and 21801 deletions

View file

@ -27,7 +27,7 @@ jobs:
build: build:
runs-on: docker runs-on: docker
container: container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
steps: steps:
- name: Clone yavsc - name: Clone yavsc
run: | run: |
@ -44,7 +44,7 @@ jobs:
- name: Secret scan - name: Secret scan
run: | run: |
echo "🔍 Scanning for secrets..." echo "🔍 Scanning for secrets..."
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \ cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \
- name: Build - name: Build
run: | run: |
echo "🏗️ Building the solution..." echo "🏗️ Building the solution..."
@ -56,11 +56,3 @@ jobs:
--report-xunit \ --report-xunit \
--report-xunit-html \ --report-xunit-html \
--report-xunit-html-filename "test-results.html" --report-xunit-html-filename "test-results.html"
TEST_EXIT_CODE=$?
echo "Test exit code: $TEST_EXIT_CODE"
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE
else
echo "✅ All tests passed successfully."
fi

View file

@ -52,7 +52,7 @@ jobs:
release: release:
runs-on: docker runs-on: docker
container: container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
steps: steps:
- name: Clone du repo au tag demandé - name: Clone du repo au tag demandé
env: env:

View file

@ -1,3 +0,0 @@
# Conventions du projet
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.

View file

@ -8,7 +8,8 @@ on:
jobs: jobs:
production-deploy: production-deploy:
runs-on: ubuntu-26.04 runs-on: ubuntu-latest
steps: steps:
# 1. Récupération du code # 1. Récupération du code
- name: Check out the repo - name: Check out the repo
@ -24,24 +25,8 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 10.0.x
- name: Workloads restore
run: dotnet workload restore
- name: Test - name: Test
env: run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
TEST_FAILED: ""
run: make test || export TEST_FAILED=$?
- uses: actions/upload-artifact@v7
with:
name: my-artifact
path: test-reports/**/*
- name: Fail if tests failed
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
run: exit $TEST_FAILED
# 4. Build et Push de l'image de production finale # 4. Build et Push de l'image de production finale
- name: Build and push production image - name: Build and push production image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7

6
.gitignore vendored
View file

@ -43,9 +43,3 @@ DataDir/
*.log *.log
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
# Exclude test reports (local test report, not for the repo)
/test-reports
/doc/**/.*.md

View file

@ -1,6 +0,0 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks

18
.vscode/launch.json vendored
View file

@ -22,9 +22,11 @@
}, },
{ {
"name": "Android Debug", "name": "Android Debug",
"type": "maui", "type": "mono",
"request": "launch", "preLaunchTask": "run-debug-android",
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj" "request": "attach",
"address": "localhost",
"port": 55555
}, },
{ {
"name": "Android Attach - Debug", "name": "Android Attach - Debug",
@ -41,15 +43,9 @@
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc Org",
"type": "coreclr", "type": "dotnet",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"cwd": "${workspaceFolder}/src/Yavsc.Org",
"preLaunchTask": "build-web-yavsc-org"
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc Blogs",

View file

@ -45,10 +45,5 @@
] ]
} }
}, },
"dotnet.defaultSolution": "yavsc.sln", "dotnet.defaultSolution": "yavsc.sln"
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
"chat.tools.terminal.autoApprove": {
"dotnet build": true,
"dotnet test": true
}
} }

34
.vscode/tasks.json vendored
View file

@ -4,13 +4,9 @@
"tasks": [ "tasks": [
{ {
"label": "build-debug-android", "label": "run-debug-android",
"command": "dotnet", "command": "dotnet",
"detail": "Build the PostIt Android project in debug mode",
"type": "shell", "type": "shell",
"runOptions": {
},
"problemMatcher": ["$msCompile"], "problemMatcher": ["$msCompile"],
"options": { "options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
@ -93,34 +89,6 @@
}, },
"isBackground": true "isBackground": true
}, },
{
"label": "build-web-yavsc-org",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Org"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "build-web-yavsc-blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Blogs"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{ {
"label": "dotnet: build-postit-desktop", "label": "dotnet: build-postit-desktop",
"type": "process", "type": "process",

View file

@ -1,388 +1,5 @@
# Changelog # Changelog
## [1.0.8-rc25] - unstable
### Added
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
et transmise par e-mail et SignalR.
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
pour exposer les notifications publiques et ciblées à PostIt sur la
Home page ; les notifications d'administration restent filtrées.
* [PostIt] Affichage des notifications sur la page d'accueil, avec
actualisation, indicateur de chargement et message d'erreur. Les
notifications publiques sont accessibles sans connexion et la liste
est rechargée lors d'un changement de session.
### Changed
* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ;
les liens provisoires des nouveaux billets sont résolus au premier
enregistrement. Save conserve l'édition ouverte et est mis en valeur
lorsque le titre, le corps ou les pièces jointes sont modifiés.
* [Projet] Instructions Copilot persistantes : mise à jour du changelog
par défaut à chaque commit, avec justification explicite des exceptions.
### Fixed
* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau
réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché.
Les fenêtres sont fermées après chaque test et les quatre boutons
de navigation sont vérifiés.
* [PostIt] Les liens Markdown des pièces jointes utilisent désormais
le même filtre de nom que l'upload serveur, avant l'encodage URL.
Le libellé conserve le nom original, notamment ses accents.
* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés
dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour
éviter les fragments d'URL et permettre le téléchargement via les
fichiers statiques, notamment pour les noms accentués.
* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont
désormais enregistrés côté serveur pour les requêtes authentifiées :
le middleware de télémétrie est maintenant branché après
l'authentification, ce qui permet de comptabiliser `ApiCalls` et
`BandwidthMb` au lieu d'enregistrer des zéros.
## [1.0.8-rc24] - unstable
### Added
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
### Changed
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
l'espace disponible et défilent séparément ; un séparateur permet
de redimensionner les deux zones.
### Fixed
* [PostIt] Défilement des articles longs en édition et en aperçu :
suppression de la limite de hauteur qui masquait le contenu Markdown
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
couvrent les barres automatiques, le redimensionnement et le
défilement indépendant de la liste.
## [1.0.8-rc23] - unstable
### Added
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
liste, mis à jour lors de la publication ou du retour en brouillon.
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
d'avatar, confirmation e-mail, préférence de courriel mensuel,
création ou changement de mot de passe et activation/désactivation
de l'authentification à deux facteurs.
* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des
comptes bancaires, dissociation des connexions externes et suppression
du compte avec confirmation du nom d'utilisateur.
* [PostIt] Configuration du profil professionnel et des activités,
navigation vers les cercles et les publications depuis le compte.
* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche
dans les métadonnées du profil et consultation de la consommation
de ressources.
* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de
ressources, API de synthèse et migration `ResourceUsageRecords`.
* [Tests] Couverture des parcours profil et professionnel, de l'API de
consommation de ressources et du renouvellement des jetons :
rotation à usage unique, appels simultanés, réponses 401 concurrentes
et rejet d'un jeton de renouvellement révoqué.
### Changed
* [PostIt] Réorganisation de la page de compte en sections de profil,
sécurité et préférences ; actions de modification désactivées tant
que le profil n'est pas chargé ou qu'une opération est en cours.
* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark`
dans les pages de profil et de configuration professionnelle.
* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par
PostIt, avec l'état de sécurité, les connexions externes, les comptes
bancaires et les compteurs du compte.
### Fixed
* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores
EF nécessaires à `UserManager<ApplicationUser>` : leur absence
empêchait l'activation du contrôleur de compte et provoquait une 500.
* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton
ne contient pas d'identifiant utilisateur ou que le compte n'existe
plus, au lieu d'une exception serveur.
* [PostIt] Synchronisation de tous les chemins de renouvellement des
jetons, y compris après une 401 et au démarrage : les appels
concurrents ne réutilisent plus le même `refresh_token`. Un rejet
`invalid_grant` efface la session et invite à se reconnecter.
* [PostIt] Effacement des informations obsolètes après un échec de
rechargement du profil et normalisation des valeurs de calendrier
lors de la sauvegarde.
* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel
lors de son enregistrement.
* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir
d'un résumé agrégé : opérations dédiées avec contrôle de propriété
et validation de longueur des champs.
### Known limitations
* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des
crédits ne sont pas encore disponibles dans l'interface native.
## [1.0.8-rc22] - unstable
### Fixed
User experience login into PostIt
## [1.0.8-rc21] - unstable
### Fixed
User experience editing the blog article.
## [1.0.8-rc20] - unstable
Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,
envoi/réception de messages), enregistreur audio et localisation dans
PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx
apprêté pour WebSocket.
### Added
* [PostIt] Interface de chat : page `ChatPage` (connexion au hub,
saisie du nom de salon, liste des messages et des membres présents),
entrée depuis la page d'accueil.
* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage`
(Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec
envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en
focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`).
* [PostIt] Modèle multi-salles côté client : `JoinedRooms`,
`SelectedRoom` (salle en focus), messages et utilisateurs indexés par
salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la
salle active ; un `Join` sur une salle déjà jointe ne fait que
changer le focus.
* [PostIt] Enregistreur audio (`AudioRecorder`, interface
`IPCMProcessor`) et ressources de localisation
(`Resources.{resx,en-EN,pt-PT}`).
* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à
chaud ; encore KO).
* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade
WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et
`proxy_buffering off` — sans quoi la connexion SignalR tombait au
premier silence ou l'upgrade échouait derrière nginx.
### Changed
* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et
enrichi d'une commande d'action optionnelle (`CheckCommand` +
`CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut
propose désormais l'action contextuelle (Connecter / Déconnecter /
Reconnecter / Vérifier la connexion).
* [PostIt] `ChatViewModel` suit le cycle de vie complet de la
connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de
`WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un
`IsConnected` optimiste.
* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` :
`ChatRoomInfo` n'expose que des champs publics, que System.Text.Json
ne sérialise pas par défaut — le client recevait des objets vides
(`Name` null, `Users` vide) et la liste des salons joints n'était
jamais alimentée.
* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName`
en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le
relire depuis un dictionnaire jamais alimenté.
* [PostIt.Android] `network_security_config.xml` déplacé sous
`Resources/xml/` (chemin canonique Android).
### Fixed
* [Yavsc.Server] `KeyNotFoundException` dans
`HubConnectionManager.OnConnected` à chaque connexion SignalR :
`ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
d'un `SetUserName` disparu lors d'une refactorisation. L'exception
dans `OnConnectedAsync` faisait fermer la connexion par le hub, et
PostIt affichait « Déconnecté (The server closed the connection…) »
immédiatement après la connexion.
* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de
`ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à
la racine du service, pas sous le préfixe versionné).
* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"`
sur une grille à 3 rangées (rangée hors limites, bandeau invisible).
## [1.0.8-rc19] - unstable
[PostIt] Blogs UX
### Fixed
La saisie du billet est maintenant plutôt possible,
en petit écran.
## [1.0.8-rc18] - unstable
* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
(arbre de stockage indexé par le login, pas par le `sub`), boutons
d'action des listes réparés (binding `$parent`), revendication `name`
enfin présente dans les access tokens (branchement du `ProfileService`),
et négociation HTTP/3 côté client PostIt.
### Added
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
desktop sans libmsquic).
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
(`CanExecute`) et déclenche l'appel API au clic.
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
mint un vrai JWT via le token endpoint IdentityServer (password grant,
`openid profile`) et asserte que l'access token porte `name`=login et
que `GetUserName()` résout vers ce login.
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
découple `sub` et login, téléverse sous le login, et asserte que
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
le `sub` n'existe pas).
### Changed
* [Yavsc.Org] Branchement du `ProfileService` custom
(`AddProfileService<ProfileService>()` après
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
IdentityServer8 retombait sur son profile service par défaut, qui
n'émet que les claims listés dans les `UserClaims` des API resources.
`name` n'en faisait jamais partie, donc tout access token était sans
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
`User.Identity.Name` injoignable. Le service émet désormais
`name`=`user.UserName`, `email` et les rôles pour tout client dont
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
courts de claims tels qu'émis).
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
la racine de stockage par utilisateur.
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
de test ; réensemencement des identity resources openid/profile dans
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
in-memory partagé et les laissait absentes → `invalid_scope`).
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
(défaut = subject) pour refléter la forme production où
`sub` (GUID) ≠ `name` (login).
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
sérialiser avec le build d'hôte qui mute les registres statiques
`BillingService`/`Config` (race parallèle pré-existante).
### Fixed
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
stockage perso est indexé par le login partout ailleurs
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
le endpoint de téléchargement et les avatars utilisent
`user.UserName`). Une fois l'upload corrigé, les fichiers
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
l'utilisateur en base et liste sous `user.UserName`.
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
interpole toujours par `uid`.
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
pas un contrôle — donc résolvait à `null` et le `Button` restait
désactivé (grisé) et inerte au clic. Corrigé en
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
pas sélectionnables/copiables : le texte était un `TextBlock` (non
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
renvoyait un 40x alors que le fichier était bien supprimé du disque.
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
du contrôleur transformait cette exception en 400, d'où le message
perçu « le fichier n'existe pas sur le serveur » tandis que la
suppression avait réussi. La taille est désormais capturée avant la
suppression.
* PostIt default settings
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
production où `IsPublished` disparaissait du payload de liste du blog API.
### Added
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
le détail de demande et la page d'édition de devis.
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
(`EstimateApiController`) et les demandes front-office
(`FrontOfficeApiController`).
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
`UserFileAttachments`.
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
`README.nginx.md`.
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
`FileSystemApiTests` (pièces jointes et stockage perso).
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
### Changed
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
production sur le vhost Org. La preprod et la prod partageant la même
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
la config par nginx.
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
provient de la variable d'environnement
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
source.
### Fixed
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
de la liste du blog API. Le endpoint `GetBlogspot` retourne
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
déclaré (l'interface), qui omet les propriétés du type concret —
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
`CommentsController` (Index/Details/…) atterrissaient sur la même route
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
`SwaggerGeneratorException` (conflit method/path) au chargement de
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
les actions scaffoldées.
## [1.0.8-rc16] - unstable ## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être Flux de signature et de génération de devis : un devis peut désormais être
@ -829,8 +446,6 @@ the ACL now comes along with the article,
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 [1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7

View file

@ -24,24 +24,41 @@
# ===================================================================== # =====================================================================
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2 ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
WORKDIR /src WORKDIR /src
# (1) Manifest + fichiers de projet pour le cache de restore
COPY *.sln ./
COPY Directory.*.props ./
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# (2) Tout le code source
COPY . . COPY . .
# (4) Restore
RUN dotnet restore RUN dotnet restore
# Build des trois services web principaux # (5) Build des trois services web principaux
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
# Build APK Android (utilisé par le workflow docker-publish-android). # (6) Build APK Android (utilisé par le workflow docker-publish-android).
ARG ANDROID_TARGET_RID=android-arm64 ARG ANDROID_TARGET_RID=android-arm64
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
# Publish des trois services web (les artifacts sont consommés par # (7) Publish des trois services web (les artifacts sont consommés par
# les stages publish-org / publish-api / publish-blogs ci-dessous). # les stages publish-org / publish-api / publish-blogs ci-dessous).
RUN mkdir -p /app/publish RUN mkdir -p /app/publish
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org

View file

@ -1,12 +1,36 @@
# Utilisation de l'image globale pour le build et l'exécution # Utilisation de l'image globale pour le build et l'exécution
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env
WORKDIR /src WORKDIR /src
# 1. Copie de la solution # 1. Copie de la solution
COPY *.sln ./
COPY Directory.*.props ./
# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
# Projets associés à PostIt
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# 4. Copie de l'intégralité du code source
COPY . . COPY . .
RUN dotnet workload restore
# 4. Restauration des dépendances pour tous les projets # 4. Restauration des dépendances pour tous les projets
RUN dotnet restore RUN dotnet restore
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
COPY . .
# 6. Compilation du serveur Web principal # 6. Compilation du serveur Web principal
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly

View file

@ -5,6 +5,8 @@ DESTDIR=/tmp/yavsc
APP_PATH=srv/www/yavsc APP_PATH=srv/www/yavsc
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
include .env
all: all:
dotnet build --nologo dotnet build --nologo
@ -19,29 +21,7 @@ src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
test: test:
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \ ASPNETCORE_ENVIRONMENT=Development dotnet test
--report-xunit-html \
--results-directory "test-reports"
test-postit:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "postit-test-results.html" \
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
Api-backend-test:
Blogs-backend-test:
Org-backend-test:
%-backend-test:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "$*-backend-test-results.html" \
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
watch: watch:
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj

View file

@ -232,12 +232,6 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
* ~/Views/Home/Index.cshtml * ~/Views/Home/Index.cshtml
* ~/Views/Home/About.cshtml * ~/Views/Home/About.cshtml
# 👥 Crédits & Co-auteurs
Ce projet a été développé de manière hybride :
- **Conception, architecture et revue de code :** Paul Schneider
- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf))
# Remerciements # Remerciements
Photo de <a href="https://unsplash.com/fr/@dewang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Dewang Gupta</a>sur <a href="https://unsplash.com/fr/photos/homme-debout-pres-dun-arbre-Mu3T3DmvQQw?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a> Photo de <a href="https://unsplash.com/fr/@dewang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Dewang Gupta</a>sur <a href="https://unsplash.com/fr/photos/homme-debout-pres-dun-arbre-Mu3T3DmvQQw?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>

View file

@ -14,7 +14,7 @@ reinstall: copy-binaries
@echo "💯 backend started" @echo "💯 backend started"
reinstall-prod: reinstall-prod:
make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/$(APP_INSTALL_PREFIX)yavscOrg.service: generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$($*_PORT)" \ HTTP_PORT="$*_$(HTTP_PORT)" \
APPDIR="$(APPDIR)" \ APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
@ -45,76 +45,6 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
NGINX_TEMPLATE := template.vhost
NGINX_OUT_DIR := generated/nginx
NGINX_HOSTS := Org Blogs Api
DOMAIN_ROOT ?= pschneider.fr
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
# prod Org -> yavsc.pschneider.fr
# preprod Org -> preprod-yavsc.pschneider.fr
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
@cat $(NGINX_TEMPLATE) \
| DOMAIN="$(call nginx_domain,$*)" \
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
HTTP_HOST="$(HTTP_HOST)" \
BACKEND_PORT="$($*_PORT)" \
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
@echo Created nginx vhost: $@
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
generate-nginx-prod:
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
$(NGINX_OUT_DIR):
@mkdir -p $@
# --- appsettings (générés depuis template.appsettings-*.json) ---
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
#
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
# contient un \). jq lit le secret depuis l'environnement (export) ->
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
export ASPNETCORE_Smtp__Password
APPSETTINGS_OUT_DIR := generated/appsettings
APPSETTINGS_HOSTS := org api blogs
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
@jq --arg domain "$(DOMAIN_ROOT)" \
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
template.appsettings-$*.json > $@
@echo Created appsettings: $@
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
$(APPSETTINGS_OUT_DIR):
@mkdir -p $@
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*

View file

@ -1,134 +0,0 @@
# Vhosts nginx — génération par `make generate-nginx`
Les vhosts nginx sont **générés** à l'image des unités de service systemd
(`template.service` + `envsubst` → `generated/*.service`) : un template
unique `contrib/template.vhost` est substitué par `envsubst` vers
`contrib/generated/nginx/`, qui est **gitignoré**.
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
`template.appsettings-*.json`) est une capacité complémentaire — non
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
uniquement), mais disponible si Kestrel est un jour exposé en direct.
Directives générées (par vhost) :
```nginx
listen 443 ssl; listen [::]:443 ssl; http2 on;
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_protocols TLSv1.3;
```
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
confondus).
## Prérequis côté host
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
`listen ... quic` suffit à activer h3) :
```bash
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
```
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
backports ou `nginx-full`.
2. **UDP 443 ouvert** :
```bash
sudo ufw allow 443/udp
sudo ufw status | grep '443/udp' # → ALLOW
```
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
échoue sur le bind IPv6).
4. Certbot / certs Let's Encrypt déjà en place (chemins
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
## Génération
```bash
cd contrib
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
make generate-nginx
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
make generate-nginx-prod
# ou manuellement :
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
```
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
`https` en prod, `http` en preprod.
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|-------|--------------|-------------|-------------------------|------------------------|
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
Domaines : `<prefix><subdomain>.pschneider.fr`
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
## Déploiement sur le host
```bash
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
# (idem yavscApi, yavscBlogs)
sudo nginx -t && sudo systemctl reload nginx
```
## Vérification (sur le host)
```bash
# 1. Module h3 présent
nginx -V 2>&1 | grep -o 'http_v3_module'
# 2. UDP 443 ouvert
sudo ufw status | grep '443/udp'
# 3. En-tête Alt-Svc annoncé
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
# 5. Fallback h2 et h1 toujours fonctionnels
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
```
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
Network → colonne « Protocole » → `h3` après mise à niveau.
## Comment ça marche (template + envsubst)
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
via `envsubst` avec une **allowlist** :
```makefile
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
```
Cette allowlist limite la substitution aux 5 variables citées, de sorte
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
allowlist, `envsubst` détruirait ces variables nginx.
## Hors périmètre
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
systemd + nginx).
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).

View file

@ -1,33 +0,0 @@
{
"Site": {
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["api"],
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN"
],
"Bills": "bills",
"TempDir": "temp"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:92"
},
"Https": {
"Url": "https://localhost:93",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,40 +0,0 @@
{
"Site": {
"Title": "Yavsc Blogs",
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:90"
},
"Https": {
"Url": "https://localhost:91",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,74 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": [
"blogs",
"api"
],
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
"ApiUrl": "https://preprod-api.$DOMAIN",
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN",
"https://preprod-api.$DOMAIN"
],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Admin": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"UserFilesRoot": "files",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"
},
"Smtp": {
"Host": "mercure.pschneider.fr",
"Port": 465,
"SenderName": "Paul Schneider",
"SenderEmail": "paul@pschneider.fr",
"UserName": "paul",
"Password": "$SMTP_PASSWORD"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:88"
},
"Https": {
"Url": "https://localhost:89",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,81 +0,0 @@
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
#
# Variables substituées par envsubst (allowlist) :
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
# même IP, donc seule la prod (Org) l'active.
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
# préservées (hors allowlist).
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
# ACME ; ne pas servir l'app en HTTP clair.)
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
return 301 https://$host$request_uri;
}
server {
# 1. TCP : HTTPS classique + HTTP/2
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
listen 443 quic$QUIC_OPTS;
listen [::]:443 quic$QUIC_OPTS;
http3 on;
server_name $DOMAIN;
# 3. Certificats Let's Encrypt (Certbot).
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
ssl_protocols TLSv1.3;
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 100s;
proxy_buffering off;
}
# SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout
# long, sinon la connexion tombe au premier silence ou l'upgrade échoue.
# Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui
# exigerait une directive map {} globale hors de ce vhost) : SignalR
# négocie toujours un upgrade sur ce chemin, et la négociation initiale
# (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet
# en-tête côté Kestrel.
location /chathub {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_buffering off;
}
include server-status.conf;
}

View file

@ -126,20 +126,6 @@ le DI est construit. Ordre, dans cet ordre :
> à cette table, *ne pas* ajouter de registration pour > à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`. > `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Défilement de la page des billets
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
dépasser le viewport pour que les barres automatiques s'activent.
Ne pas placer cette grille dans un `StackPanel` vertical ou un
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
## Navigation ## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"` Le host de navigation est un `NavigationPage x:Name="NavRoot"`

View file

@ -26,7 +26,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: web-runtime target: web-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -53,7 +53,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
# # Sans ce montage, 'docker compose up' échoue côté web avec # # Sans ce montage, 'docker compose up' échoue côté web avec
@ -71,7 +73,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: api-runtime target: api-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -88,7 +90,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro
@ -101,7 +105,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: blogs-runtime target: blogs-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -118,7 +122,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro

View file

@ -4,7 +4,7 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase> <AvaloniaVersionBase>12.1.2</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
@ -13,21 +13,16 @@
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" /> <PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Mapsui" Version="5.1.0" /> <PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" /> <PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" /> <PackageVersion Include="Material.Avalonia" Version="3.20.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" /> <PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="PvRecorder" Version="1.2.13" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
@ -36,4 +31,4 @@
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" /> <PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -2,7 +2,6 @@
using Android.App; using Android.App;
using Android.Content; using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using Android.Views;
using AndroidX.Core.Provider; using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text; using AndroidX.Emoji2.Text;
using Avalonia.Android; using Avalonia.Android;
@ -16,9 +15,7 @@ namespace PostIt.Android;
Theme = "@style/MyTheme.NoActionBar", Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon", Icon = "@drawable/icon",
MainLauncher = true, MainLauncher = true,
WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)
]
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>

View file

@ -11,7 +11,7 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -27,14 +27,4 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code </Project>
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
this file's item groups, so the removal must run in a target. -->
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
<ItemGroup>
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
</ItemGroup>
</Target>
</Project>

View file

@ -1,10 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application android:label="PostIt" android:icon="@drawable/Icon">
android:label="PostIt"
android:icon="@drawable/Icon"
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true">
</application> </application>
</manifest> </manifest>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain> <!-- Debug address -->
</domain-config>
</network-security-config>

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -1,7 +1,6 @@
using System; using System;
using Avalonia; using Avalonia;
using PostIt.Services; using PostIt.Services;
using PostIt.CLIOptions;
namespace PostIt.Desktop; namespace PostIt.Desktop;

View file

@ -73,14 +73,6 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{ {
_recorder.Calls.Add((method, path, contentFactory)); _recorder.Calls.Add((method, path, contentFactory));
if (typeof(T) == typeof(BlogPostDto))
return Task.FromResult((T)(object)new BlogPostDto
{
Id = 42,
Title = "Mon premier billet",
AuthorId = "tester",
Article = "Contenu du billet de test.",
});
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }
} }

View file

@ -20,13 +20,8 @@ namespace PostIt.Tests;
/// </summary> /// </summary>
public class BlogAttachmentLinkTests public class BlogAttachmentLinkTests
{ {
[Theory] [Fact]
[InlineData("note.txt", "note.txt")] public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
string originalName, string urlName)
{ {
const string authority = "https://idp.example.test"; const string authority = "https://idp.example.test";
@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests
vm.DraftTitle = "Après"; vm.DraftTitle = "Après";
vm.DraftArticle = "Contenu modifié."; vm.DraftArticle = "Contenu modifié.";
vm.DraftAttachments.Add(new BlogUploadFile( vm.DraftAttachments.Add(new BlogUploadFile(
originalName, "note.txt",
System.Text.Encoding.UTF8.GetBytes("payload test"), System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain")); "text/plain"));
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle);
Assert.Empty(recorder.Calls);
var draftBeforeSave = vm.DraftArticle;
await vm.SaveAsync(); await vm.SaveAsync();
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); // The update branch issues two PUTs: the multipart one
// carrying the file, then — because TryAppendAttachmentLinks
// appended a link — a JSON one carrying the final article.
var linkPut = recorder.Calls.LastOrDefault(
c => c.method == HttpMethod.Put);
Assert.NotEqual(default, linkPut); Assert.NotEqual(default, linkPut);
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body); var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent()); var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First()); var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
var sentStringContent = multipartContent.First() as StringContent ;
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
using var document = System.Text.Json.JsonDocument.Parse(sentString); Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
var article = document.RootElement.GetProperty("article").GetString();
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
// The regression shape: a relative, authority-less link. // The regression shape: a relative, authority-less link.
Assert.DoesNotContain("](/files/", article); Assert.DoesNotContain("](/files/", sentString);
Assert.Equal(draftBeforeSave, vm.DraftArticle);
Assert.NotNull(vm.SelectedPost);
Assert.False(vm.IsDraftModified);
}
[Fact]
public async Task New_post_resolves_only_the_provisional_link_at_first_save()
{
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"),
new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } });
vm.DraftTitle = "Nouveau billet";
vm.DraftArticle = "Texte conservé.";
vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 }));
Assert.Contains("postit-attachment:", vm.DraftArticle);
Assert.Empty(recorder.Calls);
await vm.SaveAsync();
Assert.Equal("Nouveau billet", vm.DraftTitle);
Assert.Contains("Texte conservé.", vm.DraftArticle);
Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)",
vm.DraftArticle);
Assert.DoesNotContain("postit-attachment:", vm.DraftArticle);
Assert.Equal(42, vm.SelectedPost?.Id);
Assert.Empty(vm.DraftAttachments);
Assert.False(vm.IsDraftModified);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
} }
} }

View file

@ -1,5 +1,4 @@
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -23,8 +22,7 @@ public class EstimateEditionPageViewModelTests
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{ {
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
} }
[Fact] [Fact]
@ -145,8 +143,10 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
var payload = Assert.IsType<EstimateDto>(postCall.Body); Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId); Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId); Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType); Assert.Equal("Brush", payload.CommandType);
@ -216,8 +216,7 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); var payload = Assert.IsType<EstimateDto>(api.LastBody);
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(2, payload.Bill.Count); Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent); Assert.True(vm.HasSent);
@ -231,20 +230,11 @@ public class EstimateEditionPageViewModelTests
public object? LastBody { get; private set; } public object? LastBody { get; private set; }
public Exception? Failure { get; init; } public Exception? Failure { get; init; }
/// <summary>Historique complet des appels (le plus récent en dernier).</summary> public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
{ {
LastMethod = method; LastMethod = method;
LastPath = path; LastPath = path;
LastBody = body; LastBody = body;
Calls.Add((method, path, body));
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (Failure is not null) if (Failure is not null)
{ {
@ -263,7 +253,9 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
Record(method, path, body); LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -273,12 +265,6 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct); => CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -1,199 +0,0 @@
using System.Net.Http;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class HomePageNotificationsTests
{
[Fact]
public async Task Refresh_loads_public_notifications_from_the_business_host()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
await vm.RefreshNotificationsAsync();
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
Assert.True(transport.WasAnonymous);
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
Assert.False(vm.IsLoadingNotifications);
Assert.False(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Old"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.False(vm.HasNoNotifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
Assert.False(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
await vm.RefreshNotificationsAsync();
Assert.Null(vm.NotificationsError);
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
[Fact]
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
var oldResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = oldResponse.Task;
var oldLoad = vm.RefreshNotificationsAsync();
Assert.True(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
await vm.RefreshNotificationsAsync();
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
await oldLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.False(vm.IsLoadingNotifications);
}
[AvaloniaFact]
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
var settings = new Settings();
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(settings, store);
try
{
var session = new SessionStatusViewModel { Api = api };
var transport = new NotificationTransport();
var vm = CreateViewModel(transport, session);
session.Refresh();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
vm.Notifications.Add(Note(9, "Private"));
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
session.SetError("Disconnected");
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.True(transport.WasAnonymous);
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
await privateLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
File.Delete(tokenPath);
}
}
[AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{
var transport = new NotificationTransport
{
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
};
var vm = CreateViewModel(transport);
var page = new HomePage { DataContext = vm };
var nav = new NavigationPage();
var window = new Window { Content = nav, Width = 320, Height = 450 };
try
{
window.Show();
await nav.PushAsync(page);
window.UpdateLayout();
Assert.Single(vm.Notifications);
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
Assert.Contains(texts, t => t.Text == "Notification title"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
Assert.Contains(texts, t => t.Text == "Notification body"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
await nav.PushAsync(new ContentPage());
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
window.Close();
}
}
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
SessionStatusViewModel? session = null)
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
private static Notification Note(long id, string title)
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
private sealed class NotificationTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public bool WasAnonymous { get; private set; }
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> ReadAsync<T>(path, true);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;
WasAnonymous = anonymous;
return (T)(object)await Response;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync()
{
Http.Dispose();
return ValueTask.CompletedTask;
}
}
}

View file

@ -1,4 +1,3 @@
using Yavsc.Abstract.Identity;
using PostIt.ViewModels; using PostIt.ViewModels;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -13,170 +12,4 @@ public class HomePageProviderFlowTests
Assert.NotNull(vm.OpenProviderRequests); Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null)); Assert.True(vm.OpenProviderRequests.CanExecute(null));
} }
[Fact]
public void HomePage_exposes_profile_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProfile);
Assert.True(vm.OpenProfile.CanExecute(null));
}
[Fact]
public void HomePage_exposes_performer_configuration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenPerformerConfiguration);
Assert.True(vm.OpenPerformerConfiguration.CanExecute(null));
}
[Fact]
public void HomePage_exposes_administration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
}
[Fact]
public void Administration_page_filters_users_by_search_text()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol", FullName = "Carol Smith" });
vm.SearchText = "ali";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_filters_users_by_full_name()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.SearchText = "example";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_tracks_selected_user()
{
var vm = new AdministrationPageViewModel();
var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" };
vm.Users.Add(user);
vm.SelectedUser = user;
Assert.Same(user, vm.SelectedUser);
Assert.Equal("alice", vm.SelectedUser?.UserName);
}
[Fact]
public void UserProfilePage_exposes_full_name_and_address_fields()
{
var vm = new UserProfilePageViewModel();
Assert.Equal("—", vm.FullName);
Assert.Equal("—", vm.Address);
Assert.Equal("Non configuré", vm.DedicatedGoogleCalendar);
Assert.Equal("Aucune information bancaire", vm.BankInfoSummary);
Assert.NotNull(vm.RefreshCommand);
}
[Fact]
public void ProfileUpdateRequest_allows_username_updates()
{
var request = new ProfileUpdateRequest
{
UserName = "alice.new"
};
Assert.Equal("alice.new", request.UserName);
}
[Fact]
public void UserProfilePage_can_save_profile_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
Assert.NotNull(vm.SaveProfileCommand);
Assert.False(vm.SaveProfileCommand.CanExecute(null));
}
[Fact]
public void UserProfilePage_builds_profile_payload_for_calendar_and_bank_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal("alice.new", payload.UserName);
Assert.Equal("calendar-123", payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void UserProfilePage_turns_placeholder_values_into_empty_payload_fields()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "Non configuré",
BankInfoSummary = "Aucune information bancaire"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal(string.Empty, payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void PerformerConfigurationPage_preserves_current_user_id_in_save_payload()
{
var vm = new PerformerConfigurationPageViewModel
{
PerformerId = "user-42",
UserName = "alice",
ExerciseCountryCode = "fr",
SIREN = "123456789",
Website = "https://example.com",
Active = true,
AcceptNotifications = true,
AcceptPublicContact = true,
UseGeoLocalizationToReduceDistanceWithClients = true
};
var payload = vm.BuildPerformerProfileSettings();
Assert.Equal("user-42", payload.PerformerId);
Assert.Equal("alice", payload.UserName);
Assert.Equal("fr", payload.ExerciseCountryCode);
}
} }

View file

@ -1,34 +0,0 @@
Loin vers l'infini s'étendent
Les grands prés marécageux .
Pas un seul oiseau ne chante
Sur les arbres secs et creux !
*(Refrain)*
Ô terre de détresse,
Où nous devons sans cesse,
Piocher, piocher .
Dans ce camp morne et sauvage
Entouré de murs de fer,
Il nous semble vivre en cage
Au milieu d'un grand désert .
Bruit des pas et bruit des armes,
Sentinelles jours et nuits,
Et du sang, et des cris, des larmes,
La mort pour celui qui fuit .
Mais un jour dans notre vie,
Le printemps refleurira .
Liberté, liberté chérie
Je dirai : « Tu es à moi ! »
*(Dernier refrain)*
Ô terre d'allégresse,
Où nous pourrons sans cesse,
Aimer, aimer !
[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs)

View file

@ -1,143 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using PostIt.Controls;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class MarkdownEditorScrollTests
{
private static readonly string LongArticle =
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = LongArticle,
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
AssertScrollable(scroller);
var initialViewport = scroller.Viewport.Height;
window.Height = 300;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(scroller.Viewport.Height < initialViewport);
AssertScrollable(scroller);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage();
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;
var posts = page.FindControl<ListBox>("PostsListBox")!;
posts.ItemsSource = Enumerable.Range(1, 50)
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
var navigation = new NavigationPage();
navigation.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Width = 800, Height = 700, Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
AssertScrollable(scroller);
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
Assert.NotNull(editorBottom);
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Short_article_needs_no_vertical_scrollbar(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = "Short article",
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
}
finally
{
CloseWindow(window, editor);
}
}
private static void CloseWindow(Window window, MarkdownEditorControl editor)
{
// The package's shared renderer caches the visual; detach it between headless applications.
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
window.UpdateLayout();
window.Close();
}
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
{
if (!preview)
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
.GetVisualDescendants().OfType<ScrollViewer>().Single();
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
}
private static void AssertScrollable(ScrollViewer scroller)
{
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
scroller.ScrollToEnd();
Assert.True(scroller.Offset.Y > 0);
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
}
}

View file

@ -1,208 +0,0 @@
using System.Net.Http;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless UI coverage for the "Mes fichiers" page
/// (<see cref="MyFilesPage"/>). The user reported that once a file
/// is listed, "l'item est inerte au click, et ses boutons restent
/// grisés, désactivés" — neither the row nor its action buttons
/// (Télécharger / Supprimer) respond to a click.
///
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
/// the Avalonia headless platform with a stub
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
/// realised visual tree to find the row's action buttons and
/// assert the two things that would silently break and reproduce
/// the "grayed / inert" symptom:</para>
/// <list type="number">
/// <item><description>The <c>Command</c> binding
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
/// resolved to a non-null command — a null command leaves the
/// button disabled (grayed) and click-silent.</description></item>
/// <item><description>The command is <b>armed</b>
/// (<c>CanExecute</c> true) for the listed file — an
/// <c>AsyncRelayCommand</c> with
/// <c>AllowConcurrentExecutions=false</c> (the
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
/// default) disables itself while running; a button stuck
/// "running" is exactly the grayed-and-inert report.</description></item>
/// <item><description>Executing the command actually invokes the
/// file download / delete on the API client — the row
/// "activates" on click instead of being inert.</description></item>
/// </list>
/// <para>The OS save picker is absent under headless, so
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
/// download command's <c>catch</c> sets an error status — but the
/// API download is awaited <em>before</em> the picker, so the call
/// is recorded regardless. We do not <c>await</c> the command's
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
/// (platform-dependent) picker.</para>
/// </summary>
public class MyFilesPageHeadlessTests
{
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
{
var api = new StubFsApi();
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
var vm = new MyFilesViewModel(fsClient);
var page = new MyFilesPage { DataContext = vm };
return (page, vm, api);
}
/// <summary>
/// Mount the page the way the app does — pushed onto a
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
/// (a <see cref="ContentPage"/> realizes its content template via
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
/// listing a single file by awaiting
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
/// <see cref="MyFilesViewModel.Files"/> before the first layout
/// pass lets the <c>ItemsControl</c> realise its item templates
/// under headless.
/// </summary>
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
{
var (page, vm, api) = CreatePage();
await vm.InitializeAsync();
var nav = new NavigationPage();
nav.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Content = nav };
window.Show();
// Let bindings / the ItemsControl item templates apply.
await Task.Delay(50);
return (page, vm, api);
}
/// <summary>
/// Find the first action button in a file row whose content
/// matches <paramref name="content"/>. The file rows live in an
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
/// walk the visual tree.
/// </summary>
private static Button? FindRowButton(MyFilesPage page, string content)
=> page.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content is string s && s == content);
[AvaloniaFact]
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
// Sanity: the listing populated a file row.
Assert.NotEmpty(vm.Files);
Assert.NotNull(vm.Files[0].Id);
var downloadBtn = FindRowButton(page, "Télécharger");
Assert.NotNull(downloadBtn);
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
var param = downloadBtn.CommandParameter;
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
"Télécharger must be armed (CanExecute true) for a listed file.");
// Act: click → fire the command.
downloadBtn.Command.Execute(param);
// Assert: the row activated — the API download was invoked
// (it is awaited before the headless-absent save picker).
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
}
[AvaloniaFact]
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
Assert.NotEmpty(vm.Files);
var deleteBtn = FindRowButton(page, "Supprimer");
Assert.NotNull(deleteBtn);
Assert.NotNull(deleteBtn!.Command);
var param = deleteBtn.CommandParameter;
Assert.True(deleteBtn.Command!.CanExecute(param),
"Supprimer must be armed (CanExecute true) for a listed file.");
deleteBtn.Command.Execute(param);
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
}
private sealed class StubFsApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
=> Calls.Add((method, path, body));
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (typeof(T) == typeof(UserDirectoryDto))
{
var dir = new UserDirectoryDto
{
UserName = "alice",
Files = new[]
{
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
}
};
return Task.FromResult((T)(object)dir);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -21,11 +21,9 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
private readonly RSA _rsa; private readonly RSA _rsa;
private readonly string _kid; private readonly string _kid;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private readonly HashSet<string> _refreshTokens = new(StringComparer.Ordinal);
public string Issuer { get; } public string Issuer { get; }
public string LoopbackRedirectUri { get; } public string LoopbackRedirectUri { get; }
public int RefreshRequestCount { get; private set; }
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback) private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{ {
@ -102,7 +100,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["response_types_supported"] = new[] { "code" }, ["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" }, ["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" }, ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" }, ["grant_types_supported"] = new[] { "authorization_code" },
["code_challenge_methods_supported"] = new[] { "S256" }, ["code_challenge_methods_supported"] = new[] { "S256" },
}; };
@ -134,16 +132,6 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
body = await reader.ReadToEndAsync(); body = await reader.ReadToEndAsync();
var form = ParseForm(body); var form = ParseForm(body);
if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token")
{
RefreshRequestCount++;
if (!form.TryGetValue("refresh_token", out var suppliedToken)
|| !_refreshTokens.Remove(suppliedToken))
{
await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400);
return;
}
}
// We accept any code and don't validate PKCE on the stub side; // We accept any code and don't validate PKCE on the stub side;
// the OidcClient itself validates the redirect_uri match. // the OidcClient itself validates the redirect_uri match.
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@ -154,13 +142,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests", ["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
["exp"] = now + 600, ["exp"] = now + 600,
["iat"] = now, ["iat"] = now,
["jti"] = Guid.NewGuid().ToString("N"),
}; };
var accessToken = SignJwt(claims); var accessToken = SignJwt(claims);
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_'); .TrimEnd('=').Replace('+', '-').Replace('/', '_');
_refreshTokens.Add(refreshToken);
var response = new var response = new
{ {
access_token = accessToken, access_token = accessToken,
@ -207,10 +193,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
return dict; return dict;
} }
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200) private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
{ {
response.ContentType = "application/json"; response.ContentType = "application/json";
response.StatusCode = statusCode; response.StatusCode = 200;
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
await response.OutputStream.WriteAsync(bytes); await response.OutputStream.WriteAsync(bytes);
response.Close(); response.Close();

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,132 +1,12 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views.Blogs;
namespace PostIt.Tests; namespace PostIt.Tests;
public class PostItViewModelTests public class PostItViewModelTests
{ {
[Fact]
public void Draft_modified_tracks_title_body_and_attachments_not_publication()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
Assert.False(vm.IsDraftModified);
vm.DraftIsPublished = true;
Assert.False(vm.IsDraftModified);
vm.DraftTitle = "Autre";
Assert.True(vm.IsDraftModified);
vm.DraftTitle = "Titre";
Assert.False(vm.IsDraftModified);
vm.DraftArticleDocument!.Insert(0, "Edit ");
Assert.True(vm.IsDraftModified);
vm.DraftArticle = "Corps";
Assert.False(vm.IsDraftModified);
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
Assert.True(vm.IsDraftModified);
}
[AvaloniaFact]
public async Task Save_keeps_selection_and_editor_open_and_resets_highlight()
{
var recorder = new CallRecorder();
var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder),
"https://blogs.example.test/api/v1/"));
var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.Posts.Add(selected);
vm.SearchText = "";
vm.SelectedPost = selected;
var page = new BlogsPage { DataContext = vm };
var navigation = new NavigationPage();
await navigation.PushAsync(page);
var window = new Window { Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
selected = Assert.Single(vm.FilteredPosts);
selected.AuthorId = "tester";
selected.Title = "Titre";
selected.Article = "Corps";
page.PostsListBox.SelectedItem = selected;
Dispatcher.UIThread.RunJobs();
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Same(selected, vm.SelectedPost);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
vm.DraftTitle = "Titre modifié";
Dispatcher.UIThread.RunJobs();
Assert.Contains("modified", page.SaveButton.Classes);
Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight);
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
await vm.SaveAsync();
Dispatcher.UIThread.RunJobs();
Assert.Same(page, navigation.NavigationStack.Last());
Assert.Same(selected, vm.SelectedPost);
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal("Corps", vm.DraftArticle);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
}
finally
{
window.Close();
}
}
[Fact]
public async Task Failed_save_preserves_draft_attachments_and_modified_state()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.SelectedPost = post;
vm.DraftTitle = "Titre modifié";
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
var article = vm.DraftArticle;
await vm.SaveAsync();
Assert.Same(post, vm.SelectedPost);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal(article, vm.DraftArticle);
Assert.Single(vm.DraftAttachments);
Assert.True(vm.IsDraftModified);
Assert.False(vm.IsBusy);
}
[AvaloniaFact]
public void Published_badge_tracks_post_publication_state()
{
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
var page = new BlogsPage();
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
row.DataContext = post;
var window = new Window { Content = row };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
border => border.Name == "PublishedBadge");
Assert.True(badge.IsVisible);
post.IsPublished = false;
Dispatcher.UIThread.RunJobs();
Assert.False(badge.IsVisible);
post.IsPublished = true;
Dispatcher.UIThread.RunJobs();
Assert.True(badge.IsVisible);
}
finally
{
window.Close();
}
}
[Fact] [Fact]
public void SearchCommand_filters_posts_by_title_article_or_author() public void SearchCommand_filters_posts_by_title_article_or_author()
@ -181,22 +61,11 @@ public class PostItViewModelTests
var viewModel = new BlogsViewModel(blog); var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
var changes = new List<string?>();
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
await viewModel.SetPublishStateAsync(true); await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue); Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished); Assert.True(viewModel.DraftIsPublished);
Assert.True(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
await viewModel.SetPublishStateAsync(false);
Assert.False(api.LastPublishValue);
Assert.False(viewModel.DraftIsPublished);
Assert.False(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
} }
/// <summary>Test fake that always throws if the API is invoked.</summary> /// <summary>Test fake that always throws if the API is invoked.</summary>

View file

@ -1,117 +0,0 @@
using System.Net.Http;
using System.Text.Json;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ResourceUsageApiClientTests
{
[Fact]
public async Task GetCurrentAsync_calls_user_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetCurrentAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(12.5m, usage.ApiCalls);
Assert.Equal(3.2m, usage.CpuSeconds);
}
[Fact]
public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetAdminOverviewAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(2, usage.Count);
Assert.Equal("administrator", usage[0].UserName);
}
private sealed class FakeYavscApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public string? LastMethod { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method.Method;
LastPath = path;
if (typeof(T) == typeof(ResourceUsageSummary))
{
var summary = new ResourceUsageSummary
{
UserId = "user-42",
UserName = "alice",
ApiCalls = 12.5m,
CpuSeconds = 3.2m,
BandwidthMb = 18.4m,
StorageMb = 51.2m,
EstimatedCompensation = 4.9m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
};
return Task.FromResult((T)(object)summary);
}
if (typeof(T) == typeof(List<ResourceUsageSummary>))
{
var list = new List<ResourceUsageSummary>
{
new()
{
UserId = "admin-user",
UserName = "administrator",
ApiCalls = 90m,
CpuSeconds = 14m,
BandwidthMb = 500m,
StorageMb = 2048m,
EstimatedCompensation = 32m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
},
new()
{
UserId = "user-99",
UserName = "bob",
ApiCalls = 40m,
CpuSeconds = 7m,
BandwidthMb = 120m,
StorageMb = 600m,
EstimatedCompensation = 15m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
}
};
return Task.FromResult((T)(object)list);
}
throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}");
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -22,36 +23,41 @@ namespace PostIt.Tests;
/// <c>new Window().Show()</c> throws because the harness has not /// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para> /// been initialised for that thread.</para>
/// ///
/// <para>These tests set the DataContext directly on the banner /// <para>The session banner's <c>DataContext</c> is not wired in
/// mounted in the window. The production code path is exercised /// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para> /// end-to-end by the manual launch, not here.</para>
/// </summary> /// </summary>
public class SessionStatusBannerTests : IDisposable public class SessionStatusBannerTests
{ {
private MainWindow? _window;
[AvaloniaFact] [AvaloniaFact]
public void Banner_renders_four_buttons_in_the_visual_tree() public void Banner_renders_three_buttons_in_the_visual_tree()
{ {
SessionStatusBanner banner = CreateBanner(); MainWindow window = new MainWindow();
window.Show();
var buttons = banner.GetVisualDescendants() var buttons = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.ToList(); .ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.Equal(4, buttons.Count); Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter"); Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter"); Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres"); Assert.Contains(buttons, b => b.Content as string == "Paramètres");
Assert.Contains(buttons, b => b.Content as string == "À propos");
} }
[AvaloniaFact] [AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out() public void Banner_login_button_is_visible_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); MainWindow window = new MainWindow();
window.Show();
var login = banner.GetVisualDescendants() var login = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se connecter"); .Single(b => b.Content as string == "Se connecter");
@ -63,9 +69,12 @@ public class SessionStatusBannerTests : IDisposable
[AvaloniaFact] [AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out() public void Banner_logout_button_is_hidden_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); SessionStatusBanner banner = CreateBanner();
Assert.False((banner.DataContext as SessionStatusViewModel)!
.IsLoggedIn); // VM default
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
var logout = banner.GetVisualDescendants() var logout = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter"); .Single(b => b.Content as string == "Se déconnecter");
@ -82,23 +91,22 @@ public class SessionStatusBannerTests : IDisposable
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Paramètres"); .Single(b => b.Content as string == "Paramètres");
// Paramètres has no IsVisible binding and is always shown. // Paramètres is the only button with no IsVisible
// binding — always shown. The user's only path to the
// settings page goes through this button.
Assert.True(settings.IsVisible); Assert.True(settings.IsVisible);
} }
private SessionStatusBanner CreateBanner() private static SessionStatusBanner CreateBanner()
{ {
_window = new MainWindow(); MainWindow window = new MainWindow();
_window.Show(); window.Show();
var banner = _window.GetVisualDescendants() var banner = window.MainView.SessionBanner;
.OfType<SessionStatusBanner>() var status = new SessionStatusViewModel();
.Single(); banner.DataContext = status;
banner.DataContext = new SessionStatusViewModel();
return banner; return banner;
} }
public void Dispose() => _window?.Close();
[AvaloniaFact] [AvaloniaFact]
public void Banner_session_label_reflects_DataContext() public void Banner_session_label_reflects_DataContext()
{ {

View file

@ -1,38 +0,0 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.Models;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// The <see cref="StatusBar"/> surfaces the page's action status
/// (success / warning / error message). The user reported being
/// unable to copy these messages — the status text was a
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
/// now a <see cref="SelectableTextBlock"/> so the message can be
/// selected and copied (Ctrl+C / context menu). This test pins that:
/// if the message control regresses to a plain <c>TextBlock</c>,
/// selectability is lost and the test fails.
/// </summary>
public class StatusBarTests
{
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
var bar = new StatusBar {
DataContext =
StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
var message = bar.GetVisualDescendants()
.OfType<SelectableTextBlock>()
.FirstOrDefault();
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}

View file

@ -17,56 +17,6 @@ namespace PostIt.Tests;
/// </summary> /// </summary>
public class YavscApiClientTests public class YavscApiClientTests
{ {
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
await Assert.ThrowsAsync<HttpRequestException>(() =>
client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
{
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
var store = new TokenStore(tokensPath);
if (hasSession)
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var client = new YavscApiClient(new Settings(), store);
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
Assert.Equal(2, posts.Count);
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
private static int GetFreePort() private static int GetFreePort()
{ {
var l = new TcpListener(IPAddress.Loopback, 0); var l = new TcpListener(IPAddress.Loopback, 0);
@ -420,105 +370,20 @@ public class YavscApiClientTests
} }
} }
[Fact] // SKIPPED — see comment.
public async Task Concurrent_silent_login_and_api_calls_share_one_refresh() //
{ // We can't cover "TrySilentLoginAsync purges the store when the
using var authority = await OIDCStubAuthority.StartAsync(); // refresh token is rejected" with OidcStubAuthority: the stub's
using var apiServer = new StubApiServer(); // /connect/token endpoint is unconditional and hands out a fresh
await apiServer.StartAsync(); // refresh token regardless of what the caller sends. To exercise
var settings = BuildSettings(authority, apiServer.BaseUrl); // the RefreshFailedException path we'd need an authority option
var tokensPath = TokensPath(); // to fail on a specific refresh-token string; until then the
try // production refresh-failure path is covered manually (and by
{ // the structural guarantee that _store.Clear() runs in the catch
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath); // block of ForceRefreshAsync when result.IsError).
ExpireCachedAccessToken(tokensPath); //
var store = new TokenStore(tokensPath); // [Fact]
var previousRefreshToken = store.Load()!.RefreshToken; // public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... }
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var ct = TestContext.Current.CancellationToken;
var silentLogins = Enumerable.Range(0, 8)
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
Assert.All(silentLogins, login => Assert.True(login.Result));
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task Concurrent_401_responses_share_one_refresh()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var client = await LoginAndPersistAsync(settings, authority, tokensPath);
apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}";
var ct = TestContext.Current.CancellationToken;
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(calls);
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
var tokens = store.Load()!;
store.Save(tokens with
{
RefreshToken = "revoked-refresh-token",
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
Assert.True(error.IsPermanent);
Assert.Contains("reconnecter", error.Message);
Assert.False(client.HasValidSession);
Assert.Null(store.Load());
Assert.Equal(0, apiServer.RequestCount);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
private static T Last<T>(System.Collections.Generic.List<T> list) private static T Last<T>(System.Collections.Generic.List<T> list)
{ {
@ -602,7 +467,6 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
public string BaseUrl { get; private set; } = string.Empty; public string BaseUrl { get; private set; } = string.Empty;
public List<string> SeenBearers { get; } = new(); public List<string> SeenBearers { get; } = new();
public int RequestCount => _requestCount; public int RequestCount => _requestCount;
public string? RejectedBearer { get; set; }
public StubApiServer(bool forceFirstRequest = false) public StubApiServer(bool forceFirstRequest = false)
{ {
@ -635,8 +499,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
if (!string.IsNullOrEmpty(auth)) if (!string.IsNullOrEmpty(auth))
SeenBearers.Add(auth!); SeenBearers.Add(auth!);
if ((_forceFirstRequest && _requestCount == 1) if (_forceFirstRequest && _requestCount == 1)
|| (RejectedBearer is not null && auth == RejectedBearer))
{ {
ctx.Response.StatusCode = 401; ctx.Response.StatusCode = 401;
ctx.Response.Close(); ctx.Response.Close();

View file

@ -6,26 +6,18 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Helpers; using PostIt.Helpers;
using System.Globalization;
using Live.Avalonia;
using System.Diagnostics;
using System.Linq;
using ReactiveUI;
using System.Reactive;
namespace PostIt; namespace PostIt;
public partial class App : Application, ILiveView, IVMPusherApp public partial class App : Application
{ {
public static bool LiveMode { get; private set; }
private int _bootStarted; private int _bootStarted;
public static string? CliConfigFileSpecification { get; private set; } public string? CliConfigFileSpecification {  get; private set; }
/// <summary> /// <summary>
/// DI container the platform entry points hand to ViewModels so /// DI container the platform entry points hand to ViewModels so
@ -50,122 +42,30 @@ public partial class App : Application, ILiveView, IVMPusherApp
{ {
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = new ServiceCollection().BuildPostItServices(); this.ServiceProvider = new ServiceCollection().BuildServices();
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
if (!LiveMode) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Debugging requires pdb loading etc, so we disable live reloading InitializeClassicDesktopLifetime(settings, desktop);
// during a test run with an attached debugger.
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
InitializeClassicDesktopLifetime(settings, desktop);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
} }
else else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{ {
// Here, we create a new LiveViewHost, located in the 'Live.Avalonia' InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
// namespace, and pass an ILiveView implementation to it. The ILiveView }
// implementation should have a parameterless constructor! Next, we else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
// start listening for any changes in the source files. And then, we {
// show the LiveViewHost window. Simple enough, huh? InitializeSingleViewLifetime(settings, singleViewPlatform);
var window = new LiveViewHost(this, Console.WriteLine);
var view = ServiceProvider!.GetRequiredService<MainView>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
InitializeClassicDesktopLifetime(window, view, settings, desktop);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
window.StartWatchingSourceFilesForHotReloading();
Console.WriteLine("LiveViewHost started for hot reloading.");
window.Show();
} }
#if DEBUG
// Here we subscribe to ReactiveUI default exception handler to avoid app
// termination in case if we do something wrong in our view models. See:
// https://www.reactiveui.net/docs/handbook/default-exception-handler/
//
// In case if you are using another MV* framework, please refer to its
// documentation explaining global exception handling.
Console.WriteLine("Debug mode: ReactiveUI default exception handler is set.");
RxApp.DefaultExceptionHandler = Observer.Create<Exception>(Console.WriteLine);
#endif
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private bool IsProduction()
{
#if DEBUG
return false;
#else
return true;
#endif
}
public async Task<Page> PushPageAsync(ViewModelBase viewModel)
{
var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {viewModel.GetType().Name}.");
}
Control? view = template.Build(viewModel) ;
if (view is null)
{
throw new InvalidOperationException(
$"Template for {viewModel.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = viewModel;
MainView? mainView = (Current as App).View;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = mainView.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return page;
}
await mainView.NavRoot.PushAsync(page);
return page;
}
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform) public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{ {
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>(); singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
ConfigureRootView(View); ConfigureRootView(View);
ApplyStettings(settings); ApplyDarkMode(settings);
} }
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime) public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
@ -174,7 +74,7 @@ public partial class App : Application, ILiveView, IVMPusherApp
() => () =>
{ {
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>()); this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
ApplyStettings(settings); ApplyDarkMode(settings);
return View!; return View!;
}; };
} }
@ -182,35 +82,37 @@ public partial class App : Application, ILiveView, IVMPusherApp
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop) public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{ {
var window = ServiceProvider!.GetRequiredService<MainWindow>(); var window = ServiceProvider!.GetRequiredService<MainWindow>();
var mainView = (MainView)CreateView(window);
InitializeClassicDesktopLifetime(window, mainView, settings, desktop);
}
public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
this.ConfigureRootView(mainView);
ApplyStettings(settings);
desktop.MainWindow = window; desktop.MainWindow = window;
window.Content = mainView; this.ConfigureRootView(window.MainView);
window.Show();
ApplyDarkMode(settings);
} }
public void ConfigureRootView(MainView rootView) public void ConfigureRootView(MainView rootView)
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{ {
rootView.AttachedToVisualTree += (_, _) => // Remplacer Window.NavRoot par rootView.NavRoot
Dispatcher.UIThread.Post(() => _ = BootOnceAsync()); rootView.NavRoot.PopToRootAsync();
};
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>(); rootView.SessionBanner.DataContext = sessionStatus;
sessionStatus.LogoutCompleted += () => this.View = rootView;
}
private async Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{ {
// Remplacer Window.NavRoot par rootView.NavRoot return;
rootView.NavRoot.PopToRootAsync(); }
};
if (rootView.DataContext==null)
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
rootView.SessionBanner.DataContext = sessionStatus; var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
this.View = rootView; await BootAsync(this.ServiceProvider!, api);
} }
/// <summary> /// <summary>
@ -224,24 +126,10 @@ public partial class App : Application, ILiveView, IVMPusherApp
View = mainView ?? throw new ArgumentNullException(nameof(mainView)); View = mainView ?? throw new ArgumentNullException(nameof(mainView));
} }
private static void ApplyStettings(Settings settings) private static void ApplyDarkMode(Settings settings)
{ {
if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage)) Application.Current!.RequestedThemeVariant =
Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage); settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
Current!.RequestedThemeVariant =
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
}
private async Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{
return;
}
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
await BootAsync(this.ServiceProvider!, api);
} }
/// <summary> /// <summary>
@ -259,9 +147,9 @@ public partial class App : Application, ILiveView, IVMPusherApp
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
var homePageVm = provider.GetRequiredService<HomePageViewModel>(); var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!; var app = (App)Current!;
await app.PushPageAsync(homePageVm); await app.PushPageAsync(homePage);
} }
/// <summary> /// <summary>
@ -317,31 +205,8 @@ public partial class App : Application, ILiveView, IVMPusherApp
await View!.NavRoot.PopAsync(); await View!.NavRoot.PopAsync();
} }
internal static void UseConfigFileWhenLoading(string configFile) internal void UseConfigFileWhenLoading(string configFile)
{ {
CliConfigFileSpecification = configFile; this.CliConfigFileSpecification = configFile;
}
internal static void EnableLiveMode()
{
LiveMode = true;
}
public object CreateView(Window window)
{
if (this.ServiceProvider == null)
{
var serviceCollection = new ServiceCollection();
this.ServiceProvider = serviceCollection.BuildPostItServices();
}
if (window.DataContext == null)
window.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
// The AppView class will inherit the DataContext
// of the window. The AppView class can be a
// UserControl, a Grid, a TextBlock, whatever.
this.View = ServiceProvider!.GetRequiredService<MainView>();
this.ConfigureRootView(this.View);
return this.View;
} }
} }

View file

@ -4,21 +4,15 @@ using Mono.Options;
using PostIt; using PostIt;
using PostIt.ViewModels; using PostIt.ViewModels;
namespace PostIt.CLIOptions;
public static class CLIOptionHandler public static class CLIOptionHandler
{ {
public static void HandleArgs(string[] args) public static void HandleArgs(string[] args)
{ {
bool showHelp = false; bool showHelp = false;
string configFile = null; string configFile = null;
bool liveMode = false;
var options = new OptionSet { var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v }, { "c|configuration=", "Configuration file to load", (string v) => configFile = v },
{ "l|live", "Enable live mode", v => liveMode = true },
{ "v|version", "Show the application version", v => Console.WriteLine("PostIt version "
+ typeof(App).Assembly.GetName().Version?.ToString()) },
{ "h|help", "Show this message and exit", v => showHelp = true } { "h|help", "Show this message and exit", v => showHelp = true }
}; };
@ -32,11 +26,8 @@ public static class CLIOptionHandler
} }
if (configFile != null) if (configFile != null)
{ {
App.UseConfigFileWhenLoading(configFile); var app = App.Current as App;
} app.UseConfigFileWhenLoading(configFile);
if (liveMode)
{
App.EnableLiveMode();
} }
} }
} }

View file

@ -1,56 +0,0 @@
<UserControl
x:Class="PostIt.Controls.MarkdownEditorControl"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls"
x:Name="Root"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
Padding="0">
<AvaloniaEdit:TextEditor
x:Name="SourceEditor"
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="0"
Padding="5,5,5,15"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" />
</Border>
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8">
<ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
<md:MarkdownViewer
x:Name="PreviewViewer"
HorizontalContentAlignment="Stretch"
VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
</ScrollViewer>
</Border>
</Grid>
</UserControl>

View file

@ -1,63 +0,0 @@
using System;
using Avalonia;
using Avalonia.Controls;
using AvaloniaEdit.Document;
namespace PostIt.Controls;
public partial class MarkdownEditorControl : UserControl
{
public static readonly StyledProperty<string?> MarkdownTextProperty =
AvaloniaProperty.Register<MarkdownEditorControl, string?>(nameof(MarkdownText));
public static readonly StyledProperty<TextDocument?> DocumentProperty =
AvaloniaProperty.Register<MarkdownEditorControl, TextDocument?>(nameof(Document));
public static readonly StyledProperty<bool> IsPreviewVisibleProperty =
AvaloniaProperty.Register<MarkdownEditorControl, bool>(nameof(IsPreviewVisible), false);
public string? MarkdownText
{
get => GetValue(MarkdownTextProperty);
set => SetValue(MarkdownTextProperty, value);
}
public TextDocument? Document
{
get => GetValue(DocumentProperty);
set => SetValue(DocumentProperty, value);
}
public bool IsPreviewVisible
{
get => GetValue(IsPreviewVisibleProperty);
set => SetValue(IsPreviewVisibleProperty, value);
}
public MarkdownEditorControl()
{
InitializeComponent();
PropertyChanged += OnPropertyChanged;
}
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == MarkdownTextProperty)
{
var text = e.NewValue as string ?? string.Empty;
if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal))
{
Document = new TextDocument(text);
}
}
else if (e.Property == DocumentProperty)
{
var newDocument = e.NewValue as TextDocument;
var text = newDocument?.Text ?? string.Empty;
if (!string.Equals(MarkdownText, text, StringComparison.Ordinal))
{
MarkdownText = text;
}
}
}
}

View file

@ -10,19 +10,13 @@
Padding="8"> Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Glyph}" <TextBlock Text="{Binding Glyph}"
FontWeight="Bold" FontWeight="Bold"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
VerticalAlignment="Top"/> VerticalAlignment="Top"/>
<SelectableTextBlock Text="{Binding Message}" <TextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
TextWrapping="Wrap" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="{Binding CheckCommandLabel}"
Foreground="{Binding Foreground}"
VerticalAlignment="Center"
IsVisible="{Binding HasCheckCommand}"
Command="{Binding CheckCommand}"
/>
</StackPanel> </StackPanel>
</Border> </Border>
</UserControl> </UserControl>

View file

@ -2,10 +2,8 @@ using System;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -13,7 +11,7 @@ namespace PostIt.Helpers;
public static class ServiceCollectionHelpers public static class ServiceCollectionHelpers
{ {
public static IServiceProvider BuildPostItServices(this ServiceCollection services) public static IServiceProvider BuildServices(this ServiceCollection services)
{ {
var settings = new Settings(); var settings = new Settings();
settings.Load(); settings.Load();
@ -24,7 +22,6 @@ public static class ServiceCollectionHelpers
var api = new YavscApiClient(settings, tokenStore); var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl); var client = new BlogApiClient(api, settings.BlogsApiUrl);
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
@ -35,19 +32,13 @@ public static class ServiceCollectionHelpers
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues // Vues
services.AddTransient<MainWindow>(); services.AddSingleton<MainView>();
services.AddTransient<MainView>(); services.AddSingleton<BlogsPage>();
services.AddTransient<BlogsPage>(); services.AddSingleton<MainWindow>();
// Models
services.AddSingleton<ChatViewModel>();
// SettingsPage is a singleton: there must be one and only one // SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app. // instance of the settings UI for the lifetime of the app.
@ -61,13 +52,11 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh // the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits. // SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddSingleton<AboutPage>();
services.AddSingleton<HomePage>(); services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>(); services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>(); services.AddTransient<CommandFormsPage>();
services.AddTransient<ChatPage>();
services.AddTransient<RdvPage>(); services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>(); services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>(); services.AddTransient<BillingQueriesPage>();
@ -76,11 +65,6 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateEditionPage>(); services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>(); services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
services.AddTransient<UserProfilePage>();
services.AddTransient<PerformerConfigurationPage>();
services.AddTransient<AdministrationPage>();
services.AddTransient<ResourceUsagePage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -93,16 +77,9 @@ public static class ServiceCollectionHelpers
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient); services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<AboutPageViewModel>();
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddTransient<UserProfilePageViewModel>();
services.AddTransient<PerformerConfigurationPageViewModel>();
services.AddTransient<AdministrationPageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>(); services.AddSingleton<ActivitiesPageViewModel>();

View file

@ -0,0 +1,53 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task<Page> PushPageAsync(this App app, ViewModelBase vm)
{
var window = app.View;
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return page;
}
await window.NavRoot.PushAsync(page);
return page;
}
}

View file

@ -1,10 +0,0 @@
using System.Threading.Tasks;
using PostIt.ViewModels;
using Avalonia.Controls;
namespace PostIt;
internal interface IVMPusherApp
{
Task<Page> PushPageAsync(ViewModelBase viewModel);
}

View file

@ -1,6 +0,0 @@
using System;
public interface IPCMProcessor : IDisposable
{
void Process(short[] audioFrame);
}

View file

@ -1,72 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Yavsc.Lang {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
public Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Yavsc.Lang.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Bienvenue dans PostIt !!!.
/// </summary>
public static string GreetingText {
get {
return ResourceManager.GetString("GreetingText", resourceCulture);
}
}
}
}

View file

@ -1,5 +0,0 @@
<root>
<data name="GreetingText">
<value>Welcome to PostIt!</value>
</data>
</root>

View file

@ -1,5 +0,0 @@
<root>
<data name="GreetingText">
<value>Bemvindo em PostIt!</value>
</data>
</root>

View file

@ -1,5 +0,0 @@
<root>
<data name="GreetingText">
<value>Bienvenue dans PostIt !!!</value>
</data>
</root>

View file

@ -1,15 +0,0 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO de message de salon, calqué sur la charge envoyée par
/// <c>ChatHub.Send</c> côté serveur ({ Name, Room, Message }).
/// Le protocole JSON de SignalR sérialise en camelCase ; la
/// désérialisation est insensible à la casse par défaut.
/// </summary>
public class ChatMessage
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -1,14 +0,0 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO minimal calqué sur l'objet anonyme émis par le hub
/// (<c>{ Name, Room, Message }</c>) via l'événement
/// <c>ReceiveMessage</c>.
/// </summary>
public class ChatMessageDto
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace PostIt.ViewModels.Chat
{
// DTO minimal calqué sur votre logique serveur ChatRoomInfo
public class ChatRoomInfo
{
public string? Name { get; set; }
public List<string> Users { get; set; } = new();
}
}

View file

@ -1,6 +0,0 @@
namespace PostIt.ViewModels;
internal interface IWithLabel
{
string Label { get; }
}

View file

@ -1,8 +0,0 @@
namespace PostIt.Models;
public enum StatusSeverity
{
Info,
Warning,
Error
}

View file

@ -5,10 +5,8 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -32,36 +30,16 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="LibVLCSharp.Avalonia" />
<PackageReference Include="Live.Avalonia" />
<PackageReference Include="Mapsui" /> <PackageReference Include="Mapsui" />
<PackageReference Include="Mapsui.Avalonia12" /> <PackageReference Include="Mapsui.Avalonia12" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" /> <PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" /> <PackageReference Include="Mono.Options" />
<PackageReference Include="PvRecorder" />
<PackageReference Include="Silk.NET.OpenAL" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" /> <ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> </Project>
<EmbeddedResource Update="Lang/Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Compile Update="Lang/Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Lang/Resources.resx</DependentUpon>
</Compile>
<Watch Include="**\*.xaml" />
</ItemGroup>
</Project>

View file

@ -1,67 +0,0 @@
using System;
using System.Threading;
using Pv;
public class AudioRecorder : IDisposable
{
private int frameLength;
PvRecorder recorder;
Thread thread;
short[] audioFrame;
IPCMProcessor processor;
public bool processing { get; private set; }
public AudioRecorder(IPCMProcessor processor, int frameLength=512, int deviceIndex = -1)
{
this.frameLength = frameLength;
this.recorder = PvRecorder.Create(frameLength: frameLength, deviceIndex: deviceIndex);
this.audioFrame = new short[frameLength];
this.processor=processor;
}
public static string [] GetAvailableDevices()
{
return PvRecorder.GetAvailableDevices();
}
public void Start()
{
recorder.Start();
thread = new Thread(ThreadLoop);
thread.Start();
}
public void ThreadLoop()
{
while (recorder.IsRecording)
{
if (thread.ThreadState != ThreadState.Running)
{
break;
}
if (processing)
{
Thread.Sleep(10);
continue;
}
processing = true;
audioFrame = recorder.Read();
processor.Process(audioFrame);
processing = false;
}
}
public void Stop()
{
thread.Interrupt();
thread.Join();
recorder.Stop();
thread = null;
}
public void Dispose()
{
recorder.Dispose();
}
}

View file

@ -39,16 +39,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
private readonly SemaphoreSlim _refreshGate = new(1, 1); private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens; private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Options.Set(AnonymousRequest, true);
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null) public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{ {
@ -59,16 +49,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_bearer = new BearerTokenHandler(this); _bearer = new BearerTokenHandler(this);
Http = new HttpClient(_bearer, disposeHandler: true); Http = new HttpClient(_bearer, disposeHandler: true);
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
// fetch, et Android si libmsquic n'est pas disponible. Tous les
// clients Yavsc.Api.Client partagent cette même instance `Http`.
Http.DefaultRequestVersion = new Version(3, 0);
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
_tokens = store.Load(); _tokens = store.Load();
} }
@ -324,7 +304,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
// Server rejected: could be revocation, clock skew, audience mismatch. // Server rejected: could be revocation, clock skew, audience mismatch.
// Force a refresh and retry exactly once. // Force a refresh and retry exactly once.
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path); using var retry = new HttpRequestMessage(method, path);
if (contentFactory is not null) if (contentFactory is not null)
@ -389,46 +369,13 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
return; return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
}
private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null)
{
await _refreshGate.WaitAsync(ct).ConfigureAwait(false); await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
try try
{ {
var tokens = _tokens; if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
if (rejectedAccessToken is null
? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew
: !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal))
return; return;
var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
if (result.IsError)
{
var isPermanent = result.Error is "invalid_grant" or "invalid_client";
if (isPermanent)
{
_store.Clear();
_tokens = null;
}
throw new RefreshFailedException(
isPermanent
? $"Session expirée. Veuillez vous reconnecter ({result.Error})."
: $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.",
isPermanent);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? tokens.IdToken);
_store.Save(_tokens);
} }
finally finally
{ {
@ -436,6 +383,30 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
} }
} }
private async Task ForceRefreshAsync(CancellationToken ct)
{
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
throw new InvalidOperationException("No refresh token available.");
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
// Refresh token dead: revoked, expired, or rotation-theft
// detected. Purge and force an interactive re-login.
_store.Clear();
_tokens = null;
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? _tokens.IdToken);
_store.Save(_tokens);
}
/// <summary> /// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a /// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image /// single multipart file named <c>file</c> and validates the image
@ -478,9 +449,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await attemptUpload().ConfigureAwait(false); var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false); response = await attemptUpload().ConfigureAwait(false);
} }
@ -534,9 +504,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await sendOnce().ConfigureAwait(false); var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false); await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false); response = await sendOnce().ConfigureAwait(false);
} }
@ -624,8 +593,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
if (_owner._tokens is not null if (_owner._tokens is not null)
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
request.Headers.Authorization = new AuthenticationHeaderValue( request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken); "Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken); return base.SendAsync(request, cancellationToken);

View file

@ -5,10 +5,8 @@ using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
namespace PostIt; namespace PostIt;
@ -43,7 +41,6 @@ public class ViewLocator : IDataTemplate
{ {
BlogsViewModel => services.GetRequiredService<BlogsPage>(), BlogsViewModel => services.GetRequiredService<BlogsPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(), ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(), CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
@ -59,12 +56,6 @@ public class ViewLocator : IDataTemplate
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(), EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(), EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Models; using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;

View file

@ -12,7 +12,6 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -8,7 +8,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -12,7 +12,6 @@ using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -133,18 +132,14 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
{ {
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>(); var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
Activity, Activity,
Performer, Performer,
Form, Form,
_billingClient, _billingClient,
frontClient, frontClient,
fsClient,
details, details,
IsReadOnly, IsReadOnly);
canAttach: !IsReadOnly);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -16,17 +15,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
{ {
private readonly BillingApiClient _billingClient; private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient; private readonly FrontOfficeApiClient _frontClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQueryDetailsDto _details; private readonly BillingQueryDetailsDto _details;
/// <summary>
/// Vrai lorsque l'utilisateur courant est le client de la demande
/// et peut y attacher/détacher des fichiers de son espace perso.
/// Le fournisseur consulte et télécharge les pièces mais n'attache
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
/// </summary>
public bool CanAttach { get; }
public ActivityInfo Activity { get; } public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; } public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; } public CommandFormSummary Form { get; }
@ -62,10 +52,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
get => false; get => false;
@ -84,27 +70,20 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
CommandFormSummary form, CommandFormSummary form,
BillingApiClient billingClient, BillingApiClient billingClient,
FrontOfficeApiClient frontClient, FrontOfficeApiClient frontClient,
UserFilesApiClient fsClient,
BillingQueryDetailsDto details, BillingQueryDetailsDto details,
bool isReadOnly, bool isReadOnly)
bool canAttach)
{ {
Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form)); Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
_details = details ?? throw new ArgumentNullException(nameof(details)); _details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
CanAttach = canAttach;
this.SetInfoStatus("Details de commande charges."); this.SetInfoStatus("Details de commande charges.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
[RelayCommand] [RelayCommand]
private async Task OpenEditorAsync() private async Task OpenEditorAsync()
{ {
@ -155,110 +134,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>Charge les pièces jointes de la demande.</summary>
public async Task InitializeAsync()
{
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task LoadQueryAttachmentsAsync()
{
try
{
var list = await _frontClient
.GetQueryAttachmentsAsync(BillingCode, Id)
.ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
// Non bloquant : les pièces jointes sont un complément.
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
}
}
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché à la demande par référence via
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
/// (<see cref="CanAttach"/>).
/// </summary>
[RelayCommand]
private async Task AddQueryAttachmentAsync()
{
if (!CanAttach) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingCode = BillingCode;
var queryId = Id;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
{
if (attachment is null || !CanAttach) return;
IsBusy = true;
try
{
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée de la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary> /// <summary>
/// Download the estimate linked to this command as a LaTeX source /// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is /// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
@ -388,4 +263,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
QueryStatus.Success => "V", QueryStatus.Success => "V",
_ => "i", _ => "i",
}; };
} }

View file

@ -6,7 +6,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -10,7 +10,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -26,7 +25,6 @@ namespace PostIt.ViewModels;
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQuerySummaryDto _query; private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id; public long QueryId => _query.Id;
@ -70,14 +68,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
public partial long? EstimateId { get; set; }
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public decimal Total => Lines.Sum(line => line.LineTotal); public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}"; public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
@ -96,14 +86,10 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateEditionPageViewModel( public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
BillingQuerySummaryDto query,
EstimateApiClient estimateClient,
UserFilesApiClient fsClient)
{ {
_query = query ?? throw new ArgumentNullException(nameof(query)); _query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
EstimateDescription = query.Description ?? string.Empty; EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged; Lines.CollectionChanged += OnLinesCollectionChanged;
@ -112,9 +98,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée."); this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
[RelayCommand] [RelayCommand]
private void AddLine() private void AddLine()
{ {
@ -158,11 +141,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true); var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true; HasSent = true;
EstimateId = created.Id;
OnPropertyChanged(nameof(SendLabel)); OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus( this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée."); $"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
await LoadAttachmentsAsync().ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
@ -190,106 +171,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>
/// Charge les pièces jointes rattachées au devis courant
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
[RelayCommand]
private async Task LoadAttachmentsAsync()
{
if (EstimateId is null || _estimateClient is null) return;
try
{
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
}
}
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché au devis par référence via
/// <c>POST estimate/{id}/attachments</c>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
private async Task AddAttachmentAsync()
{
if (EstimateId is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var estimateId = EstimateId.Value;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
await LoadAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachFileAsync(AttachmentDto? attachment)
{
if (attachment is null || EstimateId is null) return;
IsBusy = true;
try
{
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée du devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
internal EstimateDto BuildPayload() internal EstimateDto BuildPayload()
{ {
return new EstimateDto return new EstimateDto

View file

@ -9,7 +9,6 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -13,7 +13,6 @@ using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -156,7 +155,6 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.ConfigureAwait(true); .ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>(); var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
activity, activity,
@ -164,12 +162,9 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
form, form,
_billingClient, _billingClient,
frontClient, frontClient,
fsClient,
details, details,
isReadOnly: false, isReadOnly: false);
canAttach: false);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (Exception ex) catch (Exception ex)
@ -248,13 +243,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
throw new InvalidOperationException("Application PostIt indisponible."); throw new InvalidOperationException("Application PostIt indisponible.");
} }
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>(); var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }

View file

@ -1,99 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class AdministrationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial ObservableCollection<UserInfo> Users { get; set; } = new();
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement…";
[ObservableProperty]
public partial UserInfo? SelectedUser { get; set; }
public IReadOnlyList<UserInfo> FilteredUsers =>
string.IsNullOrWhiteSpace(SearchText)
? Users.ToList()
: Users.Where(user =>
(user.UserName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.FullName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.Email ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.UserId ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.ToList();
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public AdministrationPageViewModel(YavscApiClient? api)
{
_api = api;
}
public AdministrationPageViewModel() : this(null) { }
public Task InitializeAsync() => RefreshAsync();
partial void OnSearchTextChanged(string value)
{
OnPropertyChanged(nameof(FilteredUsers));
}
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
StatusMessage = "Client API d’administration indisponible.";
return;
}
IsBusy = true;
try
{
var query = SearchText.Trim();
var usersEndpoint = string.IsNullOrWhiteSpace(query)
? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString()
: new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString();
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true);
Users.Clear();
foreach (var user in users ?? [])
{
Users.Add(user);
}
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
}
catch (Exception ex)
{
Users.Clear();
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -11,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using Yavsc.Server.Helpers; using PostIt.Helpers;
using AvaloniaEdit.Document; using AvaloniaEdit.Document;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -39,66 +38,18 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <summary>Editor buffer for the post body. Same pattern as /// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary> /// <see cref="DraftTitle"/>.</summary>
public string? DraftArticle public string DraftArticle
{ {
get => DraftArticleDocument?.Text ?? null; get => DraftArticleDocument.Text;
set set {
{
var currentText = DraftArticleDocument?.Text;
if (string.Equals(currentText, value, StringComparison.Ordinal))
{
OnPropertyChanged(nameof(DraftArticle));
return;
}
OnPropertyChanging(nameof(DraftArticle)); OnPropertyChanging(nameof(DraftArticle));
try DraftArticleDocument = new TextDocument(value);
{ this.OnPropertyChanged(nameof(DraftArticle));
DraftArticleDocument = value is not null ? new TextDocument(value) : null;
}
catch (Exception ex)
{
StatusMessage = $"Error setting DraftArticleDocument: {ex.Message}";
// Handle any exceptions that might occur when setting the DraftArticleDocument
}
OnPropertyChanged(nameof(DraftArticle));
} }
} }
private TextDocument? _draftArticleDocument; [ObservableProperty]
public partial TextDocument DraftArticleDocument {get; set;}
public TextDocument? DraftArticleDocument
{
get => _draftArticleDocument;
set
{
if (ReferenceEquals(_draftArticleDocument, value))
{
return;
}
if (_draftArticleDocument is not null)
{
_draftArticleDocument.TextChanged -= DraftArticleDocument_TextChanged;
}
_draftArticleDocument = value;
if (_draftArticleDocument is not null)
{
_draftArticleDocument.TextChanged += DraftArticleDocument_TextChanged;
}
OnPropertyChanged(nameof(DraftArticleDocument));
UpdateDraftModified();
}
}
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(DraftArticle));
UpdateDraftModified();
}
/// <summary>Editor buffer for the post's publication state. /// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the /// Reflects the server-side <c>IsPublished</c> flag (the
@ -111,14 +62,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// mutable field. Toggling is its own action.</summary> /// mutable field. Toggling is its own action.</summary>
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } public partial bool DraftIsPublished { get; set; }
[ObservableProperty]
public partial bool IsDraftModified { get; private set; }
private bool _preserveDraft;
private string _savedTitle = string.Empty;
private string _savedArticle = string.Empty;
private readonly Dictionary<string, string> _pendingAttachmentLinks = new();
public bool IsLoaded { get; private set; } public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
@ -168,7 +111,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}); });
} }
[RelayCommand(CanExecute = nameof(CanSave))] [RelayCommand]
internal async Task SaveAsync() internal async Task SaveAsync()
{ {
// The button is already disabled when the title is empty // The button is already disabled when the title is empty
@ -186,6 +129,16 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{ {
var attachments = DraftAttachments.ToArray(); var attachments = DraftAttachments.ToArray();
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behavior copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0) if (SelectedPost is null || SelectedPost.Id == 0)
{ {
var draft = new BlogPostDto var draft = new BlogPostDto
@ -196,22 +149,12 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished IsPublished = DraftIsPublished
}; };
var savedTitle = draft.Title;
var savedArticle = draft.Article;
var created = await BlogClient!.CreatePostAsync(draft, attachments); var created = await BlogClient!.CreatePostAsync(draft, attachments);
if (created is not null) if (created is not null)
{ {
_preserveDraft = true; SelectedPost = created;
try
{
SelectedPost = created;
}
finally
{
_preserveDraft = false;
}
if (ResolvePendingAttachmentLinks(created)) if (TryAppendAttachmentLinks(created, attachments))
{ {
var linkUpdate = new BlogPostDto var linkUpdate = new BlogPostDto
{ {
@ -223,22 +166,16 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = created.DateCreated, DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
savedTitle = linkUpdate.Title;
savedArticle = linkUpdate.Article;
await BlogClient.UpdatePostAsync(created.Id, linkUpdate); await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
} }
created.Title = savedTitle;
created.Article = savedArticle;
this.SetInfoStatus($"Billet {created.Id} créé."); this.SetInfoStatus($"Billet {created.Id} créé.");
DraftAttachments.Clear(); DraftAttachments.Clear();
MarkDraftSaved(savedTitle, savedArticle);
} }
else
throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé.");
} }
else else
{ {
TryAppendAttachmentLinks(SelectedPost, attachments);
var update = new BlogPostDto var update = new BlogPostDto
{ {
Id = SelectedPost.Id, Id = SelectedPost.Id,
@ -250,11 +187,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
SelectedPost.Title = update.Title;
SelectedPost.Article = update.Article;
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear(); DraftAttachments.Clear();
MarkDraftSaved(update.Title, update.Article);
} }
await RefreshPostsAsync(); await RefreshPostsAsync();
@ -450,7 +384,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
Posts = new ObservableCollection<BlogPostDto>(); Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>(); FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>(); DraftAttachments = new ObservableCollection<BlogUploadFile>();
DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged;
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
this.SetInfoStatus("Prêt."); this.SetInfoStatus("Prêt.");
@ -462,7 +395,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
MarkDraftSaved();
IsLoaded = false; IsLoaded = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor // and we use it as-is. Test path: tests call this constructor
@ -523,7 +455,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
partial void OnSelectedPostChanged(BlogPostDto? value) partial void OnSelectedPostChanged(BlogPostDto? value)
{ {
if (_preserveDraft) return;
// Mirror the selection into the editor buffer so the // Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content // XAML-bound TextBox/TextEditor show the right content
// when the user clicks a post in the list. When the // when the user clicks a post in the list. When the
@ -537,8 +468,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// null selection so a fresh draft starts unpublished. // null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false; DraftIsPublished = value?.IsPublished ?? false;
DraftAttachments.Clear(); DraftAttachments.Clear();
_pendingAttachmentLinks.Clear();
MarkDraftSaved();
UpdateCommandStates(); UpdateCommandStates();
} }
@ -547,28 +476,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// Save's CanExecute depends on the buffer: the button must // Save's CanExecute depends on the buffer: the button must
// enable as soon as the user has typed a non-whitespace // enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected. // title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
{
SaveCommand.NotifyCanExecuteChanged();
UpdateDraftModified();
}
private void UpdateDraftModified()
{
IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal)
|| !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal)
|| (DraftAttachments?.Count ?? 0) > 0;
}
private void MarkDraftSaved()
=> MarkDraftSaved(DraftTitle, DraftArticle);
private void MarkDraftSaved(string? title, string? article)
{
_savedTitle = title ?? string.Empty;
_savedArticle = article ?? string.Empty;
UpdateDraftModified();
}
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()
@ -581,6 +489,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
} }
ApplyFilter(); ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
} }
private void ApplyFilter() private void ApplyFilter()
@ -595,29 +507,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified); .OrderByDescending(p => p.DateModified);
var selection = SelectedPost; FilteredPosts.Clear();
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray(); foreach (var post in filtered)
_preserveDraft = true;
try
{ {
for (var i = FilteredPosts.Count - 1; i >= 0; i--) FilteredPosts.Add(post);
{
if (!visiblePosts.Contains(FilteredPosts[i]))
FilteredPosts.RemoveAt(i);
}
for (var i = 0; i < visiblePosts.Length; i++)
{
var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]);
if (previousIndex < 0)
FilteredPosts.Insert(i, visiblePosts[i]);
else if (previousIndex != i)
FilteredPosts.Move(previousIndex, i);
}
SelectedPost = selection;
}
finally
{
_preserveDraft = false;
} }
} }
@ -656,53 +549,40 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
} }
} }
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
{ {
if (e.NewItems is not null) if (attachments.Count == 0)
{ return false;
foreach (BlogUploadFile attachment in e.NewItems)
{
var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
string fileUrl;
if (SelectedPost is { Id: > 0 } post)
fileUrl = AttachmentUrl(post, storedName);
else
{
fileUrl = $"postit-attachment:{Guid.NewGuid():N}";
_pendingAttachmentLinks.Add(fileUrl, storedName);
}
var article = DraftArticle ?? string.Empty;
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
{
var prefix = article.Length == 0 ? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + markdownLine;
}
}
}
UpdateDraftModified();
}
private string AttachmentUrl(BlogPostDto post, string storedName) var ownerSegment = post.Author?.UserName;
{ if (string.IsNullOrWhiteSpace(ownerSegment))
var owner = post.Author?.UserName ?? post.AuthorId; ownerSegment = post.AuthorId;
if (string.IsNullOrWhiteSpace(owner))
throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier."); if (string.IsNullOrWhiteSpace(ownerSegment))
return ResolveUserFileUrl( return false;
$"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}");
}
private bool ResolvePendingAttachmentLinks(BlogPostDto post)
{
var article = DraftArticle ?? string.Empty; var article = DraftArticle ?? string.Empty;
foreach (var link in _pendingAttachmentLinks) var links = new List<string>();
article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
StringComparison.Ordinal); foreach (var attachment in attachments)
var changed = article != DraftArticle; {
if (changed) DraftArticle = article; var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
_pendingAttachmentLinks.Clear(); var fileUrl = ResolveUserFileUrl(relativePath);
return changed; var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
links.Add(markdownLine);
}
if (links.Count == 0)
return false;
var prefix = article.Length == 0
? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + string.Join("\n", links);
return true;
} }
private string ResolveUserFileUrl(string relativePath) private string ResolveUserFileUrl(string relativePath)
@ -711,7 +591,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
if (!string.IsNullOrWhiteSpace(authority) if (!string.IsNullOrWhiteSpace(authority)
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
{ {
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri; return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
} }
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";

View file

@ -1,357 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services;
using Yavsc;
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// ViewModel pour la gestion du chat,
/// incluant la connexion au Hub SignalR,
/// la gestion des salons et des utilisateurs connectés.
/// </summary>
public partial class ChatViewModel : ViewModelBase
{
private readonly Settings _settings;
private readonly YavscApiClient? _api;
// Le paramètre optionnel permet l'instanciation design-time
// (Design.DataContext dans ChatPage.axaml) sans conteneur DI.
public ChatViewModel(YavscApiClient? api = null)
{
var app = App.Current as App;
_settings = app!.ServiceProvider!.GetRequiredService<Settings>(); // Assurez-vous que _settings est initialisé avant utilisation.
_api = api;
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
FocusedMessages = _systemMessages;
}
private HubConnection? _connection;
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
[ObservableProperty]
public partial string NickName { get; set; } = "";
[ObservableProperty]
public partial StatusNotice Status { get; set; }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial bool IsConnected { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial string MessageToSend { get; set; } = "";
// Salles jointes (choix de la salle en focus dans la vue).
public ObservableCollection<string> JoinedRooms { get; } = new();
// Salle en focus ; null = aucune salle, le journal affiche
// alors les notifications système.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial string? SelectedRoom { get; set; }
// Notifications globales (connexion, erreurs de join, ...)
private readonly ObservableCollection<string> _systemMessages = new();
// Messages et utilisateurs présents, indexés par salle.
private readonly Dictionary<string, ObservableCollection<string>> _messagesByRoom = new();
private readonly Dictionary<string, ObservableCollection<string>> _usersByRoom = new();
private static readonly ObservableCollection<string> _noUsers = new();
// Vues "focus" exposées à la page : pointent vers la salle
// sélectionnée, ou vers le journal système / une liste vide.
[ObservableProperty]
public partial ObservableCollection<string> FocusedMessages { get; set; }
[ObservableProperty]
public partial ObservableCollection<string> FocusedUsers { get; set; } = _noUsers;
partial void OnSelectedRoomChanged(string? value)
{
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
? msgs : _systemMessages;
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
? users : _noUsers;
}
// Trace une notification système dans la vue courante
// (et dans le journal global pour consultation ultérieure).
private void Log(string line)
{
_systemMessages.Add(line);
if (!ReferenceEquals(FocusedMessages, _systemMessages))
FocusedMessages.Add(line);
}
// Commandes pour les boutons
[RelayCommand]
public async Task ConnectCommand() => await ConnectAsync();
[RelayCommand]
public async Task JoinRoomCommand() => await JoinRoomAsync();
// Le serveur (ChatHub.Send) refuse tout message d'un utilisateur
// qui n'a pas joint le salon ("NoJoinNoSend") : on garde la
// commande inactive tant qu'aucune salle n'est en focus.
private bool CanSendMessage() =>
IsConnected && SelectedRoom != null && !string.IsNullOrWhiteSpace(MessageToSend);
[RelayCommand(CanExecute = nameof(CanSendMessage))]
private async Task SendMessageAsync()
{
if (_connection == null || SelectedRoom == null) return;
var text = MessageToSend.Trim();
try
{
await _connection.InvokeAsync("Send", SelectedRoom, text);
MessageToSend = string.Empty;
}
catch (Exception ex)
{
Log($"Erreur d'envoi : {ex.Message}");
}
}
public override bool CanNavigateNext { get; protected set; } = false;
public override bool CanNavigatePrevious { get; protected set; } = true;
private async Task DisconnectAsync()
{
if (_connection != null)
{
await _connection.StopAsync();
await _connection.DisposeAsync();
_connection = null;
}
IsConnected = false;
SelectedRoom = null;
JoinedRooms.Clear();
_messagesByRoom.Clear();
_usersByRoom.Clear();
_systemMessages.Clear();
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
private async Task ConnectAsync()
{
try
{
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
if (_api == null || !_api.HasValidSession)
{
Status = new StatusNotice("Session expirée : reconnectez-vous avant d'ouvrir le chat.", StatusSeverity.Error,
new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
return;
}
// Le hub est mappé à la racine du service ("/chathub"),
// pas sous le préfixe versionné "/api/v1" : on le retire.
var hubBase = _settings.ApiUrl.TrimEnd('/');
if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase))
hubBase = hubBase[..^"/api/v1".Length];
var hubUrl = hubBase + "/" + Constants.ChatHubPath;
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl,
options =>
{
// Le hub est [Authorize] : on attache le Bearer OIDC.
// AccessTokenProvider est rappelé à chaque reconnexion,
// donc on tente un refresh silencieux à chaque appel.
// Un refresh mort (invalid_grant) ne doit pas faire
// échouer StartAsync avec une exception interne : on
// renvoie le token courant (éventuellement expiré) et
// le serveur répondra un 401 propre, surfacé à l'UI.
options.AccessTokenProvider = async () =>
{
await _api.TrySilentLoginAsync();
return _api.CurrentAccessToken;
};
})
.WithAutomaticReconnect()
.Build();
// 1. Ecoute des notifications globales (notifyUser)
_connection.On<string, string, string>("notifyUser", (type, user, msg) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"[{type}] {user}: {msg}"));
});
// 2. Ecoute des notifications de salon (notifyRoom)
_connection.On<string, string, string>("notifyRoom", (type, room, user) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (_messagesByRoom.TryGetValue(room, out var roomLog))
roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
else
Log($"*{user} a interagi avec le salon {room} (Action: {type})");
if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users)
&& !users.Contains(user))
users.Add(user);
});
});
// 3. Réception de l'état complet suite au Join réussi (joint)
_connection.On<ChatRoomInfo>("joint", (roomInfo) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
});
// 4. Réception des messages de salon (diffusés par ChatHub.Send)
_connection.On<ChatMessage>("ReceiveMessage", (msg) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var room = msg.Room ?? "?";
if (!_messagesByRoom.TryGetValue(room, out var roomLog))
roomLog = _messagesByRoom[room] = new();
roomLog.Add($"{msg.Name} : {msg.Message}");
});
});
await _connection.StartAsync();
IsConnected = true;
Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
// WithAutomaticReconnect ne lève pas d'exception en cas de
// perte réseau : l'état passe par Reconnecting/Closed. Sans
// ces handlers, IsConnected resterait optimiste et Join()
// échouerait avec "connection is not active".
_connection.Reconnecting += error =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
IsConnected = false;
Status = new StatusNotice($"Connexion perdue ({error?.Message ?? "réseau"}), reconnexion...", StatusSeverity.Error,
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
});
return Task.CompletedTask;
};
_connection.Reconnected += async _ =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
IsConnected = true;
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
});
// La reconnexion crée une nouvelle ConnectionId côté
// serveur : l'appartenance aux salons est perdue, il
// faut rejoindre à nouveau le salon courant.
if (!string.IsNullOrWhiteSpace(CurrentRoom))
await JoinRoomAsync();
};
_connection.Closed += error =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
IsConnected = false;
Status = new StatusNotice($"Déconnecté ({error?.Message ?? "fin de connexion"})", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
});
return Task.CompletedTask;
};
}
catch (Exception ex)
{
Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error, new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
IsConnected = false;
}
}
// Alimente JoinedRooms et les collections par salle à partir
// du ChatRoomInfo renvoyé par le hub (valeur de retour du Join
// et/ou événement "joint"). Idempotent.
private void OnRoomJoined(ChatRoomInfo roomInfo)
{
// ChatHub.Join renseigne toujours Name (salle existante ou
// créée) ; un Name nul signalerait un payload inattendu.
var roomName = roomInfo.Name ?? CurrentRoom;
if (roomInfo.Name == null)
Log("OnRoomJoined : nom de salle absent du payload, repli sur la salle demandée.");
if (!_messagesByRoom.TryGetValue(roomName, out var roomLog))
roomLog = _messagesByRoom[roomName] = new();
roomLog.Add($"Salon rejoint avec succès : {roomName}");
var users = _usersByRoom[roomName] = new();
foreach (var u in roomInfo.Users) users.Add(u);
if (!JoinedRooms.Contains(roomName))
JoinedRooms.Add(roomName);
SelectedRoom = roomName;
}
private Task CheckConnectionAsync()
{
var state = _connection?.State.ToString() ?? "aucune connexion";
Log($"État de la connexion : {state}");
return Task.CompletedTask;
}
private async Task JoinRoomAsync()
{
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
// Salle déjà jointe : on se contente de la mettre en focus.
if (JoinedRooms.Contains(CurrentRoom))
{
SelectedRoom = CurrentRoom;
return;
}
// InvokeCoreAsync exige une connexion active : avec la
// reconnexion automatique, l'état peut être Reconnecting au
// moment de l'appel. On tente une (re)connexion si nécessaire.
if (_connection.State != HubConnectionState.Connected)
{
Log($"Connexion non active ({_connection.State}), tentative de reconnexion...");
try
{
await _connection.StartAsync();
}
catch (Exception ex)
{
Log($"Reconnexion impossible : {ex.Message}");
return;
}
}
try
{
// Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo)
var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom);
if (info == null)
{
Log("Erreur lors de la validation du salon par le serveur.");
}
else
{
// On alimente le modèle depuis la valeur de retour,
// sans attendre l'événement "joint".
OnRoomJoined(info);
}
}
catch (Exception ex)
{
Log($"Erreur Join: {ex.Message}");
}
}
}
}

View file

@ -1,33 +0,0 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace PostIt.ViewModels.Chat
{
internal class RelayCheckCommand : ICommand, IWithLabel
{
public string Label { get; }
private Func<Task> value;
public RelayCheckCommand(string v, Func<Task> value)
{
this.Label = v;
this.value = value;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return value != null;
}
public void Execute(object? parameter)
{
if (value != null)
{
_ = value();
}
}
}
}

View file

@ -2,7 +2,6 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -6,7 +6,6 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;

View file

@ -1,77 +0,0 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace PostIt.ViewModels;
/// <summary>
/// ViewModel de la page « À propos de PostIt ». Page statique :
/// version de l'application, description, licence et liens vers le
/// dépôt Forgejo et son suivi de tickets.
/// </summary>
public partial class AboutPageViewModel : ViewModelBase
{
public const string RepositoryUrl = "https://forgejo.pschneider.fr/notazof/yavsc";
public const string IssuesUrl = RepositoryUrl + "/issues";
public AboutPageViewModel()
{
Version = ReadVersion();
}
public string Version { get; }
public string Description { get; } =
"PostIt est le client de la plateforme Yavsc : blog, devis, " +
"demandes de prestations et fichiers personnels.";
public string License { get; } =
"Licence WTFPL — Do What The Fuck You Want To Public License, version 2";
public override bool CanNavigateNext
{
get => false;
protected set => throw new NotImplementedException();
}
public override bool CanNavigatePrevious
{
get => true;
protected set => throw new NotImplementedException();
}
[RelayCommand]
private Task OpenRepository() => OpenUrlAsync(RepositoryUrl);
[RelayCommand]
private Task OpenIssues() => OpenUrlAsync(IssuesUrl);
private static async Task OpenUrlAsync(string url)
{
var app = (App)App.Current!;
var topLevel = TopLevel.GetTopLevel(app.View);
if (topLevel is not null)
{
await topLevel.Launcher.LaunchUriAsync(new Uri(url)).ConfigureAwait(true);
}
}
private static string ReadVersion()
{
var assembly = typeof(AboutPageViewModel).Assembly;
var info = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrEmpty(info))
{
// "1.1.0-estimate.1+364.Branch.x.Sha.y" → "1.1.0-estimate.1"
var plus = info.IndexOf('+');
return plus > 0 ? info[..plus] : info;
}
return assembly.GetName().Version?.ToString() ?? "inconnue";
}
}

View file

@ -1,5 +1,3 @@
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public interface IActionStatusViewModel public interface IActionStatusViewModel
@ -31,4 +29,4 @@ public static class ActionStatusViewModelExtensions
_ => StatusNotice.Info(normalizedMessage), _ => StatusNotice.Info(normalizedMessage),
}; };
} }
} }

View file

@ -1,16 +1,11 @@
using System; using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase public class HomePageViewModel : ViewModelBase
@ -18,26 +13,6 @@ public class HomePageViewModel : ViewModelBase
public YavscApiClient Api { get; } public YavscApiClient Api { get; }
public Settings Settings { get; } public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; } public SessionStatusViewModel SessionStatus { get; }
public ObservableCollection<Notification> Notifications { get; } = new();
private readonly NotificationsApiClient? _notificationsClient;
private int _notificationsLoadVersion;
private bool _isLoadingNotifications;
private string? _notificationsError;
private bool _notificationsLoaded;
public bool IsLoadingNotifications
{
get => _isLoadingNotifications;
private set => SetProperty(ref _isLoadingNotifications, value);
}
public string? NotificationsError
{
get => _notificationsError;
private set => SetProperty(ref _notificationsError, value);
}
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
private string _welcomeText = "Welcome to PostIt!"; private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText public string WelcomeText
@ -49,95 +24,23 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus, public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
NotificationsApiClient notificationsClient)
{ {
Api = api; Api = api;
Settings = settings; Settings = settings;
SessionStatus = sessionStatus; SessionStatus = sessionStatus;
_notificationsClient = notificationsClient;
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
} }
public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
public IAsyncRelayCommand OpenAdministration { get; }
public IAsyncRelayCommand OpenResourceUsage { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public async Task RefreshNotificationsAsync()
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
var version = ++_notificationsLoadVersion;
Notifications.Clear();
NotificationsError = null;
_notificationsLoaded = false;
OnPropertyChanged(nameof(HasNoNotifications));
IsLoadingNotifications = true;
try
{
var notifications = SessionStatus.IsLoggedIn
? await _notificationsClient.GetCurrentAsync()
: await _notificationsClient.GetPublicAsync();
if (version != _notificationsLoadVersion)
return;
if (notifications is null)
throw new InvalidOperationException("The notifications response is empty.");
foreach (var notification in notifications)
Notifications.Add(notification);
_notificationsLoaded = true;
}
catch (Exception ex)
{
if (version == _notificationsLoadVersion)
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
}
finally
{
if (version == _notificationsLoadVersion)
{
IsLoadingNotifications = false;
OnPropertyChanged(nameof(HasNoNotifications));
}
}
}
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
return;
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
}
private async Task OpenActivitiesAsync() private async Task OpenActivitiesAsync()
{ {
@ -198,110 +101,6 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
private async Task OpenChatAsync()
{
var app = (App?)Application.Current;
var vm = app!.ServiceProvider?.GetRequiredService<ChatViewModel>();
await app.PushPageAsync(vm!);
}
/// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>.
/// </summary>
private async Task OpenMyFilesAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new MyFilesViewModel(fsClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenProfileAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<UserProfilePageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page profil indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenPerformerConfigurationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<PerformerConfigurationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page performer indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenAdministrationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<AdministrationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page administration indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenResourceUsageAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var resourceUsageClient = app.ServiceProvider?.GetRequiredService<ResourceUsageApiClient>();
if (resourceUsageClient is null)
{
throw new InvalidOperationException("Client usage indisponible.");
}
var vm = new ResourceUsagePageViewModel(resourceUsageClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can
@ -311,7 +110,7 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a /// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless. /// designer-only duplicate instance is therefore harmless.
/// </summary> /// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!) public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
{ {
} }

View file

@ -1,90 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ResourceUsagePageViewModel : ViewModelBase
{
private readonly ResourceUsageApiClient _client;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial bool IsAdminView { get; set; }
[ObservableProperty]
public partial ResourceUsageSummary? CurrentSummary { get; set; }
[ObservableProperty]
public partial ObservableCollection<ResourceUsageSummary> Overview { get; set; } = new();
public string Heading => IsAdminView ? "Vue d'administration des usages" : "Mon usage des ressources";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public ResourceUsagePageViewModel(ResourceUsageApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
Overview.Clear();
try
{
var adminOverview = await _client.GetAdminOverviewAsync().ConfigureAwait(true);
if (adminOverview is { Count: > 0 })
{
foreach (var item in adminOverview)
{
Overview.Add(item);
}
CurrentSummary = adminOverview[0];
IsAdminView = true;
return;
}
}
catch (Exception)
{
// Non-admin users are not allowed on the admin route; fall back
// to the current-user summary below.
}
var current = await _client.GetCurrentAsync().ConfigureAwait(true);
CurrentSummary = current;
if (current is not null)
{
Overview.Clear();
Overview.Add(current);
}
IsAdminView = false;
}
catch (Exception ex)
{
CurrentSummary = null;
Overview.Clear();
IsAdminView = false;
throw new InvalidOperationException($"Impossible de charger l'usage des ressources : {ex.Message}", ex);
}
finally
{
IsBusy = false;
}
}
}

View file

@ -143,11 +143,4 @@ public partial class SessionStatusViewModel : ViewModelBase
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true); await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
} }
[RelayCommand]
internal async Task OpenAbout()
{
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<AboutPageViewModel>()).ConfigureAwait(true);
}
} }

View file

@ -0,0 +1,35 @@
namespace PostIt.ViewModels;
public enum StatusSeverity
{
Info,
Warning,
Error
}
public sealed class StatusNotice
{
public string Message { get; }
public StatusSeverity Severity { get; }
public string Glyph { get; }
public string Background { get; }
public string BorderBrush { get; }
public string Foreground { get; }
private StatusNotice(string message, StatusSeverity severity)
{
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
Severity = severity;
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
}
public static StatusNotice Info(string message) => new(message, StatusSeverity.Info);
public static StatusNotice Warning(string message) => new(message, StatusSeverity.Warning);
public static StatusNotice Error(string message) => new(message, StatusSeverity.Error);
}

View file

@ -1,612 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class UserProfilePageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly SessionStatusViewModel? _sessionStatus;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
[NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
[NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))]
[NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
[NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
[NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))]
[NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))]
public partial bool IsProfileLoaded { get; set; }
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string CurrentUserName { get; set; } = "—";
[ObservableProperty]
public partial string UserId { get; set; } = "—";
[ObservableProperty]
public partial string FullName { get; set; } = "—";
[ObservableProperty]
public partial string Email { get; set; } = "—";
[ObservableProperty]
public partial string Address { get; set; } = "—";
[ObservableProperty]
public partial string Avatar { get; set; } = "—";
[ObservableProperty]
public partial string Roles { get; set; } = "—";
[ObservableProperty]
public partial string DedicatedGoogleCalendar { get; set; } = "Non configuré";
[ObservableProperty]
public partial string BankInfoSummary { get; set; } = "Aucune information bancaire";
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial bool EmailConfirmed { get; set; }
[ObservableProperty]
public partial bool AllowMonthlyEmail { get; set; }
[ObservableProperty]
public partial bool HasPassword { get; set; }
[ObservableProperty]
public partial bool TwoFactorEnabled { get; set; }
[ObservableProperty]
public partial int ExternalLoginCount { get; set; }
[ObservableProperty]
public partial long PostsCounter { get; set; }
[ObservableProperty]
public partial long DiskUsage { get; set; }
[ObservableProperty]
public partial long DiskQuota { get; set; }
[ObservableProperty]
public partial decimal Credits { get; set; }
[ObservableProperty]
public partial string CurrentPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string NewPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string ConfirmPassword { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankIban { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankBic { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankCode { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankWicketCode { get; set; } = string.Empty;
[ObservableProperty]
public partial string BankAccountNumber { get; set; } = string.Empty;
[ObservableProperty]
public partial string UsernameConfirmation { get; set; } = string.Empty;
[ObservableProperty]
public partial int BankedKey { get; set; }
public ObservableCollection<BankAccountInfo> BankAccounts { get; } = [];
public ObservableCollection<LinkedExternalLogin> ExternalLogins { get; } = [];
public ObservableCollection<CalendarOption> Calendars { get; } = [];
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null)
{
_api = api;
_sessionStatus = sessionStatus;
}
public UserProfilePageViewModel() : this(null, null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
IsProfileLoaded = false;
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
IsProfileLoaded = false;
try
{
var me = await _api.CallAsync<Me>(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true);
if (me is null)
{
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
}
UserName = me.UserName ?? "—";
CurrentUserName = UserName;
UserId = me.UserId;
FullName = me.FullName ?? "—";
Email = me.Email ?? "—";
Address = me.Address ?? "—";
Avatar = string.IsNullOrWhiteSpace(me.Avatar) ? "Aucun avatar" : me.Avatar;
Roles = me.Roles is { Length: > 0 } ? string.Join(", ", me.Roles) : "Aucun rôle";
DedicatedGoogleCalendar = string.IsNullOrWhiteSpace(me.DedicatedGoogleCalendar) ? "Non configuré" : me.DedicatedGoogleCalendar;
BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary;
EmailConfirmed = me.EmailConfirmed;
AllowMonthlyEmail = me.AllowMonthlyEmail;
HasPassword = me.HasPassword;
TwoFactorEnabled = me.TwoFactorEnabled;
ExternalLoginCount = me.ExternalLoginCount;
PostsCounter = me.PostsCounter;
DiskUsage = me.DiskUsage;
DiskQuota = me.DiskQuota;
Credits = me.Credits;
BankAccounts.Clear();
foreach (var account in me.BankAccounts)
{
BankAccounts.Add(account);
}
ExternalLogins.Clear();
foreach (var login in me.ExternalLogins)
{
ExternalLogins.Add(login);
}
IsProfileLoaded = true;
StatusMessage = "Profil chargé.";
}
catch (Exception ex)
{
IsProfileLoaded = false;
UserName = "—";
CurrentUserName = "—";
UserId = "—";
FullName = "—";
Email = "—";
Address = "—";
Avatar = "—";
Roles = "—";
DedicatedGoogleCalendar = "Non configuré";
BankInfoSummary = "Aucune information bancaire";
EmailConfirmed = false;
AllowMonthlyEmail = false;
HasPassword = false;
TwoFactorEnabled = false;
ExternalLoginCount = 0;
PostsCounter = 0;
DiskUsage = 0;
DiskQuota = 0;
Credits = 0;
BankAccounts.Clear();
ExternalLogins.Clear();
Calendars.Clear();
StatusMessage = $"Impossible de charger le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public ProfileUpdateRequest BuildProfileUpdateRequest()
{
static string NormalizeField(string? value, string placeholder)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
return string.Equals(trimmed, placeholder, StringComparison.OrdinalIgnoreCase)
? string.Empty
: trimmed;
}
return new ProfileUpdateRequest
{
UserName = NormalizeField(UserName, "—"),
FullName = NormalizeField(FullName, "—"),
Address = NormalizeField(Address, "—"),
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré")
};
}
private bool CanSaveProfile() => _api is not null && IsProfileLoaded && !IsBusy;
[RelayCommand(CanExecute = nameof(CanSaveProfile))]
public async Task SaveProfileAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var payload = BuildProfileUpdateRequest();
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString();
await _api.CallAsync(HttpMethod.Put, endpoint, payload).ConfigureAwait(true);
StatusMessage = "Profil enregistré.";
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SetMonthlyEmailPreferenceAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
await UpdateBooleanPreferenceAsync(
"account/preferences/monthly-email",
new MonthlyEmailPreferenceRequest { Enabled = AllowMonthlyEmail },
enabled => AllowMonthlyEmail = enabled,
"préférence de courriel");
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SetTwoFactorAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
await UpdateBooleanPreferenceAsync(
"account/security/two-factor",
new TwoFactorRequest { Enabled = TwoFactorEnabled },
enabled => TwoFactorEnabled = enabled,
"authentification à deux facteurs");
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task ChangePasswordAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (string.IsNullOrWhiteSpace(NewPassword)
|| !string.Equals(NewPassword, ConfirmPassword, StringComparison.Ordinal))
{
StatusMessage = "Le nouveau mot de passe et sa confirmation doivent être identiques.";
return;
}
IsBusy = true;
try
{
var endpoint = BuildEndpoint("account/security/password");
await _api.CallAsync(HttpMethod.Put, endpoint, new ChangePasswordRequest
{
CurrentPassword = CurrentPassword,
NewPassword = NewPassword,
ConfirmPassword = ConfirmPassword
}).ConfigureAwait(true);
CurrentPassword = string.Empty;
NewPassword = string.Empty;
ConfirmPassword = string.Empty;
HasPassword = true;
StatusMessage = "Mot de passe mis à jour.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de modifier le mot de passe : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task AddBankAccountAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (string.IsNullOrWhiteSpace(BankIban) && string.IsNullOrWhiteSpace(BankBic))
{
StatusMessage = "Renseignez un IBAN ou un BIC.";
return;
}
IsBusy = true;
try
{
var endpoint = BuildEndpoint("account/bank-info");
await _api.CallAsync<BankAccountInfo>(HttpMethod.Post, endpoint, new BankAccountInfoRequest
{
IBAN = BankIban.Trim(),
BIC = BankBic.Trim(),
BankCode = BankCode.Trim(),
WicketCode = BankWicketCode.Trim(),
AccountNumber = BankAccountNumber.Trim(),
BankedKey = BankedKey
}).ConfigureAwait(true);
BankIban = string.Empty;
BankBic = string.Empty;
BankCode = string.Empty;
BankWicketCode = string.Empty;
BankAccountNumber = string.Empty;
BankedKey = 0;
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’ajouter le compte bancaire : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task DeleteBankAccountAsync(BankAccountInfo? account)
{
if (_api is null || account is null)
{
return;
}
IsBusy = true;
try
{
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/bank-info/{account.Id}")).ConfigureAwait(true);
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer le compte bancaire : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task RemoveExternalLoginAsync(LinkedExternalLogin? login)
{
if (_api is null || login is null)
{
return;
}
IsBusy = true;
try
{
var query = $"provider={Uri.EscapeDataString(login.Provider)}&providerKey={Uri.EscapeDataString(login.ProviderKey)}";
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/security/external-logins?{query}")).ConfigureAwait(true);
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer cette connexion externe : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task SendEmailConfirmationAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
await _api.CallAsync<JsonElement>(HttpMethod.Post, BuildEndpoint("account/email/confirmation")).ConfigureAwait(true);
StatusMessage = "E-mail de confirmation envoyé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’envoyer l’e-mail de confirmation : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task LoadCalendarsAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var calendars = await _api.CallAsync<CalendarOption[]>(
HttpMethod.Get,
BuildEndpoint("account/calendars")).ConfigureAwait(true);
Calendars.Clear();
foreach (var calendar in calendars ?? [])
{
Calendars.Add(calendar);
}
StatusMessage = Calendars.Count == 0
? "Aucun agenda Google dont vous êtes propriétaire n’est disponible."
: "Agendas Google chargés.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger les agendas Google : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task ChooseCalendarAsync(CalendarOption? calendar)
{
if (calendar is null)
{
DedicatedGoogleCalendar = "Non configuré";
}
else
{
DedicatedGoogleCalendar = calendar.Id;
}
await SaveProfileAsync().ConfigureAwait(true);
}
[RelayCommand(CanExecute = nameof(CanModifyAccount))]
public async Task DeleteAccountAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
if (!string.Equals(UsernameConfirmation, CurrentUserName, StringComparison.Ordinal))
{
StatusMessage = "Saisissez exactement votre nom d’utilisateur pour confirmer la suppression.";
return;
}
IsBusy = true;
try
{
await _api.CallAsync(HttpMethod.Delete, BuildEndpoint("account/me"), new DeleteAccountRequest
{
UsernameConfirmation = UsernameConfirmation
}).ConfigureAwait(true);
UsernameConfirmation = string.Empty;
StatusMessage = "Compte supprimé.";
if (_sessionStatus is not null)
{
await _sessionStatus.LogoutAsync().ConfigureAwait(true);
}
else
{
await _api.LogoutAsync().ConfigureAwait(true);
}
}
catch (Exception ex)
{
StatusMessage = $"Impossible de supprimer le compte : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task UpdateBooleanPreferenceAsync<TRequest>(
string path,
TRequest request,
Action<bool> applyValue,
string preferenceName)
where TRequest : class
{
IsBusy = true;
try
{
var endpoint = BuildEndpoint(path);
var response = await _api!.CallAsync<System.Text.Json.JsonElement>(HttpMethod.Put, endpoint, request).ConfigureAwait(true);
var enabled = response.GetProperty("enabled").GetBoolean();
applyValue(enabled);
StatusMessage = $"Préférence mise à jour : {preferenceName}.";
}
catch (Exception ex)
{
await RefreshAsync().ConfigureAwait(true);
StatusMessage = $"Impossible de mettre à jour {preferenceName} : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private string BuildEndpoint(string path) =>
new Uri(new Uri(_api!.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), path).ToString();
private bool CanModifyAccount() => _api is not null && IsProfileLoaded && !IsBusy;
}

View file

@ -1,230 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Gestion de l'espace de stockage personnel (« My Files ») :
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
/// (fournisseur) ou à une demande (client) par référence : lorsque
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
/// puis revient à la page précédente.
/// </summary>
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly UserFilesApiClient _fs;
private readonly Action<long>? _onFilePicked;
/// <summary>
/// Sous-répertoire courant, relatif à la racine de l'espace perso
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
/// </summary>
[ObservableProperty]
public partial string CurrentPath { get; set; } = string.Empty;
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
? "Mes fichiers"
: $"Mes fichiers / {CurrentPath}";
public bool IsPickerMode => _onFilePicked is not null;
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
[ObservableProperty]
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
{
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_onFilePicked = onFilePicked;
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public MyFilesViewModel() : this(null!) { }
public async Task InitializeAsync()
{
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task RefreshAsync()
{
IsBusy = true;
try
{
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
Files.Clear();
SubDirectories.Clear();
if (dir?.Files is not null)
foreach (var f in dir.Files) Files.Add(f);
if (dir?.SubDirectories is not null)
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenDirAsync(DirEntryDto? dir)
{
if (dir is null) return;
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task UpAsync()
{
if (!CanGoUp) return;
CurrentPath = ParentPath(CurrentPath);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
{
if (uploads is null || uploads.Count == 0) return;
IsBusy = true;
try
{
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteFileAsync(FileEntryDto? file)
{
if (file is null) return;
IsBusy = true;
try
{
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
this.SetInfoStatus($"Fichier supprimé.");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null) return;
IsBusy = true;
try
{
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PickFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null)
{
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
return;
}
var callback = _onFilePicked;
if (callback is null)
{
// Mode gestion : le téléchargement est l'action par défaut.
await DownloadFileAsync(file).ConfigureAwait(true);
return;
}
callback(file.Id.Value);
await GoBackAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task BackAsync()
{
await GoBackAsync().ConfigureAwait(true);
}
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
await app.GoBackAsync().ConfigureAwait(true);
}
private static string CombinePath(string current, string name)
{
if (string.IsNullOrWhiteSpace(current)) return name;
return $"{current}/{name}";
}
private static string ParentPath(string current)
{
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
}

View file

@ -1,196 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitySelectionOption : ObservableObject
{
public string Code { get; }
public string Name { get; }
[ObservableProperty]
public partial bool IsSelected { get; set; }
public ActivitySelectionOption(string code, string name)
{
Code = code;
Name = name;
}
}
public partial class PerformerConfigurationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly ActivityApiClient? _activityClient;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string PerformerId { get; set; } = string.Empty;
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string ExerciseCountryCode { get; set; } = "fr";
[ObservableProperty]
public partial string SIREN { get; set; } = string.Empty;
[ObservableProperty]
public partial string Website { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Active { get; set; }
[ObservableProperty]
public partial bool AcceptNotifications { get; set; }
[ObservableProperty]
public partial bool AcceptPublicContact { get; set; }
[ObservableProperty]
public partial bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivitySelectionOption> AvailableActivities { get; set; } = new();
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public PerformerConfigurationPageViewModel(YavscApiClient? api, ActivityApiClient? activityClient)
{
_api = api;
_activityClient = activityClient;
}
public PerformerConfigurationPageViewModel() : this(null, null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null || _activityClient is null)
{
StatusMessage = "Client API de configuration indisponible.";
return;
}
IsBusy = true;
try
{
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var dto = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Get, endpoint).ConfigureAwait(true);
PerformerId = dto?.PerformerId ?? string.Empty;
UserName = dto?.UserName ?? "—";
ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode;
SIREN = dto?.SIREN ?? string.Empty;
Website = dto?.WebSite ?? string.Empty;
Active = dto?.Active ?? false;
AcceptNotifications = dto?.AcceptNotifications ?? false;
AcceptPublicContact = dto?.AcceptPublicContact ?? false;
UseGeoLocalizationToReduceDistanceWithClients = dto?.UseGeoLocalizationToReduceDistanceWithClients ?? false;
var catalog = await _activityClient.GetCatalogAsync().ConfigureAwait(true);
var options = Flatten(catalog)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(a => new ActivitySelectionOption(a.Code, a.Name)
{
IsSelected = dto?.SelectedActivityCodes.Contains(a.Code, StringComparer.OrdinalIgnoreCase) == true
})
.ToList();
AvailableActivities.Clear();
foreach (var option in options)
{
AvailableActivities.Add(option);
}
StatusMessage = "Profil performer chargé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public PerformerProfileSettings BuildPerformerProfileSettings()
{
return new PerformerProfileSettings
{
PerformerId = string.IsNullOrWhiteSpace(PerformerId) ? string.Empty : PerformerId,
UserName = UserName,
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
SIREN = SIREN,
WebSite = Website,
Active = Active,
AcceptNotifications = AcceptNotifications,
AcceptPublicContact = AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = UseGeoLocalizationToReduceDistanceWithClients,
SelectedActivityCodes = AvailableActivities
.Where(a => a.IsSelected)
.Select(a => a.Code)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
[RelayCommand]
public async Task SaveAsync()
{
if (_api is null)
{
StatusMessage = "Client API de configuration indisponible.";
return;
}
IsBusy = true;
try
{
var dto = BuildPerformerProfileSettings();
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var response = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Put, endpoint, dto).ConfigureAwait(true);
StatusMessage = response is not null ? "Configuration performer enregistrée." : "Configuration performer enregistrée.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private static IEnumerable<ActivityInfo> Flatten(IEnumerable<ActivityInfo> activities)
{
foreach (var activity in activities)
{
yield return activity;
foreach (var child in Flatten(activity.Children ?? Enumerable.Empty<ActivityInfo>()))
{
yield return child;
}
}
}
}

View file

@ -8,8 +8,6 @@ using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Globalization;
using PostIt.Models;
[assembly: InternalsVisibleTo("PostIt.Tests")] [assembly: InternalsVisibleTo("PostIt.Tests")]
@ -46,7 +44,6 @@ public partial class Settings : ViewModelBase
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
[JsonIgnore]
public bool Loaded { get; private set; } = false; public bool Loaded { get; private set; } = false;
@ -62,16 +59,8 @@ public partial class Settings : ViewModelBase
/// <see cref="Authentication"/>. /// <see cref="Authentication"/>.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
[JsonIgnore]
public partial bool IsDirty { get; private set; } = false; public partial bool IsDirty { get; private set; } = false;
[JsonIgnore]
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
[JsonIgnore]
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
public string SelectedLanguage { get; internal set; } = CultureInfo.CurrentUICulture.Name;
/// <summary> /// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c> /// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
@ -272,9 +261,10 @@ public partial class Settings : ViewModelBase
} }
var app = App.Current as App; var app = App.Current as App;
if (App.CliConfigFileSpecification!=null) if (app!=null)
if (app.CliConfigFileSpecification!=null)
{ {
SettingsFileFullName = App.CliConfigFileSpecification; SettingsFileFullName = app.CliConfigFileSpecification;
} }
if (SettingsFileFullName is not null) if (SettingsFileFullName is not null)
@ -542,4 +532,7 @@ public partial class Settings : ViewModelBase
/// </summary> /// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
} }

View file

@ -1,40 +0,0 @@
using System.Windows.Input;
using PostIt.Models;
namespace PostIt.ViewModels;
public sealed class StatusNotice
{
public string Message { get; }
public StatusSeverity Severity { get; }
public string Glyph { get; }
public string Background { get; }
public string BorderBrush { get; }
public string Foreground { get; }
public bool HasCheckCommand { get => CheckCommand != null; }
public ICommand? CheckCommand { get; }
public string CheckCommandLabel { get; }
public StatusNotice(string message, StatusSeverity severity, ICommand? checkCommand)
{
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
Severity = severity;
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
CheckCommand = checkCommand;
CheckCommandLabel = HasCheckCommand ?
(checkCommand is IWithLabel labeled ? labeled.Label : "Check") : string.Empty;
}
public static StatusNotice Info(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Info, checkCommand);
public static StatusNotice Warning(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Warning, checkCommand);
public static StatusNotice Error(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Error, checkCommand);
}

View file

@ -1,7 +1,6 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueryDetailsPage" x:Class="PostIt.Views.BillingQueryDetailsPage"
x:DataType="vm:BillingQueryDetailsPageViewModel" x:DataType="vm:BillingQueryDetailsPageViewModel"
@ -81,53 +80,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du client) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddQueryAttachmentCommand}"
IsVisible="{Binding CanAttach}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).CanAttach}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Expander Header="Metadonnees techniques" <Expander Header="Metadonnees techniques"
IsExpanded="False"> IsExpanded="False">
<Border BorderThickness="1" <Border BorderThickness="1"

View file

@ -1,7 +1,6 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage" x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel" x:DataType="vm:EstimateEditionPageViewModel"
@ -134,52 +133,6 @@
</Grid> </Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
IsVisible="{Binding HasSent}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddAttachmentCommand}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View file

@ -1,234 +1,165 @@
<ContentPage <ContentPage xmlns="https://github.com/avaloniaui"
x:Class="PostIt.Views.Blogs.BlogsPage" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns="https://github.com/avaloniaui" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:postitControls="using:PostIt.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls" xmlns:models="using:Yavsc.Blogspot"
xmlns:models="using:Yavsc.Blogspot" xmlns:views="using:PostIt.Views.Blogs"
xmlns:postitControls="using:PostIt.Controls" xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:views="using:PostIt.Views.Blogs" mc:Ignorable="d"
xmlns:vm="using:PostIt.ViewModels" x:Class="PostIt.Views.Blogs.BlogsPage"
HorizontalAlignment="Stretch" x:DataType="vm:BlogsViewModel"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch"
x:DataType="vm:BlogsViewModel" VerticalAlignment="Stretch">
mc:Ignorable="d">
<Design.DataContext> <Design.DataContext>
<vm:BlogsViewModel /> <vm:BlogsViewModel />
</Design.DataContext> </Design.DataContext>
<ContentPage.Styles> <ScrollViewer
<Style Selector="CommandBarButton.modified"> VerticalScrollBarVisibility="Visible"
<Setter Property="Background" Value="#FFF0C2" /> HorizontalScrollBarVisibility="Disabled"
<Setter Property="Foreground" Value="#663C00" /> Padding="5"
<Setter Property="FontWeight" Value="Bold" /> Margin="5"
</Style> >
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<WrapPanel x:Name="BlogsHeaderPanel"
Margin="5"
Orientation="Horizontal">
<TextBlock
FontSize="20"
FontWeight="SemiBold"
Text="Blog Posts" />
<TextBox
HorizontalAlignment="Right"
PlaceholderText="Filter by title or author..."
Text="{Binding SearchText, Mode=TwoWay}" />
</WrapPanel>
<CommandBar x:Name="BlogsCommandBar"
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="SaveButton"
Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
Label="Save">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="AddAttachmentButton"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="ManageAclButton"
Command="{Binding ManageAclAsync}"
Label="ACL">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}"
Label="Mes cercles">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Label="Publié">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDevAsync}"
Label="[DEV] Signature">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid"> <StackPanel HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
<TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
<TextBox HorizontalAlignment="Right"
Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Filter by title or author..." />
</StackPanel>
<CommandBar IsDynamicOverflowEnabled="True"
HorizontalAlignment="Right" >
<CommandBar.PrimaryCommands>
<CommandBarButton Label="Refresh" Command="{Binding RefreshAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="SaveButton" Label="Save" Command="{Binding SaveAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Delete" Command="{Binding DeleteAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Ajouter fichiers" Click="AddAttachment_Click"
x:Name="AddAttachmentButton">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="ManageAclButton"
Label="ACL"
Command="{Binding ManageAclAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="OpenCirclesButton"
Label="Mes cercles" Command="{Binding OpenCirclesAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton Label="Publié"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton Label="Markdown Viewer"
IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton x:Name="OpenSignatureDevButton"
Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Margin="12" RowSpacing="12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="4" /> <RowDefinition Height="Auto" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
Grid.Row="0" <ListBox ItemsSource="{Binding FilteredPosts}"
Padding="8" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
BorderBrush="Gray"
BorderThickness="1">
<ListBox
x:Name="PostsListBox"
IsEnabled="{Binding !IsBusy}"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4"> <StackPanel Spacing="4" >
<StackPanel Orientation="Horizontal" Spacing="8"> <TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock <TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
VerticalAlignment="Center" <TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
FontWeight="SemiBold"
Text="{Binding Title}" />
<Border
x:Name="PublishedBadge"
VerticalAlignment="Center"
Padding="6,2"
Background="#F2F7F2"
BorderBrush="#2F6D2F"
BorderThickness="1"
CornerRadius="6"
IsVisible="{Binding IsPublished}">
<TextBlock
FontSize="10"
FontWeight="SemiBold"
Foreground="#2F6D2F"
Text="Publié" />
</Border>
</StackPanel>
<TextBlock
FontSize="10"
Foreground="Gray"
Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" />
<TextBlock
FontSize="10"
Foreground="DarkSlateGray"
Text="{Binding AuthorId}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
</Border> </Border>
<GridSplitter <md:MarkdownViewer Grid.Row="2" Name="MarkdownViewer"
Grid.Row="1" IsVisible="{Binding IsMarkdownViewerVisible}"
HorizontalAlignment="Stretch" MarkdownText="{Binding DraftArticle}" />
ResizeDirection="Rows" />
<Grid <Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8"
Grid.Row="2" IsVisible="{Binding !IsMarkdownViewerVisible}">
HorizontalAlignment="Stretch" <Grid RowSpacing="10"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch"
RowSpacing="10"> VerticalAlignment="Stretch">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> </Grid.RowDefinitions>
</Grid.RowDefinitions> <Grid.ColumnDefinitions>
<Grid.ColumnDefinitions> <ColumnDefinition Width="*" />
<ColumnDefinition Width="*" /> </Grid.ColumnDefinitions>
</Grid.ColumnDefinitions>
<TextBlock <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
Grid.Row="0" <TextBox x:Name="DraftTitleTextBox"
FontWeight="SemiBold" Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
Text="Post detail" />
<TextBox
x:Name="DraftTitleTextBox"
Grid.Row="1"
PlaceholderText="Title"
Text="{Binding DraftTitle, Mode=TwoWay}" />
<postitControls:MarkdownEditorControl <AvaloniaEdit:TextEditor
Grid.Row="2" Grid.Row="2" VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsPreviewVisible="{Binding IsMarkdownViewerVisible, Mode=TwoWay}"
MarkdownText="{Binding DraftArticle, Mode=TwoWay}"
Document="{Binding DraftArticleDocument, Mode=TwoWay}" />
<Border x:Name="AttachmentsBorder" Document="{Binding DraftArticleDocument, Mode=TwoWay}"
Grid.Row="3" ShowLineNumbers="True"
Padding="8" FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
BorderBrush="LightGray"
BorderThickness="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Pièces jointes" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid> <Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4"
DataContext="{Binding ActionStatus}" />
</Grid>
</Border>
</Grid> </Grid>
</Grid> </StackPanel>
</ScrollViewer>
</ContentPage> </ContentPage>

View file

@ -1,9 +1,9 @@
using System;
using System.IO; using System.IO;
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
namespace PostIt.Views.Blogs; namespace PostIt.Views.Blogs;
@ -50,6 +50,7 @@ public partial class BlogsPage : ContentPage
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name))); uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
} }
vm.DraftAttachments.Clear();
foreach (var upload in uploads) foreach (var upload in uploads)
vm.DraftAttachments.Add(upload); vm.DraftAttachments.Add(upload);
} }

View file

@ -1,111 +0,0 @@
<ContentPage
x:Class="PostIt.Views.Chat.ChatPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat"
x:DataType="vm:ChatViewModel"
>
<Design.DataContext>
<vm:ChatViewModel />
</Design.DataContext>
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
<Grid
Grid.Row="1"
Margin="0,0,0,10"
ColumnDefinitions="*, 200">
<!-- Journal des flux de messages & notifications -->
<Grid Grid.Column="0" RowDefinitions="Auto, *">
<StackPanel
Grid.Row="0"
Margin="0,0,0,5"
Orientation="Horizontal"
Spacing="10">
<TextBox
Width="200"
VerticalAlignment="Center"
Text="{Binding CurrentRoom}"
PlaceholderText="Nom du salon" />
<Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" />
<!-- Choix de la salle en focus parmi celles jointes -->
<ComboBox
Width="180"
VerticalAlignment="Center"
ItemsSource="{Binding JoinedRooms}"
PlaceholderText="Salle active"
SelectedItem="{Binding SelectedRoom}" />
</StackPanel>
<ListBox
Grid.Row="1"
BorderBrush="#E0E0E0"
BorderThickness="1"
ItemsSource="{Binding FocusedMessages}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') -->
<Border
Grid.Column="1"
Margin="10,0,0,0"
BorderBrush="#E0E0E0"
BorderThickness="1">
<Grid RowDefinitions="Auto, *">
<TextBlock
Grid.Row="0"
Margin="5"
HorizontalAlignment="Center"
FontWeight="Bold"
Text="Membres Présents" />
<ListBox
Grid.Row="1"
BorderThickness="0"
ItemsSource="{Binding FocusedUsers}" />
</Grid>
</Border>
</Grid>
<!-- Saisie et envoi d'un message dans le salon joint -->
<Grid
Grid.Row="2"
Margin="0,0,0,10"
ColumnDefinitions="*, Auto">
<TextBox
Grid.Column="0"
VerticalAlignment="Center"
AcceptsReturn="False"
IsEnabled="{Binding !!SelectedRoom}"
PlaceholderText="Votre message..."
Text="{Binding MessageToSend}">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding SendMessageCommand}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Button
Grid.Column="1"
Margin="10,0,0,0"
VerticalAlignment="Center"
Command="{Binding SendMessageCommand}"
Content="Envoyer" />
</Grid>
<postitControls:StatusBar
x:Name="StatusBar"
Grid.Row="3"
DataContext="{Binding Status}" />
</Grid>
</ContentPage>

View file

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
public ChatPage()
{
InitializeComponent();
}
}

View file

@ -15,9 +15,6 @@ using PostIt.Services;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands; namespace PostIt.Views.Commands;
using Mapsui.Tiling; // Ensure you have this namespace
public partial class RdvPage : ContentPage public partial class RdvPage : ContentPage
{ {
@ -47,16 +44,6 @@ public partial class RdvPage : ContentPage
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService(); _reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent(); InitializeComponent();
InitializeMap(); InitializeMap();
// ... inside your Map setup logic ...
string version = GetType().Assembly.GetName().Version?.ToString() ?? "1.1";
// 1. Create a unique user-agent string for your app (e.g., "AppName/Version (ContactEmail)")
string myCustomUserAgent = $"PostIt/{version} (paul@pschneider.fr)";
// 2. Pass your custom user agent into the OpenStreetMap helper
var osmLayer = OpenStreetMap.CreateTileLayer(myCustomUserAgent);
// 3. add your newly identified layer
_locationMap.Map.Layers.Add(osmLayer);
} }
private void InitializeComponent() private void InitializeComponent()

Some files were not shown because too many files have changed in this diff Show more