diff --git a/TEST_PLAN.md b/TEST_PLAN.md new file mode 100644 index 0000000..cef8896 --- /dev/null +++ b/TEST_PLAN.md @@ -0,0 +1,17 @@ +# Test Plan + +1. **Build and restore dependencies** + - Run `./setup.sh` to restore NuGet and client libraries. + - Ensure the command completes without errors. + +2. **Run unit tests** + - Execute `dotnet test website/MyWebApp.sln`. + - All tests should pass. + +3. **Manual UI smoke test** + - Start the application with `dotnet run --project website/MyWebApp`. + - Verify admin pages load correctly: + - Create and edit pages and sections using the unified section editor. + - Insert blocks via the updated API endpoints. + - Confirm media uploads still work and page layouts render as before. + diff --git a/extension/html/drive-auth.html b/extension/html/drive-auth.html index 77ee7c5..8bf3aa7 100644 --- a/extension/html/drive-auth.html +++ b/extension/html/drive-auth.html @@ -1,8 +1,8 @@ - + - Загрузка в Google Drive + Upload to Google Drive +
diff --git a/extension/html/terms-of-service.html b/extension/html/terms-of-service.html index f04c436..a08fdff 100644 --- a/extension/html/terms-of-service.html +++ b/extension/html/terms-of-service.html @@ -4,121 +4,7 @@ Terms of Service - Screen Area Recorder Pro - +
diff --git a/extension/styles/privacy-policy.css b/extension/styles/privacy-policy.css new file mode 100644 index 0000000..6e6dbd8 --- /dev/null +++ b/extension/styles/privacy-policy.css @@ -0,0 +1,128 @@ + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 900px; + margin: 0 auto; + padding: 20px; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + min-height: 100vh; + } + .container { + background: white; + padding: 40px; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.1); + border: 1px solid rgba(255,255,255,0.2); + } + h1 { + color: #2c3e50; + border-bottom: 4px solid #3498db; + padding-bottom: 15px; + font-size: 2.2em; + margin-bottom: 10px; + } + h2 { + color: #34495e; + margin-top: 35px; + border-left: 5px solid #3498db; + padding-left: 20px; + font-size: 1.4em; + } + h3 { + color: #2c3e50; + margin-top: 25px; + font-size: 1.2em; + } + .highlight { + background: linear-gradient(135deg, #e8f4fd 0%, #c8e6f5 100%); + padding: 20px; + border-radius: 8px; + border-left: 5px solid #3498db; + margin: 25px 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + .warning { + background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%); + padding: 20px; + border-radius: 8px; + border-left: 5px solid #f39c12; + margin: 25px 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + .success { + background: linear-gradient(135deg, #d4edda 0%, #a8e6cf 100%); + padding: 20px; + border-radius: 8px; + border-left: 5px solid #27ae60; + margin: 25px 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + ul, ol { + margin: 15px 0; + padding-left: 30px; + } + li { + margin: 8px 0; + line-height: 1.5; + } + .last-updated { + font-style: italic; + color: #7f8c8d; + text-align: center; + margin-bottom: 30px; + background: #ecf0f1; + padding: 10px; + border-radius: 5px; + } + .contact-info { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + padding: 25px; + border-radius: 8px; + margin: 30px 0; + border: 1px solid #dee2e6; + } + .emoji { + font-size: 1.2em; + margin-right: 8px; + } + .table-container { + overflow-x: auto; + margin: 20px 0; + } + table { + width: 100%; + border-collapse: collapse; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + th, td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #ddd; + } + th { + background: #3498db; + color: white; + font-weight: 600; + } + .privacy-feature { + display: flex; + align-items: center; + margin: 10px 0; + padding: 10px; + background: #f8f9fa; + border-radius: 5px; + } + .check { + color: #27ae60; + font-size: 1.3em; + margin-right: 10px; + } + .cross { + color: #e74c3c; + font-size: 1.3em; + margin-right: 10px; + } diff --git a/extension/styles/terms-of-service.css b/extension/styles/terms-of-service.css new file mode 100644 index 0000000..6df02db --- /dev/null +++ b/extension/styles/terms-of-service.css @@ -0,0 +1,113 @@ + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 900px; + margin: 0 auto; + padding: 20px; + background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fecfef 100%); + min-height: 100vh; + } + .container { + background: white; + padding: 40px; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.1); + border: 1px solid rgba(255,255,255,0.2); + } + h1 { + color: #2c3e50; + border-bottom: 4px solid #e74c3c; + padding-bottom: 15px; + font-size: 2.2em; + margin-bottom: 10px; + } + h2 { + color: #34495e; + margin-top: 35px; + border-left: 5px solid #e74c3c; + padding-left: 20px; + font-size: 1.4em; + } + h3 { + color: #2c3e50; + margin-top: 25px; + font-size: 1.2em; + } + .highlight { + background: linear-gradient(135deg, #fff5f5 0%, #ffebee 100%); + padding: 20px; + border-radius: 8px; + border-left: 5px solid #e74c3c; + margin: 25px 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + .warning { + background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%); + padding: 20px; + border-radius: 8px; + border-left: 5px solid #f39c12; + margin: 25px 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + .success { + background: linear-gradient(135deg, #d4edda 0%, #a8e6cf 100%); + padding: 20px; + border-radius: 8px; + border-left: 5px solid #27ae60; + margin: 25px 0; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + .legal-box { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + padding: 25px; + border-radius: 8px; + border: 2px solid #6c757d; + margin: 25px 0; + font-weight: 500; + } + ul, ol { + margin: 15px 0; + padding-left: 30px; + } + li { + margin: 8px 0; + line-height: 1.5; + } + .last-updated { + font-style: italic; + color: #7f8c8d; + text-align: center; + margin-bottom: 30px; + background: #ecf0f1; + padding: 10px; + border-radius: 5px; + } + .caps { + text-transform: uppercase; + font-weight: bold; + font-size: 1.1em; + } + .emoji { + font-size: 1.2em; + margin-right: 8px; + } + .prohibited-list { + background: #ffebee; + padding: 15px; + border-radius: 5px; + border-left: 4px solid #f44336; + } + .allowed-list { + background: #e8f5e8; + padding: 15px; + border-radius: 5px; + border-left: 4px solid #4caf50; + } + .contact-info { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + padding: 25px; + border-radius: 8px; + margin: 30px 0; + border: 1px solid #dee2e6; + } diff --git a/rename-zone.patch b/rename-zone.patch new file mode 100644 index 0000000..7b5c6e6 --- /dev/null +++ b/rename-zone.patch @@ -0,0 +1,777 @@ +diff --git a/website/MyWebApp.Tests/PageSectionTests.cs b/website/MyWebApp.Tests/PageSectionTests.cs +index e6d1f72..29643b7 100644 +--- a/website/MyWebApp.Tests/PageSectionTests.cs ++++ b/website/MyWebApp.Tests/PageSectionTests.cs +@@ -22,7 +22,7 @@ public class PageSectionTests + context.Pages.Add(page); + context.SaveChanges(); + +- context.PageSections.Add(new PageSection { PageId = page.Id, Area = "header", Html = "

hi

", Type = PageSectionType.Html }); ++ context.PageSections.Add(new PageSection { PageId = page.Id, Zone = "header", Html = "

hi

", Type = PageSectionType.Html }); + + context.SaveChanges(); + } +@@ -30,7 +30,7 @@ public class PageSectionTests + using (var context = new ApplicationDbContext(options)) + { + var section = context.PageSections.Include(s => s.Page) +- .Single(s => s.Area == "header" && s.Page!.Slug == "test"); ++ .Single(s => s.Zone == "header" && s.Page!.Slug == "test"); + Assert.Equal("

hi

", section.Html); + Assert.Equal("test", section.Page!.Slug); + } +diff --git a/website/MyWebApp.Tests/SanitizationTests.cs b/website/MyWebApp.Tests/SanitizationTests.cs +index 44e1adc..3246fa6 100644 +--- a/website/MyWebApp.Tests/SanitizationTests.cs ++++ b/website/MyWebApp.Tests/SanitizationTests.cs +@@ -40,7 +40,7 @@ public class SanitizationTests + Layout = "single-column", + Sections = new List + { +- new PageSection { Area = "main", Html = "

b

" } ++ new PageSection { Zone = "main", Html = "

b

" } + } + }; + var result = await controller.Create(model); +@@ -55,7 +55,7 @@ public class SanitizationTests + var (ctx, layout, sanitizer) = CreateServices(); + var controller = new AdminPageSectionController(ctx, layout, sanitizer); + +- var model = new PageSection { PageId = ctx.Pages.First().Id, Area = "test", Html = "
hi
", Type = PageSectionType.Html }; ++ var model = new PageSection { PageId = ctx.Pages.First().Id, Zone = "main", Html = "
hi
", Type = PageSectionType.Html }; + var result = await controller.Create(model, null); + + Assert.IsType(result); +@@ -73,7 +73,7 @@ public class SanitizationTests + Slug = "edit", + Title = "Edit", + Layout = "single-column", +- Sections = new List { new PageSection { Area = "main", Html = "

a

" } } ++ Sections = new List { new PageSection { Zone = "main", Html = "

a

" } } + }; + await controller.Create(createModel); + var page = ctx.Pages.Single(p => p.Slug == "edit"); +@@ -83,7 +83,7 @@ public class SanitizationTests + Slug = page.Slug, + Title = page.Title, + Layout = page.Layout, +- Sections = new List { new PageSection { Area = "main", Html = "

b

" } } ++ Sections = new List { new PageSection { Zone = "main", Html = "

b

" } } + }; + var result = await controller.Edit(model); + var section = ctx.PageSections.Single(s => s.PageId == page.Id); +@@ -96,10 +96,10 @@ public class SanitizationTests + { + var (ctx, layout, sanitizer) = CreateServices(); + var controller = new AdminPageSectionController(ctx, layout, sanitizer); +- var model = new PageSection { PageId = ctx.Pages.First().Id, Area = "md", Html = "# Hello\n", Type = PageSectionType.Markdown }; ++ var model = new PageSection { PageId = ctx.Pages.First().Id, Zone = "md", Html = "# Hello\n", Type = PageSectionType.Markdown }; + var result = await controller.Create(model, null); + Assert.IsType(result); +- var section = ctx.PageSections.First(s => s.Area == "md"); ++ var section = ctx.PageSections.First(s => s.Zone == "md"); + Assert.Contains("

", section.Html); + Assert.DoesNotContain("(result); +- var section = ctx.PageSections.First(s => s.Area == "code"); ++ var section = ctx.PageSections.First(s => s.Zone == "code"); + Assert.Contains("<b>test</b>", section.Html); + } + +@@ -124,10 +124,10 @@ public class SanitizationTests + var bytes = new byte[] {1,2,3}; + using var stream = new System.IO.MemoryStream(bytes); + var file = new FormFile(stream, 0, bytes.Length, "file", "img.png"); +- var model = new PageSection { PageId = ctx.Pages.First().Id, Area = "img", Type = PageSectionType.Image }; ++ var model = new PageSection { PageId = ctx.Pages.First().Id, Zone = "img", Type = PageSectionType.Image }; + var result = await controller.Create(model, file); + Assert.IsType(result); +- var section = ctx.PageSections.First(s => s.Area == "img"); ++ var section = ctx.PageSections.First(s => s.Zone == "img"); + Assert.Contains(" AddToPage(int id, int pageId, string area) ++ public async Task AddToPage(int id, int pageId, string zone) + { + var template = await _db.BlockTemplates.FindAsync(id); + var page = await _db.Pages.FindAsync(pageId); +@@ -143,23 +143,23 @@ public class AdminBlockTemplateController : Controller + { + return NotFound(); + } +- area = area?.Trim() ?? string.Empty; +- if (string.IsNullOrEmpty(area)) ++ zone = zone?.Trim() ?? string.Empty; ++ if (string.IsNullOrEmpty(zone)) + { + await LoadPagesAsync(); + ViewBag.BlockId = id; +- ModelState.AddModelError("area", "Area required"); ++ ModelState.AddModelError("zone", "Zone required"); + return View(); + } + var sort = await _db.PageSections +- .Where(s => s.PageId == pageId && s.Area == area) ++ .Where(s => s.PageId == pageId && s.Zone == zone) + .Select(s => s.SortOrder) + .DefaultIfEmpty(-1) + .MaxAsync() + 1; + var section = new PageSection + { + PageId = pageId, +- Area = area, ++ Zone = zone, + SortOrder = sort, + Html = template.Html, + Type = PageSectionType.Html +@@ -192,12 +192,12 @@ public class AdminBlockTemplateController : Controller + [HttpGet] + public async Task GetSections(int id) + { +- var areas = await _db.PageSections.AsNoTracking() ++ var zones = await _db.PageSections.AsNoTracking() + .Where(s => s.PageId == id) +- .Select(s => s.Area) ++ .Select(s => s.Zone) + .Distinct() + .OrderBy(a => a) + .ToListAsync(); +- return Json(areas); ++ return Json(zones); + } + } +diff --git a/website/MyWebApp/Controllers/AdminContentController.cs b/website/MyWebApp/Controllers/AdminContentController.cs +index 7e54fea..bd78896 100644 +--- a/website/MyWebApp/Controllers/AdminContentController.cs ++++ b/website/MyWebApp/Controllers/AdminContentController.cs +@@ -65,11 +65,11 @@ public class AdminContentController : Controller + model.PublishDate = DateTime.UtcNow; + } + var sections = model.Sections?.ToList() ?? new List(); +- if (sections.Any(s => !LayoutService.IsValidArea(model.Layout, s.Area))) ++ if (sections.Any(s => !LayoutService.IsValidZone(model.Layout, s.Zone))) + { + ModelState.AddModelError(string.Empty, "Invalid area for selected layout."); + } +- if (!sections.Any(s => s.Area == "main")) ++ if (!sections.Any(s => s.Zone == "main")) + { + ModelState.AddModelError(string.Empty, "Main area cannot be empty."); + } +@@ -131,11 +131,11 @@ public class AdminContentController : Controller + model.PublishDate = DateTime.UtcNow; + } + var sections = model.Sections?.ToList() ?? new List(); +- if (sections.Any(s => !LayoutService.IsValidArea(model.Layout, s.Area))) ++ if (sections.Any(s => !LayoutService.IsValidZone(model.Layout, s.Zone))) + { + ModelState.AddModelError(string.Empty, "Invalid area for selected layout."); + } +- if (!sections.Any(s => s.Area == "main")) ++ if (!sections.Any(s => s.Zone == "main")) + { + ModelState.AddModelError(string.Empty, "Main area cannot be empty."); + } +diff --git a/website/MyWebApp/Controllers/AdminPageSectionController.cs b/website/MyWebApp/Controllers/AdminPageSectionController.cs +index d440e5f..fc748ee 100644 +--- a/website/MyWebApp/Controllers/AdminPageSectionController.cs ++++ b/website/MyWebApp/Controllers/AdminPageSectionController.cs +@@ -31,9 +31,9 @@ public class AdminPageSectionController : Controller + if (!string.IsNullOrWhiteSpace(q)) + { + q = q.ToLowerInvariant(); +- query = query.Where(s => s.Area.ToLower().Contains(q) || s.Html.ToLower().Contains(q) || s.Page.Slug.ToLower().Contains(q)); ++ query = query.Where(s => s.Zone.ToLower().Contains(q) || s.Html.ToLower().Contains(q) || s.Page.Slug.ToLower().Contains(q)); + } +- var sections = await query.OrderBy(s => s.Page.Slug).ThenBy(s => s.Area).ToListAsync(); ++ var sections = await query.OrderBy(s => s.Page.Slug).ThenBy(s => s.Zone).ToListAsync(); + ViewBag.Query = q; + return View(sections); + } +@@ -59,11 +59,6 @@ public class AdminPageSectionController : Controller + await LoadPagesAsync(); + return View(model); + } +- var pageLayout = await _db.Pages.Where(p => p.Id == model.PageId).Select(p => p.Layout).FirstOrDefaultAsync(); +- if (!LayoutService.IsValidArea(pageLayout ?? "single-column", model.Area)) +- { +- ModelState.AddModelError(string.Empty, "Invalid area for selected layout."); +- } + if (!ModelState.IsValid) + { + await LoadPagesAsync(); +@@ -93,11 +88,6 @@ public class AdminPageSectionController : Controller + await LoadPagesAsync(); + return View(model); + } +- var pageLayout = await _db.Pages.Where(p => p.Id == model.PageId).Select(p => p.Layout).FirstOrDefaultAsync(); +- if (!LayoutService.IsValidArea(pageLayout ?? "single-column", model.Area)) +- { +- ModelState.AddModelError(string.Empty, "Invalid area for selected layout."); +- } + if (!ModelState.IsValid) + { + await LoadPagesAsync(); +@@ -165,10 +155,10 @@ public class AdminPageSectionController : Controller + } + + [HttpGet] +- public async Task GetAreasForPage(int id) ++ public async Task GetZonesForPage(int id) + { + var layout = await _db.Pages.Where(p => p.Id == id).Select(p => p.Layout).FirstOrDefaultAsync() ?? "single-column"; +- var areas = LayoutService.GetAreas(layout); +- return Json(areas); ++ var zones = LayoutService.GetZones(layout); ++ return Json(zones); + } + } +diff --git a/website/MyWebApp/Data/ApplicationDbContext.cs b/website/MyWebApp/Data/ApplicationDbContext.cs +index 3a5f5e0..d1f2415 100644 +--- a/website/MyWebApp/Data/ApplicationDbContext.cs ++++ b/website/MyWebApp/Data/ApplicationDbContext.cs +@@ -54,7 +54,7 @@ namespace MyWebApp.Data + + modelBuilder.Entity() + +- .HasIndex(s => new { s.PageId, s.Area, s.SortOrder }); ++ .HasIndex(s => new { s.PageId, s.Zone, s.SortOrder }); + + + modelBuilder.Entity() +@@ -105,7 +105,7 @@ namespace MyWebApp.Data + { + Id = 1, + PageId = 1, +- Area = "header", ++ Zone = "header", + SortOrder = 0, + + Type = PageSectionType.Html, +@@ -117,7 +117,7 @@ namespace MyWebApp.Data + { + Id = 2, + PageId = 1, +- Area = "footer", ++ Zone = "footer", + SortOrder = 0, + + Type = PageSectionType.Html, +diff --git a/website/MyWebApp/Migrations/20250617_RenameAreaToZone.cs b/website/MyWebApp/Migrations/20250617_RenameAreaToZone.cs +new file mode 100644 +index 0000000..0b2e978 +--- /dev/null ++++ b/website/MyWebApp/Migrations/20250617_RenameAreaToZone.cs +@@ -0,0 +1,23 @@ ++using Microsoft.EntityFrameworkCore.Migrations; ++ ++namespace MyWebApp.Migrations ++{ ++ public partial class _20250617_RenameAreaToZone : Migration ++ { ++ protected override void Up(MigrationBuilder migrationBuilder) ++ { ++ migrationBuilder.RenameColumn( ++ name: "Area", ++ table: "PageSections", ++ newName: "Zone"); ++ } ++ ++ protected override void Down(MigrationBuilder migrationBuilder) ++ { ++ migrationBuilder.RenameColumn( ++ name: "Zone", ++ table: "PageSections", ++ newName: "Area"); ++ } ++ } ++} +diff --git a/website/MyWebApp/Migrations/ApplicationDbContextModelSnapshot.cs b/website/MyWebApp/Migrations/ApplicationDbContextModelSnapshot.cs +new file mode 100644 +index 0000000..872d909 +--- /dev/null ++++ b/website/MyWebApp/Migrations/ApplicationDbContextModelSnapshot.cs +@@ -0,0 +1,14 @@ ++using Microsoft.EntityFrameworkCore; ++using Microsoft.EntityFrameworkCore.Infrastructure; ++using MyWebApp.Data; ++ ++namespace MyWebApp.Migrations ++{ ++ [DbContext(typeof(ApplicationDbContext))] ++ partial class ApplicationDbContextModelSnapshot : ModelSnapshot ++ { ++ protected override void BuildModel(ModelBuilder modelBuilder) ++ { ++ } ++ } ++} +diff --git a/website/MyWebApp/Models/PageSection.cs b/website/MyWebApp/Models/PageSection.cs +index 294236d..f0cf8c5 100644 +--- a/website/MyWebApp/Models/PageSection.cs ++++ b/website/MyWebApp/Models/PageSection.cs +@@ -21,7 +21,7 @@ public class PageSection + + [Required] + [MaxLength(64)] +- public string Area { get; set; } = string.Empty; ++ public string Zone { get; set; } = string.Empty; + + public int SortOrder { get; set; } + +diff --git a/website/MyWebApp/Program.cs b/website/MyWebApp/Program.cs +index da50f5a..650cc87 100644 +--- a/website/MyWebApp/Program.cs ++++ b/website/MyWebApp/Program.cs +@@ -317,7 +317,7 @@ static void UpgradePageSectionsTable(ApplicationDbContext db) + db.Database.ExecuteSqlRaw(@"CREATE TABLE PageSections ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + PageId INTEGER NOT NULL, +- Area TEXT NOT NULL, ++ Zone TEXT NOT NULL, + SortOrder INTEGER NOT NULL DEFAULT 0, + Type INTEGER NOT NULL DEFAULT 0, + Html TEXT, +@@ -327,8 +327,8 @@ static void UpgradePageSectionsTable(ApplicationDbContext db) + ViewCount INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(PageId) REFERENCES Pages(Id) ON DELETE CASCADE + )"); +- db.Database.ExecuteSqlRaw("CREATE INDEX IX_PageSections_PageId_Area_SortOrder ON PageSections(PageId, Area, SortOrder)"); +- db.Database.ExecuteSqlRaw(@"INSERT INTO PageSections (Id, PageId, Area, SortOrder, Type, Html) VALUES ++ db.Database.ExecuteSqlRaw("CREATE INDEX IX_PageSections_PageId_Zone_SortOrder ON PageSections(PageId, Zone, SortOrder)"); ++ db.Database.ExecuteSqlRaw(@"INSERT INTO PageSections (Id, PageId, Zone, SortOrder, Type, Html) VALUES + (1, 1, 'header', 0, 0, ''), + (2, 1, 'footer', 0, 0, '
© 2025 - Screen Area Recorder Pro
')"); + } +@@ -342,6 +342,8 @@ static void UpgradePageSectionsTable(ApplicationDbContext db) + columns.Add(reader.GetString(1)); + } + reader.Close(); ++ if (columns.Contains("Area") && !columns.Contains("Zone")) ++ db.Database.ExecuteSqlRaw("ALTER TABLE PageSections RENAME COLUMN Area TO Zone"); + if (!columns.Contains("SortOrder")) + db.Database.ExecuteSqlRaw("ALTER TABLE PageSections ADD COLUMN SortOrder INTEGER NOT NULL DEFAULT 0"); + if (!columns.Contains("Type")) +@@ -365,8 +367,8 @@ static void UpgradePageSectionsTable(ApplicationDbContext db) + idx.Close(); + if (indexes.Contains("IX_PageSections_PageId_Area")) + db.Database.ExecuteSqlRaw("DROP INDEX IX_PageSections_PageId_Area"); +- if (!indexes.Contains("IX_PageSections_PageId_Area_SortOrder")) +- db.Database.ExecuteSqlRaw("CREATE INDEX IX_PageSections_PageId_Area_SortOrder ON PageSections(PageId, Area, SortOrder)"); ++ if (!indexes.Contains("IX_PageSections_PageId_Zone_SortOrder")) ++ db.Database.ExecuteSqlRaw("CREATE INDEX IX_PageSections_PageId_Zone_SortOrder ON PageSections(PageId, Zone, SortOrder)"); + } + } + catch (Exception ex) +@@ -562,7 +564,7 @@ static void UpgradeLayoutHeader(ApplicationDbContext db) + return; + + var section = db.PageSections +- .FirstOrDefault(s => s.PageId == layoutId && s.Area == "header"); ++ .FirstOrDefault(s => s.PageId == layoutId && s.Zone == "header"); + if (section == null) + return; + +diff --git a/website/MyWebApp/Services/LayoutService.cs b/website/MyWebApp/Services/LayoutService.cs +index a53b4c6..a9aed2f 100644 +--- a/website/MyWebApp/Services/LayoutService.cs ++++ b/website/MyWebApp/Services/LayoutService.cs +@@ -17,12 +17,12 @@ public class LayoutService + ["two-column-sidebar"] = new[] { "main", "sidebar" } + }; + +- public static bool IsValidArea(string layout, string area) ++ public static bool IsValidZone(string layout, string zone) + { +- return LayoutZones.TryGetValue(layout, out var zones) && zones.Contains(area); ++ return LayoutZones.TryGetValue(layout, out var zones) && zones.Contains(zone); + } + +- public static string[] GetAreas(string layout) ++ public static string[] GetZones(string layout) + { + return LayoutZones.TryGetValue(layout, out var zones) ? zones : Array.Empty(); + } +@@ -39,7 +39,7 @@ public class LayoutService + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var parts = await db.PageSections.AsNoTracking() +- .Where(s => s.Page.Slug == "layout" && s.Area == "header") ++ .Where(s => s.Page.Slug == "layout" && s.Zone == "header") + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); +@@ -54,7 +54,7 @@ public class LayoutService + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var parts = await db.PageSections.AsNoTracking() +- .Where(s => s.Page.Slug == "layout" && s.Area == "footer") ++ .Where(s => s.Page.Slug == "layout" && s.Zone == "footer") + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); +@@ -63,11 +63,11 @@ public class LayoutService + }); + } + +- public async Task GetSectionAsync(ApplicationDbContext db, int pageId, string area) ++ public async Task GetSectionAsync(ApplicationDbContext db, int pageId, string zone) + { +- ++ + var parts = await db.PageSections.AsNoTracking() +- .Where(s => s.PageId == pageId && s.Area == area) ++ .Where(s => s.PageId == pageId && s.Zone == zone) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); +diff --git a/website/MyWebApp/Services/TokenRenderService.cs b/website/MyWebApp/Services/TokenRenderService.cs +index a71ae00..cc6f4fb 100644 +--- a/website/MyWebApp/Services/TokenRenderService.cs ++++ b/website/MyWebApp/Services/TokenRenderService.cs +@@ -50,9 +50,9 @@ public class TokenRenderService + var parts = param.Split(':', 2); + if (parts.Length == 2 && int.TryParse(parts[0], out var pageId)) + { +- var area = parts[1]; ++ var zone = parts[1]; + var htmlParts = await db.PageSections.AsNoTracking() +- .Where(s => s.PageId == pageId && s.Area == area) ++ .Where(s => s.PageId == pageId && s.Zone == zone) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); +diff --git a/website/MyWebApp/TagHelpers/PageBlocksTagHelper.cs b/website/MyWebApp/TagHelpers/PageBlocksTagHelper.cs +index 6f8c8cb..2f7dc72 100644 +--- a/website/MyWebApp/TagHelpers/PageBlocksTagHelper.cs ++++ b/website/MyWebApp/TagHelpers/PageBlocksTagHelper.cs +@@ -17,13 +17,13 @@ public class PageBlocksTagHelper : TagHelper + } + + public int PageId { get; set; } +- public string Area { get; set; } = string.Empty; ++ public string Zone { get; set; } = string.Empty; + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + output.TagName = null; + var htmlParts = await _db.PageSections.AsNoTracking() +- .Where(s => s.PageId == PageId && s.Area == Area) ++ .Where(s => s.PageId == PageId && s.Zone == Zone) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); +diff --git a/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml b/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml +index a9c6d99..8e06142 100644 +--- a/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml ++++ b/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml +@@ -16,8 +16,8 @@ + +

+
+- +- ++ ++ +
+ + +diff --git a/website/MyWebApp/Views/AdminContent/_SectionEditor.cshtml b/website/MyWebApp/Views/AdminContent/_SectionEditor.cshtml +index 01027e7..82e7f54 100644 +--- a/website/MyWebApp/Views/AdminContent/_SectionEditor.cshtml ++++ b/website/MyWebApp/Views/AdminContent/_SectionEditor.cshtml +@@ -15,8 +15,8 @@ + + +
+- +- ++ ++ +
+
+ +diff --git a/website/MyWebApp/Views/AdminPageSection/Create.cshtml b/website/MyWebApp/Views/AdminPageSection/Create.cshtml +index 3e1d846..4f30413 100644 +--- a/website/MyWebApp/Views/AdminPageSection/Create.cshtml ++++ b/website/MyWebApp/Views/AdminPageSection/Create.cshtml +@@ -12,12 +12,12 @@ + +
+
+- +- ++ ++ +
+ + @await Html.PartialAsync("_SectionEditor", Model) + + + +- ++ +diff --git a/website/MyWebApp/Views/AdminPageSection/Delete.cshtml b/website/MyWebApp/Views/AdminPageSection/Delete.cshtml +index a93a86a..a426762 100644 +--- a/website/MyWebApp/Views/AdminPageSection/Delete.cshtml ++++ b/website/MyWebApp/Views/AdminPageSection/Delete.cshtml +@@ -6,7 +6,7 @@ +

Delete Section

+
+ +-

Are you sure you want to delete @Model.Area for page @Model.PageId?

++

Are you sure you want to delete @Model.Zone for page @Model.PageId?

+ + Cancel +
+diff --git a/website/MyWebApp/Views/AdminPageSection/Edit.cshtml b/website/MyWebApp/Views/AdminPageSection/Edit.cshtml +index 641ab5c..590e245 100644 +--- a/website/MyWebApp/Views/AdminPageSection/Edit.cshtml ++++ b/website/MyWebApp/Views/AdminPageSection/Edit.cshtml +@@ -13,12 +13,12 @@ + +
+
+- +- ++ ++ +
+ + @await Html.PartialAsync("_SectionEditor", Model) + + + +- ++ +diff --git a/website/MyWebApp/Views/AdminPageSection/Index.cshtml b/website/MyWebApp/Views/AdminPageSection/Index.cshtml +index eb878dc..8115b47 100644 +--- a/website/MyWebApp/Views/AdminPageSection/Index.cshtml ++++ b/website/MyWebApp/Views/AdminPageSection/Index.cshtml +@@ -12,7 +12,7 @@ + + + +- ++ + + + +@@ -20,7 +20,7 @@ + { + + +- ++ + + + +diff --git a/website/MyWebApp/wwwroot/css/admin.css b/website/MyWebApp/wwwroot/css/admin.css +index ac0c040..e891877 100644 +--- a/website/MyWebApp/wwwroot/css/admin.css ++++ b/website/MyWebApp/wwwroot/css/admin.css +@@ -1278,15 +1278,15 @@ form.mb-3 { + background: #0ea5e9; + color: #fff; + } +-.area-group { ++.zone-group { + border: 1px solid #e2e8f0; + padding: 0.5rem; + margin-bottom: 1rem; + } +-.area-group h3 { ++.zone-group h3 { + margin: 0 0 0.5rem 0; + text-transform: capitalize; + } +-.area-sections { ++.zone-sections { + min-height: 10px; + } +diff --git a/website/MyWebApp/wwwroot/js/page-editor.js b/website/MyWebApp/wwwroot/js/page-editor.js +index 9dec3f2..533e07c 100644 +--- a/website/MyWebApp/wwwroot/js/page-editor.js ++++ b/website/MyWebApp/wwwroot/js/page-editor.js +@@ -10,32 +10,32 @@ window.addEventListener('load', () => { + + function buildGroups() { + container.innerHTML = ''; +- (layoutZones[currentLayout] || []).forEach(a => { ++ (layoutZones[currentLayout] || []).forEach(z => { + const group = document.createElement('div'); +- group.className = 'area-group'; +- group.dataset.area = a; ++ group.className = 'zone-group'; ++ group.dataset.zone = z; + const h = document.createElement('h3'); +- h.textContent = a; ++ h.textContent = z; + const div = document.createElement('div'); +- div.className = 'area-sections'; ++ div.className = 'zone-sections'; + group.appendChild(h); + group.appendChild(div); + container.appendChild(group); + }); + } + +- function populateAreas(select) { ++ function populateZones(select) { + if (!select) return; + const current = select.dataset.selected || select.value; +- select.innerHTML = (layoutZones[currentLayout] || []).map(a => ``).join(''); ++ select.innerHTML = (layoutZones[currentLayout] || []).map(z => ``).join(''); + if (current) select.value = current; + select.dataset.selected = ''; + } + + function placeSection(section) { +- const select = section.querySelector('.area-select'); +- const area = select ? select.value : 'main'; +- const group = container.querySelector(`.area-group[data-area='${area}'] .area-sections`); ++ const select = section.querySelector('.zone-select'); ++ const zone = select ? select.value : 'main'; ++ const group = container.querySelector(`.zone-group[data-zone='${zone}'] .zone-sections`); + if (group) group.appendChild(section); + } + +@@ -43,11 +43,11 @@ window.addEventListener('load', () => { + const preview = document.getElementById('layout-preview'); + if (!preview) return; + preview.innerHTML = ''; +- (layoutZones[currentLayout] || []).forEach(a => { ++ (layoutZones[currentLayout] || []).forEach(z => { + const div = document.createElement('div'); + div.className = 'preview-zone'; +- div.dataset.area = a; +- div.textContent = a; ++ div.dataset.zone = z; ++ div.textContent = z; + preview.appendChild(div); + }); + } +@@ -56,9 +56,9 @@ window.addEventListener('load', () => { + const zone = e.target.closest('.preview-zone'); + if (!zone) return; + if (activeIndex !== null) { +- const select = document.querySelector(`.area-select[data-index='${activeIndex}']`); ++ const select = document.querySelector(`.zone-select[data-index='${activeIndex}']`); + if (select) { +- select.value = zone.dataset.area; ++ select.value = zone.dataset.zone; + placeSection(select.closest('.section-editor')); + updateIndexes(); + } +@@ -91,7 +91,7 @@ window.addEventListener('load', () => { + buildGroups(); + existing.forEach(el => { + const idx = el.dataset.index; +- populateAreas(el.querySelector('.area-select')); ++ populateZones(el.querySelector('.zone-select')); + placeSection(el); + initSectionEditor(idx); + }); +@@ -105,7 +105,7 @@ window.addEventListener('load', () => { + currentLayout = layoutSelect.value; + buildGroups(); + document.querySelectorAll('.section-editor').forEach(sec => { +- populateAreas(sec.querySelector('.area-select')); ++ populateZones(sec.querySelector('.zone-select')); + placeSection(sec); + }); + updateIndexes(); +@@ -123,7 +123,7 @@ window.addEventListener('load', () => { + }); + + container.addEventListener('change', e => { +- if (e.target.classList.contains('area-select')) { ++ if (e.target.classList.contains('zone-select')) { + const section = e.target.closest('.section-editor'); + placeSection(section); + updateIndexes(); +@@ -137,7 +137,7 @@ window.addEventListener('load', () => { + temp.innerHTML = html; + const section = temp.firstElementChild; + section.dataset.index = index; +- populateAreas(section.querySelector('.area-select')); ++ populateZones(section.querySelector('.zone-select')); + placeSection(section); + initSectionEditor(index); + updateIndexes(); +@@ -163,7 +163,7 @@ window.addEventListener('load', () => { + dest.value = src.value; + } + }); +- populateAreas(clone.querySelector('.area-select')); ++ populateZones(clone.querySelector('.zone-select')); + placeSection(clone); + initSectionEditor(index); + if (editors[original.dataset.index]) { +diff --git a/website/MyWebApp/wwwroot/js/page-section-area.js b/website/MyWebApp/wwwroot/js/page-section-area.js +deleted file mode 100644 +index c105b77..0000000 +--- a/website/MyWebApp/wwwroot/js/page-section-area.js ++++ /dev/null +@@ -1,19 +0,0 @@ +-window.addEventListener('load', () => { +- const pageSelect = document.querySelector('select[name="PageId"]'); +- const areaSelect = document.getElementById('area-select'); +- if (!pageSelect || !areaSelect) return; +- +- function loadAreas() { +- const id = pageSelect.value; +- if (!id) { areaSelect.innerHTML = ''; return; } +- fetch(`/AdminPageSection/GetAreasForPage/${id}`) +- .then(r => r.json()) +- .then(list => { +- areaSelect.innerHTML = list.map(a => ``).join(''); +- if (areaSelect.dataset.selected) +- areaSelect.value = areaSelect.dataset.selected; +- }); +- } +- loadAreas(); +- pageSelect.addEventListener('change', loadAreas); +-}); +diff --git a/website/MyWebApp/wwwroot/js/page-section-zone.js b/website/MyWebApp/wwwroot/js/page-section-zone.js +new file mode 100644 +index 0000000..9b2d0d3 +--- /dev/null ++++ b/website/MyWebApp/wwwroot/js/page-section-zone.js +@@ -0,0 +1,19 @@ ++window.addEventListener('load', () => { ++ const pageSelect = document.querySelector('select[name="PageId"]'); ++ const zoneSelect = document.getElementById('zone-select'); ++ if (!pageSelect || !zoneSelect) return; ++ ++ function loadZones() { ++ const id = pageSelect.value; ++ if (!id) { zoneSelect.innerHTML = ''; return; } ++ fetch(`/AdminPageSection/GetZonesForPage/${id}`) ++ .then(r => r.json()) ++ .then(list => { ++ zoneSelect.innerHTML = list.map(a => ``).join(''); ++ if (zoneSelect.dataset.selected) ++ zoneSelect.value = zoneSelect.dataset.selected; ++ }); ++ } ++ loadZones(); ++ pageSelect.addEventListener('change', loadZones); ++}); diff --git a/website/MyWebApp.Tests/AdminBlockTemplateControllerTests.cs b/website/MyWebApp.Tests/AdminBlockTemplateControllerTests.cs new file mode 100644 index 0000000..d4632be --- /dev/null +++ b/website/MyWebApp.Tests/AdminBlockTemplateControllerTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using MyWebApp.Controllers; +using MyWebApp.Data; +using MyWebApp.Models; +using MyWebApp.Services; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +public class AdminBlockTemplateControllerTests +{ + private static (AdminBlockTemplateController controller, ApplicationDbContext ctx, SqliteConnection conn) Create() + { + var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(conn) + .Options; + var ctx = new ApplicationDbContext(options); + ctx.Database.EnsureCreated(); + var sanitizer = new HtmlSanitizerService(); + var controller = new AdminBlockTemplateController(ctx, sanitizer); + return (controller, ctx, conn); + } + + [Fact] + public async Task AddToPage_InvalidReturnsViewWithSelections() + { + var tuple = Create(); + using var connection = tuple.conn; + var ctx = tuple.ctx; + var controller = tuple.controller; + var template = new BlockTemplate { Name = "b", Html = "x" }; + ctx.BlockTemplates.Add(template); + ctx.SaveChanges(); + + var homeId = ctx.Pages.Single(p => p.Slug == "home").Id; + var result = await controller.AddToPage(template.Id, new List { homeId }, "", "Admin"); + var view = Assert.IsType(result); + Assert.False(controller.ModelState.IsValid); + var selected = Assert.IsType>(controller.ViewBag.SelectedPageIds); + Assert.Contains(homeId, selected); + Assert.Equal("", controller.ViewBag.SelectedZone as string); + Assert.Equal("Admin", controller.ViewBag.SelectedRole as string); + } + + [Fact] + public async Task Create_InvalidModelPreservesSelections() + { + var tuple = Create(); + using var connection = tuple.conn; + var ctx = tuple.ctx; + var controller = tuple.controller; + var homeId = ctx.Pages.Single(p => p.Slug == "home").Id; + var model = new BlockTemplate(); + controller.ModelState.AddModelError("Name", "required"); + var result = await controller.Create(model, new List { homeId }, "main", "Admin"); + var view = Assert.IsType(result); + Assert.False(controller.ModelState.IsValid); + var selected = Assert.IsType>(controller.ViewBag.SelectedPageIds); + Assert.Contains(homeId, selected); + Assert.Equal("main", controller.ViewBag.SelectedZone as string); + Assert.Equal("Admin", controller.ViewBag.SelectedRole as string); + } +} diff --git a/website/MyWebApp.Tests/BasicAuthAttributeTests.cs b/website/MyWebApp.Tests/BasicAuthAttributeTests.cs index 3a4705c..0f96b1c 100644 --- a/website/MyWebApp.Tests/BasicAuthAttributeTests.cs +++ b/website/MyWebApp.Tests/BasicAuthAttributeTests.cs @@ -34,7 +34,7 @@ public void NoHeader_ReturnsUnauthorized() var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); services.Configure(o => { o.Username = "admin"; o.Password = "SecurePass123"; }); services.AddSingleton(new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { {"AdminAuth:Username","admin"}, {"AdminAuth:Password","SecurePass123"} }).Build()); + .AddInMemoryCollection(new Dictionary { { "AdminAuth:Username", "admin" }, { "AdminAuth:Password", "SecurePass123" } }).Build()); var provider = services.BuildServiceProvider(); var http = new DefaultHttpContext { RequestServices = provider }; var ctx = new AuthorizationFilterContext( @@ -51,7 +51,7 @@ public void ValidHeader_AllowsAccess() var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); services.Configure(o => { o.Username = "admin"; o.Password = "SecurePass123"; }); services.AddSingleton(new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { {"AdminAuth:Username","admin"}, {"AdminAuth:Password","SecurePass123"} }).Build()); + .AddInMemoryCollection(new Dictionary { { "AdminAuth:Username", "admin" }, { "AdminAuth:Password", "SecurePass123" } }).Build()); var provider = services.BuildServiceProvider(); var http = new DefaultHttpContext { RequestServices = provider }; var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:SecurePass123")); @@ -71,7 +71,7 @@ public void WrongHeader_ReturnsUnauthorized() var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); services.Configure(o => { o.Username = "admin"; o.Password = "SecurePass123"; }); services.AddSingleton(new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { {"AdminAuth:Username","admin"}, {"AdminAuth:Password","SecurePass123"} }).Build()); + .AddInMemoryCollection(new Dictionary { { "AdminAuth:Username", "admin" }, { "AdminAuth:Password", "SecurePass123" } }).Build()); var provider = services.BuildServiceProvider(); var http = new DefaultHttpContext { RequestServices = provider }; var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:wrong")); @@ -103,7 +103,7 @@ public void Session_AllowsAccess() var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); services.Configure(o => { o.Username = "admin"; o.Password = "SecurePass123"; }); services.AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { {"AdminAuth:Username","admin"}, {"AdminAuth:Password","SecurePass123"} }).Build()); + .AddInMemoryCollection(new Dictionary { { "AdminAuth:Username", "admin" }, { "AdminAuth:Password", "SecurePass123" } }).Build()); var provider = services.BuildServiceProvider(); var http = new DefaultHttpContext { RequestServices = provider, Session = new DummySession() }; http.Session.SetString("IsAdmin", "true"); diff --git a/website/MyWebApp.Tests/LayoutServiceTests.cs b/website/MyWebApp.Tests/LayoutServiceTests.cs new file mode 100644 index 0000000..b759b0f --- /dev/null +++ b/website/MyWebApp.Tests/LayoutServiceTests.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; +using MyWebApp.Services; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Xunit; + +public class LayoutServiceTests +{ + [Fact] + public void CanReadZonesFromConfig() + { + var config = new ConfigurationBuilder().Build(); + var memory = new MemoryCache(new MemoryCacheOptions()); + var cache = new CacheService(memory); + var accessor = new HttpContextAccessor(); + var tokens = new TokenRenderService(accessor); + var service = new LayoutService(cache, tokens, accessor); + + Assert.True(LayoutService.LayoutZones.ContainsKey("single-column")); + Assert.Contains("sidebar", LayoutService.LayoutZones["two-column-sidebar"]); + } +} diff --git a/website/MyWebApp.Tests/NavigationTests.cs b/website/MyWebApp.Tests/NavigationTests.cs index 23677c1..7ead488 100644 --- a/website/MyWebApp.Tests/NavigationTests.cs +++ b/website/MyWebApp.Tests/NavigationTests.cs @@ -1,9 +1,11 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; using MyWebApp.Data; using MyWebApp.Models; using MyWebApp.Services; +using Microsoft.AspNetCore.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; using Xunit; @@ -22,8 +24,17 @@ public async Task PublishingPage_ShowsTitleOnceInHeader() context.Database.EnsureCreated(); var memory = new MemoryCache(new MemoryCacheOptions()); var cache = new CacheService(memory); - var tokens = new TokenRenderService(); - var layout = new LayoutService(cache, tokens); + var accessor = new HttpContextAccessor(); + var tokens = new TokenRenderService(accessor); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + {"Layouts:single-column:0", "main"}, + {"Layouts:two-column-sidebar:0", "main"}, + {"Layouts:two-column-sidebar:1", "sidebar"} + }) + .Build(); + var layout = new LayoutService(cache, tokens, accessor); context.Pages.Add(new Page { Slug = "about", Title = "About", Layout = "single-column", IsPublished = true }); context.SaveChanges(); diff --git a/website/MyWebApp.Tests/PageSectionTests.cs b/website/MyWebApp.Tests/PageSectionTests.cs index 29643b7..4c99507 100644 --- a/website/MyWebApp.Tests/PageSectionTests.cs +++ b/website/MyWebApp.Tests/PageSectionTests.cs @@ -21,9 +21,9 @@ public void CanAddAndRetrievePageSection() var page = new Page { Slug = "test", Title = "Test", Layout = "single-column" }; context.Pages.Add(page); context.SaveChanges(); - + context.PageSections.Add(new PageSection { PageId = page.Id, Zone = "header", Html = "

hi

", Type = PageSectionType.Html }); - + context.SaveChanges(); } diff --git a/website/MyWebApp.Tests/SanitizationTests.cs b/website/MyWebApp.Tests/SanitizationTests.cs index 3246fa6..188ce5d 100644 --- a/website/MyWebApp.Tests/SanitizationTests.cs +++ b/website/MyWebApp.Tests/SanitizationTests.cs @@ -1,6 +1,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; using MyWebApp.Controllers; @@ -11,7 +12,7 @@ public class SanitizationTests { - private static (ApplicationDbContext ctx, LayoutService layout, HtmlSanitizerService sanitizer) CreateServices() + private static (ApplicationDbContext ctx, LayoutService layout, ContentProcessingService content, TokenRenderService tokens, HtmlSanitizerService sanitizer) CreateServices() { var connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); @@ -22,17 +23,27 @@ private static (ApplicationDbContext ctx, LayoutService layout, HtmlSanitizerSer ctx.Database.EnsureCreated(); var memory = new MemoryCache(new MemoryCacheOptions()); var cache = new CacheService(memory); - var tokens = new TokenRenderService(); - var layout = new LayoutService(cache, tokens); + var accessor = new HttpContextAccessor(); + var tokens = new TokenRenderService(accessor); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + {"Layouts:single-column:0", "main"}, + {"Layouts:two-column-sidebar:0", "main"}, + {"Layouts:two-column-sidebar:1", "sidebar"} + }) + .Build(); + var layout = new LayoutService(cache, tokens, accessor); var sanitizer = new HtmlSanitizerService(); - return (ctx, layout, sanitizer); + var content = new ContentProcessingService(sanitizer); + return (ctx, layout, content, tokens, sanitizer); } - [Fact(Skip="Create sanitization covered by section tests")] + [Fact(Skip = "Create sanitization covered by section tests")] public async Task CreatePage_SanitizesHtml() { - var (ctx, layout, sanitizer) = CreateServices(); - var controller = new AdminContentController(ctx, layout, sanitizer); + var (ctx, layout, content, tokens, sanitizer) = CreateServices(); + var controller = new AdminContentController(ctx, layout, content, tokens, sanitizer); var model = new Page { Slug = "test", @@ -52,22 +63,22 @@ public async Task CreatePage_SanitizesHtml() [Fact] public async Task CreateSection_SanitizesHtml() { - var (ctx, layout, sanitizer) = CreateServices(); - var controller = new AdminPageSectionController(ctx, layout, sanitizer); - + var (ctx, layout, content, _, _) = CreateServices(); + var controller = new AdminPageSectionController(ctx, layout, content); + var model = new PageSection { PageId = ctx.Pages.First().Id, Zone = "main", Html = "
hi
", Type = PageSectionType.Html }; var result = await controller.Create(model, null); - + Assert.IsType(result); var section = ctx.PageSections.First(); Assert.DoesNotContain("(result); @@ -107,8 +118,8 @@ public async Task CreateSection_MarkdownConverted() [Fact] public async Task CreateSection_CodeEncoded() { - var (ctx, layout, sanitizer) = CreateServices(); - var controller = new AdminPageSectionController(ctx, layout, sanitizer); + var (ctx, layout, content, _, _) = CreateServices(); + var controller = new AdminPageSectionController(ctx, layout, content); var model = new PageSection { PageId = ctx.Pages.First().Id, Zone = "code", Html = "test", Type = PageSectionType.Code }; var result = await controller.Create(model, null); Assert.IsType(result); @@ -119,9 +130,9 @@ public async Task CreateSection_CodeEncoded() [Fact] public async Task CreateSection_ImageStoresTag() { - var (ctx, layout, sanitizer) = CreateServices(); - var controller = new AdminPageSectionController(ctx, layout, sanitizer); - var bytes = new byte[] {1,2,3}; + var (ctx, layout, content, _, _) = CreateServices(); + var controller = new AdminPageSectionController(ctx, layout, content); + var bytes = new byte[] { 1, 2, 3 }; using var stream = new System.IO.MemoryStream(bytes); var file = new FormFile(stream, 0, bytes.Length, "file", "img.png"); var model = new PageSection { PageId = ctx.Pages.First().Id, Zone = "img", Type = PageSectionType.Image }; diff --git a/website/MyWebApp/Controllers/AccountController.cs b/website/MyWebApp/Controllers/AccountController.cs index 63caa45..919a823 100644 --- a/website/MyWebApp/Controllers/AccountController.cs +++ b/website/MyWebApp/Controllers/AccountController.cs @@ -21,8 +21,9 @@ public class AccountController : Controller private bool HasRole(string role) { - var roles = HttpContext.Session.GetString("Roles")?.Split(',') ?? Array.Empty(); - return roles.Contains(role); + var roles = HttpContext.Session.GetString("Roles"); + var roleNames = string.IsNullOrWhiteSpace(roles) ? new[] { "Anonym" } : roles.Split(','); + return roleNames.Contains(role); } public AccountController(ApplicationDbContext db, CaptchaService captchaService, IEmailSender emailSender, ILogger logger) diff --git a/website/MyWebApp/Controllers/AdminBlockTemplateController.cs b/website/MyWebApp/Controllers/AdminBlockTemplateController.cs index 52799a5..34119a8 100644 --- a/website/MyWebApp/Controllers/AdminBlockTemplateController.cs +++ b/website/MyWebApp/Controllers/AdminBlockTemplateController.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Http; using System.Collections.Generic; +using System.Linq; using System.IO; using System.Text.Json; using MyWebApp.Data; @@ -26,6 +27,12 @@ public AdminBlockTemplateController(ApplicationDbContext db, HtmlSanitizerServic private async Task LoadPagesAsync() { ViewBag.Pages = await _db.Pages.AsNoTracking().OrderBy(p => p.Slug).ToListAsync(); + ViewBag.Roles = await _db.Roles.AsNoTracking().OrderBy(r => r.Name).ToListAsync(); + ViewBag.Zones = await _db.PageSections.AsNoTracking() + .Select(s => s.Zone) + .Distinct() + .OrderBy(z => z) + .ToListAsync(); } public async Task Index() @@ -34,20 +41,30 @@ public async Task Index() return View(items); } - public IActionResult Create() + public async Task Create() { + await LoadPagesAsync(); return View(new BlockTemplate()); } [HttpPost] [ValidateAntiForgeryToken] - public async Task Create(BlockTemplate model) + public async Task Create(BlockTemplate model, List? pageIds, string? zone, string? role) { - if (!ModelState.IsValid) return View(model); + if (!ModelState.IsValid) + { + await LoadPagesAsync(); + ViewBag.SelectedPageIds = pageIds ?? new List(); + ViewBag.SelectedZone = zone; + ViewBag.SelectedRole = role; + return View(model); + } model.Html = _sanitizer.Sanitize(model.Html); _db.BlockTemplates.Add(model); _db.BlockTemplateVersions.Add(new BlockTemplateVersion { Template = model, Html = model.Html }); await _db.SaveChangesAsync(); + await AddSectionsAsync(model, pageIds, zone, role); + await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } @@ -55,18 +72,28 @@ public async Task Edit(int id) { var item = await _db.BlockTemplates.FindAsync(id); if (item == null) return NotFound(); + await LoadPagesAsync(); return View(item); } [HttpPost] [ValidateAntiForgeryToken] - public async Task Edit(BlockTemplate model) + public async Task Edit(BlockTemplate model, List? pageIds, string? zone, string? role) { - if (!ModelState.IsValid) return View(model); + if (!ModelState.IsValid) + { + await LoadPagesAsync(); + ViewBag.SelectedPageIds = pageIds ?? new List(); + ViewBag.SelectedZone = zone; + ViewBag.SelectedRole = role; + return View(model); + } model.Html = _sanitizer.Sanitize(model.Html); _db.Update(model); _db.BlockTemplateVersions.Add(new BlockTemplateVersion { BlockTemplateId = model.Id, Html = model.Html }); await _db.SaveChangesAsync(); + await AddSectionsAsync(model, pageIds, zone, role); + await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } @@ -124,80 +151,125 @@ public async Task Import(IFormFile? file) return RedirectToAction(nameof(Index)); } + [HttpGet] + public async Task GetBlocks() + { + var items = await _db.BlockTemplates.AsNoTracking() + .OrderBy(t => t.Name) + .Select(t => new { t.Id, t.Name, Preview = t.Html.Length > 200 ? t.Html.Substring(0, 200) + "..." : t.Html }) + .ToListAsync(); + return Json(items); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CreateFromSection(string name, string html) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(html)) + return BadRequest(); + html = _sanitizer.Sanitize(html); + var t = new BlockTemplate { Name = name, Html = html }; + _db.BlockTemplates.Add(t); + _db.BlockTemplateVersions.Add(new BlockTemplateVersion { Template = t, Html = html }); + await _db.SaveChangesAsync(); + return Json(new { t.Id }); + } + public async Task AddToPage(int id) { var item = await _db.BlockTemplates.FindAsync(id); if (item == null) return NotFound(); await LoadPagesAsync(); ViewBag.BlockId = id; + ViewBag.SelectedPageIds = new List(); + ViewBag.SelectedZone = string.Empty; + ViewBag.SelectedRole = string.Empty; return View(); } [HttpPost] [ValidateAntiForgeryToken] - public async Task AddToPage(int id, int pageId, string zone) + public async Task AddToPage(int id, List pageIds, string zone, string role) { var template = await _db.BlockTemplates.FindAsync(id); - var page = await _db.Pages.FindAsync(pageId); - if (template == null || page == null) + if (template == null) return NotFound(); + if (pageIds == null || pageIds.Count == 0) { - return NotFound(); + await LoadPagesAsync(); + ViewBag.BlockId = id; + ViewBag.SelectedPageIds = pageIds ?? new List(); + ViewBag.SelectedZone = zone; + ViewBag.SelectedRole = role; + ModelState.AddModelError("pageIds", "Page selection required"); + return View(); } + var roleEntity = await _db.Roles.FirstOrDefaultAsync(r => r.Name == role); + int? roleId = roleEntity?.Id; zone = zone?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(zone)) { await LoadPagesAsync(); ViewBag.BlockId = id; + ViewBag.SelectedPageIds = pageIds; + ViewBag.SelectedZone = zone; + ViewBag.SelectedRole = role; ModelState.AddModelError("zone", "Zone required"); return View(); } - var sort = await _db.PageSections - .Where(s => s.PageId == pageId && s.Zone == zone) - .Select(s => s.SortOrder) - .DefaultIfEmpty(-1) - .MaxAsync() + 1; - var section = new PageSection + if (pageIds.Contains(0)) { - PageId = pageId, - Zone = zone, - SortOrder = sort, - Html = template.Html, - Type = PageSectionType.Html - }; - _db.PageSections.Add(section); + pageIds = await _db.Pages.Select(p => p.Id).ToListAsync(); + } + foreach (var pageId in pageIds) + { + var maxSort = await _db.PageSections + .Where(s => s.PageId == pageId && s.Zone == zone) + .Select(s => (int?)s.SortOrder) + .MaxAsync(); + var sort = (maxSort ?? -1) + 1; + var section = new PageSection + { + PageId = pageId, + Zone = zone, + SortOrder = sort, + Html = template.Html, + Type = PageSectionType.Html, + RoleId = roleId + }; + _db.PageSections.Add(section); + } await _db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } - [HttpGet] - public async Task GetBlocks() - { - var items = await _db.BlockTemplates.AsNoTracking() - .OrderBy(t => t.Name) - .Select(t => new { t.Id, t.Name }) - .ToListAsync(); - return Json(items); - } - - [HttpGet] - public async Task GetPages() - { - var pages = await _db.Pages.AsNoTracking() - .OrderBy(p => p.Slug) - .Select(p => new { p.Id, p.Slug }) - .ToListAsync(); - return Json(pages); - } - - [HttpGet] - public async Task GetSections(int id) + private async Task AddSectionsAsync(BlockTemplate template, List? pageIds, string? zone, string? role) { - var zones = await _db.PageSections.AsNoTracking() - .Where(s => s.PageId == id) - .Select(s => s.Zone) - .Distinct() - .OrderBy(a => a) - .ToListAsync(); - return Json(zones); + if (pageIds == null || pageIds.Count == 0 || string.IsNullOrWhiteSpace(zone)) + return; + var roleEntity = await _db.Roles.FirstOrDefaultAsync(r => r.Name == role); + int? roleId = roleEntity?.Id; + zone = zone!.Trim(); + if (pageIds.Contains(0)) + { + pageIds = await _db.Pages.Select(p => p.Id).ToListAsync(); + } + foreach (var pageId in pageIds) + { + var maxSort = await _db.PageSections + .Where(s => s.PageId == pageId && s.Zone == zone) + .Select(s => (int?)s.SortOrder) + .MaxAsync(); + var sort = (maxSort ?? -1) + 1; + var section = new PageSection + { + PageId = pageId, + Zone = zone, + SortOrder = sort, + Html = template.Html, + Type = PageSectionType.Html, + RoleId = roleId + }; + _db.PageSections.Add(section); + } } } diff --git a/website/MyWebApp/Controllers/AdminContentController.cs b/website/MyWebApp/Controllers/AdminContentController.cs index bd78896..99aef70 100644 --- a/website/MyWebApp/Controllers/AdminContentController.cs +++ b/website/MyWebApp/Controllers/AdminContentController.cs @@ -7,8 +7,6 @@ using MyWebApp.Models; using MyWebApp.Services; using Microsoft.AspNetCore.Http; -using Markdig; -using System.IO; using System.Linq; namespace MyWebApp.Controllers; @@ -18,12 +16,16 @@ public class AdminContentController : Controller { private readonly ApplicationDbContext _db; private readonly LayoutService _layout; + private readonly ContentProcessingService _content; + private readonly TokenRenderService _tokens; private readonly HtmlSanitizerService _sanitizer; - public AdminContentController(ApplicationDbContext db, LayoutService layout, HtmlSanitizerService sanitizer) + public AdminContentController(ApplicationDbContext db, LayoutService layout, ContentProcessingService content, TokenRenderService tokens, HtmlSanitizerService sanitizer) { _db = db; _layout = layout; + _content = content; + _tokens = tokens; _sanitizer = sanitizer; } @@ -33,6 +35,8 @@ private async Task LoadTemplatesAsync() .OrderBy(t => t.Name).ToListAsync(); ViewBag.Permissions = await _db.Permissions.AsNoTracking() .OrderBy(p => p.Name).ToListAsync(); + ViewBag.Roles = await _db.Roles.AsNoTracking() + .OrderBy(r => r.Name).ToListAsync(); } public async Task Index() @@ -43,11 +47,11 @@ public async Task Index() public async Task Create() { - + await LoadTemplatesAsync(); ViewBag.Sections = new List(); return View("PageEditor", new Page()); - + } [HttpPost] @@ -92,7 +96,7 @@ public async Task Create(Page model) s.Id = 0; s.PageId = model.Id; var file = files.FirstOrDefault(f => f.Name == $"Sections[{i}].File"); - await PrepareHtmlAsync(s, file); + await _content.PrepareHtmlAsync(s, file); _db.PageSections.Add(s); } await _db.SaveChangesAsync(); @@ -101,6 +105,25 @@ public async Task Create(Page model) return RedirectToAction(nameof(Index)); } + [HttpPost] + public async Task Preview([FromBody] PreviewRequest model) + { + var layout = string.IsNullOrWhiteSpace(model.Layout) ? "single-column" : model.Layout; + var zones = new Dictionary(); + foreach (var kv in model.Zones ?? new Dictionary()) + { + if (!LayoutService.IsValidZone(layout, kv.Key)) continue; + var clean = _sanitizer.Sanitize(kv.Value ?? string.Empty); + zones[kv.Key] = await _tokens.RenderAsync(_db, clean); + } + ViewBag.HeaderHtml = await _layout.GetHeaderAsync(_db); + ViewBag.FooterHtml = await _layout.GetFooterAsync(_db); + ViewBag.PageLayout = layout; + ViewBag.ZoneHtml = zones; + Response.Headers["Content-Security-Policy"] = "default-src 'self'"; + return View("~/Views/Pages/Show.cshtml", new Page { Title = model.Title }); + } + public async Task Edit(int id) { var page = await _db.Pages.FindAsync(id); @@ -113,7 +136,7 @@ public async Task Edit(int id) ViewBag.Sections = await _db.PageSections.Where(s => s.PageId == id) .OrderBy(s => s.SortOrder).ToListAsync(); return View("PageEditor", page); - + } [HttpPost] @@ -160,7 +183,7 @@ public async Task Edit(Page model) s.Id = 0; s.PageId = model.Id; var file = files.FirstOrDefault(f => f.Name == $"Sections[{i}].File"); - await PrepareHtmlAsync(s, file); + await _content.PrepareHtmlAsync(s, file); _db.PageSections.Add(s); } await _db.SaveChangesAsync(); @@ -169,38 +192,6 @@ public async Task Edit(Page model) return RedirectToAction(nameof(Index)); } - private async Task PrepareHtmlAsync(PageSection model, IFormFile? file) - { - switch (model.Type) - { - case PageSectionType.Html: - model.Html = _sanitizer.Sanitize(model.Html); - break; - case PageSectionType.Markdown: - var html = Markdig.Markdown.ToHtml(model.Html ?? string.Empty); - model.Html = _sanitizer.Sanitize(html); - break; - case PageSectionType.Code: - model.Html = $"
{System.Net.WebUtility.HtmlEncode(model.Html)}
"; - break; - case PageSectionType.Image: - case PageSectionType.Video: - if (file != null && file.Length > 0) - { - var uploads = Path.Combine("wwwroot", "uploads"); - Directory.CreateDirectory(uploads); - var name = Path.GetFileName(file.FileName); - var path = Path.Combine(uploads, name); - using var stream = new FileStream(path, FileMode.Create); - await file.CopyToAsync(stream); - if (model.Type == PageSectionType.Image) - model.Html = $""; - else - model.Html = $""; - } - break; - } - } public async Task Delete(int id) { diff --git a/website/MyWebApp/Controllers/AdminPageSectionController.cs b/website/MyWebApp/Controllers/AdminPageSectionController.cs index fc748ee..ecfa844 100644 --- a/website/MyWebApp/Controllers/AdminPageSectionController.cs +++ b/website/MyWebApp/Controllers/AdminPageSectionController.cs @@ -1,9 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Http; -using Markdig; -using System.IO; using MyWebApp.Data; using MyWebApp.Filters; using MyWebApp.Models; @@ -16,13 +13,13 @@ public class AdminPageSectionController : Controller { private readonly ApplicationDbContext _db; private readonly LayoutService _layout; - private readonly HtmlSanitizerService _sanitizer; + private readonly ContentProcessingService _content; - public AdminPageSectionController(ApplicationDbContext db, LayoutService layout, HtmlSanitizerService sanitizer) + public AdminPageSectionController(ApplicationDbContext db, LayoutService layout, ContentProcessingService content) { _db = db; _layout = layout; - _sanitizer = sanitizer; + _content = content; } public async Task Index(string? q) @@ -42,6 +39,7 @@ private async Task LoadPagesAsync() { ViewBag.Pages = await _db.Pages.AsNoTracking().OrderBy(p => p.Slug).ToListAsync(); ViewBag.Permissions = await _db.Permissions.AsNoTracking().OrderBy(p => p.Name).ToListAsync(); + ViewBag.Roles = await _db.Roles.AsNoTracking().OrderBy(r => r.Name).ToListAsync(); } public async Task Create() @@ -64,7 +62,7 @@ public async Task Create(PageSection model, IFormFile? file) await LoadPagesAsync(); return View(model); } - await PrepareHtmlAsync(model, file); + await _content.PrepareHtmlAsync(model, file); _db.PageSections.Add(model); await _db.SaveChangesAsync(); _layout.Reset(); @@ -93,45 +91,13 @@ public async Task Edit(PageSection model, IFormFile? file) await LoadPagesAsync(); return View(model); } - await PrepareHtmlAsync(model, file); + await _content.PrepareHtmlAsync(model, file); _db.Update(model); await _db.SaveChangesAsync(); _layout.Reset(); return RedirectToAction(nameof(Index)); } - private async Task PrepareHtmlAsync(PageSection model, IFormFile? file) - { - switch (model.Type) - { - case PageSectionType.Html: - model.Html = _sanitizer.Sanitize(model.Html); - break; - case PageSectionType.Markdown: - var html = Markdig.Markdown.ToHtml(model.Html ?? string.Empty); - model.Html = _sanitizer.Sanitize(html); - break; - case PageSectionType.Code: - model.Html = $"
{System.Net.WebUtility.HtmlEncode(model.Html)}
"; - break; - case PageSectionType.Image: - case PageSectionType.Video: - if (file != null && file.Length > 0) - { - var uploads = Path.Combine("wwwroot", "uploads"); - Directory.CreateDirectory(uploads); - var name = Path.GetFileName(file.FileName); - var path = Path.Combine(uploads, name); - using var stream = new FileStream(path, FileMode.Create); - await file.CopyToAsync(stream); - if (model.Type == PageSectionType.Image) - model.Html = $""; - else - model.Html = $""; - } - break; - } - } public async Task Delete(int id) { @@ -154,11 +120,4 @@ public async Task DeleteConfirmed(int id) return RedirectToAction(nameof(Index)); } - [HttpGet] - public async Task GetZonesForPage(int id) - { - var layout = await _db.Pages.Where(p => p.Id == id).Select(p => p.Layout).FirstOrDefaultAsync() ?? "single-column"; - var zones = LayoutService.GetZones(layout); - return Json(zones); - } } diff --git a/website/MyWebApp/Controllers/AdminRoleController.cs b/website/MyWebApp/Controllers/AdminRoleController.cs new file mode 100644 index 0000000..31da78d --- /dev/null +++ b/website/MyWebApp/Controllers/AdminRoleController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MyWebApp.Data; +using MyWebApp.Filters; +using MyWebApp.Models; +using System.Linq; +using System.Threading.Tasks; + +namespace MyWebApp.Controllers; + +[RoleAuthorize("Admin")] +public class AdminRoleController : Controller +{ + private readonly ApplicationDbContext _db; + + public AdminRoleController(ApplicationDbContext db) + { + _db = db; + } + + public async Task Index() + { + var roles = await _db.Roles.AsNoTracking().OrderBy(r => r.Name).ToListAsync(); + return View(roles); + } + + public IActionResult Create() + { + return View(new Role()); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(Role model) + { + if (!ModelState.IsValid) return View(model); + _db.Roles.Add(model); + await _db.SaveChangesAsync(); + return RedirectToAction(nameof(Index)); + } + + public async Task Edit(int id) + { + var role = await _db.Roles + .Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Id == id); + if (role == null) return NotFound(); + var permissions = await _db.Permissions.AsNoTracking().OrderBy(p => p.Name).ToListAsync(); + var vm = new RoleEditViewModel + { + Role = role, + SelectedPermissions = role.Permissions.Select(p => p.PermissionId).ToList() + }; + ViewBag.Permissions = permissions; + return View(vm); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(RoleEditViewModel model) + { + var role = await _db.Roles + .Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Id == model.Role.Id); + if (role == null) return NotFound(); + role.Name = model.Role.Name; + _db.RolePermissions.RemoveRange(role.Permissions); + role.Permissions.Clear(); + foreach (var pid in model.SelectedPermissions.Distinct()) + { + role.Permissions.Add(new RolePermission { RoleId = role.Id, PermissionId = pid }); + } + await _db.SaveChangesAsync(); + return RedirectToAction(nameof(Index)); + } + + public async Task Delete(int id) + { + var role = await _db.Roles.FindAsync(id); + if (role == null) return NotFound(); + return View(role); + } + + [HttpPost, ActionName("Delete")] + [ValidateAntiForgeryToken] + public async Task DeleteConfirmed(int id) + { + var role = await _db.Roles.FindAsync(id); + if (role != null) + { + _db.Roles.Remove(role); + await _db.SaveChangesAsync(); + } + return RedirectToAction(nameof(Index)); + } +} diff --git a/website/MyWebApp/Controllers/ApiController.cs b/website/MyWebApp/Controllers/ApiController.cs new file mode 100644 index 0000000..717e707 --- /dev/null +++ b/website/MyWebApp/Controllers/ApiController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MyWebApp.Data; +using MyWebApp.Filters; +using MyWebApp.Services; + +namespace MyWebApp.Controllers; + +[RoleAuthorize("Admin")] +public class ApiController : Controller +{ + private readonly ApplicationDbContext _db; + + public ApiController(ApplicationDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task GetBlocks() + { + var items = await _db.BlockTemplates.AsNoTracking() + .OrderBy(t => t.Name) + .Select(t => new { t.Id, t.Name }) + .ToListAsync(); + return Json(items); + } + + [HttpGet] + public async Task GetPages() + { + var pages = await _db.Pages.AsNoTracking() + .OrderBy(p => p.Slug) + .Select(p => new { p.Id, p.Slug }) + .ToListAsync(); + return Json(pages); + } + + [HttpGet] + public async Task GetSections(int id) + { + var zones = await _db.PageSections.AsNoTracking() + .Where(s => s.PageId == id) + .Select(s => s.Zone) + .Distinct() + .OrderBy(a => a) + .ToListAsync(); + return Json(zones); + } + + [HttpGet] + public async Task GetZonesForPage(int id) + { + var layout = await _db.Pages.Where(p => p.Id == id).Select(p => p.Layout).FirstOrDefaultAsync() ?? "single-column"; + var zones = LayoutService.GetZones(layout); + return Json(zones); + } +} diff --git a/website/MyWebApp/Controllers/BaseController.cs b/website/MyWebApp/Controllers/BaseController.cs index d3e348b..e8ddd1f 100644 --- a/website/MyWebApp/Controllers/BaseController.cs +++ b/website/MyWebApp/Controllers/BaseController.cs @@ -33,8 +33,9 @@ protected bool CheckDatabase() protected bool HasRole(string role) { - var roles = HttpContext.Session.GetString("Roles")?.Split(',') ?? Array.Empty(); - return roles.Contains(role); + var roles = HttpContext.Session.GetString("Roles"); + var roleNames = string.IsNullOrWhiteSpace(roles) ? new[] { "Anonym" } : roles.Split(','); + return roleNames.Contains(role); } protected bool IsAdmin() diff --git a/website/MyWebApp/Controllers/PagesController.cs b/website/MyWebApp/Controllers/PagesController.cs index 8d5cae0..dc94419 100644 --- a/website/MyWebApp/Controllers/PagesController.cs +++ b/website/MyWebApp/Controllers/PagesController.cs @@ -29,6 +29,16 @@ public async Task Show(string? slug) { return NotFound(); } + var roles = HttpContext.Session.GetString("Roles"); + var roleNames = string.IsNullOrWhiteSpace(roles) ? new[] { "Anonym" } : roles.Split(','); + if (page.RoleId != null) + { + var allowed = await Db.Roles.AsNoTracking().Where(r => roleNames.Contains(r.Name)).Select(r => r.Id).ToListAsync(); + if (!allowed.Contains(page.RoleId.Value)) + { + return Unauthorized(); + } + } var header = await _layout.GetSectionAsync(Db, page.Id, "header"); if (string.IsNullOrEmpty(header)) diff --git a/website/MyWebApp/Data/ApplicationDbContext.cs b/website/MyWebApp/Data/ApplicationDbContext.cs index d1f2415..4ef668a 100644 --- a/website/MyWebApp/Data/ApplicationDbContext.cs +++ b/website/MyWebApp/Data/ApplicationDbContext.cs @@ -53,9 +53,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .IsUnique(); modelBuilder.Entity() - + .HasIndex(s => new { s.PageId, s.Zone, s.SortOrder }); - + modelBuilder.Entity() .HasIndex(t => t.Token) @@ -90,14 +90,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Id = 1, Slug = "layout", Title = "Layout", - Layout = "single-column" + Layout = "single-column", + RoleId = null }, new Page { Id = 2, Slug = "home", Title = "Home", - Layout = "single-column" + Layout = "single-column", + RoleId = null }); modelBuilder.Entity().HasData( @@ -107,11 +109,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) PageId = 1, Zone = "header", SortOrder = 0, - + Type = PageSectionType.Html, - + Html = "" - , ViewCount = 0 + }, new PageSection { @@ -119,17 +121,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) PageId = 1, Zone = "footer", SortOrder = 0, - + Type = PageSectionType.Html, - + Html = "
© 2025 - Screen Area Recorder Pro
" - + }); modelBuilder.Entity().HasData( new Role { Id = 1, Name = "Admin" }, new Role { Id = 2, Name = "User" }, - new Role { Id = 3, Name = "Moderator" }); + new Role { Id = 3, Name = "Moderator" }, + new Role { Id = 4, Name = "Anonym" }); // provider specific optimizations var provider = Database.ProviderName ?? string.Empty; diff --git a/website/MyWebApp/Filters/RoleAuthorizeAttribute.cs b/website/MyWebApp/Filters/RoleAuthorizeAttribute.cs index dd09b5a..6bf3ebf 100644 --- a/website/MyWebApp/Filters/RoleAuthorizeAttribute.cs +++ b/website/MyWebApp/Filters/RoleAuthorizeAttribute.cs @@ -17,8 +17,9 @@ public RoleAuthorizeAttribute(params string[] roles) public void OnAuthorization(AuthorizationFilterContext context) { var session = context.HttpContext.Session; - var roles = session.GetString("Roles")?.Split(',') ?? Array.Empty(); - if (!_roles.Any(r => roles.Contains(r))) + var roles = session.GetString("Roles"); + var roleNames = string.IsNullOrWhiteSpace(roles) ? new[] { "Anonym" } : roles.Split(','); + if (!_roles.Any(r => roleNames.Contains(r))) { var returnUrl = context.HttpContext.Request.Path + context.HttpContext.Request.QueryString; context.Result = new RedirectToActionResult("Login", "Account", new { returnUrl }); diff --git a/website/MyWebApp/Models/AdminModels.cs b/website/MyWebApp/Models/AdminModels.cs index 7b98518..86273b7 100644 --- a/website/MyWebApp/Models/AdminModels.cs +++ b/website/MyWebApp/Models/AdminModels.cs @@ -40,4 +40,10 @@ public class FileStatsViewModel public DownloadFile File { get; set; } = new DownloadFile(); public int DownloadCount { get; set; } } + + public class RoleEditViewModel + { + public Role Role { get; set; } = new Role(); + public IList SelectedPermissions { get; set; } = new List(); + } } diff --git a/website/MyWebApp/Models/Page.cs b/website/MyWebApp/Models/Page.cs index ee8a599..71545c5 100644 --- a/website/MyWebApp/Models/Page.cs +++ b/website/MyWebApp/Models/Page.cs @@ -45,6 +45,10 @@ public class Page [MaxLength(256)] public string? FeaturedImage { get; set; } + public int? RoleId { get; set; } + + public Role? Role { get; set; } + public ICollection Sections { get; set; } = new List(); } } diff --git a/website/MyWebApp/Models/PageSection.cs b/website/MyWebApp/Models/PageSection.cs index f0cf8c5..8b97aa4 100644 --- a/website/MyWebApp/Models/PageSection.cs +++ b/website/MyWebApp/Models/PageSection.cs @@ -25,9 +25,9 @@ public class PageSection public int SortOrder { get; set; } - + public PageSectionType Type { get; set; } = PageSectionType.Html; - + public string Html { get; set; } = string.Empty; @@ -37,9 +37,12 @@ public class PageSection public int? PermissionId { get; set; } - public int ViewCount { get; set; } + public int? RoleId { get; set; } + public Page? Page { get; set; } public Permission? Permission { get; set; } + + public Role? Role { get; set; } } diff --git a/website/MyWebApp/Models/PreviewRequest.cs b/website/MyWebApp/Models/PreviewRequest.cs new file mode 100644 index 0000000..d918565 --- /dev/null +++ b/website/MyWebApp/Models/PreviewRequest.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace MyWebApp.Models; + +public class PreviewRequest +{ + public string Layout { get; set; } = "single-column"; + public string Title { get; set; } = string.Empty; + public Dictionary Zones { get; set; } = new(); +} + diff --git a/website/MyWebApp/Program.cs b/website/MyWebApp/Program.cs index 650cc87..3902050 100644 --- a/website/MyWebApp/Program.cs +++ b/website/MyWebApp/Program.cs @@ -149,6 +149,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var smtpSection = builder.Configuration.GetSection("Smtp"); @@ -184,19 +185,20 @@ { app.Logger.LogInformation("Database schema created."); } - if (provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase)) - { - db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;"); - db.Database.ExecuteSqlRaw("PRAGMA synchronous=NORMAL;"); + if (provider.Equals("sqlite", StringComparison.OrdinalIgnoreCase)) + { + db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;"); + db.Database.ExecuteSqlRaw("PRAGMA synchronous=NORMAL;"); - UpgradeDownloadFilesTable(db); - UpgradePageSectionsTable(db); - UpgradePagesTable(db); - UpgradeMediaItemsTable(db); - UpgradeBlockTemplatesTable(db); - UpgradePermissionsTable(db); - UpgradeLayoutHeader(db); - } + UpgradeDownloadFilesTable(db); + UpgradePageSectionsTable(db); + UpgradePagesTable(db); + UpgradeMediaItemsTable(db); + UpgradeBlockTemplatesTable(db); + UpgradeRolesTable(db); + UpgradePermissionsTable(db); + UpgradeLayoutHeader(db); + } if (db.Database.CanConnect()) { cacheService.WarmCache(db); @@ -324,7 +326,6 @@ static void UpgradePageSectionsTable(ApplicationDbContext db) StartDate TEXT, EndDate TEXT, PermissionId INTEGER, - ViewCount INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(PageId) REFERENCES Pages(Id) ON DELETE CASCADE )"); db.Database.ExecuteSqlRaw("CREATE INDEX IX_PageSections_PageId_Zone_SortOrder ON PageSections(PageId, Zone, SortOrder)"); @@ -354,8 +355,8 @@ FOREIGN KEY(PageId) REFERENCES Pages(Id) ON DELETE CASCADE db.Database.ExecuteSqlRaw("ALTER TABLE PageSections ADD COLUMN EndDate TEXT"); if (!columns.Contains("PermissionId")) db.Database.ExecuteSqlRaw("ALTER TABLE PageSections ADD COLUMN PermissionId INTEGER"); - if (!columns.Contains("ViewCount")) - db.Database.ExecuteSqlRaw("ALTER TABLE PageSections ADD COLUMN ViewCount INTEGER NOT NULL DEFAULT 0"); + if (!columns.Contains("RoleId")) + db.Database.ExecuteSqlRaw("ALTER TABLE PageSections ADD COLUMN RoleId INTEGER"); cmd.CommandText = "PRAGMA index_list('PageSections')"; using var idx = cmd.ExecuteReader(); @@ -439,6 +440,10 @@ static void UpgradePagesTable(ApplicationDbContext db) { db.Database.ExecuteSqlRaw("ALTER TABLE Pages ADD COLUMN FeaturedImage TEXT"); } + if (!columns.Contains("RoleId")) + { + db.Database.ExecuteSqlRaw("ALTER TABLE Pages ADD COLUMN RoleId INTEGER"); + } } catch (Exception ex) { @@ -514,6 +519,46 @@ FOREIGN KEY(BlockTemplateId) REFERENCES BlockTemplates(Id) ON DELETE CASCADE } } +static void UpgradeRolesTable(ApplicationDbContext db) +{ + try + { + using var conn = db.Database.GetDbConnection(); + if (conn.State != System.Data.ConnectionState.Open) + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='Roles'"; + var exists = cmd.ExecuteScalar() != null; + if (!exists) + { + db.Database.ExecuteSqlRaw(@"CREATE TABLE Roles ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL + )"); + db.Database.ExecuteSqlRaw("CREATE UNIQUE INDEX IX_Roles_Name ON Roles(Name)"); + db.Database.ExecuteSqlRaw(@"INSERT INTO Roles (Id, Name) VALUES + (1, 'Admin'), (2, 'User'), (3, 'Moderator')"); + } + + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='UserRoles'"; + exists = cmd.ExecuteScalar() != null; + if (!exists) + { + db.Database.ExecuteSqlRaw(@"CREATE TABLE UserRoles ( + SiteUserId INTEGER NOT NULL, + RoleId INTEGER NOT NULL, + PRIMARY KEY(SiteUserId, RoleId), + FOREIGN KEY(SiteUserId) REFERENCES SiteUsers(Id) ON DELETE CASCADE, + FOREIGN KEY(RoleId) REFERENCES Roles(Id) ON DELETE CASCADE + )"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Schema upgrade failed: {ex.Message}"); + } +} + static void UpgradePermissionsTable(ApplicationDbContext db) { try diff --git a/website/MyWebApp/Services/ContentProcessingService.cs b/website/MyWebApp/Services/ContentProcessingService.cs new file mode 100644 index 0000000..d121c3b --- /dev/null +++ b/website/MyWebApp/Services/ContentProcessingService.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; +using System.IO; +using System.Threading.Tasks; +using Markdig; +using MyWebApp.Models; + +namespace MyWebApp.Services; + +public class ContentProcessingService +{ + private readonly HtmlSanitizerService _sanitizer; + + public ContentProcessingService(HtmlSanitizerService sanitizer) + { + _sanitizer = sanitizer; + } + + public async Task PrepareHtmlAsync(PageSection model, IFormFile? file) + { + switch (model.Type) + { + case PageSectionType.Html: + model.Html = _sanitizer.Sanitize(model.Html); + break; + case PageSectionType.Markdown: + var html = Markdown.ToHtml(model.Html ?? string.Empty); + model.Html = _sanitizer.Sanitize(html); + break; + case PageSectionType.Code: + model.Html = $"
{System.Net.WebUtility.HtmlEncode(model.Html)}
"; + break; + case PageSectionType.Image: + case PageSectionType.Video: + if (file != null && file.Length > 0) + { + var uploads = Path.Combine("wwwroot", "uploads"); + Directory.CreateDirectory(uploads); + var name = Path.GetFileName(file.FileName); + var path = Path.Combine(uploads, name); + using var stream = new FileStream(path, FileMode.Create); + await file.CopyToAsync(stream); + if (model.Type == PageSectionType.Image) + model.Html = $""; + else + model.Html = $""; + } + break; + } + } +} diff --git a/website/MyWebApp/Services/LayoutService.cs b/website/MyWebApp/Services/LayoutService.cs index a9aed2f..5f4caac 100644 --- a/website/MyWebApp/Services/LayoutService.cs +++ b/website/MyWebApp/Services/LayoutService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Http; using System.Linq; using MyWebApp.Data; @@ -8,6 +9,7 @@ public class LayoutService { private readonly CacheService _cache; private readonly TokenRenderService _tokens; + private readonly IHttpContextAccessor _accessor; private const string HeaderKey = "layout_header"; private const string FooterKey = "layout_footer"; @@ -27,53 +29,119 @@ public static string[] GetZones(string layout) return LayoutZones.TryGetValue(layout, out var zones) ? zones : Array.Empty(); } - public LayoutService(CacheService cache, TokenRenderService tokens) + public LayoutService(CacheService cache, TokenRenderService tokens, IHttpContextAccessor accessor) { _cache = cache; _tokens = tokens; + _accessor = accessor; + } + + private string[] GetRoles() + { + var roles = _accessor.HttpContext?.Session.GetString("Roles"); + if (string.IsNullOrWhiteSpace(roles)) + { + return new[] { "Anonym" }; + } + return roles.Split(','); + } + + private async Task> GetAllowedPermissionsAsync(ApplicationDbContext db, string[] roles) + { + if (roles.Length == 0) return new List(); + return await db.RolePermissions.AsNoTracking() + .Where(rp => roles.Contains(rp.Role!.Name)) + .Select(rp => rp.PermissionId) + .Distinct() + .ToListAsync(); + } + + private async Task> GetRoleIdsAsync(ApplicationDbContext db, string[] roles) + { + if (roles.Length == 0) return new List(); + return await db.Roles.AsNoTracking() + .Where(r => roles.Contains(r.Name)) + .Select(r => r.Id) + .ToListAsync(); } public async Task GetHeaderAsync(ApplicationDbContext db) { - return await _cache.GetOrCreateAsync(HeaderKey, async e => + var roles = GetRoles(); + var roleIds = await GetRoleIdsAsync(db, roles); + if (roles.Length == 0) { - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - var parts = await db.PageSections.AsNoTracking() - .Where(s => s.Page.Slug == "layout" && s.Zone == "header") - .OrderBy(s => s.SortOrder) - .Select(s => s.Html) - .ToListAsync(); - var html = string.Join(System.Environment.NewLine, parts); - return await _tokens.RenderAsync(db, html); - }); + return await _cache.GetOrCreateAsync(HeaderKey, async e => + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var parts = await db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "header" && s.PermissionId == null && s.RoleId == null) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); + var html = string.Join(System.Environment.NewLine, parts); + return await _tokens.RenderAsync(db, html); + }); + } + + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "header"); + query = query.Where(s => + (s.PermissionId == null || allowed.Contains(s.PermissionId.Value)) && + (s.RoleId == null || roleIds.Contains(s.RoleId.Value))); + var parts2 = await query.OrderBy(s => s.SortOrder).Select(s => s.Html).ToListAsync(); + var html2 = string.Join(System.Environment.NewLine, parts2); + return await _tokens.RenderAsync(db, html2); } public async Task GetFooterAsync(ApplicationDbContext db) { - return await _cache.GetOrCreateAsync(FooterKey, async e => + var roles = GetRoles(); + var roleIds = await GetRoleIdsAsync(db, roles); + if (roles.Length == 0) { - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - var parts = await db.PageSections.AsNoTracking() - .Where(s => s.Page.Slug == "layout" && s.Zone == "footer") - .OrderBy(s => s.SortOrder) - .Select(s => s.Html) - .ToListAsync(); - var html = string.Join(System.Environment.NewLine, parts); - return await _tokens.RenderAsync(db, html); - }); + return await _cache.GetOrCreateAsync(FooterKey, async e => + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var parts = await db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "footer" && s.PermissionId == null && s.RoleId == null) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); + var html = string.Join(System.Environment.NewLine, parts); + return await _tokens.RenderAsync(db, html); + }); + } + + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "footer"); + query = query.Where(s => + (s.PermissionId == null || allowed.Contains(s.PermissionId.Value)) && + (s.RoleId == null || roleIds.Contains(s.RoleId.Value))); + var parts2 = await query.OrderBy(s => s.SortOrder).Select(s => s.Html).ToListAsync(); + var html2 = string.Join(System.Environment.NewLine, parts2); + return await _tokens.RenderAsync(db, html2); } public async Task GetSectionAsync(ApplicationDbContext db, int pageId, string zone) { - - var parts = await db.PageSections.AsNoTracking() - .Where(s => s.PageId == pageId && s.Zone == zone) - .OrderBy(s => s.SortOrder) - .Select(s => s.Html) - .ToListAsync(); + var roles = GetRoles(); + var roleIds = await GetRoleIdsAsync(db, roles); + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.PageId == pageId && s.Zone == zone); + if (allowed.Count == 0 && roleIds.Count == 0) + query = query.Where(s => s.PermissionId == null && s.RoleId == null); + else + query = query.Where(s => + (s.PermissionId == null || allowed.Contains(s.PermissionId.Value)) && + (s.RoleId == null || roleIds.Contains(s.RoleId.Value))); + var parts = await query.OrderBy(s => s.SortOrder).Select(s => s.Html).ToListAsync(); var html = string.Join(System.Environment.NewLine, parts); return await _tokens.RenderAsync(db, html); - + } public void Reset() diff --git a/website/MyWebApp/Services/TokenRenderService.cs b/website/MyWebApp/Services/TokenRenderService.cs index cc6f4fb..20f923a 100644 --- a/website/MyWebApp/Services/TokenRenderService.cs +++ b/website/MyWebApp/Services/TokenRenderService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Http; using System.Linq; using MyWebApp.Data; @@ -8,6 +9,41 @@ namespace MyWebApp.Services; public class TokenRenderService { private static readonly Regex TokenRegex = new(@"\{\{(block|section):([^{}]+)\}\}|\{\{nav\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly IHttpContextAccessor _accessor; + + public TokenRenderService(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + private string[] GetRoles() + { + var roles = _accessor.HttpContext?.Session.GetString("Roles"); + if (string.IsNullOrWhiteSpace(roles)) + { + return new[] { "Anonym" }; + } + return roles.Split(','); + } + + private async Task> GetAllowedPermissionsAsync(ApplicationDbContext db, string[] roles) + { + if (roles.Length == 0) return new List(); + return await db.RolePermissions.AsNoTracking() + .Where(rp => roles.Contains(rp.Role!.Name)) + .Select(rp => rp.PermissionId) + .Distinct() + .ToListAsync(); + } + + private async Task> GetRoleIdsAsync(ApplicationDbContext db, string[] roles) + { + if (roles.Length == 0) return new List(); + return await db.Roles.AsNoTracking() + .Where(r => roles.Contains(r.Name)) + .Select(r => r.Id) + .ToListAsync(); + } public Task RenderAsync(ApplicationDbContext db, string html) { @@ -20,8 +56,10 @@ async Task Replace(Match match) { if (match.Value.StartsWith("{{nav", StringComparison.OrdinalIgnoreCase)) { + var roles = GetRoles(); + var roleIds = await GetRoleIdsAsync(db, roles); var pages = await db.Pages.AsNoTracking() - .Where(p => p.IsPublished && p.Slug != "layout" && p.Slug != "home") + .Where(p => p.IsPublished && p.Slug != "layout" && p.Slug != "home" && (p.RoleId == null || roleIds.Contains(p.RoleId.Value))) .OrderBy(p => p.Title) .Select(p => new { p.Slug, p.Title }) .ToListAsync(); @@ -51,8 +89,18 @@ async Task Replace(Match match) if (parts.Length == 2 && int.TryParse(parts[0], out var pageId)) { var zone = parts[1]; - var htmlParts = await db.PageSections.AsNoTracking() - .Where(s => s.PageId == pageId && s.Zone == zone) + var roles = GetRoles(); + var roleIds = await GetRoleIdsAsync(db, roles); + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.PageId == pageId && s.Zone == zone); + if (allowed.Count == 0 && roleIds.Count == 0) + query = query.Where(s => s.PermissionId == null && s.RoleId == null); + else + query = query.Where(s => + (s.PermissionId == null || allowed.Contains(s.PermissionId.Value)) && + (s.RoleId == null || roleIds.Contains(s.RoleId.Value))); + var htmlParts = await query .OrderBy(s => s.SortOrder) .Select(s => s.Html) .ToListAsync(); diff --git a/website/MyWebApp/Views/Account/ForgotPassword.cshtml b/website/MyWebApp/Views/Account/ForgotPassword.cshtml index d8ef692..a708af6 100644 --- a/website/MyWebApp/Views/Account/ForgotPassword.cshtml +++ b/website/MyWebApp/Views/Account/ForgotPassword.cshtml @@ -11,7 +11,7 @@ -
+
captcha
diff --git a/website/MyWebApp/Views/Account/Login.cshtml b/website/MyWebApp/Views/Account/Login.cshtml index 06bf5ac..97dec13 100644 --- a/website/MyWebApp/Views/Account/Login.cshtml +++ b/website/MyWebApp/Views/Account/Login.cshtml @@ -21,7 +21,7 @@
-
+
captcha
@@ -31,7 +31,7 @@
-
+ -
+
captcha
diff --git a/website/MyWebApp/Views/Account/ResetPassword.cshtml b/website/MyWebApp/Views/Account/ResetPassword.cshtml index 91af517..c02a867 100644 --- a/website/MyWebApp/Views/Account/ResetPassword.cshtml +++ b/website/MyWebApp/Views/Account/ResetPassword.cshtml @@ -8,7 +8,7 @@
-
+
captcha
diff --git a/website/MyWebApp/Views/Admin/_AdminLayout.cshtml b/website/MyWebApp/Views/Admin/_AdminLayout.cshtml index 1dbfc7e..538b4a3 100644 --- a/website/MyWebApp/Views/Admin/_AdminLayout.cshtml +++ b/website/MyWebApp/Views/Admin/_AdminLayout.cshtml @@ -28,6 +28,7 @@ Files Media Blocks + Roles Pages Logout diff --git a/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml b/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml index 8e06142..a7a0231 100644 --- a/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml +++ b/website/MyWebApp/Views/AdminBlockTemplate/AddToPage.cshtml @@ -1,4 +1,9 @@ @using MyWebApp.Models +@{ + var selectedPages = ViewBag.SelectedPageIds as List ?? new List(); + var selectedZone = ViewBag.SelectedZone as string ?? string.Empty; + var selectedRole = ViewBag.SelectedRole as string ?? string.Empty; +} @{ ViewData["Title"] = "Add Block To Page"; Layout = "../Admin/_AdminLayout"; @@ -7,17 +12,52 @@
- - +
+
+ +
- + +
+
+ + +
+ +@section Scripts { + +} diff --git a/website/MyWebApp/Views/AdminBlockTemplate/Create.cshtml b/website/MyWebApp/Views/AdminBlockTemplate/Create.cshtml index 756c339..a53d5d4 100644 --- a/website/MyWebApp/Views/AdminBlockTemplate/Create.cshtml +++ b/website/MyWebApp/Views/AdminBlockTemplate/Create.cshtml @@ -17,6 +17,8 @@
+ + @await Html.PartialAsync("_PageAssignment") diff --git a/website/MyWebApp/Views/AdminBlockTemplate/Edit.cshtml b/website/MyWebApp/Views/AdminBlockTemplate/Edit.cshtml index f82cedb..9047c6f 100644 --- a/website/MyWebApp/Views/AdminBlockTemplate/Edit.cshtml +++ b/website/MyWebApp/Views/AdminBlockTemplate/Edit.cshtml @@ -18,6 +18,8 @@ + + @await Html.PartialAsync("_PageAssignment") diff --git a/website/MyWebApp/Views/AdminBlockTemplate/Index.cshtml b/website/MyWebApp/Views/AdminBlockTemplate/Index.cshtml index 5ad8c03..64f8481 100644 --- a/website/MyWebApp/Views/AdminBlockTemplate/Index.cshtml +++ b/website/MyWebApp/Views/AdminBlockTemplate/Index.cshtml @@ -10,7 +10,7 @@
PageAreaType
PageZoneType
@s.Page?.Slug@s.Area@s.Zone@s.Type
- + @foreach (var t in Model) { @@ -18,7 +18,6 @@ - } diff --git a/website/MyWebApp/Views/AdminBlockTemplate/_PageAssignment.cshtml b/website/MyWebApp/Views/AdminBlockTemplate/_PageAssignment.cshtml new file mode 100644 index 0000000..e2a40f5 --- /dev/null +++ b/website/MyWebApp/Views/AdminBlockTemplate/_PageAssignment.cshtml @@ -0,0 +1,51 @@ +@using MyWebApp.Models +@{ + var selectedPages = ViewBag.SelectedPageIds as List ?? new List(); + var selectedZone = ViewBag.SelectedZone as string ?? string.Empty; + var selectedRole = ViewBag.SelectedRole as string ?? string.Empty; +} +
+

Page Assignment

+
+ + + +
+
+ + +
+
+ + +
+
+@section Scripts { + +} diff --git a/website/MyWebApp/Views/AdminContent/PageEditor.cshtml b/website/MyWebApp/Views/AdminContent/PageEditor.cshtml index 77bd2d0..847a8e2 100644 --- a/website/MyWebApp/Views/AdminContent/PageEditor.cshtml +++ b/website/MyWebApp/Views/AdminContent/PageEditor.cshtml @@ -1,15 +1,29 @@ @model MyWebApp.Models.Page @using MyWebApp.Models -@using MyWebApp.Services @using Microsoft.AspNetCore.Mvc.ViewFeatures @{ var sections = ViewBag.Sections as List ?? new List(); + var roles = ViewBag.Roles as List ?? new List(); Layout = "../Admin/_AdminLayout"; var isNew = Model.Id == 0; ViewData["Title"] = isNew ? "Create Page" : "Edit Page"; }

@ViewData["Title"]

-
+
+ + +
+ + + +
+
+
+ +
@@ -17,11 +31,26 @@
+
+ + +
@@ -38,8 +67,8 @@
@for (int i = 0; i < sections.Count; i++) { - var vd = new ViewDataDictionary(ViewData) { ["Index"] = i }; - @await Html.PartialAsync("_SectionEditor", sections[i], vd) + var vd = new ViewDataDictionary(ViewData) { ["Index"] = i, ["Roles"] = roles }; + @await Html.PartialAsync("~/Views/Shared/_SectionEditor.cshtml", sections[i], vd) }
@if (ViewBag.Templates is List templates && templates.Count > 0) @@ -56,15 +85,25 @@ } - + +
+
+ +
+
+
@section Scripts { + + diff --git a/website/MyWebApp/Views/AdminPageSection/Edit.cshtml b/website/MyWebApp/Views/AdminPageSection/Edit.cshtml index 590e245..6f30388 100644 --- a/website/MyWebApp/Views/AdminPageSection/Edit.cshtml +++ b/website/MyWebApp/Views/AdminPageSection/Edit.cshtml @@ -4,10 +4,15 @@ Layout = "../Admin/_AdminLayout"; var pages = ViewBag.Pages as List; var permissions = ViewBag.Permissions as List; + var roles = ViewBag.Roles as List ?? new List(); }

Edit Section

+
+ + +
@@ -17,8 +22,9 @@
-@await Html.PartialAsync("_SectionEditor", Model) +@await Html.PartialAsync("~/Views/Shared/_SectionEditor.cshtml", Model, new ViewDataDictionary(ViewData) { ["Roles"] = roles }) + diff --git a/website/MyWebApp/Views/AdminPageSection/_SectionEditor.cshtml b/website/MyWebApp/Views/AdminPageSection/_SectionEditor.cshtml deleted file mode 100644 index 4e72db6..0000000 --- a/website/MyWebApp/Views/AdminPageSection/_SectionEditor.cshtml +++ /dev/null @@ -1,41 +0,0 @@ -@model MyWebApp.Models.PageSection -@using MyWebApp.Models -
- - -
-
-
- -
-
- -
-
- -
-
- -
- diff --git a/website/MyWebApp/Views/AdminRole/Create.cshtml b/website/MyWebApp/Views/AdminRole/Create.cshtml new file mode 100644 index 0000000..e24893f --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Create.cshtml @@ -0,0 +1,10 @@ +@model MyWebApp.Models.Role +@{ + ViewData["Title"] = "Create Role"; + Layout = "../Admin/_AdminLayout"; +} +

Create Role

+
+
+ + diff --git a/website/MyWebApp/Views/AdminRole/Delete.cshtml b/website/MyWebApp/Views/AdminRole/Delete.cshtml new file mode 100644 index 0000000..64bca4a --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Delete.cshtml @@ -0,0 +1,12 @@ +@model MyWebApp.Models.Role +@{ + ViewData["Title"] = "Delete Role"; + Layout = "../Admin/_AdminLayout"; +} +

Delete Role

+
+ +

Are you sure you want to delete "@Model.Name"?

+ | + Cancel + diff --git a/website/MyWebApp/Views/AdminRole/Edit.cshtml b/website/MyWebApp/Views/AdminRole/Edit.cshtml new file mode 100644 index 0000000..0e2b95d --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Edit.cshtml @@ -0,0 +1,21 @@ +@model MyWebApp.Models.RoleEditViewModel +@{ + ViewData["Title"] = "Edit Role"; + Layout = "../Admin/_AdminLayout"; + var permissions = ViewBag.Permissions as List; +} +

Edit Role

+
+ +
+
+ + @foreach (var p in permissions) + { +
+ @p.Name +
+ } +
+ + diff --git a/website/MyWebApp/Views/AdminRole/Index.cshtml b/website/MyWebApp/Views/AdminRole/Index.cshtml new file mode 100644 index 0000000..5b94e09 --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Index.cshtml @@ -0,0 +1,20 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Roles"; + Layout = "../Admin/_AdminLayout"; +} +

Roles

+

Create New

+
Name
Name
@t.Name Edit DeleteAdd to Page
+ + +@foreach (var r in Model) +{ + + + + + +} + +
Name
@r.NameEditDelete
diff --git a/website/MyWebApp/Views/Shared/_SectionEditor.cshtml b/website/MyWebApp/Views/Shared/_SectionEditor.cshtml new file mode 100644 index 0000000..b6c82a7 --- /dev/null +++ b/website/MyWebApp/Views/Shared/_SectionEditor.cshtml @@ -0,0 +1,43 @@ +@model MyWebApp.Models.PageSection +@using MyWebApp.Models +@{ + var idxObj = ViewData["Index"]; + var idx = idxObj?.ToString() ?? "0"; + var prefix = idxObj != null ? $"Sections[{idx}]." : string.Empty; + var roles = ViewData["Roles"] as List ?? new List(); +} +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
diff --git a/website/MyWebApp/appsettings.json b/website/MyWebApp/appsettings.json index 300d0b0..d67cbf2 100644 --- a/website/MyWebApp/appsettings.json +++ b/website/MyWebApp/appsettings.json @@ -17,6 +17,10 @@ "Theme": { "Name": "dark" }, + "Layouts": { + "single-column": [ "main" ], + "two-column-sidebar": [ "main", "sidebar" ] + }, "Session": { "TimeoutMinutes": 30 }, diff --git a/website/MyWebApp/wwwroot/css/admin.css b/website/MyWebApp/wwwroot/css/admin.css index e891877..050e665 100644 --- a/website/MyWebApp/wwwroot/css/admin.css +++ b/website/MyWebApp/wwwroot/css/admin.css @@ -1290,3 +1290,94 @@ form.mb-3 { .zone-sections { min-height: 10px; } +.zone-group.drag-over { + border-color: #0ea5e9; + background: #f0f9ff; +} +.drop-indicator { + height: 4px; + background: #0ea5e9; + margin: 4px 0; + border-radius: 2px; +} +.section-editor { + border: 1px solid #cbd5e1; + background: #fff; + padding: 0.5rem; + margin-bottom: 0.5rem; +} +.section-editor:hover { + box-shadow: 0 0 0 2px #0ea5e9 inset; +} +.layout-preview { + border: 1px solid #e2e8f0; + padding: 0.5rem; +} +.zone-group[data-zone="main"] { border-color: #2563eb; } +.zone-group[data-zone="sidebar"] { border-color: #16a34a; } + +/* Block library panel */ +.page-editor { + display: flex; + gap: 1rem; +} + +.block-library { + width: 220px; + flex-shrink: 0; + border-right: 1px solid #e2e8f0; + padding-right: 1rem; + margin-right: 1rem; +} + +.block-library .search { + width: 100%; + margin-bottom: 0.5rem; +} + +.block-card { + border: 1px solid #cbd5e1; + background: #fff; + padding: 0.5rem; + margin-bottom: 0.5rem; + cursor: grab; +} + +.block-card:hover { + box-shadow: 0 0 0 2px #0ea5e9 inset; +} + +.block-card .block-preview { + font-size: 0.8rem; + color: #555; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-bottom: 0.25rem; +} +\n +/* Live preview styles */ +.mode-toggle { + margin-bottom: 0.5rem; + display: flex; + gap: 0.5rem; + align-items: center; +} +.mode-toggle .device-buttons { + margin-left: auto; + display: flex; + gap: 0.25rem; +} +.mode-btn.active, +.device-btn.active { + background: #0ea5e9; + color: #fff; +} +.unsaved-indicator { color: #dc2626; margin-left: 0.25rem; display: none; } +.preview-wrapper { display: none; margin-top: 1rem; } +.page-editor.preview .editor-main, +.page-editor.preview .block-library { display: none; } +.page-editor.preview .preview-wrapper { display: block; } +.preview-container { border: 1px solid #e2e8f0; margin: 0 auto; } +.preview-container iframe { width: 100%; height: 600px; border: none; } + diff --git a/website/MyWebApp/wwwroot/css/site.css b/website/MyWebApp/wwwroot/css/site.css index ec8965e..d12d09d 100644 --- a/website/MyWebApp/wwwroot/css/site.css +++ b/website/MyWebApp/wwwroot/css/site.css @@ -5,7 +5,7 @@ /* Form container */ form[method="post"] { max-width: 400px !important; - width: 400px !important; + width: 100% !important; margin: 1rem auto 0 auto !important; /* Remove bottom margin */ padding: 2rem !important; background: #ffffff !important; @@ -19,7 +19,7 @@ form[method="post"] { /* Wide forms */ form[action*="Register"] { max-width: 500px !important; - width: 500px !important; + width: 100% !important; } /* Input fields remain as is */ @@ -101,21 +101,21 @@ form[method="post"] input[type="checkbox"] { accent-color: #0d6efd !important; } -form[method="post"] > div:has(input[type="checkbox"]) { +.form-check { display: flex !important; align-items: center !important; margin-bottom: 1.5rem !important; cursor: pointer !important; } - form[method="post"] > div:has(input[type="checkbox"]) label { + .form-check label { margin: 0 !important; cursor: pointer !important; font-size: 0.9rem !important; } /* Captcha remains as is */ -form[method="post"] > div:has(img[src*="captcha"]) { +.captcha-container { display: flex !important; align-items: center !important; gap: 1rem !important; @@ -140,7 +140,7 @@ form[method="post"] img[src*="captcha" i] { border-color: #0d6efd !important; } -form[method="post"] > div:has(img[src*="captcha"]) input[type="text"] { +.captcha-container input[type="text"] { flex: 1 !important; margin-bottom: 0 !important; min-width: 100px !important; @@ -151,8 +151,8 @@ form[method="post"] > div:has(img[src*="captcha"]) input[type="text"] { ========================================== */ /* Links block - FULLY MERGED with form */ -form[method="post"] + div:has(a) { - width: 400px !important; +form[method="post"] + .form-links { + width: 100% !important; max-width: 400px !important; margin: 0 auto 2rem auto !important; /* WITHOUT negative margin */ text-align: center !important; @@ -168,8 +168,8 @@ form[method="post"] + div:has(a) { } /* For wide forms */ -form[action*="Register"] + div:has(a) { - width: 500px !important; +form[action*="Register"] + .form-links { + width: 100% !important; max-width: 500px !important; } @@ -204,7 +204,7 @@ form[method="post"] + div { padding: 1.5rem !important; } - form[method="post"] + div:has(a) { + form[method="post"] + .form-links { width: calc(100vw - 2rem) !important; max-width: none !important; margin: 0 1rem 2rem 1rem !important; @@ -217,13 +217,13 @@ form[method="post"] + div { font-size: 16px !important; } - form[method="post"] > div:has(img[src*="captcha"]) { + .captcha-container { flex-direction: column !important; align-items: center !important; gap: 0.75rem !important; } - form[method="post"] > div:has(img[src*="captcha"]) input[type="text"] { + .captcha-container input[type="text"] { width: 100% !important; } } @@ -232,13 +232,8 @@ form[method="post"] + div { ADDITIONAL FIXES ========================================== */ -/* Remove any margins between form and links block */ -form[method="post"]:has(+ div:has(a)) { - margin-bottom: 0 !important; -} - /* Ensure smooth transition */ -form[method="post"] + div:has(a):before { +form[method="post"] + .form-links:before { content: ""; position: absolute; top: -1px; @@ -248,3 +243,14 @@ form[method="post"] + div:has(a):before { background: #ffffff; z-index: 1; } + +/* Error styling shared across themes */ +.error { + color: #dc2626; + background: #fef2f2; + border: 1px solid #fecaca; + padding: var(--space-md, 1rem); + border-radius: var(--radius-md, 0.375rem); + margin-bottom: var(--space-md, 1rem); + font-weight: 500; +} diff --git a/website/MyWebApp/wwwroot/js/block-editor.js b/website/MyWebApp/wwwroot/js/block-editor.js index e2d7440..b9ab34a 100644 --- a/website/MyWebApp/wwwroot/js/block-editor.js +++ b/website/MyWebApp/wwwroot/js/block-editor.js @@ -8,7 +8,7 @@ window.addEventListener('load', () => { const sectionSelect = document.getElementById('section-select'); blockBtn?.addEventListener('click', () => { - fetch('/AdminBlockTemplate/GetBlocks') + fetch('/Api/GetBlocks') .then(r => r.json()) .then(list => { blockSelect.innerHTML = '' + @@ -26,7 +26,7 @@ window.addEventListener('load', () => { }); sectionBtn?.addEventListener('click', () => { - fetch('/AdminBlockTemplate/GetPages') + fetch('/Api/GetPages') .then(r => r.json()) .then(list => { pageSelect.innerHTML = '' + @@ -40,7 +40,7 @@ window.addEventListener('load', () => { pageSelect?.addEventListener('change', () => { const id = pageSelect.value; if (!id) return; - fetch(`/AdminBlockTemplate/GetSections/${id}`) + fetch(`/Api/GetSections/${id}`) .then(r => r.json()) .then(list => { sectionSelect.innerHTML = '' + diff --git a/website/MyWebApp/wwwroot/js/block-library.js b/website/MyWebApp/wwwroot/js/block-library.js new file mode 100644 index 0000000..38fa7f5 --- /dev/null +++ b/website/MyWebApp/wwwroot/js/block-library.js @@ -0,0 +1,43 @@ +window.addEventListener('load', () => { + const panel = document.getElementById('block-library'); + if (!panel) return; + const search = document.getElementById('block-search'); + const list = panel.querySelector('.block-list'); + let blocks = []; + fetch('/AdminBlockTemplate/GetBlocks') + .then(r => r.json()) + .then(data => { blocks = data; render(blocks); }); + + function render(items) { + list.innerHTML = items.map(b => { + const encoded = b.preview.replace(//g, '>'); + return `
+
${b.name}
+
${encoded}
+ +
`; + }).join(''); + } + + search?.addEventListener('input', () => { + const q = search.value.toLowerCase(); + const filtered = blocks.filter(b => b.name.toLowerCase().includes(q)); + render(filtered); + }); + + list.addEventListener('click', e => { + const btn = e.target.closest('.quick-edit'); + if (btn) { + const id = btn.dataset.id; + window.location = `/AdminBlockTemplate/Edit/${id}`; + } + }); + + list.addEventListener('dragstart', e => { + const card = e.target.closest('.block-card'); + if (!card) return; + const ev = new CustomEvent('blockdragstart', { detail: card.dataset.id }); + document.dispatchEvent(ev); + e.dataTransfer.effectAllowed = 'copy'; + }); +}); diff --git a/website/MyWebApp/wwwroot/js/page-editor.js b/website/MyWebApp/wwwroot/js/page-editor.js index 533e07c..1280a9c 100644 --- a/website/MyWebApp/wwwroot/js/page-editor.js +++ b/website/MyWebApp/wwwroot/js/page-editor.js @@ -3,10 +3,24 @@ window.addEventListener('load', () => { if (!container) return; const templateHtml = document.getElementById('section-template').innerHTML.trim(); let sectionCount = container.querySelectorAll('.section-editor').length; - const editors = {}; + const editors = new Map(); + document.addEventListener('section-editor:ready', e => { + editors.set(e.detail.index, e.detail.quill); + e.detail.quill.on('text-change', schedulePreview); + }); let activeIndex = null; const layoutSelect = document.getElementById('layout-select'); let currentLayout = layoutSelect ? layoutSelect.value : 'single-column'; + const pageEditor = document.querySelector('.page-editor'); + const previewWrapper = document.getElementById('preview-wrapper'); + const previewFrame = document.getElementById('preview-frame'); + const unsavedIndicator = document.getElementById('unsaved-indicator'); + const modeEdit = document.getElementById('mode-edit'); + const modePreview = document.getElementById('mode-preview'); + const deviceBtns = document.querySelectorAll('.device-btn'); + const previewContainer = document.getElementById('preview-container'); + let dirty = false; + let previewTimer = null; function buildGroups() { container.innerHTML = ''; @@ -15,7 +29,7 @@ window.addEventListener('load', () => { group.className = 'zone-group'; group.dataset.zone = z; const h = document.createElement('h3'); - h.textContent = z; + h.innerHTML = `${z} `; const div = document.createElement('div'); div.className = 'zone-sections'; group.appendChild(h); @@ -24,6 +38,62 @@ window.addEventListener('load', () => { }); } + function updateZoneCounts() { + document.querySelectorAll('.zone-group').forEach(g => { + const count = g.querySelectorAll('.section-editor').length; + const span = g.querySelector('.zone-count'); + if (span) span.textContent = `(${count})`; + }); + } + + function collectData() { + const zones = {}; + document.querySelectorAll('.zone-group').forEach(g => { + const zone = g.dataset.zone; + const html = Array.from(g.querySelectorAll('.section-editor')).map(sec => { + const idx = sec.dataset.index; + const typeSel = document.getElementById(`type-select-${idx}`); + if (typeSel && typeSel.value === 'Html') { + const q = editors.get(idx); + return q ? q.root.innerHTML : ''; + } + const inp = document.getElementById(`Html-${idx}`); + return inp ? inp.value : ''; + }).join('\n'); + zones[zone] = html; + }); + return { + layout: layoutSelect ? layoutSelect.value : 'single-column', + title: document.getElementById('title-input')?.value || '', + zones + }; + } + + function renderPreview() { + const data = collectData(); + fetch('/AdminContent/Preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value + }, + body: JSON.stringify(data) + }) + .then(r => r.text()) + .then(html => { + if (previewFrame) previewFrame.srcdoc = html; + dirty = false; + if (unsavedIndicator) unsavedIndicator.style.display = 'none'; + }); + } + + function schedulePreview() { + dirty = true; + if (unsavedIndicator) unsavedIndicator.style.display = 'inline'; + clearTimeout(previewTimer); + previewTimer = setTimeout(renderPreview, 300); + } + function populateZones(select) { if (!select) return; const current = select.dataset.selected || select.value; @@ -47,7 +117,8 @@ window.addEventListener('load', () => { const div = document.createElement('div'); div.className = 'preview-zone'; div.dataset.zone = z; - div.textContent = z; + const count = container.querySelectorAll(`.zone-group[data-zone='${z}'] .section-editor`).length; + div.textContent = `${z} (${count})`; preview.appendChild(div); }); } @@ -55,6 +126,8 @@ window.addEventListener('load', () => { document.getElementById('layout-preview')?.addEventListener('click', e => { const zone = e.target.closest('.preview-zone'); if (!zone) return; + const group = container.querySelector(`.zone-group[data-zone='${zone.dataset.zone}']`); + if (group) group.scrollIntoView({ behavior: 'smooth' }); if (activeIndex !== null) { const select = document.querySelector(`.zone-select[data-index='${activeIndex}']`); if (select) { @@ -70,7 +143,7 @@ window.addEventListener('load', () => { templateSelect.addEventListener('change', () => { const id = templateSelect.value; if (!id) return; - if (activeIndex === null || !editors[activeIndex]) { + if (activeIndex === null || !editors.has(activeIndex)) { alert('Select a section first'); templateSelect.value = ''; return; @@ -78,7 +151,7 @@ window.addEventListener('load', () => { fetch(`/AdminBlockTemplate/Html/${id}`) .then(r => r.text()) .then(html => { - const quill = editors[activeIndex]; + const quill = editors.get(activeIndex); quill.root.innerHTML = html; const input = document.getElementById(`Html-${activeIndex}`); if (input) input.value = html; @@ -93,9 +166,10 @@ window.addEventListener('load', () => { const idx = el.dataset.index; populateZones(el.querySelector('.zone-select')); placeSection(el); - initSectionEditor(idx); + document.dispatchEvent(new CustomEvent('section-editor:add', { detail: el })); }); updatePreview(); + updateZoneCounts(); document.getElementById('add-section').addEventListener('click', () => { addSection(); @@ -110,15 +184,30 @@ window.addEventListener('load', () => { }); updateIndexes(); updatePreview(); + updateZoneCounts(); }); container.addEventListener('click', e => { if (e.target.classList.contains('remove-section')) { e.target.closest('.section-editor').remove(); updateIndexes(); + updateZoneCounts(); } else if (e.target.classList.contains('duplicate-section')) { const original = e.target.closest('.section-editor'); duplicateSection(original); + } else if (e.target.classList.contains('add-library')) { + const section = e.target.closest('.section-editor'); + const idx = section.dataset.index; + const name = prompt('Block name'); + if (!name) return; + const q = editors.get(idx); + const html = q ? q.root.innerHTML : ''; + const token = document.querySelector('input[name="__RequestVerificationToken"]').value; + fetch('/AdminBlockTemplate/CreateFromSection', { + method: 'POST', + headers: { 'RequestVerificationToken': token }, + body: new URLSearchParams({ name, html }) + }).then(() => alert('Saved to library')); } }); @@ -127,10 +216,11 @@ window.addEventListener('load', () => { const section = e.target.closest('.section-editor'); placeSection(section); updateIndexes(); + updateZoneCounts(); } }); - function addSection() { + function addSection(htmlContent = '', zone = null) { const index = sectionCount++; const html = templateHtml.replace(/__index__/g, index); const temp = document.createElement('div'); @@ -139,9 +229,31 @@ window.addEventListener('load', () => { section.dataset.index = index; populateZones(section.querySelector('.zone-select')); placeSection(section); - initSectionEditor(index); + document.dispatchEvent(new CustomEvent('section-editor:add', { detail: section })); + if (htmlContent) { + const q = editors.get(index); + if (q) q.root.innerHTML = htmlContent; + const input = document.getElementById(`Html-${index}`); + if (input) input.value = htmlContent; + } + if (zone) { + const select = section.querySelector('.zone-select'); + if (select) { select.value = zone; } + placeSection(section); + } updateIndexes(); updatePreview(); + updateZoneCounts(); + return index; + } + + function addSectionFromBlock(id, zone) { + fetch(`/AdminBlockTemplate/Html/${id}`) + .then(r => r.text()) + .then(html => { + addSection(html, zone); + autoSave(); + }); } function duplicateSection(original) { @@ -165,14 +277,17 @@ window.addEventListener('load', () => { }); populateZones(clone.querySelector('.zone-select')); placeSection(clone); - initSectionEditor(index); - if (editors[original.dataset.index]) { - editors[index].root.innerHTML = editors[original.dataset.index].root.innerHTML; + document.dispatchEvent(new CustomEvent('section-editor:add', { detail: clone })); + const orig = editors.get(original.dataset.index); + const copy = editors.get(index); + if (orig && copy) { + copy.root.innerHTML = orig.root.innerHTML; const destInput = clone.querySelector(`#Html-${index}`); - if (destInput) destInput.value = editors[index].root.innerHTML; + if (destInput) destInput.value = copy.root.innerHTML; } updateIndexes(); updatePreview(); + updateZoneCounts(); } function updateIndexes() { @@ -187,28 +302,66 @@ window.addEventListener('load', () => { } let dragged = null; + let draggedBlockId = null; + document.addEventListener('blockdragstart', e => { + draggedBlockId = e.detail; + }); + const dropIndicator = document.createElement('div'); + dropIndicator.className = 'drop-indicator'; + container.addEventListener('dragstart', e => { dragged = e.target.closest('.section-editor'); + draggedBlockId = null; + if (dragged) { + dragged.classList.add('dragging'); + document.querySelectorAll('.zone-group').forEach(z => z.classList.add('drag-over')); + } e.dataTransfer.effectAllowed = 'move'; }); + container.addEventListener('dragover', e => { e.preventDefault(); + const zone = e.target.closest('.zone-group'); const target = e.target.closest('.section-editor'); - if (dragged && target && target !== dragged) { + if (zone) zone.classList.add('drag-over'); + if (!draggedBlockId && dragged && target && target !== dragged) { const rect = target.getBoundingClientRect(); const next = (e.clientY - rect.top) > (rect.height / 2); - target.parentNode.insertBefore(dragged, next ? target.nextSibling : target); + target.parentNode.insertBefore(dropIndicator, next ? target.nextSibling : target); } }); + + ['dragleave', 'drop'].forEach(evt => { + container.addEventListener(evt, e => { + const zone = e.target.closest('.zone-group'); + if (zone) zone.classList.remove('drag-over'); + }); + }); + container.addEventListener('drop', e => { e.preventDefault(); + const zone = e.target.closest('.zone-group'); + if (draggedBlockId && zone) { + addSectionFromBlock(draggedBlockId, zone.dataset.zone); + draggedBlockId = null; + document.querySelectorAll('.zone-group.drag-over').forEach(z => z.classList.remove('drag-over')); + return; + } + if (dropIndicator.parentNode) { + dropIndicator.parentNode.insertBefore(dragged, dropIndicator); + dropIndicator.remove(); + } + document.querySelectorAll('.zone-group.drag-over').forEach(z => z.classList.remove('drag-over')); + if (dragged) dragged.classList.remove('dragging'); + dragged = null; updateIndexes(); + updateZoneCounts(); }); const form = document.querySelector('form'); if (form) { form.addEventListener('submit', () => { - Object.entries(editors).forEach(([key, quill]) => { + editors.forEach((quill, key) => { const typeSelect = document.getElementById(`type-select-${key}`); if (typeSelect && typeSelect.value === 'Html') { const input = document.getElementById(`Html-${key}`); @@ -218,34 +371,34 @@ window.addEventListener('load', () => { }); } - function initSectionEditor(index) { - const typeSelect = document.getElementById(`type-select-${index}`); - const htmlDiv = document.getElementById(`html-editor-${index}`); - const markdownDiv = document.getElementById(`markdown-editor-${index}`); - const codeDiv = document.getElementById(`code-editor-${index}`); - const fileDiv = document.getElementById(`file-editor-${index}`); - const quillInput = document.getElementById(`Html-${index}`); - const markdown = markdownDiv ? markdownDiv.querySelector('textarea') : null; - const code = codeDiv ? codeDiv.querySelector('textarea') : null; - const quill = new Quill(`#quill-editor-${index}`, { theme: 'snow' }); - quill.root.innerHTML = quillInput.value || ''; - quill.root.addEventListener('click', () => { activeIndex = index; }); - quill.root.addEventListener('focus', () => { activeIndex = index; }); - editors[index] = quill; - - function update() { - const type = typeSelect.value; - htmlDiv.style.display = type === 'Html' ? 'block' : 'none'; - markdownDiv.style.display = type === 'Markdown' ? 'block' : 'none'; - codeDiv.style.display = type === 'Code' ? 'block' : 'none'; - fileDiv.style.display = (type === 'Image' || type === 'Video') ? 'block' : 'none'; - if (markdown) markdown.disabled = type !== 'Markdown'; - if (code) code.disabled = type !== 'Code'; - quillInput.disabled = type !== 'Html'; - const fileInput = fileDiv ? fileDiv.querySelector('input[type="file"]') : null; - if (fileInput) fileInput.disabled = !(type === 'Image' || type === 'Video'); - } - update(); - typeSelect.addEventListener('change', update); + function autoSave() { + if (!form) return; + const fd = new FormData(form); + fetch(form.action, { method: 'POST', body: fd }); } + + + modePreview?.addEventListener('click', () => { + pageEditor?.classList.add('preview'); + renderPreview(); + }); + + modeEdit?.addEventListener('click', () => { + pageEditor?.classList.remove('preview'); + }); + + deviceBtns.forEach(btn => { + btn.addEventListener('click', () => { + deviceBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + if (previewContainer) previewContainer.style.width = btn.dataset.width; + }); + }); + + document.querySelector('.editor-main')?.addEventListener('input', schedulePreview); + document.querySelector('.editor-main')?.addEventListener('change', schedulePreview); + form?.addEventListener('submit', () => { + dirty = false; + if (unsavedIndicator) unsavedIndicator.style.display = 'none'; + }); }); diff --git a/website/MyWebApp/wwwroot/js/page-section-zone.js b/website/MyWebApp/wwwroot/js/page-section-zone.js index 9b2d0d3..ed662bb 100644 --- a/website/MyWebApp/wwwroot/js/page-section-zone.js +++ b/website/MyWebApp/wwwroot/js/page-section-zone.js @@ -6,7 +6,7 @@ window.addEventListener('load', () => { function loadZones() { const id = pageSelect.value; if (!id) { zoneSelect.innerHTML = ''; return; } - fetch(`/AdminPageSection/GetZonesForPage/${id}`) + fetch(`/Api/GetZonesForPage/${id}`) .then(r => r.json()) .then(list => { zoneSelect.innerHTML = list.map(a => ``).join(''); diff --git a/website/MyWebApp/wwwroot/js/section-editor.js b/website/MyWebApp/wwwroot/js/section-editor.js new file mode 100644 index 0000000..ed00676 --- /dev/null +++ b/website/MyWebApp/wwwroot/js/section-editor.js @@ -0,0 +1,39 @@ +(() => { + const editors = new WeakMap(); + + function init(container) { + if (!container) return; + const index = container.dataset.index; + const typeSelect = container.querySelector(`#type-select-${index}`); + const htmlDiv = container.querySelector(`#html-editor-${index}`); + const markdownDiv = container.querySelector(`#markdown-editor-${index}`); + const codeDiv = container.querySelector(`#code-editor-${index}`); + const fileDiv = container.querySelector(`#file-editor-${index}`); + const quillInput = container.querySelector(`#Html-${index}`); + if (!typeSelect || !quillInput) return; + const quill = new Quill(`#quill-editor-${index}`, { theme: 'snow' }); + quill.root.innerHTML = quillInput.value || ''; + quill.on('text-change', () => { + quillInput.value = quill.root.innerHTML; + container.dispatchEvent(new Event('input', { bubbles: true })); + }); + editors.set(container, quill); + container.dispatchEvent(new CustomEvent('section-editor:ready', { detail: { quill, index } })); + + function update() { + const type = typeSelect.value; + if (htmlDiv) htmlDiv.style.display = type === 'Html' ? 'block' : 'none'; + if (markdownDiv) markdownDiv.style.display = type === 'Markdown' ? 'block' : 'none'; + if (codeDiv) codeDiv.style.display = type === 'Code' ? 'block' : 'none'; + if (fileDiv) fileDiv.style.display = (type === 'Image' || type === 'Video') ? 'block' : 'none'; + } + typeSelect.addEventListener('change', update); + update(); + } + + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.section-editor').forEach(init); + }); + + document.addEventListener('section-editor:add', e => init(e.detail)); +})();