postit and them also
This commit is contained in:
parent
de616809ae
commit
fa7d6242f1
18 changed files with 5322 additions and 121 deletions
16
src/PostIt/PostIt/Models/BlogPost.cs
Normal file
16
src/PostIt/PostIt/Models/BlogPost.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
78
src/PostIt/PostIt/Services/BlogApiClient.cs
Normal file
78
src/PostIt/PostIt/Services/BlogApiClient.cs
Normal file
|
|
@ -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<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25)
|
||||
{
|
||||
var result = await _httpClient.GetFromJsonAsync<List<BlogPost>>($"api/blog?start={start}&take={take}", _serializerOptions).ConfigureAwait(false);
|
||||
return result ?? new List<BlogPost>();
|
||||
}
|
||||
|
||||
public Task<BlogPost?> GetPostAsync(long id)
|
||||
=> _httpClient.GetFromJsonAsync<BlogPost>($"api/blog/{id}", _serializerOptions);
|
||||
|
||||
public async Task<BlogPost?> CreatePostAsync(BlogPost post)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/blog", post, _serializerOptions).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<BlogPost>(_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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BlogPost> _posts = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<BlogPost> _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<Task> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<Design.DataContext>
|
||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
||||
<vm:MainViewModel />
|
||||
</Design.DataContext>
|
||||
<StackPanel>
|
||||
|
||||
<Label>Hello</Label>
|
||||
<AvaloniaEdit:TextEditor
|
||||
ShowLineNumbers="True"
|
||||
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
|
||||
Background="AliceBlue"
|
||||
Foreground="Black"
|
||||
Watermark="Hit me strong!"
|
||||
/>
|
||||
<StackPanel Margin="12" Spacing="12">
|
||||
<TextBlock Text="PostIt Blog API Interface" FontSize="20" FontWeight="Bold" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,1*" RowDefinitions="Auto,Auto,Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||
<TextBlock Text="API URL" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding ApiUrl, Mode=TwoWay}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Bearer token" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding BearerToken, Mode=TwoWay}" PlaceholderText="Optional token for blog scope" />
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Search" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Search title, article, author" />
|
||||
|
||||
<StackPanel Grid.Row="3" Grid.ColumnSpan="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Command="{Binding LoadPostsCommand}" Content="Load posts" />
|
||||
<Button Command="{Binding SearchCommand}" Content="Filter" />
|
||||
<Button Command="{Binding NewCommand}" Content="New post" />
|
||||
<Button Command="{Binding SaveCommand}" Content="Save" />
|
||||
<Button Command="{Binding DeleteCommand}" Content="Delete" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="2*,3*" RowDefinitions="*" ColumnSpacing="12">
|
||||
<Border BorderBrush="Gray" BorderThickness="1" Padding="8">
|
||||
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:BlogPost">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
|
||||
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<Border BorderBrush="Gray" BorderThickness="1" Padding="8">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Post detail" FontWeight="SemiBold" />
|
||||
<TextBox Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" />
|
||||
<TextBox Text="{Binding SelectedPost.AuthorId, Mode=TwoWay}" PlaceholderText="Author id" />
|
||||
<AvaloniaEdit:TextEditor
|
||||
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}"
|
||||
ShowLineNumbers="True"
|
||||
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
|
||||
Height="320"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue