upload
This commit is contained in:
66
Views/ArticleEditorWindow.xaml
Normal file
66
Views/ArticleEditorWindow.xaml
Normal file
@@ -0,0 +1,66 @@
|
||||
<Window x:Class="SimpleWiki.Views.ArticleEditorWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Редактор статьи"
|
||||
Width="1320"
|
||||
Height="860"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource BaseBrush}">
|
||||
<Border Background="{StaticResource BaseBrush}" Padding="20">
|
||||
<Grid Background="{StaticResource BaseBrush}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="420" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Style="{StaticResource CardBorder}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Hidden">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource PageTitle}" Text="Редактор статьи" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="Markdown поддерживается." />
|
||||
|
||||
<TextBlock Text="Заголовок" />
|
||||
<TextBox x:Name="TitleTextBox" MaxLength="150" TextChanged="AnyInput_TextChanged" />
|
||||
|
||||
<TextBlock Text="Краткое описание" />
|
||||
<TextBox x:Name="SummaryTextBox" MaxLength="400" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Height="120" TextChanged="AnyInput_TextChanged" />
|
||||
|
||||
<TextBlock Text="Markdown" />
|
||||
<TextBox x:Name="MarkdownTextBox"
|
||||
MaxLength="20000"
|
||||
AcceptsReturn="True"
|
||||
VerticalScrollBarVisibility="Hidden"
|
||||
HorizontalScrollBarVisibility="Hidden"
|
||||
TextWrapping="Wrap"
|
||||
Height="420"
|
||||
Background="{StaticResource CrustBrush}"
|
||||
TextChanged="AnyInput_TextChanged" />
|
||||
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="Сохранить" Click="SaveButton_Click" />
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Отмена" Click="CancelButton_Click" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource BaseBrush}">
|
||||
<GridSplitter Width="8" HorizontalAlignment="Center" VerticalAlignment="Stretch" Margin="4,0" />
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Style="{StaticResource CardBorder}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Style="{StaticResource SectionTitle}" Text="Предпросмотр" />
|
||||
<Border Grid.Row="1" Margin="0,16,0,0" Background="{StaticResource CrustBrush}" BorderBrush="{StaticResource Surface1Brush}" BorderThickness="1" CornerRadius="12" ClipToBounds="True">
|
||||
<Grid Background="{StaticResource CrustBrush}">
|
||||
<WebBrowser x:Name="PreviewBrowser" Margin="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
147
Views/ArticleEditorWindow.xaml.cs
Normal file
147
Views/ArticleEditorWindow.xaml.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SimpleWiki.Data;
|
||||
using SimpleWiki.Models;
|
||||
using SimpleWiki.Services;
|
||||
|
||||
namespace SimpleWiki.Views;
|
||||
|
||||
public partial class ArticleEditorWindow : Window
|
||||
{
|
||||
private readonly User _currentUser;
|
||||
private readonly int? _articleId;
|
||||
private bool _isPreviewBrowserReady;
|
||||
private string _pendingPreviewHtml = string.Empty;
|
||||
|
||||
public ArticleEditorWindow(User currentUser, int? articleId = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_currentUser = currentUser;
|
||||
_articleId = articleId;
|
||||
_pendingPreviewHtml = MarkdownService.ToHtmlDocument("Предпросмотр", "# Предпросмотр\n\nПодготовка предпросмотра...");
|
||||
Loaded += ArticleEditorWindow_Loaded;
|
||||
ContentRendered += ArticleEditorWindow_ContentRendered;
|
||||
}
|
||||
|
||||
|
||||
private void ArticleEditorWindow_ContentRendered(object? sender, EventArgs e)
|
||||
{
|
||||
_isPreviewBrowserReady = true;
|
||||
Dispatcher.BeginInvoke(RenderPreviewIfReady, DispatcherPriority.ApplicationIdle);
|
||||
}
|
||||
|
||||
private void ArticleEditorWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_articleId.HasValue)
|
||||
{
|
||||
using var db = new AppDbContext();
|
||||
var article = db.Articles.AsNoTracking().FirstOrDefault(x => x.Id == _articleId.Value);
|
||||
if (article is not null)
|
||||
{
|
||||
TitleTextBox.Text = article.Title;
|
||||
SummaryTextBox.Text = article.Summary;
|
||||
MarkdownTextBox.Text = article.MarkdownContent;
|
||||
}
|
||||
}
|
||||
|
||||
UpdatePreview();
|
||||
}
|
||||
|
||||
private void AnyInput_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||
{
|
||||
UpdatePreview();
|
||||
}
|
||||
|
||||
private void UpdatePreview()
|
||||
{
|
||||
var title = string.IsNullOrWhiteSpace(TitleTextBox.Text) ? "Новая статья" : TitleTextBox.Text.Trim();
|
||||
var content = string.IsNullOrWhiteSpace(MarkdownTextBox.Text)
|
||||
? "# Предпросмотр\n\nЗдесь появится отрендеренный markdown."
|
||||
: MarkdownTextBox.Text;
|
||||
|
||||
_pendingPreviewHtml = MarkdownService.ToHtmlDocument(title, content);
|
||||
RenderPreviewIfReady();
|
||||
}
|
||||
|
||||
private void RenderPreviewIfReady()
|
||||
{
|
||||
if (!_isPreviewBrowserReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PreviewBrowser.NavigateToString(_pendingPreviewHtml);
|
||||
}
|
||||
|
||||
private void SaveButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var titleError = InputValidator.ValidateArticleTitle(TitleTextBox.Text);
|
||||
var summaryError = InputValidator.ValidateArticleSummary(SummaryTextBox.Text);
|
||||
var markdownError = InputValidator.ValidateArticleMarkdown(MarkdownTextBox.Text);
|
||||
var errors = InputValidator.JoinErrors(titleError, summaryError, markdownError);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errors))
|
||||
{
|
||||
MessageBox.Show(errors, "Ошибка валидации", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var slug = SlugHelper.Generate(TitleTextBox.Text);
|
||||
|
||||
if (_articleId.HasValue)
|
||||
{
|
||||
var article = db.Articles.FirstOrDefault(x => x.Id == _articleId.Value);
|
||||
if (article is null)
|
||||
{
|
||||
MessageBox.Show("Статья не найдена.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var duplicate = db.Articles.AsNoTracking().Any(x => x.Slug == slug && x.Id != article.Id);
|
||||
if (duplicate)
|
||||
{
|
||||
MessageBox.Show("Статья с таким заголовком уже существует.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
article.Title = TitleTextBox.Text.Trim();
|
||||
article.Summary = SummaryTextBox.Text.Trim();
|
||||
article.MarkdownContent = MarkdownTextBox.Text.Trim();
|
||||
article.Slug = slug;
|
||||
article.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
var duplicate = db.Articles.AsNoTracking().Any(x => x.Slug == slug);
|
||||
if (duplicate)
|
||||
{
|
||||
MessageBox.Show("Статья с таким заголовком уже существует.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var article = new Article
|
||||
{
|
||||
Title = TitleTextBox.Text.Trim(),
|
||||
Summary = SummaryTextBox.Text.Trim(),
|
||||
MarkdownContent = MarkdownTextBox.Text.Trim(),
|
||||
Slug = slug,
|
||||
AuthorId = _currentUser.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Articles.Add(article);
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
35
Views/ForgotPasswordWindow.xaml
Normal file
35
Views/ForgotPasswordWindow.xaml
Normal file
@@ -0,0 +1,35 @@
|
||||
<Window x:Class="SimpleWiki.Views.ForgotPasswordWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
PreviewTextInput="Window_PreviewTextInput"
|
||||
Title="Simple Wiki - Восстановление доступа"
|
||||
Width="560"
|
||||
Height="760"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BaseBrush}">
|
||||
<Border Background="{StaticResource BaseBrush}" Padding="28">
|
||||
<Grid Background="{StaticResource BaseBrush}">
|
||||
<Border Style="{StaticResource CardBorder}" Padding="28">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource PageTitle}" Text="Восстановление пароля" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="SMTP нужен." />
|
||||
|
||||
<TextBlock Text="Email" />
|
||||
<TextBox x:Name="EmailTextBox" MaxLength="256" />
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="Отправить код" Click="SendCodeButton_Click" />
|
||||
|
||||
<TextBlock Margin="0,18,0,0" Text="Код подтверждения" />
|
||||
<TextBox x:Name="CodeTextBox" MaxLength="6" />
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Проверить код" Click="VerifyCodeButton_Click" />
|
||||
|
||||
<TextBlock Margin="0,18,0,0" Text="Новый пароль" />
|
||||
<PasswordBox x:Name="NewPasswordBox" MaxLength="64" />
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="Сменить пароль" Click="ChangePasswordButton_Click" />
|
||||
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Назад ко входу" Click="BackButton_Click" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
116
Views/ForgotPasswordWindow.xaml.cs
Normal file
116
Views/ForgotPasswordWindow.xaml.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SimpleWiki.Data;
|
||||
using SimpleWiki.Services;
|
||||
|
||||
namespace SimpleWiki.Views;
|
||||
|
||||
public partial class ForgotPasswordWindow : Window
|
||||
{
|
||||
private string? _sentCode;
|
||||
private bool _isCodeVerified;
|
||||
|
||||
public ForgotPasswordWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void SendCodeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var emailError = InputValidator.ValidateEmail(EmailTextBox.Text);
|
||||
if (!string.IsNullOrWhiteSpace(emailError))
|
||||
{
|
||||
MessageBox.Show(emailError, "Ошибка валидации", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var email = EmailTextBox.Text.Trim().ToLowerInvariant();
|
||||
var exists = db.Users.AsNoTracking().Any(x => x.Email.ToLower() == email);
|
||||
if (!exists)
|
||||
{
|
||||
MessageBox.Show("Пользователь с таким email не найден.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_sentCode = EmailService.SendPasswordResetCode(email);
|
||||
_isCodeVerified = false;
|
||||
MessageBox.Show("Код отправлен на email.", "Успех", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(ex.Message, "Ошибка отправки", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void VerifyCodeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var codeError = InputValidator.ValidateResetCode(CodeTextBox.Text);
|
||||
if (!string.IsNullOrWhiteSpace(codeError))
|
||||
{
|
||||
MessageBox.Show(codeError, "Ошибка валидации", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_isCodeVerified = !string.IsNullOrWhiteSpace(_sentCode) && CodeTextBox.Text.Trim() == _sentCode;
|
||||
|
||||
if (_isCodeVerified)
|
||||
{
|
||||
MessageBox.Show("Код подтверждён.", "Успех", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show("Неверный код.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ChangePasswordButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_isCodeVerified)
|
||||
{
|
||||
MessageBox.Show("Сначала подтвердите код из письма.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var passwordError = InputValidator.ValidatePassword(NewPasswordBox.Password);
|
||||
if (!string.IsNullOrWhiteSpace(passwordError))
|
||||
{
|
||||
MessageBox.Show(passwordError, "Ошибка валидации", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var email = EmailTextBox.Text.Trim().ToLowerInvariant();
|
||||
var user = db.Users.FirstOrDefault(x => x.Email.ToLower() == email);
|
||||
if (user is null)
|
||||
{
|
||||
MessageBox.Show("Пользователь не найден.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
user.PasswordHash = PasswordHasher.HashPassword(NewPasswordBox.Password);
|
||||
db.SaveChanges();
|
||||
|
||||
MessageBox.Show("Пароль изменён.", "Успех", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
new LoginWindow().Show();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void BackButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
new LoginWindow().Show();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Window_PreviewTextInput(object sender, TextCompositionEventArgs e)
|
||||
{
|
||||
if (CodeTextBox.IsKeyboardFocusWithin)
|
||||
{
|
||||
e.Handled = !Regex.IsMatch(e.Text, "^[0-9]+$");
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Views/LoginWindow.xaml
Normal file
39
Views/LoginWindow.xaml
Normal file
@@ -0,0 +1,39 @@
|
||||
<Window x:Class="SimpleWiki.Views.LoginWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Simple Wiki - Вход"
|
||||
Width="520"
|
||||
Height="640"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BaseBrush}">
|
||||
<Border Background="{StaticResource BaseBrush}" Padding="28">
|
||||
<Grid Background="{StaticResource BaseBrush}">
|
||||
<Border Style="{StaticResource CardBorder}" Padding="28">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource PageTitle}" Text="Simple Wiki" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="Вход по email." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="Email" />
|
||||
<TextBox x:Name="EmailTextBox" MaxLength="256" />
|
||||
|
||||
<TextBlock Text="Пароль" />
|
||||
<PasswordBox x:Name="PasswordBox" MaxLength="64" />
|
||||
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="Войти" Click="LoginButton_Click" />
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Создать аккаунт" Click="OpenRegisterButton_Click" />
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Забыли пароль?" Click="OpenForgotPasswordButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
53
Views/LoginWindow.xaml.cs
Normal file
53
Views/LoginWindow.xaml.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Windows;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SimpleWiki.Data;
|
||||
using SimpleWiki.Services;
|
||||
|
||||
namespace SimpleWiki.Views;
|
||||
|
||||
public partial class LoginWindow : Window
|
||||
{
|
||||
public LoginWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void LoginButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var emailError = InputValidator.ValidateEmail(EmailTextBox.Text);
|
||||
var passwordError = string.IsNullOrWhiteSpace(PasswordBox.Password) ? "Пароль обязателен." : null;
|
||||
var errors = InputValidator.JoinErrors(emailError, passwordError);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errors))
|
||||
{
|
||||
MessageBox.Show(errors, "Ошибка валидации", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var email = EmailTextBox.Text.Trim().ToLowerInvariant();
|
||||
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Email.ToLower() == email);
|
||||
|
||||
if (user is null || !PasswordHasher.VerifyPassword(PasswordBox.Password, user.PasswordHash))
|
||||
{
|
||||
MessageBox.Show("Неверный email или пароль.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
SessionService.CurrentUser = user;
|
||||
new MainWindow(user).Show();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OpenRegisterButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
new RegisterWindow().Show();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OpenForgotPasswordButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
new ForgotPasswordWindow().Show();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
93
Views/MainWindow.xaml
Normal file
93
Views/MainWindow.xaml
Normal file
@@ -0,0 +1,93 @@
|
||||
<Window x:Class="SimpleWiki.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:models="clr-namespace:SimpleWiki.Models"
|
||||
Title="Simple Wiki"
|
||||
Width="1440"
|
||||
Height="900"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BaseBrush}">
|
||||
<Border Background="{StaticResource BaseBrush}" Padding="20">
|
||||
<Grid Background="{StaticResource BaseBrush}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="320" />
|
||||
<ColumnDefinition Width="380" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Margin="0,0,16,0">
|
||||
<Border Style="{StaticResource CardBorder}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitle}" Text="Профиль" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="Это и есть персональная информация без хеша пароля. Бумажная методичка была бы довольна." />
|
||||
<TextBlock x:Name="FullNameTextBlock" FontSize="18" FontWeight="SemiBold" Margin="0,12,0,6" />
|
||||
<TextBlock x:Name="EmailTextBlock" Foreground="{StaticResource SubtextBrush}" />
|
||||
<TextBlock x:Name="RoleTextBlock" Foreground="{StaticResource BlueBrush}" Margin="0,6,0,0" />
|
||||
<TextBlock x:Name="CreatedAtTextBlock" Foreground="{StaticResource SubtextBrush}" Margin="0,6,0,0" />
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Выйти" Click="LogoutButton_Click" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource CardBorder}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitle}" Text="Поиск" />
|
||||
<TextBox x:Name="SearchTextBox" MaxLength="150" TextChanged="SearchTextBox_TextChanged" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="Фильтр по заголовку и краткому описанию." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="1" Style="{StaticResource CardBorder}" Margin="0,0,16,0">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitle}" Text="Статьи" />
|
||||
<StackPanel x:Name="AdminToolbar" Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<Button Width="100" Style="{StaticResource PrimaryButton}" Content="Добавить" Click="AddArticleButton_Click" />
|
||||
<Button Width="100" Margin="10,6,0,0" Style="{StaticResource SecondaryButton}" Content="Изменить" Click="EditArticleButton_Click" />
|
||||
<Button Width="100" Margin="10,6,0,0" Style="{StaticResource DangerButton}" Content="Удалить" Click="DeleteArticleButton_Click" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ArticlesListBox" SelectionChanged="ArticlesListBox_SelectionChanged" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type models:Article}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="16" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource SubtextBrush}" Margin="0,6,0,6" TextWrapping="Wrap" />
|
||||
<TextBlock Foreground="{StaticResource BlueBrush}"
|
||||
Text="{Binding Author.FullName, StringFormat=Автор: {0}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Style="{StaticResource CardBorder}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="ArticleTitleTextBlock" Style="{StaticResource PageTitle}" Text="Выберите статью" />
|
||||
<TextBlock x:Name="ArticleSummaryTextBlock" Foreground="{StaticResource SubtextBrush}" TextWrapping="Wrap" Margin="0,0,0,8" />
|
||||
<TextBlock x:Name="ArticleMetaTextBlock" Foreground="{StaticResource BlueBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1" Margin="0,16,0,0" Background="{StaticResource CrustBrush}" BorderBrush="{StaticResource Surface1Brush}" BorderThickness="1" CornerRadius="12" ClipToBounds="True">
|
||||
<Grid Background="{StaticResource CrustBrush}">
|
||||
<WebBrowser x:Name="ArticleBrowser" Margin="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
173
Views/MainWindow.xaml.cs
Normal file
173
Views/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SimpleWiki.Data;
|
||||
using SimpleWiki.Models;
|
||||
using SimpleWiki.Services;
|
||||
|
||||
namespace SimpleWiki.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly User _currentUser;
|
||||
private bool _isArticleBrowserReady;
|
||||
private string _pendingArticleHtml = string.Empty;
|
||||
|
||||
public MainWindow(User currentUser)
|
||||
{
|
||||
InitializeComponent();
|
||||
_currentUser = currentUser;
|
||||
_pendingArticleHtml = MarkdownService.ToHtmlDocument("Simple Wiki", "## Загрузка...");
|
||||
Loaded += MainWindow_Loaded;
|
||||
ContentRendered += MainWindow_ContentRendered;
|
||||
}
|
||||
|
||||
private void MainWindow_ContentRendered(object? sender, EventArgs e)
|
||||
{
|
||||
_isArticleBrowserReady = true;
|
||||
Dispatcher.BeginInvoke(RenderArticleIfReady, DispatcherPriority.ApplicationIdle);
|
||||
}
|
||||
|
||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FullNameTextBlock.Text = _currentUser.FullName;
|
||||
EmailTextBlock.Text = _currentUser.Email;
|
||||
RoleTextBlock.Text = _currentUser.Role == Roles.Admin ? "Роль: администратор" : "Роль: пользователь";
|
||||
CreatedAtTextBlock.Text = $"Дата регистрации: {_currentUser.CreatedAt.ToLocalTime():dd.MM.yyyy HH:mm}";
|
||||
|
||||
var isAdmin = _currentUser.Role == Roles.Admin;
|
||||
AdminToolbar.Visibility = isAdmin ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
LoadArticles();
|
||||
}
|
||||
|
||||
private void LoadArticles(string? query = null)
|
||||
{
|
||||
using var db = new AppDbContext();
|
||||
var items = db.Articles
|
||||
.Include(x => x.Author)
|
||||
.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var term = query.Trim().ToLower();
|
||||
items = items.Where(x => x.Title.ToLower().Contains(term) || x.Summary.ToLower().Contains(term));
|
||||
}
|
||||
|
||||
var articles = items.ToList();
|
||||
ArticlesListBox.ItemsSource = articles;
|
||||
|
||||
if (articles.Count > 0)
|
||||
{
|
||||
ArticlesListBox.SelectedIndex = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
ArticleTitleTextBlock.Text = "Статей пока нет";
|
||||
ArticleSummaryTextBlock.Text = "Бебебе";
|
||||
ArticleMetaTextBlock.Text = string.Empty;
|
||||
_pendingArticleHtml = MarkdownService.ToHtmlDocument("Empty", "## Пусто\n\nПока нечего читать.");
|
||||
RenderArticleIfReady();
|
||||
}
|
||||
}
|
||||
|
||||
private void ArticlesListBox_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
if (ArticlesListBox.SelectedItem is not Article selectedArticle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var article = db.Articles
|
||||
.Include(x => x.Author)
|
||||
.FirstOrDefault(x => x.Id == selectedArticle.Id);
|
||||
|
||||
if (article is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArticleTitleTextBlock.Text = article.Title;
|
||||
ArticleSummaryTextBlock.Text = article.Summary;
|
||||
ArticleMetaTextBlock.Text = $"Автор: {article.Author?.FullName} | Создано: {article.CreatedAt.ToLocalTime():dd.MM.yyyy HH:mm}" +
|
||||
(article.UpdatedAt.HasValue ? $" | Обновлено: {article.UpdatedAt.Value.ToLocalTime():dd.MM.yyyy HH:mm}" : string.Empty);
|
||||
|
||||
_pendingArticleHtml = MarkdownService.ToHtmlDocument(article.Title, article.MarkdownContent);
|
||||
RenderArticleIfReady();
|
||||
}
|
||||
|
||||
|
||||
private void RenderArticleIfReady()
|
||||
{
|
||||
if (!_isArticleBrowserReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArticleBrowser.NavigateToString(_pendingArticleHtml);
|
||||
}
|
||||
|
||||
private void SearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||
{
|
||||
LoadArticles(SearchTextBox.Text);
|
||||
}
|
||||
|
||||
private void AddArticleButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var editor = new ArticleEditorWindow(_currentUser);
|
||||
if (editor.ShowDialog() == true)
|
||||
{
|
||||
LoadArticles(SearchTextBox.Text);
|
||||
}
|
||||
}
|
||||
|
||||
private void EditArticleButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ArticlesListBox.SelectedItem is not Article article)
|
||||
{
|
||||
MessageBox.Show("Сначала выберите статью.", "Внимание", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var editor = new ArticleEditorWindow(_currentUser, article.Id);
|
||||
if (editor.ShowDialog() == true)
|
||||
{
|
||||
LoadArticles(SearchTextBox.Text);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteArticleButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ArticlesListBox.SelectedItem is not Article article)
|
||||
{
|
||||
MessageBox.Show("Сначала выберите статью.", "Внимание", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = MessageBox.Show($"Удалить статью '{article.Title}'?", "Подтверждение", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (result != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var entity = db.Articles.FirstOrDefault(x => x.Id == article.Id);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.Articles.Remove(entity);
|
||||
db.SaveChanges();
|
||||
LoadArticles(SearchTextBox.Text);
|
||||
}
|
||||
|
||||
private void LogoutButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
SessionService.CurrentUser = null;
|
||||
new LoginWindow().Show();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
37
Views/RegisterWindow.xaml
Normal file
37
Views/RegisterWindow.xaml
Normal file
@@ -0,0 +1,37 @@
|
||||
<Window x:Class="SimpleWiki.Views.RegisterWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Simple Wiki - Регистрация"
|
||||
Width="560"
|
||||
Height="720"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BaseBrush}">
|
||||
<Border Background="{StaticResource BaseBrush}" Padding="28">
|
||||
<Grid Background="{StaticResource BaseBrush}">
|
||||
<Border Style="{StaticResource CardBorder}" Padding="28">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource PageTitle}" Text="Создать аккаунт" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="Строгая валидация включена. Машина сомневается в тебе заранее." />
|
||||
|
||||
<TextBlock Text="ФИО" />
|
||||
<TextBox x:Name="FullNameTextBox" MaxLength="100" />
|
||||
|
||||
<TextBlock Text="Email" />
|
||||
<TextBox x:Name="EmailTextBox" MaxLength="256" />
|
||||
|
||||
<TextBlock Text="Пароль" />
|
||||
<PasswordBox x:Name="PasswordBox" MaxLength="64" />
|
||||
<TextBlock Style="{StaticResource CaptionTextBlock}" Text="8-64 символа, строчная, заглавная, цифра и спецсимвол." />
|
||||
|
||||
<CheckBox x:Name="IsAdminCheckBox" Content="Администратор" />
|
||||
|
||||
<Button Style="{StaticResource PrimaryButton}" Content="Зарегистрироваться" Click="RegisterButton_Click" />
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Назад ко входу" Click="BackButton_Click" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
61
Views/RegisterWindow.xaml.cs
Normal file
61
Views/RegisterWindow.xaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Windows;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SimpleWiki.Data;
|
||||
using SimpleWiki.Models;
|
||||
using SimpleWiki.Services;
|
||||
|
||||
namespace SimpleWiki.Views;
|
||||
|
||||
public partial class RegisterWindow : Window
|
||||
{
|
||||
public RegisterWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void RegisterButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var fullNameError = InputValidator.ValidateFullName(FullNameTextBox.Text);
|
||||
var emailError = InputValidator.ValidateEmail(EmailTextBox.Text);
|
||||
var passwordError = InputValidator.ValidatePassword(PasswordBox.Password);
|
||||
var errors = InputValidator.JoinErrors(fullNameError, emailError, passwordError);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errors))
|
||||
{
|
||||
MessageBox.Show(errors, "Ошибка валидации", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new AppDbContext();
|
||||
var email = EmailTextBox.Text.Trim().ToLowerInvariant();
|
||||
var exists = db.Users.AsNoTracking().Any(x => x.Email.ToLower() == email);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
MessageBox.Show("Пользователь с таким email уже существует.", "Ошибка", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
FullName = FullNameTextBox.Text.Trim(),
|
||||
Email = email,
|
||||
PasswordHash = PasswordHasher.HashPassword(PasswordBox.Password),
|
||||
Role = IsAdminCheckBox.IsChecked == true ? Roles.Admin : Roles.User,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Users.Add(user);
|
||||
db.SaveChanges();
|
||||
|
||||
MessageBox.Show("Аккаунт создан. Теперь можно войти.", "Успех", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
new LoginWindow().Show();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void BackButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
new LoginWindow().Show();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user