diff --git a/contrib/.env-sample b/contrib/.env-sample new file mode 100644 index 00000000..fb01ede4 --- /dev/null +++ b/contrib/.env-sample @@ -0,0 +1,24 @@ +# parametres de déploiement au Makefile + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=yavsc +POSTGRES_USER=yavsc +POSTGRES_PASSWORD= + +HTTP_HOST=localhost + +Org_PORT=83 +Blogs_PORT=85 +Api_PORT=87 + +PostIt_CLIENT_ID=postit + +ASPNETCORE_Smtp__Host="mercure.pschneider.fr" +ASPNETCORE_Smtp__Port=465 +ASPNETCORE_Smtp__SenderName="Paul Schneider" +ASPNETCORE_Smtp__SenderEmail="paul@pschneider.fr" +ASPNETCORE_Smtp__UserName="paul" +ASPNETCORE_Smtp__Password="" + +DESTDIR=/srv/www/yavsc diff --git a/contrib/Makefile b/contrib/Makefile index 151045db..79145668 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,4 +1,4 @@ -APP_PROJECT_NAMES=Org Blogs +APP_PROJECT_NAMES=Org Blogs Api SLNDIR=.. include $(SLNDIR)/.env @@ -9,9 +9,11 @@ generated/: generated/yavscOrg.service: generated/yavscBlogs.service: +generated/yavscApi.service: generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @cat template.service | APP_NAME="$*" \ + DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ HTTP_PORT="$*_$(HTTP_PORT)" \ BASEAPPDIR="$(BASEAPPDIR)" \ @@ -33,11 +35,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @echo Created service file: $@ -copy-services: copy-service-Org copy-service-Blogs +copy-services: copy-service-Org copy-service-Blogs copy-service-Api copy-service-Org: /etc/systemd/system/yavscOrg.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service +copy-service-Api: /etc/systemd/system/yavscApi.service -copy-binaries: build_publish_Org build_publish_Blogs stop-services +copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services @for project in $(APP_PROJECT_NAMES); \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ echo "$${project} -> $${LCAPI}" ; \ @@ -60,6 +63,8 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services build_publish_%: clean_publish_dir_% @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj +build_publish: build_publish_Org build_publish_Blogs build_publish_Api + clean_publish_dir_%: @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @@ -84,6 +89,7 @@ stop-services: $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish +$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish showConfig: @echo CONFIGURATION: $(CONFIGURATION) @@ -92,4 +98,3 @@ showConfig: clean: @rm -rf generated -.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean diff --git a/contrib/tmp/yavscBlogs.service b/contrib/tmp/yavscBlogs.service new file mode 100644 index 00000000..718534cd --- /dev/null +++ b/contrib/tmp/yavscBlogs.service @@ -0,0 +1,37 @@ +[Unit] +Description=yavsc-Blogs +After=syslog.target +After=network.target +Wants=postgresql.service +After=postgresql.service + +[Service] +RestartSec=5s +Type=simple +User=yavsc +Group=yavsc +WorkingDirectory=/srv/www/yavsc +ExecStart=/srv/www/yavsc/Yavsc.Blogs +Restart=always +Environment="HOME=" +Environment="ANTHROPIC_API_KEY=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" +Environment="ANTHROPIC_MAX_TOKENS=255" +Environment="ASPNETCORE_Environment=" +Environment="ASPNETCORE_Kestrel__Endpoints__Http=http://localhost:Blogs_" +Environment="ASPNETCORE_ConnectionStrings__YavscConnection=Server=localhost;Port=5432;Database=yavsc;Username=yavsc;Password=4T/X+fOnE;" + +Environment="ASPNETCORE_Smtp__Host=\"mercure.pschneider.f\"" +Environment="ASPNETCORE_Smtp__Port=465" +Environment="ASPNETCORE_Smtp__SenderName=\"Paul Schneider\"" +Environment="ASPNETCORE_Smtp__SenderEmail=\"paul@pschneider.fr\"" +Environment="ASPNETCORE_Smtp__UserName=\"paul\"" +Environment="ASPNETCORE_Smtp__Password=\"j\0Dsn5=t\"" + +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=yavscBlogs + +[Install] +WantedBy=multi-user.target diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs new file mode 100644 index 00000000..124777e0 --- /dev/null +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -0,0 +1,113 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class ActivitiesPageViewModelTests +{ + [Fact] + public async Task ActivityApiClient_uses_business_absolute_paths() + { + var api = new StubActivityApi(); + var client = new ActivityApiClient(api, "https://business.example/api/v1/"); + + await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken); + await client.GetPerformersAsync("brush-pro", TestContext.Current.CancellationToken); + + Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]); + Assert.Equal("https://business.example/api/v1/activity/brush-pro/performers", api.Paths[1]); + } + + [Fact] + public async Task RefreshAsync_loads_first_activity_then_specialization_performers() + { + var api = new StubActivityApi(); + var client = new ActivityApiClient(api, "https://business.example/api/v1/"); + var vm = new ActivitiesPageViewModel(client); + + await vm.RefreshAsync(); + + Assert.Equal("brush", vm.SelectedActivity?.Code); + Assert.Single(vm.Specializations); + Assert.Equal("brush", vm.CurrentActivity?.Code); + Assert.Single(vm.Performers); + Assert.Equal("Alice", vm.Performers[0].UserName); + + await vm.ShowSpecializationAsync(vm.Specializations[0]); + + Assert.Equal("brush-pro", vm.CurrentActivity?.Code); + Assert.Single(vm.Performers); + Assert.Equal("Bob", vm.Performers[0].UserName); + Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + + await vm.ShowSpecializationAsync(null); + + Assert.Equal("brush", vm.CurrentActivity?.Code); + Assert.Single(vm.Performers); + Assert.Equal("Alice", vm.Performers[0].UserName); + } + + private sealed class StubActivityApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var activities = new List + { + new() + { + Code = "brush", + Name = "Brush", + Description = "Coiffure à domicile", + PerformerCount = 1, + Children = new List + { + new() + { + Code = "brush-pro", + Name = "Brush Pro", + Description = "Spécialisation premium", + ParentCode = "brush", + PerformerCount = 1, + } + } + } + }; + return Task.FromResult((T)(object)activities); + } + + if (typeof(T) == typeof(List)) + { + var performers = path.EndsWith("brush-pro/performers", StringComparison.Ordinal) + ? new List + { + new() { PerformerId = "pro-2", UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro" } + } + : new List + { + new() { PerformerId = "pro-1", UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush" } + }; + + return Task.FromResult((T)(object)performers); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/SignaturePadControlTests.cs b/src/PostIt/PostIt.Tests/SignaturePadControlTests.cs index c03e6850..ff1233e9 100644 --- a/src/PostIt/PostIt.Tests/SignaturePadControlTests.cs +++ b/src/PostIt/PostIt.Tests/SignaturePadControlTests.cs @@ -94,6 +94,26 @@ public class SignaturePadControlTests Assert.NotEqual(first.Strokes, third.Strokes); } + [Fact] + public void PendingStroke_is_exposed_only_while_capturing() + { + var pad = new SignaturePadControl(); + + Assert.Empty(pad.PendingStroke); + + pad.BeginCaptureForTest(); + pad.AppendPointForTest(1_000, 2_000); + pad.AppendPointForTest(3_000, 4_000); + + Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.PendingStroke); + Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.Strokes); + + pad.SealStrokeForTest(); + + Assert.Empty(pad.PendingStroke); + Assert.Equal(new[] { 2, 1_000, 2_000, 3_000, 4_000 }, pad.Strokes); + } + [Fact] public void Clear_empties_buffer_and_raises_redraw() { diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs index 3a552a05..dee8af36 100644 --- a/src/PostIt/PostIt/Controls/SignaturePadControl.cs +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -212,6 +212,17 @@ public class SignaturePadControl : TemplatedControl _pendingPoints++; } + /// + /// Test hook: mark the control as actively capturing so tests + /// can exercise the live-preview path without synthetic pointer + /// events. + /// + internal void BeginCaptureForTest() + { + _capturing = true; + _pendingPoints = 0; + } + /// /// Test hook: seal the currently-pending stroke with a length /// prefix. Mirrors what does at diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 5f19292a..98b6efb3 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -23,6 +23,7 @@ public static class ServiceCollectionHelpers var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl); var userDirectory = new UserDirectory(userSearchClient); // Vues @@ -45,6 +46,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // ViewModels services.AddSingleton(settings); services.AddSingleton(api); @@ -52,10 +54,12 @@ public static class ServiceCollectionHelpers services.AddSingleton(circleClient); services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); + services.AddSingleton(activityClient); services.AddSingleton(userDirectory); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Dialogs (modal-light pages): the ViewLocator resolves // them when a caller pushes a PostAclDialogViewModel or diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 3543a9c9..a81bf6ce 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -39,6 +39,7 @@ public class ViewLocator : IDataTemplate MainViewModel => services.GetRequiredService(), Settings => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), + ActivitiesPageViewModel => services.GetRequiredService(), SignaturePageViewModel => services.GetRequiredService(), AddCircleMemberDialogViewModel => services.GetRequiredService(), CirclesPageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs new file mode 100644 index 00000000..57e387c9 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class ActivitiesPageViewModel : ViewModelBase +{ + private readonly ActivityApiClient _client; + private bool _syncingSelection; + + [ObservableProperty] + public partial ObservableCollection Activities { get; set; } = new(); + + [ObservableProperty] + public partial ActivityBrowseItemDto? SelectedActivity { get; set; } + + [ObservableProperty] + public partial ObservableCollection Specializations { get; set; } = new(); + + [ObservableProperty] + public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; } + + [ObservableProperty] + public partial ObservableCollection Performers { get; set; } = new(); + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Choisissez une activité."; + + public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity; + public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)"; + public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)"; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public ActivitiesPageViewModel(ActivityApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value) + { + if (_syncingSelection) return; + _ = ShowActivitySafeAsync(value); + } + + partial void OnSelectedSpecializationChanged(ActivityBrowseItemDto? value) + { + if (_syncingSelection) return; + _ = ShowSpecializationSafeAsync(value); + } + + private async Task ShowActivitySafeAsync(ActivityBrowseItemDto? value) + { + try + { + await ShowActivityAsync(value); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + } + + private async Task ShowSpecializationSafeAsync(ActivityBrowseItemDto? value) + { + try + { + await ShowSpecializationAsync(value); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + } + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var list = await _client.GetCatalogAsync(); + Activities = new ObservableCollection(list ?? new()); + + var first = Activities.FirstOrDefault(); + await ShowActivityAsync(first); + if (first is null) + { + StatusMessage = "Aucune activité disponible."; + } + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + Activities = new ObservableCollection(); + Specializations = new ObservableCollection(); + Performers = new ObservableCollection(); + StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + Activities = new ObservableCollection(); + Specializations = new ObservableCollection(); + Performers = new ObservableCollection(); + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + public async Task ShowActivityAsync(ActivityBrowseItemDto? activity) + { + _syncingSelection = true; + try + { + SelectedActivity = activity; + SelectedSpecialization = null; + } + finally + { + _syncingSelection = false; + } + + OnPropertyChanged(nameof(CurrentActivity)); + OnPropertyChanged(nameof(SelectedActivityLabel)); + OnPropertyChanged(nameof(CurrentActivityLabel)); + Specializations = new ObservableCollection(activity?.Children ?? new()); + + if (activity is null) + { + Performers = new ObservableCollection(); + return; + } + + await LoadPerformersAsync(activity); + } + + public async Task ShowSpecializationAsync(ActivityBrowseItemDto? specialization) + { + _syncingSelection = true; + try + { + SelectedSpecialization = specialization; + } + finally + { + _syncingSelection = false; + } + + OnPropertyChanged(nameof(CurrentActivity)); + OnPropertyChanged(nameof(CurrentActivityLabel)); + + if (specialization is null) + { + if (SelectedActivity is not null) + { + await LoadPerformersAsync(SelectedActivity); + } + return; + } + + await LoadPerformersAsync(specialization); + } + + private async Task LoadPerformersAsync(ActivityBrowseItemDto activity) + { + IsBusy = true; + try + { + var list = await _client.GetPerformersAsync(activity.Code); + Performers = new ObservableCollection(list ?? new()); + StatusMessage = $"{activity.Name} · {Performers.Count} prestataire(s)"; + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + Performers = new ObservableCollection(); + StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous."; + } + catch (Exception ex) + { + Performers = new ObservableCollection(); + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs index 5d727729..cfd30650 100644 --- a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs @@ -1,4 +1,9 @@ +using System; +using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; namespace PostIt.ViewModels; @@ -24,8 +29,25 @@ public class HomePageViewModel : ViewModelBase Settings = settings; SessionStatus = sessionStatus; + OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); + } - public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync()); + public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushMainPageAsync); + public IAsyncRelayCommand OpenActivities { get; } + + private async Task OpenActivitiesAsync() + { + var app = (App?)Application.Current; + var vm = app?.ServiceProvider?.GetRequiredService(); + if (app is null || vm is null) + { + throw new InvalidOperationException("Activities page is not available."); + } + + await vm.RefreshAsync(); + await app.PushPageAsync(vm); + } + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index f942249b..0c7a040b 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; [ObservableProperty] - public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/"; + public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; [ObservableProperty] public partial string SearchText { get; set; } = string.Empty; @@ -154,7 +154,10 @@ public partial class Settings : ViewModelBase { "openid", // OIDC: required for the id_token "profile", // OIDC: standard profile claims - "offline_access" // OIDC: required to receive a refresh_token + "offline_access", // OIDC: required to receive a refresh_token + "blogs", + "api" + }; /// @@ -297,8 +300,15 @@ public partial class Settings : ViewModelBase // → our overridden dispatcher-safe marshaller below. else lock (_mutationGate) { + var legacyApiUrl = TryReadLegacyApiUrl(json); this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; + this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl) + ? settings.BlogsApiUrl + : legacyApiUrl ?? this.BlogsApiUrl; + this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl) + ? settings.BusinessApiUrl + : this.BusinessApiUrl; this.SearchText = settings.SearchText ?? string.Empty; if (!(settings.Authentication is null)) { @@ -343,6 +353,26 @@ public partial class Settings : ViewModelBase } } + private static string? TryReadLegacyApiUrl(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl) + && apiUrl.ValueKind == JsonValueKind.String) + { + return apiUrl.GetString(); + } + } + catch + { + // Ignore legacy payload parse errors: normal deserialization + // already reports actionable diagnostics to the caller. + } + + return null; + } + private void UseDefaultSettings() { this.Authentication = new AuthenticationSettings @@ -353,6 +383,8 @@ public partial class Settings : ViewModelBase Scopes = AuthenticationSettings.DefaultScopes }; this.DarkMode = false; + this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/"; + this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/"; this.SearchText = string.Empty; } diff --git a/src/PostIt/PostIt/Views/ActivitiesPage.axaml b/src/PostIt/PostIt/Views/ActivitiesPage.axaml new file mode 100644 index 00000000..03248a0e --- /dev/null +++ b/src/PostIt/PostIt/Views/ActivitiesPage.axaml @@ -0,0 +1,79 @@ + + + +