diff --git a/.env.sample b/.env.sample index 37048155..7655552f 100644 --- a/.env.sample +++ b/.env.sample @@ -1,18 +1,6 @@ -APP_NAME=Yavsc -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=yavsc -POSTGRES_USER=yavsc -POSTGRES_PASSWORD=lame-YAVSC_CONNECTION_PASSWORD -ASPNETCORE_ConnectionStrings__YavscConnection=Server=$POSTGRES_HOST;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD; ANTHROPIC_API_KEY= MODERATION_AUTO_REJECT_THRESHOLD=0.9 MODERATION_AUTO_APPROVE_THRESHOLD=0.7 # Limite par appel ANTHROPIC_MAX_TOKENS=256 # la modération n'a pas besoin de plus - -YAVSC_API_HOST=127.0.0.1 -YAVSC_API_PORT=6001 - -ASPNETCORE_Kestrel__Endpoints__Https__Url="https://$YAVSC_API_HOST:$YAVSC_API_PORT" diff --git a/.vscode/launch.json b/.vscode/launch.json index 18027dd1..4cb9832b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -64,7 +64,7 @@ "env": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "envFile": "${workspaceFolder}/src/Yavsc.Org/.env", + "envFile": "${workspaceFolder}/.env", "sourceFileMap": { "/Views": "${workspaceFolder}/src/Yavsc.Org/Views" } diff --git a/src/PostIt/PostIt/Models/BlogPost.cs b/src/PostIt/PostIt/Models/BlogPost.cs new file mode 100644 index 00000000..7867eb02 --- /dev/null +++ b/src/PostIt/PostIt/Models/BlogPost.cs @@ -0,0 +1,16 @@ +using System; + +namespace PostIt.Models; + +public class BlogPost +{ + public long Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Article { get; set; } + public string? Photo { get; set; } + public string? AuthorId { get; set; } + public DateTime DateCreated { get; set; } + public string? UserCreated { get; set; } + public DateTime DateModified { get; set; } + public string? UserModified { get; set; } +} diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs new file mode 100644 index 00000000..7ce7a9e4 --- /dev/null +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using PostIt.Models; + +namespace PostIt.Services; + +public sealed class BlogApiClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _serializerOptions; + + public BlogApiClient(string baseUrl, string? bearerToken = null) + : this(CreateHttpClient(baseUrl, bearerToken)) + { + } + + public BlogApiClient(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + } + + private static HttpClient CreateHttpClient(string baseUrl, string? bearerToken) + { + var client = new HttpClient + { + BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") + }; + + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken.Trim()); + } + + return client; + } + + public async Task> GetPostsAsync(int start = 0, int take = 25) + { + var result = await _httpClient.GetFromJsonAsync>($"api/blog?start={start}&take={take}", _serializerOptions).ConfigureAwait(false); + return result ?? new List(); + } + + public Task GetPostAsync(long id) + => _httpClient.GetFromJsonAsync($"api/blog/{id}", _serializerOptions); + + public async Task CreatePostAsync(BlogPost post) + { + var response = await _httpClient.PostAsJsonAsync("api/blog", post, _serializerOptions).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_serializerOptions).ConfigureAwait(false); + } + + public async Task UpdatePostAsync(long id, BlogPost post) + { + var response = await _httpClient.PutAsJsonAsync($"api/blog/{id}", post, _serializerOptions).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public async Task DeletePostAsync(long id) + { + var response = await _httpClient.DeleteAsync($"api/blog/{id}").ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 68c58f93..38be6e34 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -1,9 +1,216 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Models; +using PostIt.Services; namespace PostIt.ViewModels; public partial class MainViewModel : ViewModelBase { [ObservableProperty] - private string _greeting = "Welcome to Avalonia!"; + private string _apiUrl = "http://localhost:5000"; + + [ObservableProperty] + private string _searchText = string.Empty; + + [ObservableProperty] + private string? _bearerToken; + + [ObservableProperty] + private ObservableCollection _posts = new(); + + [ObservableProperty] + private ObservableCollection _filteredPosts = new(); + + [ObservableProperty] + private BlogPost? _selectedPost; + + [ObservableProperty] + private string _statusMessage = "Ready"; + + [ObservableProperty] + private bool _isBusy; + + public MainViewModel() + { + } + + partial void OnSearchTextChanged(string value) + { + ApplyFilter(); + } + + partial void OnSelectedPostChanged(BlogPost? value) + { + UpdateCommandStates(); + } + + partial void OnIsBusyChanged(bool value) + { + UpdateCommandStates(); + } + + [RelayCommand] + public async Task LoadPostsAsync() + { + await ExecuteAsync(async () => + { + using var client = CreateClient(); + var posts = await client.GetPostsAsync(); + Posts.Clear(); + foreach (var post in posts.OrderByDescending(p => p.DateModified)) + { + Posts.Add(post); + } + + ApplyFilter(); + StatusMessage = $"Loaded {Posts.Count} posts."; + }); + } + + [RelayCommand] + public void Search() + { + ApplyFilter(); + } + + [RelayCommand] + public async Task SaveAsync() + { + if (SelectedPost is null) + { + StatusMessage = "A post must be selected before saving."; + return; + } + + await ExecuteAsync(async () => + { + using var client = CreateClient(); + + if (SelectedPost.Id == 0) + { + SelectedPost.DateCreated = DateTime.UtcNow; + SelectedPost.DateModified = DateTime.UtcNow; + var created = await client.CreatePostAsync(SelectedPost); + if (created is not null) + { + SelectedPost = created; + StatusMessage = $"Created post {created.Id}."; + } + } + else + { + SelectedPost.DateModified = DateTime.UtcNow; + await client.UpdatePostAsync(SelectedPost.Id, SelectedPost); + StatusMessage = $"Saved post {SelectedPost.Id}."; + } + + await RefreshPostsAsync(); + }); + } + + [RelayCommand] + public async Task DeleteAsync() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Select an existing post before deleting."; + return; + } + + await ExecuteAsync(async () => + { + using var client = CreateClient(); + await client.DeletePostAsync(SelectedPost.Id); + StatusMessage = $"Deleted post {SelectedPost.Id}."; + SelectedPost = null; + await RefreshPostsAsync(); + }); + } + + [RelayCommand] + public void New() + { + SelectedPost = new BlogPost + { + Title = string.Empty, + Article = string.Empty, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + StatusMessage = "New blog post ready."; + } + + private async Task RefreshPostsAsync() + { + using var client = CreateClient(); + var posts = await client.GetPostsAsync(); + Posts.Clear(); + foreach (var post in posts.OrderByDescending(p => p.DateModified)) + { + Posts.Add(post); + } + + ApplyFilter(); + + if (SelectedPost is not null) + { + SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost; + } + } + + private void ApplyFilter() + { + var query = SearchText?.Trim(); + var filtered = string.IsNullOrWhiteSpace(query) + ? Posts.OrderByDescending(p => p.DateModified) + : Posts.Where(p => p.Title?.Contains(query, StringComparison.OrdinalIgnoreCase) == true + || p.Article?.Contains(query, StringComparison.OrdinalIgnoreCase) == true + || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) + .OrderByDescending(p => p.DateModified); + + FilteredPosts.Clear(); + foreach (var post in filtered) + { + FilteredPosts.Add(post); + } + } + + private async Task ExecuteAsync(Func action) + { + try + { + IsBusy = true; + StatusMessage = "Working..."; + await action(); + } + catch (Exception ex) + { + StatusMessage = $"Error: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private BlogApiClient CreateClient() + => new BlogApiClient(ApiUrl, BearerToken); + + private void UpdateCommandStates() + { + LoadPostsCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); + DeleteCommand.NotifyCanExecuteChanged(); + NewCommand.NotifyCanExecuteChanged(); + } + + private bool CanSave() => SelectedPost is not null && !IsBusy; + private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; } diff --git a/src/PostIt/PostIt/Views/MainView.axaml b/src/PostIt/PostIt/Views/MainView.axaml index 19bf7eb4..9b69880f 100644 --- a/src/PostIt/PostIt/Views/MainView.axaml +++ b/src/PostIt/PostIt/Views/MainView.axaml @@ -3,26 +3,68 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:PostIt.ViewModels" + xmlns:models="using:PostIt.Models" xmlns:views="using:PostIt.Views" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600" x:Class="PostIt.Views.MainView" x:DataType="vm:MainViewModel"> - - - - + + + + + + + + + + + + + + +