diff --git a/PlotLine/Controllers/FloorPlansController.cs b/PlotLine/Controllers/FloorPlansController.cs index 30bc45d..62081ba 100644 --- a/PlotLine/Controllers/FloorPlansController.cs +++ b/PlotLine/Controllers/FloorPlansController.cs @@ -114,6 +114,70 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control return RedirectToAction(nameof(Edit), new { id = floorPlanId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task UploadBackground(int floorPlanId, int floorPlanFloorId, IFormFile? backgroundImage) + { + if (backgroundImage is null) + { + TempData["FloorPlanError"] = "Choose a background image to upload."; + return RedirectToAction(nameof(Edit), new { id = floorPlanId }); + } + + var result = await floorPlans.UploadBackgroundAsync(floorPlanId, floorPlanFloorId, backgroundImage); + TempData[result.Succeeded ? "FloorPlanMessage" : "FloorPlanError"] = result.Succeeded + ? "Background image uploaded." + : result.ErrorMessage ?? "Background image could not be uploaded."; + return RedirectToAction(nameof(Edit), new { id = floorPlanId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SaveBackgroundSettings(FloorPlanFloorBackgroundSettingsViewModel model) + { + var isAsyncSave = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase); + try + { + await floorPlans.SaveBackgroundSettingsAsync(model); + if (isAsyncSave) + { + return Json(new { ok = true, message = "Saved" }); + } + + TempData["FloorPlanMessage"] = "Background settings saved."; + } + catch (InvalidOperationException ex) + { + if (isAsyncSave) + { + Response.StatusCode = StatusCodes.Status400BadRequest; + return Json(new { ok = false, message = ex.Message }); + } + + TempData["FloorPlanError"] = ex.Message; + } + + return RedirectToAction(nameof(Edit), new { id = model.FloorPlanID }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ResetBackground(int floorPlanId, int floorPlanFloorId) + { + await floorPlans.ResetBackgroundAsync(floorPlanId, floorPlanFloorId); + TempData["FloorPlanMessage"] = "Background position reset."; + return RedirectToAction(nameof(Edit), new { id = floorPlanId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RemoveBackground(int floorPlanId, int floorPlanFloorId) + { + await floorPlans.RemoveBackgroundAsync(floorPlanId, floorPlanFloorId); + TempData["FloorPlanMessage"] = "Background image removed."; + return RedirectToAction(nameof(Edit), new { id = floorPlanId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 69e9fe3..8a73819 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -223,6 +223,7 @@ public interface IFloorPlanRepository Task SavePlanAsync(FloorPlan floorPlan); Task DeletePlanAsync(int floorPlanId); Task SaveFloorAsync(FloorPlanFloor floor); + Task UpdateFloorBackgroundAsync(FloorPlanFloor floor); Task DeleteFloorAsync(int floorPlanFloorId); Task SaveBlockAsync(FloorPlanBlock block); Task DeleteBlockAsync(int floorPlanBlockId); @@ -591,6 +592,27 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task UpdateFloorBackgroundAsync(FloorPlanFloor floor) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QuerySingleAsync( + "dbo.FloorPlanFloor_UpdateBackground", + new + { + floor.FloorPlanFloorID, + floor.FloorPlanID, + floor.BackgroundImagePath, + floor.BackgroundImageOriginalFileName, + floor.BackgroundOpacity, + floor.BackgroundScale, + floor.BackgroundOffsetX, + floor.BackgroundOffsetY, + floor.BackgroundLocked + }, + commandType: CommandType.StoredProcedure); + return rows > 0; + } + public async Task DeleteFloorAsync(int floorPlanFloorId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 05119de..d322103 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1164,6 +1164,13 @@ public sealed class FloorPlanFloor public int SortOrder { get; set; } public int GridWidth { get; set; } = 24; public int GridHeight { get; set; } = 16; + public string? BackgroundImagePath { get; set; } + public string? BackgroundImageOriginalFileName { get; set; } + public decimal? BackgroundOpacity { get; set; } = 0.35m; + public decimal? BackgroundScale { get; set; } = 1m; + public decimal? BackgroundOffsetX { get; set; } + public decimal? BackgroundOffsetY { get; set; } + public bool BackgroundLocked { get; set; } = true; public DateTime CreatedDate { get; set; } public DateTime? ModifiedDate { get; set; } } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index ae31a18..6ce2b72 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -132,6 +132,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index c35c28e..523a630 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -187,6 +187,10 @@ public interface IFloorPlanService Task DeleteFloorAsync(int floorPlanFloorId); Task SaveBlockAsync(FloorPlanBlockEditViewModel model); Task SaveLayoutAsync(FloorPlanEditorViewModel model); + Task UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload); + Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model); + Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId); + Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId); Task DeleteBlockAsync(int floorPlanBlockId); } @@ -7526,6 +7530,7 @@ public sealed class FloorPlanService( IProjectRepository projects, IFloorPlanRepository floorPlans, ILocationRepository locations, + IFloorPlanBackgroundImageService backgroundImages, IProjectActivityService activity) : IFloorPlanService { public async Task ListAsync(int projectId) @@ -7706,6 +7711,85 @@ public sealed class FloorPlanService( public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId); + public async Task UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload) + { + var (plan, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId); + var result = await backgroundImages.UploadAsync(plan, floor, upload); + if (!result.Succeeded) + { + return result; + } + + var previousPath = floor.BackgroundImagePath; + floor.BackgroundImagePath = result.StoragePath; + floor.BackgroundImageOriginalFileName = result.OriginalFileName; + floor.BackgroundOpacity = Clamp(floor.BackgroundOpacity ?? 0.35m, 0.05m, 1m); + floor.BackgroundScale = Clamp(floor.BackgroundScale ?? 1m, 0.1m, 5m); + floor.BackgroundOffsetX = Clamp(floor.BackgroundOffsetX ?? 0m, -10000m, 10000m); + floor.BackgroundOffsetY = Clamp(floor.BackgroundOffsetY ?? 0m, -10000m, 10000m); + floor.BackgroundLocked = true; + + if (!await floorPlans.UpdateFloorBackgroundAsync(floor)) + { + backgroundImages.DeleteBackgroundFile(result.StoragePath); + return new(false, ErrorMessage: "Background image could not be linked to this floor."); + } + + backgroundImages.DeleteBackgroundFile(previousPath); + await activity.RecordAsync(plan.ProjectID, "Updated", "Floor Plan Background", floorPlanFloorId, floor.Name); + return result; + } + + public async Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model) + { + var (_, floor) = await GetPlanFloorAsync(model.FloorPlanID, model.FloorPlanFloorID); + floor.BackgroundOpacity = Clamp(model.BackgroundOpacity, 0.05m, 1m); + floor.BackgroundScale = Clamp(model.BackgroundScale, 0.1m, 5m); + floor.BackgroundOffsetX = Clamp(model.BackgroundOffsetX, -10000m, 10000m); + floor.BackgroundOffsetY = Clamp(model.BackgroundOffsetY, -10000m, 10000m); + floor.BackgroundLocked = model.BackgroundLocked; + + if (!await floorPlans.UpdateFloorBackgroundAsync(floor)) + { + throw new InvalidOperationException("Background settings could not be saved."); + } + } + + public async Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId) + { + var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId); + floor.BackgroundOpacity = 0.35m; + floor.BackgroundScale = 1m; + floor.BackgroundOffsetX = 0m; + floor.BackgroundOffsetY = 0m; + floor.BackgroundLocked = true; + + if (!await floorPlans.UpdateFloorBackgroundAsync(floor)) + { + throw new InvalidOperationException("Background settings could not be reset."); + } + } + + public async Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId) + { + var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId); + var previousPath = floor.BackgroundImagePath; + floor.BackgroundImagePath = null; + floor.BackgroundImageOriginalFileName = null; + floor.BackgroundOpacity = 0.35m; + floor.BackgroundScale = 1m; + floor.BackgroundOffsetX = 0m; + floor.BackgroundOffsetY = 0m; + floor.BackgroundLocked = true; + + if (!await floorPlans.UpdateFloorBackgroundAsync(floor)) + { + throw new InvalidOperationException("Background image could not be removed."); + } + + backgroundImages.DeleteBackgroundFile(previousPath); + } + private async Task ValidateBlockAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, int projectId) { if (!FloorPlanBlockTypes.IsValid(model.BlockType)) @@ -7735,6 +7819,24 @@ public sealed class FloorPlanService( } } + private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId) + { + var plan = await floorPlans.GetAsync(floorPlanId) + ?? throw new InvalidOperationException("Floor plan not found."); + var floor = await floorPlans.GetFloorAsync(floorPlanFloorId) + ?? throw new InvalidOperationException("Floor not found."); + + if (floor.FloorPlanID != plan.FloorPlanID) + { + throw new InvalidOperationException("Floor does not belong to this floor plan."); + } + + return (plan, floor); + } + + private static decimal Clamp(decimal value, decimal min, decimal max) + => Math.Min(Math.Max(value, min), max); + private async Task ResolveFloorPlanLocationAsync(FloorPlanEditViewModel model) { if (model.LocationID.HasValue && model.LocationID.Value > 0) @@ -7877,7 +7979,14 @@ public sealed class FloorPlanService( Name = floor.Name, SortOrder = floor.SortOrder, GridWidth = floor.GridWidth, - GridHeight = floor.GridHeight + GridHeight = floor.GridHeight, + BackgroundImagePath = floor.BackgroundImagePath, + BackgroundImageOriginalFileName = floor.BackgroundImageOriginalFileName, + BackgroundOpacity = floor.BackgroundOpacity ?? 0.35m, + BackgroundScale = floor.BackgroundScale ?? 1m, + BackgroundOffsetX = floor.BackgroundOffsetX ?? 0m, + BackgroundOffsetY = floor.BackgroundOffsetY ?? 0m, + BackgroundLocked = floor.BackgroundLocked }; private static FloorPlanBlockEditViewModel ToBlockEdit(FloorPlanBlock block) => new() diff --git a/PlotLine/Services/FloorPlanBackgroundImageService.cs b/PlotLine/Services/FloorPlanBackgroundImageService.cs new file mode 100644 index 0000000..0f172cd --- /dev/null +++ b/PlotLine/Services/FloorPlanBackgroundImageService.cs @@ -0,0 +1,91 @@ +using PlotLine.Models; +using SkiaSharp; + +namespace PlotLine.Services; + +public interface IFloorPlanBackgroundImageService +{ + Task UploadAsync(FloorPlan floorPlan, FloorPlanFloor floor, IFormFile upload); + void DeleteBackgroundFile(string? backgroundImagePath); +} + +public sealed record FloorPlanBackgroundImageUploadResult( + bool Succeeded, + string? StoragePath = null, + string? OriginalFileName = null, + string? ErrorMessage = null); + +public sealed class FloorPlanBackgroundImageService( + IUploadStorageService uploadStorage, + ILogger logger) : IFloorPlanBackgroundImageService +{ + private const long MaxUploadBytes = 10L * 1024L * 1024L; + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; + + public async Task UploadAsync(FloorPlan floorPlan, FloorPlanFloor floor, IFormFile upload) + { + if (upload.Length <= 0) + { + return new(false, ErrorMessage: "Choose a background image to upload."); + } + + if (upload.Length > MaxUploadBytes) + { + return new(false, ErrorMessage: "Background image must be 10 MB or smaller."); + } + + var extension = Path.GetExtension(upload.FileName); + if (!SupportedExtensions.Contains(extension)) + { + return new(false, ErrorMessage: "Background image must be a PNG, JPG, JPEG or WebP file."); + } + + var originalFileName = Path.GetFileName(upload.FileName); + if (string.IsNullOrWhiteSpace(originalFileName)) + { + originalFileName = $"background{extension}"; + } + + byte[] bytes; + await using (var stream = upload.OpenReadStream()) + using (var memory = new MemoryStream()) + { + await stream.CopyToAsync(memory); + bytes = memory.ToArray(); + } + + try + { + using var codec = SKCodec.Create(new MemoryStream(bytes)) ?? throw new InvalidDataException(); + if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp)) + { + return new(false, ErrorMessage: "Background image must be a valid PNG, JPG, JPEG or WebP file."); + } + } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException) + { + return new(false, ErrorMessage: "Background image must be a valid PNG, JPG, JPEG or WebP file."); + } + + var storedName = $"background-{Guid.NewGuid():N}{extension.ToLowerInvariant()}"; + var uploadRoot = uploadStorage.GetDirectory("floor-plans", floorPlan.ProjectID.ToString(), floorPlan.FloorPlanID.ToString(), "backgrounds"); + var absolutePath = Path.Combine(uploadRoot, storedName); + var storagePath = uploadStorage.GetPublicPath("floor-plans", floorPlan.ProjectID.ToString(), floorPlan.FloorPlanID.ToString(), "backgrounds", storedName); + + try + { + await File.WriteAllBytesAsync(absolutePath, bytes); + return new(true, storagePath, originalFileName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Floor plan background upload failed for floor {FloorPlanFloorID}.", floor.FloorPlanFloorID); + return new(false, ErrorMessage: "Background image could not be saved. Try another image."); + } + } + + public void DeleteBackgroundFile(string? backgroundImagePath) + { + uploadStorage.TryDeleteFile(backgroundImagePath, "uploads/floor-plans"); + } +} diff --git a/PlotLine/Sql/074_FloorPlanFloorBackgroundImages.sql b/PlotLine/Sql/074_FloorPlanFloorBackgroundImages.sql new file mode 100644 index 0000000..2dc5836 --- /dev/null +++ b/PlotLine/Sql/074_FloorPlanFloorBackgroundImages.sql @@ -0,0 +1,216 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundImagePath') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors ADD BackgroundImagePath nvarchar(max) NULL; +END; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundImageOriginalFileName') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors ADD BackgroundImageOriginalFileName nvarchar(255) NULL; +END; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundOpacity') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors ADD BackgroundOpacity decimal(5,2) NULL; +END; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundScale') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors ADD BackgroundScale decimal(8,3) NULL; +END; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundOffsetX') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors ADD BackgroundOffsetX decimal(10,2) NULL; +END; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundOffsetY') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors ADD BackgroundOffsetY decimal(10,2) NULL; +END; +GO + +IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundLocked') IS NULL +BEGIN + ALTER TABLE dbo.FloorPlanFloors + ADD BackgroundLocked bit NOT NULL + CONSTRAINT DF_FloorPlanFloors_BackgroundLocked DEFAULT 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor + @FloorPlanID int +AS +BEGIN + SET NOCOUNT ON; + + EXEC dbo.FloorPlan_Get @FloorPlanID; + + SELECT + fpf.FloorPlanFloorID, + fpf.FloorPlanID, + fpf.LocationID, + lp.LocationName, + lp.LocationPath, + fpf.Name, + fpf.SortOrder, + fpf.GridWidth, + fpf.GridHeight, + fpf.BackgroundImagePath, + fpf.BackgroundImageOriginalFileName, + COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity, + COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale, + COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX, + COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY, + fpf.BackgroundLocked, + fpf.CreatedDate, + fpf.ModifiedDate + FROM dbo.FloorPlanFloors fpf + LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fpf.Name; + + SELECT + fpb.FloorPlanBlockID, + fpb.FloorPlanFloorID, + fpb.LocationID, + lp.LocationName, + COALESCE(NULLIF(lp.LocationPath, N''), lp.LocationName) AS LocationPath, + fpb.X, + fpb.Y, + fpb.WidthCells, + fpb.HeightCells, + fpb.BlockType, + fpb.LabelOverride, + fpb.Notes, + fpb.CreatedDate, + fpb.ModifiedDate + FROM dbo.FloorPlanBlocks fpb + INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID + INNER JOIN dbo.LocationPaths lp ON lp.LocationID = fpb.LocationID + WHERE fpf.FloorPlanID = @FloorPlanID + ORDER BY fpf.SortOrder, fpb.Y, fpb.X, lp.LocationName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Get + @FloorPlanFloorID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + fpf.FloorPlanFloorID, + fpf.FloorPlanID, + fpf.LocationID, + lp.LocationName, + lp.LocationPath, + fpf.Name, + fpf.SortOrder, + fpf.GridWidth, + fpf.GridHeight, + fpf.BackgroundImagePath, + fpf.BackgroundImageOriginalFileName, + COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity, + COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale, + COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX, + COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY, + fpf.BackgroundLocked, + fpf.CreatedDate, + fpf.ModifiedDate + FROM dbo.FloorPlanFloors fpf + LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID + WHERE fpf.FloorPlanFloorID = @FloorPlanFloorID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Save + @FloorPlanFloorID int, + @FloorPlanID int, + @LocationID int = NULL, + @Name nvarchar(100), + @SortOrder int, + @GridWidth int, + @GridHeight int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = ProjectID FROM dbo.FloorPlans WHERE FloorPlanID = @FloorPlanID; + + IF @ProjectID IS NULL + THROW 51003, 'Floor plan not found.', 1; + + SET @Name = NULLIF(LTRIM(RTRIM(@Name)), N''); + IF @Name IS NULL + THROW 51001, 'Floor name is required.', 1; + + IF @GridWidth NOT BETWEEN 8 AND 80 OR @GridHeight NOT BETWEEN 8 AND 80 + THROW 51002, 'Grid width and height must be between 8 and 80.', 1; + + IF @LocationID IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID = @LocationID AND ProjectID = @ProjectID AND IsArchived = 0) + THROW 51008, 'Floor location must belong to this project.', 1; + + IF @FloorPlanFloorID = 0 + BEGIN + INSERT dbo.FloorPlanFloors (FloorPlanID, LocationID, Name, SortOrder, GridWidth, GridHeight, BackgroundOpacity, BackgroundScale, BackgroundOffsetX, BackgroundOffsetY, BackgroundLocked) + VALUES (@FloorPlanID, @LocationID, @Name, @SortOrder, @GridWidth, @GridHeight, 0.35, 1.000, 0.00, 0.00, 1); + + SELECT CONVERT(int, SCOPE_IDENTITY()); + RETURN; + END; + + UPDATE dbo.FloorPlanFloors + SET LocationID = @LocationID, + Name = @Name, + SortOrder = @SortOrder, + GridWidth = @GridWidth, + GridHeight = @GridHeight, + ModifiedDate = SYSUTCDATETIME() + WHERE FloorPlanFloorID = @FloorPlanFloorID + AND FloorPlanID = @FloorPlanID; + + SELECT @FloorPlanFloorID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_UpdateBackground + @FloorPlanFloorID int, + @FloorPlanID int, + @BackgroundImagePath nvarchar(max) = NULL, + @BackgroundImageOriginalFileName nvarchar(255) = NULL, + @BackgroundOpacity decimal(5,2) = NULL, + @BackgroundScale decimal(8,3) = NULL, + @BackgroundOffsetX decimal(10,2) = NULL, + @BackgroundOffsetY decimal(10,2) = NULL, + @BackgroundLocked bit = 1 +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.FloorPlanFloors + SET BackgroundImagePath = @BackgroundImagePath, + BackgroundImageOriginalFileName = @BackgroundImageOriginalFileName, + BackgroundOpacity = @BackgroundOpacity, + BackgroundScale = @BackgroundScale, + BackgroundOffsetX = @BackgroundOffsetX, + BackgroundOffsetY = @BackgroundOffsetY, + BackgroundLocked = @BackgroundLocked, + ModifiedDate = SYSUTCDATETIME() + WHERE FloorPlanFloorID = @FloorPlanFloorID + AND FloorPlanID = @FloorPlanID; + + SELECT @@ROWCOUNT; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index b72c506..f811100 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1869,6 +1869,43 @@ public sealed class FloorPlanFloorEditViewModel [Range(8, 80)] public int GridHeight { get; set; } = 16; + + public string? BackgroundImagePath { get; set; } + public string? BackgroundImageOriginalFileName { get; set; } + + [Range(0.05, 1.0)] + public decimal BackgroundOpacity { get; set; } = 0.35m; + + [Range(0.1, 5.0)] + public decimal BackgroundScale { get; set; } = 1m; + + [Range(-10000, 10000)] + public decimal BackgroundOffsetX { get; set; } + + [Range(-10000, 10000)] + public decimal BackgroundOffsetY { get; set; } + + public bool BackgroundLocked { get; set; } = true; +} + +public sealed class FloorPlanFloorBackgroundSettingsViewModel +{ + public int FloorPlanID { get; set; } + public int FloorPlanFloorID { get; set; } + + [Range(0.05, 1.0)] + public decimal BackgroundOpacity { get; set; } = 0.35m; + + [Range(0.1, 5.0)] + public decimal BackgroundScale { get; set; } = 1m; + + [Range(-10000, 10000)] + public decimal BackgroundOffsetX { get; set; } + + [Range(-10000, 10000)] + public decimal BackgroundOffsetY { get; set; } + + public bool BackgroundLocked { get; set; } = true; } public sealed class FloorPlanBlockEditViewModel diff --git a/PlotLine/Views/FloorPlans/Edit.cshtml b/PlotLine/Views/FloorPlans/Edit.cshtml index 7551269..dd0b193 100644 --- a/PlotLine/Views/FloorPlans/Edit.cshtml +++ b/PlotLine/Views/FloorPlans/Edit.cshtml @@ -6,6 +6,7 @@ var blocksByFloor = Model.Blocks.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList()); var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : Model.Blocks.FirstOrDefault()?.FloorPlanBlockID ?? 0; string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}"; + string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); }