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