();
+}
+
+public static class Roles
+{
+ public const string Admin = "Admin";
+ public const string User = "User";
+}
diff --git a/Services/EmailService.cs b/Services/EmailService.cs
new file mode 100644
index 0000000..45a36b9
--- /dev/null
+++ b/Services/EmailService.cs
@@ -0,0 +1,41 @@
+using System.Net;
+using System.Net.Mail;
+using System.Text;
+using System.Security.Cryptography;
+using SimpleWiki.Settings;
+
+namespace SimpleWiki.Services;
+
+public static class EmailService
+{
+ public static string SendPasswordResetCode(string toEmail)
+ {
+ if (!SmtpSettings.IsConfigured)
+ {
+ throw new InvalidOperationException("SMTP не настроен. Заполни Settings/SmtpSettings.cs.");
+ }
+
+ var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
+
+ using var client = new SmtpClient(SmtpSettings.Host, SmtpSettings.Port)
+ {
+ EnableSsl = SmtpSettings.EnableSsl,
+ Credentials = new NetworkCredential(SmtpSettings.Email, SmtpSettings.Password)
+ };
+
+ using var message = new MailMessage
+ {
+ From = new MailAddress(SmtpSettings.Email, SmtpSettings.DisplayName),
+ Subject = "Код подтверждения для смены пароля",
+ Body = $"Код подтверждения: {code}
Если это были не вы, просто проигнорируйте письмо.
",
+ IsBodyHtml = true,
+ BodyEncoding = Encoding.UTF8,
+ SubjectEncoding = Encoding.UTF8
+ };
+
+ message.To.Add(toEmail);
+ client.Send(message);
+
+ return code;
+ }
+}
diff --git a/Services/InputValidator.cs b/Services/InputValidator.cs
new file mode 100644
index 0000000..c7dcdd2
--- /dev/null
+++ b/Services/InputValidator.cs
@@ -0,0 +1,66 @@
+using System.Text.RegularExpressions;
+
+namespace SimpleWiki.Services;
+
+public static class InputValidator
+{
+ private static readonly Regex EmailRegex = new(@"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$", RegexOptions.Compiled);
+ private static readonly Regex FullNameRegex = new(@"^[A-Za-zА-Яа-яЁё\-\s]{2,100}$", RegexOptions.Compiled);
+ private static readonly Regex PasswordRegex = new(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,64}$", RegexOptions.Compiled);
+ private static readonly Regex CodeRegex = new(@"^\d{6}$", RegexOptions.Compiled);
+
+ public static string? ValidateFullName(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "ФИО обязательно.";
+ if (!FullNameRegex.IsMatch(value.Trim())) return "ФИО: только буквы, пробелы и дефис, от 2 до 100 символов.";
+ return null;
+ }
+
+ public static string? ValidateEmail(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "Email обязателен.";
+ if (value.Length > 256) return "Email слишком длинный.";
+ if (!EmailRegex.IsMatch(value.Trim())) return "Некорректный email.";
+ return null;
+ }
+
+ public static string? ValidatePassword(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "Пароль обязателен.";
+ if (!PasswordRegex.IsMatch(value)) return "Пароль: 8-64 символа, минимум 1 строчная, 1 заглавная, 1 цифра и 1 спецсимвол.";
+ return null;
+ }
+
+ public static string? ValidateResetCode(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "Код подтверждения обязателен.";
+ if (!CodeRegex.IsMatch(value.Trim())) return "Код должен состоять из 6 цифр.";
+ return null;
+ }
+
+ public static string? ValidateArticleTitle(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "Заголовок обязателен.";
+ if (value.Trim().Length < 3 || value.Trim().Length > 150) return "Заголовок: от 3 до 150 символов.";
+ return null;
+ }
+
+ public static string? ValidateArticleSummary(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "Краткое описание обязательно.";
+ if (value.Trim().Length < 10 || value.Trim().Length > 400) return "Краткое описание: от 10 до 400 символов.";
+ return null;
+ }
+
+ public static string? ValidateArticleMarkdown(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return "Текст статьи обязателен.";
+ if (value.Trim().Length < 20 || value.Trim().Length > 20000) return "Текст статьи: от 20 до 20000 символов.";
+ return null;
+ }
+
+ public static string JoinErrors(params string?[] errors)
+ {
+ return string.Join(Environment.NewLine, errors.Where(x => !string.IsNullOrWhiteSpace(x)));
+ }
+}
diff --git a/Services/MarkdownService.cs b/Services/MarkdownService.cs
new file mode 100644
index 0000000..bf8f8d2
--- /dev/null
+++ b/Services/MarkdownService.cs
@@ -0,0 +1,67 @@
+using Markdig;
+
+namespace SimpleWiki.Services;
+
+public static class MarkdownService
+{
+ private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
+ .UseAdvancedExtensions()
+ .Build();
+
+ public static string ToHtmlDocument(string title, string markdown)
+ {
+ var htmlBody = Markdown.ToHtml(markdown ?? string.Empty, Pipeline);
+
+ return $@"
+
+
+
+
+
+
+{System.Net.WebUtility.HtmlEncode(title)}
+
+
+
+{htmlBody}
+
+
+";
+ }
+}
diff --git a/Services/PasswordHasher.cs b/Services/PasswordHasher.cs
new file mode 100644
index 0000000..c0b74ac
--- /dev/null
+++ b/Services/PasswordHasher.cs
@@ -0,0 +1,77 @@
+using System.Security.Cryptography;
+using System.Text;
+using Konscious.Security.Cryptography;
+
+namespace SimpleWiki.Services;
+
+public static class PasswordHasher
+{
+ private const int Iterations = 3;
+ private const int MemorySizeKb = 65536;
+ private const int Parallelism = 1;
+ private const int SaltLength = 16;
+ private const int HashLength = 32;
+
+ public static string HashPassword(string password)
+ {
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ throw new ArgumentException("Пароль пустой.");
+ }
+
+ var salt = RandomNumberGenerator.GetBytes(SaltLength);
+
+ using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
+ {
+ Salt = salt,
+ Iterations = Iterations,
+ DegreeOfParallelism = Parallelism,
+ MemorySize = MemorySizeKb
+ };
+
+ var hash = argon2.GetBytes(HashLength);
+ var combined = new byte[SaltLength + HashLength];
+
+ Buffer.BlockCopy(salt, 0, combined, 0, SaltLength);
+ Buffer.BlockCopy(hash, 0, combined, SaltLength, HashLength);
+
+ return Convert.ToBase64String(combined);
+ }
+
+ public static bool VerifyPassword(string password, string hashPassword)
+ {
+ if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(hashPassword))
+ {
+ return false;
+ }
+
+ byte[] combined;
+ try
+ {
+ combined = Convert.FromBase64String(hashPassword);
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (combined.Length < SaltLength + HashLength)
+ {
+ return false;
+ }
+
+ var salt = combined.Take(SaltLength).ToArray();
+ var originalHash = combined.Skip(SaltLength).Take(HashLength).ToArray();
+
+ using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
+ {
+ Salt = salt,
+ Iterations = Iterations,
+ DegreeOfParallelism = Parallelism,
+ MemorySize = MemorySizeKb
+ };
+
+ var newHash = argon2.GetBytes(HashLength);
+ return CryptographicOperations.FixedTimeEquals(originalHash, newHash);
+ }
+}
diff --git a/Services/SessionService.cs b/Services/SessionService.cs
new file mode 100644
index 0000000..d6a42a3
--- /dev/null
+++ b/Services/SessionService.cs
@@ -0,0 +1,8 @@
+using SimpleWiki.Models;
+
+namespace SimpleWiki.Services;
+
+public static class SessionService
+{
+ public static User? CurrentUser { get; set; }
+}
diff --git a/Services/SlugHelper.cs b/Services/SlugHelper.cs
new file mode 100644
index 0000000..ad1019d
--- /dev/null
+++ b/Services/SlugHelper.cs
@@ -0,0 +1,34 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SimpleWiki.Services;
+
+public static class SlugHelper
+{
+ public static string Generate(string title)
+ {
+ if (string.IsNullOrWhiteSpace(title))
+ {
+ return string.Empty;
+ }
+
+ var normalized = title.Trim().ToLowerInvariant();
+ normalized = normalized.Replace('ё', 'е');
+
+ var sb = new StringBuilder();
+ foreach (var ch in normalized)
+ {
+ if (char.IsLetterOrDigit(ch))
+ {
+ sb.Append(ch);
+ }
+ else if (char.IsWhiteSpace(ch) || ch == '-' || ch == '_')
+ {
+ sb.Append('-');
+ }
+ }
+
+ var slug = Regex.Replace(sb.ToString(), "-+", "-").Trim('-');
+ return string.IsNullOrWhiteSpace(slug) ? $"article-{DateTime.UtcNow:yyyyMMddHHmmss}" : slug;
+ }
+}
diff --git a/Settings/SmtpSettings.cs b/Settings/SmtpSettings.cs
new file mode 100644
index 0000000..6a1b2fc
--- /dev/null
+++ b/Settings/SmtpSettings.cs
@@ -0,0 +1,13 @@
+namespace SimpleWiki.Settings;
+
+public static class SmtpSettings
+{
+ public const string Host = "smtp.gmail.com";
+ public const int Port = 587;
+ public const bool EnableSsl = true;
+ public const string Email = "debugprodevoff@gmail.com";
+ public const string Password = "eihaxsdqbdkcnllf";
+ public const string DisplayName = "Simple Wiki";
+
+ public static bool IsConfigured => true;
+}
diff --git a/SimpleWiki.csproj b/SimpleWiki.csproj
new file mode 100644
index 0000000..462c324
--- /dev/null
+++ b/SimpleWiki.csproj
@@ -0,0 +1,30 @@
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+ SimpleWiki
+ SimpleWiki
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/SimpleWiki.sln b/SimpleWiki.sln
new file mode 100644
index 0000000..8c05c53
--- /dev/null
+++ b/SimpleWiki.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36429.23 d17.14
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleWiki", "SimpleWiki.csproj", "{E64D570E-DCB3-476B-A1FC-DF698F57B88E}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E64D570E-DCB3-476B-A1FC-DF698F57B88E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E64D570E-DCB3-476B-A1FC-DF698F57B88E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E64D570E-DCB3-476B-A1FC-DF698F57B88E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E64D570E-DCB3-476B-A1FC-DF698F57B88E}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {882902AD-0410-4888-8F85-BFA5CC71128F}
+ EndGlobalSection
+EndGlobal
diff --git a/Themes/CatppuccinMocha.xaml b/Themes/CatppuccinMocha.xaml
new file mode 100644
index 0000000..ebc3a01
--- /dev/null
+++ b/Themes/CatppuccinMocha.xaml
@@ -0,0 +1,241 @@
+
+
+ #FF1E1E2E
+ #FF181825
+ #FF11111B
+ #FF313244
+ #FF45475A
+ #FF585B70
+ #FFCDD6F4
+ #FFA6ADC8
+ #FF89B4FA
+ #FFB4BEFE
+ #FFA6E3A1
+ #FFF9E2AF
+ #FFF38BA8
+ #FFF5E0DC
+ #FFFAB387
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/ArticleEditorWindow.xaml b/Views/ArticleEditorWindow.xaml
new file mode 100644
index 0000000..05e8812
--- /dev/null
+++ b/Views/ArticleEditorWindow.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/ArticleEditorWindow.xaml.cs b/Views/ArticleEditorWindow.xaml.cs
new file mode 100644
index 0000000..a609765
--- /dev/null
+++ b/Views/ArticleEditorWindow.xaml.cs
@@ -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();
+ }
+}
diff --git a/Views/ForgotPasswordWindow.xaml b/Views/ForgotPasswordWindow.xaml
new file mode 100644
index 0000000..df0dcdf
--- /dev/null
+++ b/Views/ForgotPasswordWindow.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/ForgotPasswordWindow.xaml.cs b/Views/ForgotPasswordWindow.xaml.cs
new file mode 100644
index 0000000..b51bd07
--- /dev/null
+++ b/Views/ForgotPasswordWindow.xaml.cs
@@ -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]+$");
+ }
+ }
+}
diff --git a/Views/LoginWindow.xaml b/Views/LoginWindow.xaml
new file mode 100644
index 0000000..cce49a3
--- /dev/null
+++ b/Views/LoginWindow.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/LoginWindow.xaml.cs b/Views/LoginWindow.xaml.cs
new file mode 100644
index 0000000..618e63c
--- /dev/null
+++ b/Views/LoginWindow.xaml.cs
@@ -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();
+ }
+}
diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml
new file mode 100644
index 0000000..ed3bd9f
--- /dev/null
+++ b/Views/MainWindow.xaml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/MainWindow.xaml.cs b/Views/MainWindow.xaml.cs
new file mode 100644
index 0000000..7875e2d
--- /dev/null
+++ b/Views/MainWindow.xaml.cs
@@ -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();
+ }
+}
diff --git a/Views/RegisterWindow.xaml b/Views/RegisterWindow.xaml
new file mode 100644
index 0000000..398ae45
--- /dev/null
+++ b/Views/RegisterWindow.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/RegisterWindow.xaml.cs b/Views/RegisterWindow.xaml.cs
new file mode 100644
index 0000000..8f31fd6
--- /dev/null
+++ b/Views/RegisterWindow.xaml.cs
@@ -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();
+ }
+}