Compare commits
170 changed files with 864 additions and 12221 deletions
|
|
@ -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: |
|
||||||
|
|
@ -50,6 +50,9 @@ jobs:
|
||||||
echo "🏗️ Building the solution..."
|
echo "🏗️ Building the solution..."
|
||||||
cd /src/_src && dotnet build --verbosity normal
|
cd /src/_src && dotnet build --verbosity normal
|
||||||
- name: Test
|
- name: Test
|
||||||
|
artifacts:
|
||||||
|
name: test-results
|
||||||
|
path: test-results.html
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Lancement des tests..."
|
echo "🚀 Lancement des tests..."
|
||||||
cd /src/_src && dotnet test \
|
cd /src/_src && dotnet test \
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -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.
|
|
||||||
21
.github/workflows/docker-publish-backend.yml
vendored
21
.github/workflows/docker-publish-backend.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -48,4 +48,3 @@ DataDir/
|
||||||
|
|
||||||
# Exclude test reports (local test report, not for the repo)
|
# Exclude test reports (local test report, not for the repo)
|
||||||
/test-reports
|
/test-reports
|
||||||
/doc/**/.*.md
|
|
||||||
|
|
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -46,9 +46,5 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dotnet.defaultSolution": "yavsc.sln",
|
"dotnet.defaultSolution": "yavsc.sln",
|
||||||
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
|
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln"
|
||||||
"chat.tools.terminal.autoApprove": {
|
|
||||||
"dotnet build": true,
|
|
||||||
"dotnet test": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
226
CHANGELOG.md
226
CHANGELOG.md
|
|
@ -1,237 +1,13 @@
|
||||||
# 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
|
## [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
|
||||||
|
|
||||||
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
|
|
||||||
(arbre de stockage indexé par le login, pas par le `sub`), boutons
|
(arbre de stockage indexé par le login, pas par le `sub`), boutons
|
||||||
d'action des listes réparés (binding `$parent`), revendication `name`
|
d'action des listes réparés (binding `$parent`), revendication `name`
|
||||||
enfin présente dans les access tokens (branchement du `ProfileService`),
|
enfin présente dans les access tokens (branchement du `ProfileService`),
|
||||||
et négociation HTTP/3 côté client PostIt.
|
et négociation HTTP/3 côté client PostIt.
|
||||||
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
|
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
|
||||||
|
|
|
||||||
25
Dockerfile
25
Dockerfile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -21,7 +23,8 @@ src/Yavsc/bin/output/wwwroot:
|
||||||
test:
|
test:
|
||||||
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
|
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
|
||||||
--report-xunit-html \
|
--report-xunit-html \
|
||||||
--results-directory "test-reports"
|
--results-directory "test-reports" \
|
||||||
|
--report-xunit-html-filename "test-results.html"
|
||||||
|
|
||||||
test-postit:
|
test-postit:
|
||||||
ASPNETCORE_ENVIRONMENT=Development dotnet test \
|
ASPNETCORE_ENVIRONMENT=Development dotnet test \
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"CorsAllowedOrigins": [
|
"CorsAllowedOrigins": [
|
||||||
"https://preprod-api.$DOMAIN"
|
"https://preprod-api.$DOMAIN"
|
||||||
],
|
],
|
||||||
"UserFilesRoot": "files"
|
"Blog": "blog"
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"EMail": "paul@pschneider.fr"
|
"EMail": "paul@pschneider.fr"
|
||||||
},
|
},
|
||||||
"Quota": 200000000,
|
"Quota": 200000000,
|
||||||
"UserFilesRoot": "files",
|
"Blog": "blog",
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
|
|
|
||||||
|
|
@ -57,25 +57,5 @@ server {
|
||||||
proxy_read_timeout 100s;
|
proxy_read_timeout 100s;
|
||||||
proxy_buffering off;
|
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;
|
include server-status.conf;
|
||||||
}
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ using Avalonia.Controls;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using PostIt.Controls;
|
using PostIt.Controls;
|
||||||
using PostIt.Models;
|
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
@ -22,9 +21,7 @@ public class StatusBarTests
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Status_message_is_rendered_by_a_SelectableTextBlock()
|
public void Status_message_is_rendered_by_a_SelectableTextBlock()
|
||||||
{
|
{
|
||||||
var bar = new StatusBar {
|
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
|
||||||
DataContext =
|
|
||||||
StatusNotice.Error("Échec de la suppression : boom") };
|
|
||||||
var window = new Window { Content = bar };
|
var window = new Window { Content = bar };
|
||||||
window.Show();
|
window.Show();
|
||||||
|
|
||||||
|
|
@ -35,4 +32,4 @@ public class StatusBarTests
|
||||||
Assert.NotNull(message);
|
Assert.NotNull(message);
|
||||||
Assert.Equal("Échec de la suppression : boom", message!.Text);
|
Assert.Equal("Échec de la suppression : boom", message!.Text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,19 +10,16 @@
|
||||||
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 so the user can select + copy the
|
||||||
|
status message (TextBlock is not selectable in
|
||||||
|
Avalonia). Keeps Text/TextWrapping/Foreground. -->
|
||||||
<SelectableTextBlock Text="{Binding Message}"
|
<SelectableTextBlock 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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -35,19 +33,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 +53,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>();
|
||||||
|
|
@ -77,10 +67,6 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<EstimateListPage>();
|
services.AddTransient<EstimateListPage>();
|
||||||
services.AddTransient<EstimateValidationPage>();
|
services.AddTransient<EstimateValidationPage>();
|
||||||
services.AddTransient<MyFilesPage>();
|
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 +79,10 @@ 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(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>();
|
||||||
|
|
|
||||||
53
src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
Normal file
53
src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
using System.Threading.Tasks;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace PostIt;
|
|
||||||
|
|
||||||
internal interface IVMPusherApp
|
|
||||||
{
|
|
||||||
Task<Page> PushPageAsync(ViewModelBase viewModel);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
public interface IPCMProcessor : IDisposable
|
|
||||||
{
|
|
||||||
void Process(short[] audioFrame);
|
|
||||||
}
|
|
||||||
72
src/PostIt/PostIt/Lang/Resources.Designer.cs
generated
72
src/PostIt/PostIt/Lang/Resources.Designer.cs
generated
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<root>
|
|
||||||
<data name="GreetingText">
|
|
||||||
<value>Welcome to PostIt!</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<root>
|
|
||||||
<data name="GreetingText">
|
|
||||||
<value>Bemvindo em PostIt!</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<root>
|
|
||||||
<data name="GreetingText">
|
|
||||||
<value>Bienvenue dans PostIt !!!</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace PostIt.ViewModels;
|
|
||||||
|
|
||||||
internal interface IWithLabel
|
|
||||||
{
|
|
||||||
string Label { get; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
namespace PostIt.Models;
|
|
||||||
|
|
||||||
public enum StatusSeverity
|
|
||||||
{
|
|
||||||
Info,
|
|
||||||
Warning,
|
|
||||||
Error
|
|
||||||
}
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
<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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</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>
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
@ -324,7 +314,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 +379,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 +393,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 +459,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 +514,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 +603,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);
|
||||||
|
|
|
||||||
|
|
@ -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>(),
|
||||||
|
|
@ -60,11 +57,6 @@ public class ViewLocator : IDataTemplate
|
||||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||||
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
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}" }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -388,4 +388,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
QueryStatus.Success => "V",
|
QueryStatus.Success => "V",
|
||||||
_ => "i",
|
_ => "i",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}";
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,16 +24,11 @@ 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);
|
||||||
|
|
@ -66,78 +36,13 @@ public class HomePageViewModel : ViewModelBase
|
||||||
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);
|
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 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,14 +103,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>
|
/// <summary>
|
||||||
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
|
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
|
||||||
/// servie par le host Blogs via <c>api/v1/fs</c>.
|
/// servie par le host Blogs via <c>api/v1/fs</c>.
|
||||||
|
|
@ -229,79 +126,6 @@ public class HomePageViewModel : ViewModelBase
|
||||||
await app.PushPageAsync(vm);
|
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 +135,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())
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
Normal file
35
src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,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;
|
||||||
|
|
@ -227,4 +226,4 @@ public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
var idx = current.LastIndexOf('/');
|
var idx = current.LastIndexOf('/');
|
||||||
return idx < 0 ? string.Empty : current[..idx];
|
return idx < 0 ? string.Empty : current[..idx];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
namespace PostIt.Views.Chat;
|
|
||||||
public partial class ChatPage : ContentPage
|
|
||||||
{
|
|
||||||
|
|
||||||
public ChatPage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
x:Class="PostIt.Views.AboutPage"
|
|
||||||
x:DataType="vm:AboutPageViewModel"
|
|
||||||
Header="À propos">
|
|
||||||
<Design.DataContext>
|
|
||||||
<vm:AboutPageViewModel />
|
|
||||||
</Design.DataContext>
|
|
||||||
<StackPanel HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Spacing="12"
|
|
||||||
Margin="16">
|
|
||||||
<TextBlock Text="PostIt"
|
|
||||||
FontSize="28"
|
|
||||||
FontWeight="Bold"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
<TextBlock Text="{Binding Version, StringFormat='Version {0}'}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Opacity="0.8"/>
|
|
||||||
<TextBlock Text="{Binding Description}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextAlignment="Center"
|
|
||||||
MaxWidth="420"/>
|
|
||||||
<TextBlock Text="{Binding License}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextAlignment="Center"
|
|
||||||
MaxWidth="420"
|
|
||||||
Opacity="0.8"
|
|
||||||
FontStyle="Italic"/>
|
|
||||||
<Button Content="Dépôt du projet"
|
|
||||||
Command="{Binding OpenRepositoryCommand}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
<Button Content="Signaler un problème"
|
|
||||||
Command="{Binding OpenIssuesCommand}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
|
||||||
|
|
||||||
public partial class AboutPage : ContentPage
|
|
||||||
{
|
|
||||||
public AboutPage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
x:Class="PostIt.Views.AdministrationPage"
|
|
||||||
x:DataType="vm:AdministrationPageViewModel"
|
|
||||||
Header="Administration">
|
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
|
|
||||||
<TextBlock Grid.Row="0"
|
|
||||||
Text="Administration"
|
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"/>
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="1" Spacing="8">
|
|
||||||
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
|
|
||||||
PlaceholderText="Rechercher un utilisateur..."/>
|
|
||||||
<Button Content="Actualiser"
|
|
||||||
Command="{Binding RefreshCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"/>
|
|
||||||
<ProgressBar IsVisible="{Binding IsBusy}"
|
|
||||||
IsIndeterminate="True"
|
|
||||||
Height="8"/>
|
|
||||||
<TextBlock Text="{Binding StatusMessage}"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
|
|
||||||
<ListBox ItemsSource="{Binding FilteredUsers}"
|
|
||||||
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
|
|
||||||
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
|
|
||||||
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
|
||||||
|
|
||||||
public partial class AdministrationPage : ContentPage
|
|
||||||
{
|
|
||||||
public AdministrationPage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +1,19 @@
|
||||||
<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:lang="using:Yavsc.Lang"
|
|
||||||
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
|
|
||||||
x:Class="PostIt.Views.HomePage"
|
x:Class="PostIt.Views.HomePage"
|
||||||
x:DataType="vm:HomePageViewModel"
|
x:DataType="vm:HomePageViewModel"
|
||||||
Header="Home">
|
Header="Home">
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:HomePageViewModel />
|
<vm:HomePageViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
<StackPanel HorizontalAlignment="Center"
|
||||||
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
VerticalAlignment="Center"
|
||||||
HorizontalAlignment="Stretch">
|
Spacing="12">
|
||||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
<TextBlock Text="Welcome to the Home Page"
|
||||||
FontSize="22"
|
FontSize="22"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
|
||||||
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Button Grid.Column="1" Content="Actualiser"
|
|
||||||
Command="{Binding RefreshNotificationsCommand}"
|
|
||||||
IsEnabled="{Binding !IsLoadingNotifications}"/>
|
|
||||||
</Grid>
|
|
||||||
<ProgressBar IsIndeterminate="True" Height="4"
|
|
||||||
IsVisible="{Binding IsLoadingNotifications}"/>
|
|
||||||
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
|
|
||||||
<TextBlock Text="Aucune notification pour le moment."
|
|
||||||
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
|
|
||||||
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="messaging:Notification">
|
|
||||||
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
|
|
||||||
BorderThickness="1"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<Button Content="Les Salons de discussion"
|
|
||||||
Command="{Binding OpenChat}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
|
||||||
<Button Content="Open Blog Interface"
|
<Button Content="Open Blog Interface"
|
||||||
Command="{Binding OpenBlogs}"
|
Command="{Binding OpenBlogs}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|
@ -74,22 +38,5 @@
|
||||||
Command="{Binding OpenMyFiles}"
|
Command="{Binding OpenMyFiles}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Mon profil"
|
|
||||||
Command="{Binding OpenProfile}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
|
||||||
<Button Content="Mon activité / performer"
|
|
||||||
Command="{Binding OpenPerformerConfiguration}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
|
||||||
<Button Content="Administration"
|
|
||||||
Command="{Binding OpenAdministration}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
|
||||||
<Button Content="Mon usage / administration"
|
|
||||||
Command="{Binding OpenResourceUsage}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -9,12 +8,4 @@ public partial class HomePage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(e);
|
|
||||||
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
|
|
||||||
&& vm.RefreshNotificationsCommand.CanExecute(null))
|
|
||||||
_ = vm.RefreshNotificationsAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
<views:SessionStatusBanner
|
<views:SessionStatusBanner
|
||||||
x:Name="SessionBanner"
|
x:Name="SessionBanner"
|
||||||
DataContext="{Binding SessionStatus}"
|
|
||||||
DockPanel.Dock="Bottom"/>
|
DockPanel.Dock="Bottom"/>
|
||||||
<NavigationPage x:Name="NavRoot"/>
|
<NavigationPage x:Name="NavRoot"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@
|
||||||
x:Class="PostIt.Views.MainWindow"
|
x:Class="PostIt.Views.MainWindow"
|
||||||
Icon="/Assets/avalonia-logo.ico"
|
Icon="/Assets/avalonia-logo.ico"
|
||||||
Title="PostIt">
|
Title="PostIt">
|
||||||
<views:MainView />
|
<views:MainView x:Name="MainView" />
|
||||||
</Window>
|
</Window>
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
x:Class="PostIt.Views.PerformerConfigurationPage"
|
|
||||||
x:DataType="vm:PerformerConfigurationPageViewModel"
|
|
||||||
Header="Mon activité / performer">
|
|
||||||
|
|
||||||
<ScrollViewer>
|
|
||||||
<StackPanel Margin="16" Spacing="12">
|
|
||||||
<TextBlock Text="Mon activité / performer"
|
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"/>
|
|
||||||
|
|
||||||
<Button Content="Actualiser"
|
|
||||||
Command="{Binding RefreshCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"/>
|
|
||||||
|
|
||||||
<ProgressBar IsVisible="{Binding IsBusy}"
|
|
||||||
IsIndeterminate="True"
|
|
||||||
Height="8"/>
|
|
||||||
|
|
||||||
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
|
|
||||||
<TextBox Text="{Binding ExerciseCountryCode, Mode=TwoWay}"
|
|
||||||
PlaceholderText="Code pays (fr, en...)"/>
|
|
||||||
<TextBox Text="{Binding SIREN, Mode=TwoWay}"
|
|
||||||
PlaceholderText="SIREN"/>
|
|
||||||
<TextBox Text="{Binding Website, Mode=TwoWay}"
|
|
||||||
PlaceholderText="Site web"/>
|
|
||||||
|
|
||||||
<CheckBox Content="Disponible / actif"
|
|
||||||
IsChecked="{Binding Active, Mode=TwoWay}"/>
|
|
||||||
<CheckBox Content="Accepter les notifications"
|
|
||||||
IsChecked="{Binding AcceptNotifications, Mode=TwoWay}"/>
|
|
||||||
<CheckBox Content="Accepter les demandes publiques"
|
|
||||||
IsChecked="{Binding AcceptPublicContact, Mode=TwoWay}"/>
|
|
||||||
<CheckBox Content="Utiliser la géolocalisation"
|
|
||||||
IsChecked="{Binding UseGeoLocalizationToReduceDistanceWithClients, Mode=TwoWay}"/>
|
|
||||||
|
|
||||||
<TextBlock Text="Activités" FontWeight="SemiBold"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding AvailableActivities}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<CheckBox Margin="0,0,0,6"
|
|
||||||
Content="{Binding Name}"
|
|
||||||
IsChecked="{Binding IsSelected, Mode=TwoWay}"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<Button Content="Enregistrer"
|
|
||||||
Command="{Binding SaveCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"/>
|
|
||||||
|
|
||||||
<TextBlock Text="{Binding StatusMessage}"
|
|
||||||
Foreground="#4B5563"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
|
||||||
|
|
||||||
public partial class PerformerConfigurationPage : ContentPage
|
|
||||||
{
|
|
||||||
public PerformerConfigurationPage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
xmlns:resources="using:Yavsc.Abstract.Resources"
|
|
||||||
x:Class="PostIt.Views.ResourceUsagePage"
|
|
||||||
x:DataType="vm:ResourceUsagePageViewModel"
|
|
||||||
Header="Usage des ressources">
|
|
||||||
|
|
||||||
<StackPanel Margin="16" Spacing="12">
|
|
||||||
<TextBlock Text="{Binding Heading}"
|
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"/>
|
|
||||||
|
|
||||||
<Button Content="Actualiser"
|
|
||||||
Command="{Binding RefreshCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"/>
|
|
||||||
|
|
||||||
<ProgressBar IsVisible="{Binding IsBusy}"
|
|
||||||
IsIndeterminate="True"
|
|
||||||
Height="8"/>
|
|
||||||
|
|
||||||
<StackPanel IsVisible="{Binding !IsAdminView}">
|
|
||||||
<TextBlock Text="Résumé personnel" FontWeight="Bold"/>
|
|
||||||
<StackPanel DataContext="{Binding CurrentSummary}" Spacing="4">
|
|
||||||
<TextBlock Text="{Binding UserName, StringFormat='Utilisateur : {0}'}"/>
|
|
||||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
|
||||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
|
||||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
|
||||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
|
||||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Overview}"
|
|
||||||
IsVisible="{Binding IsAdminView}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="resources:ResourceUsageSummary">
|
|
||||||
<Border Margin="0,0,0,8"
|
|
||||||
Padding="12"
|
|
||||||
CornerRadius="8"
|
|
||||||
BorderBrush="#D0D7DE"
|
|
||||||
BorderThickness="1">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="{Binding UserName}"
|
|
||||||
FontWeight="Bold"/>
|
|
||||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
|
||||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
|
||||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
|
||||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
|
||||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
|
||||||
|
|
||||||
public partial class ResourceUsagePage : ContentPage
|
|
||||||
{
|
|
||||||
public ResourceUsagePage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,9 +28,6 @@
|
||||||
<Button Content="Paramètres"
|
<Button Content="Paramètres"
|
||||||
Command="{Binding OpenSettingsCommand}"
|
Command="{Binding OpenSettingsCommand}"
|
||||||
DockPanel.Dock="Right"/>
|
DockPanel.Dock="Right"/>
|
||||||
<Button Content="À propos"
|
|
||||||
Command="{Binding OpenAboutCommand}"
|
|
||||||
DockPanel.Dock="Right"/>
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue