1
0
This commit is contained in:
Debug_pro
2026-06-07 18:19:53 +03:00
commit fb13e5a20a
33 changed files with 2382 additions and 0 deletions

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

363
.gitignore vendored Normal file
View File

@@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

12
App.xaml Normal file
View File

@@ -0,0 +1,12 @@
<Application x:Class="SimpleWiki.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="Views/LoginWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/CatppuccinMocha.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

13
App.xaml.cs Normal file
View File

@@ -0,0 +1,13 @@
using System.Windows;
using SimpleWiki.Data;
namespace SimpleWiki;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
DbInitializer.Initialize();
base.OnStartup(e);
}
}

10
AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

103
Data/AppDbContext.cs Normal file
View File

@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using SimpleWiki.Models;
namespace SimpleWiki.Data;
public class AppDbContext : DbContext
{
public AppDbContext()
{
}
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<User> Users => Set<User>();
public DbSet<Article> Articles => Set<Article>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlite("Data Source=simplewiki.db");
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>(entity =>
{
entity.ToTable("users");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id)
.ValueGeneratedOnAdd();
entity.Property(x => x.FullName)
.IsRequired()
.HasMaxLength(100);
entity.Property(x => x.Email)
.IsRequired()
.HasMaxLength(256);
entity.Property(x => x.PasswordHash)
.IsRequired()
.HasMaxLength(512);
entity.Property(x => x.Role)
.IsRequired()
.HasMaxLength(16);
entity.Property(x => x.CreatedAt)
.IsRequired();
entity.HasIndex(x => x.Email)
.IsUnique();
entity.HasMany(x => x.Articles)
.WithOne(x => x.Author)
.HasForeignKey(x => x.AuthorId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Article>(entity =>
{
entity.ToTable("articles");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id)
.ValueGeneratedOnAdd();
entity.Property(x => x.Title)
.IsRequired()
.HasMaxLength(150);
entity.Property(x => x.Slug)
.IsRequired()
.HasMaxLength(180);
entity.Property(x => x.Summary)
.IsRequired()
.HasMaxLength(400);
entity.Property(x => x.MarkdownContent)
.IsRequired()
.HasMaxLength(20000);
entity.Property(x => x.CreatedAt)
.IsRequired();
entity.Property(x => x.UpdatedAt)
.IsRequired(false);
entity.HasIndex(x => x.Slug)
.IsUnique();
entity.HasIndex(x => x.Title);
});
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace SimpleWiki.Data;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlite("Data Source=simplewiki.db");
return new AppDbContext(optionsBuilder.Options);
}
}

12
Data/DbInitializer.cs Normal file
View File

@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore;
namespace SimpleWiki.Data;
public static class DbInitializer
{
public static void Initialize()
{
using var db = new AppDbContext();
db.Database.Migrate();
}
}

View File

@@ -0,0 +1,125 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SimpleWiki.Data;
#nullable disable
namespace SimpleWiki.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260422203506_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.26");
modelBuilder.Entity("SimpleWiki.Models.Article", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuthorId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("MarkdownContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("TEXT");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Title");
b.ToTable("articles", (string)null);
});
modelBuilder.Entity("SimpleWiki.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("SimpleWiki.Models.Article", b =>
{
b.HasOne("SimpleWiki.Models.User", "Author")
.WithMany("Articles")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Author");
});
modelBuilder.Entity("SimpleWiki.Models.User", b =>
{
b.Navigation("Articles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SimpleWiki.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
FullName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "articles",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 150, nullable: false),
Slug = table.Column<string>(type: "TEXT", maxLength: 180, nullable: false),
Summary = table.Column<string>(type: "TEXT", maxLength: 400, nullable: false),
MarkdownContent = table.Column<string>(type: "TEXT", maxLength: 20000, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
AuthorId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_articles", x => x.Id);
table.ForeignKey(
name: "FK_articles_users_AuthorId",
column: x => x.AuthorId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_articles_AuthorId",
table: "articles",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_articles_Slug",
table: "articles",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_articles_Title",
table: "articles",
column: "Title");
migrationBuilder.CreateIndex(
name: "IX_users_Email",
table: "users",
column: "Email",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "articles");
migrationBuilder.DropTable(
name: "users");
}
}
}

View File

@@ -0,0 +1,122 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SimpleWiki.Data;
#nullable disable
namespace SimpleWiki.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.26");
modelBuilder.Entity("SimpleWiki.Models.Article", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuthorId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("MarkdownContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("TEXT");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Title");
b.ToTable("articles", (string)null);
});
modelBuilder.Entity("SimpleWiki.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("SimpleWiki.Models.Article", b =>
{
b.HasOne("SimpleWiki.Models.User", "Author")
.WithMany("Articles")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Author");
});
modelBuilder.Entity("SimpleWiki.Models.User", b =>
{
b.Navigation("Articles");
});
#pragma warning restore 612, 618
}
}
}

15
Models/Article.cs Normal file
View File

@@ -0,0 +1,15 @@
namespace SimpleWiki.Models;
public class Article
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string MarkdownContent { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public int AuthorId { get; set; }
public User? Author { get; set; }
}

19
Models/User.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace SimpleWiki.Models;
public class User
{
public int Id { get; set; }
public string FullName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string Role { get; set; } = Roles.User;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Article> Articles { get; set; } = new List<Article>();
}
public static class Roles
{
public const string Admin = "Admin";
public const string User = "User";
}

41
Services/EmailService.cs Normal file
View File

@@ -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 = $"<h1 style='font-family:Segoe UI'>Код подтверждения: {code}</h1><p>Если это были не вы, просто проигнорируйте письмо.</p>",
IsBodyHtml = true,
BodyEncoding = Encoding.UTF8,
SubjectEncoding = Encoding.UTF8
};
message.To.Add(toEmail);
client.Send(message);
return code;
}
}

View File

@@ -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)));
}
}

View File

@@ -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 $@"<!DOCTYPE html>
<html style='background:#1E1E2E; color:#CDD6F4;'>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='color-scheme' content='dark' />
<style>
html, body {{
margin: 0 !important;
padding: 0 !important;
min-height: 100%;
background: #1E1E2E !important;
background-color: #1E1E2E !important;
color: #CDD6F4 !important;
font-family: 'Segoe UI', sans-serif;
}}
body {{
box-sizing: border-box;
padding: 0 !important;
line-height: 1.65;
}}
.page {{
min-height: 100vh;
padding: 24px;
background: #1E1E2E !important;
color: #CDD6F4 !important;
}}
* {{ box-sizing: border-box; }}
h1, h2, h3, h4, h5, h6 {{ color: #B4BEFE !important; margin-top: 0; }}
p, ul, ol, blockquote, table, pre {{ margin-top: 0; margin-bottom: 16px; color: #CDD6F4; }}
li, span, div {{ color: inherit; }}
a {{ color: #89B4FA !important; }}
a:visited {{ color: #B4BEFE !important; }}
code {{ background: #11111B; color: #F5E0DC; padding: 3px 6px; border-radius: 8px; }}
pre {{ background: #11111B; color: #F5E0DC; padding: 14px 16px; border-radius: 12px; overflow-x: auto; }}
pre code {{ background: transparent; padding: 0; }}
blockquote {{ border-left: 4px solid #89B4FA; padding-left: 12px; color: #A6ADC8 !important; }}
table {{ border-collapse: collapse; width: 100%; background: #181825; border-radius: 12px; overflow: hidden; }}
th, td {{ border: 1px solid #45475A; padding: 10px 12px; text-align: left; color: #CDD6F4; }}
th {{ background: #313244; color: #CDD6F4; }}
hr {{ border: none; border-top: 1px solid #45475A; margin: 24px 0; }}
img {{ max-width: 100%; height: auto; border-radius: 10px; }}
</style>
<title>{System.Net.WebUtility.HtmlEncode(title)}</title>
</head>
<body bgcolor='#1E1E2E' text='#CDD6F4'>
<div class='page'>
{htmlBody}
</div>
</body>
</html>";
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
using SimpleWiki.Models;
namespace SimpleWiki.Services;
public static class SessionService
{
public static User? CurrentUser { get; set; }
}

34
Services/SlugHelper.cs Normal file
View File

@@ -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;
}
}

13
Settings/SmtpSettings.cs Normal file
View File

@@ -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;
}

30
SimpleWiki.csproj Normal file
View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<RootNamespace>SimpleWiki</RootNamespace>
<AssemblyName>SimpleWiki</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="*" />
<PackageReference Include="Markdig" Version="*" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

25
SimpleWiki.sln Normal file
View File

@@ -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

241
Themes/CatppuccinMocha.xaml Normal file
View File

@@ -0,0 +1,241 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="BaseColor">#FF1E1E2E</Color>
<Color x:Key="MantleColor">#FF181825</Color>
<Color x:Key="CrustColor">#FF11111B</Color>
<Color x:Key="Surface0Color">#FF313244</Color>
<Color x:Key="Surface1Color">#FF45475A</Color>
<Color x:Key="Surface2Color">#FF585B70</Color>
<Color x:Key="TextColor">#FFCDD6F4</Color>
<Color x:Key="SubtextColor">#FFA6ADC8</Color>
<Color x:Key="BlueColor">#FF89B4FA</Color>
<Color x:Key="LavenderColor">#FFB4BEFE</Color>
<Color x:Key="GreenColor">#FFA6E3A1</Color>
<Color x:Key="YellowColor">#FFF9E2AF</Color>
<Color x:Key="RedColor">#FFF38BA8</Color>
<Color x:Key="RosewaterColor">#FFF5E0DC</Color>
<Color x:Key="PeachColor">#FFFAB387</Color>
<SolidColorBrush x:Key="BaseBrush" Color="{StaticResource BaseColor}" />
<SolidColorBrush x:Key="MantleBrush" Color="{StaticResource MantleColor}" />
<SolidColorBrush x:Key="CrustBrush" Color="{StaticResource CrustColor}" />
<SolidColorBrush x:Key="Surface0Brush" Color="{StaticResource Surface0Color}" />
<SolidColorBrush x:Key="Surface1Brush" Color="{StaticResource Surface1Color}" />
<SolidColorBrush x:Key="Surface2Brush" Color="{StaticResource Surface2Color}" />
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="SubtextBrush" Color="{StaticResource SubtextColor}" />
<SolidColorBrush x:Key="BlueBrush" Color="{StaticResource BlueColor}" />
<SolidColorBrush x:Key="LavenderBrush" Color="{StaticResource LavenderColor}" />
<SolidColorBrush x:Key="GreenBrush" Color="{StaticResource GreenColor}" />
<SolidColorBrush x:Key="YellowBrush" Color="{StaticResource YellowColor}" />
<SolidColorBrush x:Key="RedBrush" Color="{StaticResource RedColor}" />
<SolidColorBrush x:Key="RosewaterBrush" Color="{StaticResource RosewaterColor}" />
<SolidColorBrush x:Key="PeachBrush" Color="{StaticResource PeachColor}" />
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource BaseBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
<Setter Property="TextOptions.TextRenderingMode" Value="ClearType" />
<Setter Property="SnapsToDevicePixels" Value="True" />
</Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style TargetType="Label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Margin" Value="0,8,0,12" />
</Style>
<Style x:Key="CaptionTextBlock" TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{StaticResource SubtextBrush}" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style x:Key="SectionTitle" TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style x:Key="PageTitle" TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontSize" Value="28" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<Style x:Key="CardBorder" TargetType="Border">
<Setter Property="Background" Value="{StaticResource MantleBrush}" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="18" />
<Setter Property="Margin" Value="0,0,0,12" />
<Setter Property="BorderBrush" Value="{StaticResource Surface1Brush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource Surface0Brush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="SelectionBrush" Value="{StaticResource BlueBrush}" />
<Setter Property="SelectionTextBrush" Value="{StaticResource CrustBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource Surface1Brush}" />
<Setter Property="CaretBrush" Value="{StaticResource RosewaterBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,6,0,10" />
<Setter Property="MinHeight" Value="36" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Style.Triggers>
<Trigger Property="AcceptsReturn" Value="True">
<Setter Property="VerticalContentAlignment" Value="Top" />
<Setter Property="Padding" Value="10" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource BlueBrush}" />
</Trigger>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource RedBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.7" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource Surface0Brush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource Surface1Brush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,6,0,10" />
<Setter Property="MinHeight" Value="36" />
<Style.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource BlueBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.7" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource Surface0Brush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource Surface1Brush}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,6,0,10" />
<Setter Property="MinHeight" Value="36" />
</Style>
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource CrustBrush}" />
<Setter Property="Background" Value="{StaticResource BlueBrush}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="Margin" Value="0,6,0,0" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="12"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.95" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.88" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.55" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="SecondaryButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Background" Value="{StaticResource Surface0Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource Surface1Brush}" />
</Style>
<Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
<Setter Property="Background" Value="{StaticResource RedBrush}" />
</Style>
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="14" />
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="Background" Value="{StaticResource Surface0Brush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="{TemplateBinding Background}"
CornerRadius="14"
BorderBrush="{StaticResource Surface1Brush}"
BorderThickness="1"
SnapsToDevicePixels="True">
<ContentPresenter Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#FF454B78" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#FF3A3D5A" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="GridSplitter">
<Setter Property="Background" Value="{StaticResource Surface0Brush}" />
<Setter Property="ShowsPreview" Value="True" />
</Style>
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
</ResourceDictionary>

View 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>

View 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();
}
}

View 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>

View 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
View 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
View 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
View 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
View 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
View 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>

View 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();
}
}