Implemented the first-pass Location Floor Plans foundation.
This commit is contained in:
parent
e5ed45897a
commit
601afa8806
133
PlotLine/Controllers/FloorPlansController.cs
Normal file
133
PlotLine/Controllers/FloorPlansController.cs
Normal file
@ -0,0 +1,133 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
[Authorize]
|
||||
public sealed class FloorPlansController(IFloorPlanService floorPlans) : Controller
|
||||
{
|
||||
public async Task<IActionResult> Index(int projectId)
|
||||
{
|
||||
var model = await floorPlans.ListAsync(projectId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var model = await floorPlans.GetEditorAsync(id);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(FloorPlanEditViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
|
||||
}
|
||||
|
||||
var floorPlanId = await floorPlans.SavePlanAsync(model);
|
||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SavePlan(FloorPlanEditViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var editor = await floorPlans.GetEditorAsync(model.FloorPlanID);
|
||||
if (editor is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
editor.FloorPlan = model;
|
||||
return View("Edit", editor);
|
||||
}
|
||||
|
||||
var floorPlanId = await floorPlans.SavePlanAsync(model);
|
||||
TempData["FloorPlanMessage"] = "Floor plan saved.";
|
||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveLayout(FloorPlanEditorViewModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
await floorPlans.SaveLayoutAsync(model);
|
||||
TempData["FloorPlanMessage"] = "Layout saved.";
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["FloorPlanError"] = ex.Message;
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Edit), new { id = model.FloorPlan.FloorPlanID });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeletePlan(int id, int projectId)
|
||||
{
|
||||
await floorPlans.DeletePlanAsync(id);
|
||||
TempData["FloorPlanMessage"] = "Floor plan deleted.";
|
||||
return RedirectToAction(nameof(Index), new { projectId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddFloor(FloorPlanFloorEditViewModel model)
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
await floorPlans.SaveFloorAsync(model);
|
||||
TempData["FloorPlanMessage"] = "Floor added.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Edit), new { id = model.FloorPlanID });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteFloor(int floorPlanId, int floorPlanFloorId)
|
||||
{
|
||||
await floorPlans.DeleteFloorAsync(floorPlanFloorId);
|
||||
TempData["FloorPlanMessage"] = "Floor deleted.";
|
||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
await floorPlans.SaveBlockAsync(model);
|
||||
TempData["FloorPlanMessage"] = "Block added.";
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["FloorPlanError"] = ex.Message;
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteBlock(int floorPlanId, int floorPlanBlockId)
|
||||
{
|
||||
await floorPlans.DeleteBlockAsync(floorPlanBlockId);
|
||||
TempData["FloorPlanMessage"] = "Block deleted.";
|
||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||
}
|
||||
}
|
||||
@ -214,6 +214,20 @@ public interface ILocationRepository
|
||||
Task<IReadOnlyList<SceneAssetLocation>> ListAssetsByLocationAsync(int locationId);
|
||||
}
|
||||
|
||||
public interface IFloorPlanRepository
|
||||
{
|
||||
Task<IReadOnlyList<FloorPlan>> ListByProjectAsync(int projectId);
|
||||
Task<FloorPlan?> GetAsync(int floorPlanId);
|
||||
Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks)> GetEditorAsync(int floorPlanId);
|
||||
Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId);
|
||||
Task<int> SavePlanAsync(FloorPlan floorPlan);
|
||||
Task DeletePlanAsync(int floorPlanId);
|
||||
Task<int> SaveFloorAsync(FloorPlanFloor floor);
|
||||
Task DeleteFloorAsync(int floorPlanFloorId);
|
||||
Task<int> SaveBlockAsync(FloorPlanBlock block);
|
||||
Task DeleteBlockAsync(int floorPlanBlockId);
|
||||
}
|
||||
|
||||
public interface IWarningRepository
|
||||
{
|
||||
Task<WarningLookupData> GetLookupsAsync();
|
||||
@ -522,6 +536,95 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory) : IFloorPlanRepository
|
||||
{
|
||||
public async Task<IReadOnlyList<FloorPlan>> ListByProjectAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<FloorPlan>("dbo.FloorPlan_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<FloorPlan?> GetAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<FloorPlan>("dbo.FloorPlan_Get", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks)> GetEditorAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
using var result = await connection.QueryMultipleAsync("dbo.FloorPlan_GetEditor", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
|
||||
var floorPlan = await result.ReadSingleOrDefaultAsync<FloorPlan>();
|
||||
var floors = (await result.ReadAsync<FloorPlanFloor>()).ToList();
|
||||
var blocks = (await result.ReadAsync<FloorPlanBlock>()).ToList();
|
||||
return (floorPlan, floors, blocks);
|
||||
}
|
||||
|
||||
public async Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<FloorPlanFloor>("dbo.FloorPlanFloor_Get", new { FloorPlanFloorID = floorPlanFloorId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SavePlanAsync(FloorPlan floorPlan)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FloorPlan_Save",
|
||||
new { floorPlan.FloorPlanID, floorPlan.ProjectID, floorPlan.Name, floorPlan.Description },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task DeletePlanAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync("dbo.FloorPlan_Delete", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SaveFloorAsync(FloorPlanFloor floor)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FloorPlanFloor_Save",
|
||||
new { floor.FloorPlanFloorID, floor.FloorPlanID, floor.Name, floor.SortOrder, floor.GridWidth, floor.GridHeight },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task DeleteFloorAsync(int floorPlanFloorId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync("dbo.FloorPlanFloor_Delete", new { FloorPlanFloorID = floorPlanFloorId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SaveBlockAsync(FloorPlanBlock block)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FloorPlanBlock_Save",
|
||||
new
|
||||
{
|
||||
block.FloorPlanBlockID,
|
||||
block.FloorPlanFloorID,
|
||||
block.LocationID,
|
||||
block.X,
|
||||
block.Y,
|
||||
block.WidthCells,
|
||||
block.HeightCells,
|
||||
block.BlockType,
|
||||
block.LabelOverride,
|
||||
block.Notes
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task DeleteBlockAsync(int floorPlanBlockId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync("dbo.FloorPlanBlock_Delete", new { FloorPlanBlockID = floorPlanBlockId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) : IWarningRepository
|
||||
{
|
||||
public async Task<WarningLookupData> GetLookupsAsync()
|
||||
|
||||
@ -1125,6 +1125,62 @@ public sealed class LocationRelationship
|
||||
public bool IsArchived { get; set; }
|
||||
}
|
||||
|
||||
public static class FloorPlanBlockTypes
|
||||
{
|
||||
public const string Room = "Room";
|
||||
public const string Corridor = "Corridor";
|
||||
public const string OpenSpace = "OpenSpace";
|
||||
public const string Exterior = "Exterior";
|
||||
public const string Other = "Other";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Room, Corridor, OpenSpace, Exterior, Other];
|
||||
|
||||
public static bool IsValid(string? blockType) => All.Contains(blockType ?? string.Empty, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class FloorPlan
|
||||
{
|
||||
public int FloorPlanID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
public int FloorCount { get; set; }
|
||||
public int BlockCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FloorPlanFloor
|
||||
{
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public int GridWidth { get; set; } = 24;
|
||||
public int GridHeight { get; set; } = 16;
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FloorPlanBlock
|
||||
{
|
||||
public int FloorPlanBlockID { get; set; }
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int LocationID { get; set; }
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string LocationPath { get; set; } = string.Empty;
|
||||
public int X { get; set; }
|
||||
public int Y { get; set; }
|
||||
public int WidthCells { get; set; } = 1;
|
||||
public int HeightCells { get; set; } = 1;
|
||||
public string BlockType { get; set; } = FloorPlanBlockTypes.Room;
|
||||
public string? LabelOverride { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
public string DisplayLabel => string.IsNullOrWhiteSpace(LabelOverride) ? LocationName : LabelOverride;
|
||||
}
|
||||
|
||||
public sealed class SceneAssetLocation
|
||||
{
|
||||
public int SceneAssetLocationID { get; set; }
|
||||
|
||||
@ -106,6 +106,7 @@ public class Program
|
||||
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
|
||||
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
|
||||
builder.Services.AddScoped<ILocationRepository, LocationRepository>();
|
||||
builder.Services.AddScoped<IFloorPlanRepository, FloorPlanRepository>();
|
||||
builder.Services.AddScoped<IWarningRepository, WarningRepository>();
|
||||
builder.Services.AddScoped<IContinuityWarningAcknowledgementRepository, ContinuityWarningAcknowledgementRepository>();
|
||||
builder.Services.AddScoped<ISceneDependencyRepository, SceneDependencyRepository>();
|
||||
@ -142,6 +143,7 @@ public class Program
|
||||
builder.Services.AddScoped<IAssetService, AssetService>();
|
||||
builder.Services.AddScoped<ICharacterService, CharacterService>();
|
||||
builder.Services.AddScoped<ILocationService, LocationService>();
|
||||
builder.Services.AddScoped<IFloorPlanService, FloorPlanService>();
|
||||
builder.Services.AddScoped<IContinuityValidationService, ContinuityValidationService>();
|
||||
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
builder.Services.AddScoped<IStoryBibleService, StoryBibleService>();
|
||||
|
||||
@ -177,6 +177,19 @@ public interface ILocationService
|
||||
Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId);
|
||||
}
|
||||
|
||||
public interface IFloorPlanService
|
||||
{
|
||||
Task<FloorPlanListViewModel?> ListAsync(int projectId);
|
||||
Task<FloorPlanEditorViewModel?> GetEditorAsync(int floorPlanId);
|
||||
Task<int> SavePlanAsync(FloorPlanEditViewModel model);
|
||||
Task DeletePlanAsync(int floorPlanId);
|
||||
Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model);
|
||||
Task DeleteFloorAsync(int floorPlanFloorId);
|
||||
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model);
|
||||
Task SaveLayoutAsync(FloorPlanEditorViewModel model);
|
||||
Task DeleteBlockAsync(int floorPlanBlockId);
|
||||
}
|
||||
|
||||
public interface IContinuityValidationService
|
||||
{
|
||||
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, string? category, bool includeDismissed, bool includeIntentional, bool showAcknowledgedWarnings, ContinuityValidationSummary? summary = null);
|
||||
@ -7509,6 +7522,251 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FloorPlanService(
|
||||
IProjectRepository projects,
|
||||
IFloorPlanRepository floorPlans,
|
||||
ILocationRepository locations,
|
||||
IProjectActivityService activity) : IFloorPlanService
|
||||
{
|
||||
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
|
||||
{
|
||||
var project = await projects.GetAsync(projectId);
|
||||
return project is null ? null : new FloorPlanListViewModel
|
||||
{
|
||||
Project = project,
|
||||
FloorPlans = await floorPlans.ListByProjectAsync(projectId),
|
||||
NewFloorPlan = new FloorPlanEditViewModel { ProjectID = projectId, Name = "New floor plan" }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<FloorPlanEditorViewModel?> GetEditorAsync(int floorPlanId)
|
||||
{
|
||||
var data = await floorPlans.GetEditorAsync(floorPlanId);
|
||||
if (data.FloorPlan is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var project = await projects.GetAsync(data.FloorPlan.ProjectID);
|
||||
if (project is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectLocations = await locations.ListByProjectAsync(project.ProjectID);
|
||||
return new FloorPlanEditorViewModel
|
||||
{
|
||||
Project = project,
|
||||
FloorPlan = new FloorPlanEditViewModel
|
||||
{
|
||||
FloorPlanID = data.FloorPlan.FloorPlanID,
|
||||
ProjectID = data.FloorPlan.ProjectID,
|
||||
Name = data.FloorPlan.Name,
|
||||
Description = data.FloorPlan.Description
|
||||
},
|
||||
Floors = data.Floors.Select(ToFloorEdit).ToList(),
|
||||
Blocks = data.Blocks.Select(ToBlockEdit).ToList(),
|
||||
NewFloor = new FloorPlanFloorEditViewModel
|
||||
{
|
||||
FloorPlanID = data.FloorPlan.FloorPlanID,
|
||||
Name = "New floor",
|
||||
SortOrder = data.Floors.Any() ? data.Floors.Max(x => x.SortOrder) + 10 : 10,
|
||||
GridWidth = 24,
|
||||
GridHeight = 16
|
||||
},
|
||||
NewBlock = new FloorPlanBlockEditViewModel
|
||||
{
|
||||
FloorPlanFloorID = data.Floors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
|
||||
BlockType = FloorPlanBlockTypes.Room,
|
||||
WidthCells = 2,
|
||||
HeightCells = 1
|
||||
},
|
||||
LocationOptions = projectLocations
|
||||
.Select(x => new SelectListItem(string.IsNullOrWhiteSpace(x.LocationPath) ? x.LocationName : x.LocationPath, x.LocationID.ToString()))
|
||||
.ToList(),
|
||||
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> SavePlanAsync(FloorPlanEditViewModel model)
|
||||
{
|
||||
var isNew = model.FloorPlanID == 0;
|
||||
var floorPlanId = await floorPlans.SavePlanAsync(new FloorPlan
|
||||
{
|
||||
FloorPlanID = model.FloorPlanID,
|
||||
ProjectID = model.ProjectID,
|
||||
Name = model.Name.Trim(),
|
||||
Description = model.Description
|
||||
});
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
await floorPlans.SaveFloorAsync(new FloorPlanFloor
|
||||
{
|
||||
FloorPlanID = floorPlanId,
|
||||
Name = "Ground Floor",
|
||||
SortOrder = 10,
|
||||
GridWidth = 24,
|
||||
GridHeight = 16
|
||||
});
|
||||
}
|
||||
|
||||
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Floor Plan", floorPlanId, model.Name);
|
||||
return floorPlanId;
|
||||
}
|
||||
|
||||
public async Task DeletePlanAsync(int floorPlanId)
|
||||
{
|
||||
var floorPlan = await floorPlans.GetAsync(floorPlanId);
|
||||
await floorPlans.DeletePlanAsync(floorPlanId);
|
||||
if (floorPlan is not null)
|
||||
{
|
||||
await activity.RecordAsync(floorPlan.ProjectID, "Deleted", "Floor Plan", floorPlanId, floorPlan.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model)
|
||||
{
|
||||
ValidateFloor(model);
|
||||
return await floorPlans.SaveFloorAsync(new FloorPlanFloor
|
||||
{
|
||||
FloorPlanFloorID = model.FloorPlanFloorID,
|
||||
FloorPlanID = model.FloorPlanID,
|
||||
Name = model.Name.Trim(),
|
||||
SortOrder = model.SortOrder,
|
||||
GridWidth = model.GridWidth,
|
||||
GridHeight = model.GridHeight
|
||||
});
|
||||
}
|
||||
|
||||
public Task DeleteFloorAsync(int floorPlanFloorId) => floorPlans.DeleteFloorAsync(floorPlanFloorId);
|
||||
|
||||
public async Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model)
|
||||
{
|
||||
var floor = await floorPlans.GetFloorAsync(model.FloorPlanFloorID)
|
||||
?? throw new InvalidOperationException("Floor not found.");
|
||||
var plan = await floorPlans.GetAsync(floor.FloorPlanID)
|
||||
?? throw new InvalidOperationException("Floor plan not found.");
|
||||
var projectId = plan.ProjectID;
|
||||
await ValidateBlockAsync(model, floor, projectId);
|
||||
return await floorPlans.SaveBlockAsync(ToBlock(model));
|
||||
}
|
||||
|
||||
public async Task SaveLayoutAsync(FloorPlanEditorViewModel model)
|
||||
{
|
||||
var data = await floorPlans.GetEditorAsync(model.FloorPlan.FloorPlanID);
|
||||
if (data.FloorPlan is null)
|
||||
{
|
||||
throw new InvalidOperationException("Floor plan not found.");
|
||||
}
|
||||
|
||||
await SavePlanAsync(model.FloorPlan);
|
||||
var floorsById = data.Floors.ToDictionary(x => x.FloorPlanFloorID);
|
||||
|
||||
foreach (var floorModel in model.Floors)
|
||||
{
|
||||
ValidateFloor(floorModel);
|
||||
if (floorModel.FloorPlanID != data.FloorPlan.FloorPlanID)
|
||||
{
|
||||
throw new InvalidOperationException("Floor does not belong to this floor plan.");
|
||||
}
|
||||
|
||||
await SaveFloorAsync(floorModel);
|
||||
}
|
||||
|
||||
foreach (var blockModel in model.Blocks)
|
||||
{
|
||||
if (!floorsById.TryGetValue(blockModel.FloorPlanFloorID, out var floor))
|
||||
{
|
||||
throw new InvalidOperationException("Block floor not found.");
|
||||
}
|
||||
|
||||
await ValidateBlockAsync(blockModel, floor, data.FloorPlan.ProjectID);
|
||||
await floorPlans.SaveBlockAsync(ToBlock(blockModel));
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId);
|
||||
|
||||
private async Task ValidateBlockAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, int projectId)
|
||||
{
|
||||
if (!FloorPlanBlockTypes.IsValid(model.BlockType))
|
||||
{
|
||||
throw new InvalidOperationException("Choose a valid block type.");
|
||||
}
|
||||
|
||||
if (model.X < 0 || model.Y < 0 || model.WidthCells < 1 || model.HeightCells < 1)
|
||||
{
|
||||
throw new InvalidOperationException("Blocks must have a valid position and size.");
|
||||
}
|
||||
|
||||
if (model.X + model.WidthCells > floor.GridWidth || model.Y + model.HeightCells > floor.GridHeight)
|
||||
{
|
||||
throw new InvalidOperationException("Block is outside the floor grid.");
|
||||
}
|
||||
|
||||
var location = await locations.GetAsync(model.LocationID);
|
||||
if (location is null || location.ProjectID != projectId)
|
||||
{
|
||||
throw new InvalidOperationException("Choose a location from this project.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateFloor(FloorPlanFloorEditViewModel model)
|
||||
{
|
||||
if (model.GridWidth is < 8 or > 80 || model.GridHeight is < 8 or > 80)
|
||||
{
|
||||
throw new InvalidOperationException("Grid width and height must be between 8 and 80.");
|
||||
}
|
||||
}
|
||||
|
||||
private static FloorPlanFloorEditViewModel ToFloorEdit(FloorPlanFloor floor) => new()
|
||||
{
|
||||
FloorPlanFloorID = floor.FloorPlanFloorID,
|
||||
FloorPlanID = floor.FloorPlanID,
|
||||
Name = floor.Name,
|
||||
SortOrder = floor.SortOrder,
|
||||
GridWidth = floor.GridWidth,
|
||||
GridHeight = floor.GridHeight
|
||||
};
|
||||
|
||||
private static FloorPlanBlockEditViewModel ToBlockEdit(FloorPlanBlock block) => new()
|
||||
{
|
||||
FloorPlanBlockID = block.FloorPlanBlockID,
|
||||
FloorPlanFloorID = block.FloorPlanFloorID,
|
||||
LocationID = block.LocationID,
|
||||
LocationName = block.LocationName,
|
||||
LocationPath = block.LocationPath,
|
||||
X = block.X,
|
||||
Y = block.Y,
|
||||
WidthCells = block.WidthCells,
|
||||
HeightCells = block.HeightCells,
|
||||
BlockType = block.BlockType,
|
||||
LabelOverride = block.LabelOverride,
|
||||
Notes = block.Notes
|
||||
};
|
||||
|
||||
private static FloorPlanBlock ToBlock(FloorPlanBlockEditViewModel model) => new()
|
||||
{
|
||||
FloorPlanBlockID = model.FloorPlanBlockID,
|
||||
FloorPlanFloorID = model.FloorPlanFloorID,
|
||||
LocationID = model.LocationID,
|
||||
X = model.X,
|
||||
Y = model.Y,
|
||||
WidthCells = model.WidthCells,
|
||||
HeightCells = model.HeightCells,
|
||||
BlockType = model.BlockType,
|
||||
LabelOverride = model.LabelOverride,
|
||||
Notes = model.Notes
|
||||
};
|
||||
|
||||
private static string BlockTypeLabel(string blockType) => blockType switch
|
||||
{
|
||||
FloorPlanBlockTypes.OpenSpace => "Open space",
|
||||
_ => blockType
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class CharacterService(
|
||||
IProjectRepository projects,
|
||||
IBookRepository books,
|
||||
|
||||
@ -52,6 +52,9 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
||||
["ParentLocationID"] = "Location",
|
||||
["FromLocationID"] = "Location",
|
||||
["ToLocationID"] = "Location",
|
||||
["FloorPlanID"] = "FloorPlan",
|
||||
["FloorPlanFloorID"] = "FloorPlanFloor",
|
||||
["FloorPlanBlockID"] = "FloorPlanBlock",
|
||||
["PlotLineID"] = "PlotLine",
|
||||
["ParentPlotLineID"] = "PlotLine",
|
||||
["PlotThreadID"] = "PlotThread",
|
||||
@ -68,6 +71,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
||||
["Chapters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Chapter", ["chapterId"] = "Chapter", ["bookId"] = "Book" },
|
||||
["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Character", ["characterId"] = "Character", ["projectId"] = "Project" },
|
||||
["Locations"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Location", ["locationId"] = "Location", ["returnLocationId"] = "Location", ["projectId"] = "Project" },
|
||||
["FloorPlans"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "FloorPlan", ["floorPlanId"] = "FloorPlan", ["floorPlanFloorId"] = "FloorPlanFloor", ["floorPlanBlockId"] = "FloorPlanBlock", ["projectId"] = "Project" },
|
||||
["PlotLines"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "PlotLine", ["plotLineId"] = "PlotLine", ["projectId"] = "Project" },
|
||||
["PlotThreads"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "PlotThread", ["plotThreadId"] = "PlotThread", ["plotLineId"] = "PlotLine", ["projectId"] = "Project" },
|
||||
["ProjectMetrics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" },
|
||||
|
||||
453
PlotLine/Sql/071_LocationFloorPlans.sql
Normal file
453
PlotLine/Sql/071_LocationFloorPlans.sql
Normal file
@ -0,0 +1,453 @@
|
||||
IF OBJECT_ID(N'dbo.FloorPlanBlocks', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.FloorPlanBlocks
|
||||
(
|
||||
FloorPlanBlockID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FloorPlanBlocks PRIMARY KEY,
|
||||
FloorPlanFloorID int NOT NULL,
|
||||
LocationID int NOT NULL,
|
||||
X int NOT NULL,
|
||||
Y int NOT NULL,
|
||||
WidthCells int NOT NULL,
|
||||
HeightCells int NOT NULL,
|
||||
BlockType nvarchar(40) NOT NULL CONSTRAINT DF_FloorPlanBlocks_BlockType DEFAULT N'Room',
|
||||
LabelOverride nvarchar(200) NULL,
|
||||
Notes nvarchar(max) NULL,
|
||||
CreatedDate datetime2 NOT NULL CONSTRAINT DF_FloorPlanBlocks_CreatedDate DEFAULT SYSUTCDATETIME(),
|
||||
ModifiedDate datetime2 NULL
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.FloorPlanFloors', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.FloorPlanFloors
|
||||
(
|
||||
FloorPlanFloorID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FloorPlanFloors PRIMARY KEY,
|
||||
FloorPlanID int NOT NULL,
|
||||
Name nvarchar(100) NOT NULL,
|
||||
SortOrder int NOT NULL CONSTRAINT DF_FloorPlanFloors_SortOrder DEFAULT 0,
|
||||
GridWidth int NOT NULL CONSTRAINT DF_FloorPlanFloors_GridWidth DEFAULT 24,
|
||||
GridHeight int NOT NULL CONSTRAINT DF_FloorPlanFloors_GridHeight DEFAULT 16,
|
||||
CreatedDate datetime2 NOT NULL CONSTRAINT DF_FloorPlanFloors_CreatedDate DEFAULT SYSUTCDATETIME(),
|
||||
ModifiedDate datetime2 NULL
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.FloorPlans', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.FloorPlans
|
||||
(
|
||||
FloorPlanID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FloorPlans PRIMARY KEY,
|
||||
ProjectID int NOT NULL,
|
||||
Name nvarchar(200) NOT NULL,
|
||||
Description nvarchar(max) NULL,
|
||||
CreatedDate datetime2 NOT NULL CONSTRAINT DF_FloorPlans_CreatedDate DEFAULT SYSUTCDATETIME(),
|
||||
ModifiedDate datetime2 NULL
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlans_Projects')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlans
|
||||
ADD CONSTRAINT FK_FloorPlans_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanFloors_FloorPlans')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanFloors
|
||||
ADD CONSTRAINT FK_FloorPlanFloors_FloorPlans FOREIGN KEY (FloorPlanID) REFERENCES dbo.FloorPlans(FloorPlanID) ON DELETE CASCADE;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanBlocks_FloorPlanFloors')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanBlocks
|
||||
ADD CONSTRAINT FK_FloorPlanBlocks_FloorPlanFloors FOREIGN KEY (FloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID) ON DELETE CASCADE;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanBlocks_Locations')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanBlocks
|
||||
ADD CONSTRAINT FK_FloorPlanBlocks_Locations FOREIGN KEY (LocationID) REFERENCES dbo.Locations(LocationID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_FloorPlanFloors_GridSize')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanFloors
|
||||
ADD CONSTRAINT CK_FloorPlanFloors_GridSize CHECK (GridWidth BETWEEN 8 AND 80 AND GridHeight BETWEEN 8 AND 80);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_FloorPlanBlocks_PositionSize')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanBlocks
|
||||
ADD CONSTRAINT CK_FloorPlanBlocks_PositionSize CHECK (X >= 0 AND Y >= 0 AND WidthCells >= 1 AND HeightCells >= 1);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_FloorPlanBlocks_BlockType')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FloorPlanBlocks
|
||||
ADD CONSTRAINT CK_FloorPlanBlocks_BlockType CHECK (BlockType IN (N'Room', N'Corridor', N'OpenSpace', N'Exterior', N'Other'));
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlans_ProjectID' AND object_id = OBJECT_ID(N'dbo.FloorPlans'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FloorPlans_ProjectID ON dbo.FloorPlans(ProjectID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanFloors_FloorPlanID' AND object_id = OBJECT_ID(N'dbo.FloorPlanFloors'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FloorPlanFloors_FloorPlanID ON dbo.FloorPlanFloors(FloorPlanID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanBlocks_FloorPlanFloorID' AND object_id = OBJECT_ID(N'dbo.FloorPlanBlocks'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FloorPlanBlocks_FloorPlanFloorID ON dbo.FloorPlanBlocks(FloorPlanFloorID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanBlocks_LocationID' AND object_id = OBJECT_ID(N'dbo.FloorPlanBlocks'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FloorPlanBlocks_LocationID ON dbo.FloorPlanBlocks(LocationID);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_ListByProject
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
fp.FloorPlanID,
|
||||
fp.ProjectID,
|
||||
fp.Name,
|
||||
fp.Description,
|
||||
fp.CreatedDate,
|
||||
fp.ModifiedDate,
|
||||
COUNT(DISTINCT fpf.FloorPlanFloorID) AS FloorCount,
|
||||
COUNT(fpb.FloorPlanBlockID) AS BlockCount
|
||||
FROM dbo.FloorPlans fp
|
||||
LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanID = fp.FloorPlanID
|
||||
LEFT JOIN dbo.FloorPlanBlocks fpb ON fpb.FloorPlanFloorID = fpf.FloorPlanFloorID
|
||||
WHERE fp.ProjectID = @ProjectID
|
||||
GROUP BY fp.FloorPlanID, fp.ProjectID, fp.Name, fp.Description, fp.CreatedDate, fp.ModifiedDate
|
||||
ORDER BY fp.Name;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_Get
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT FloorPlanID, ProjectID, Name, Description, CreatedDate, ModifiedDate
|
||||
FROM dbo.FloorPlans
|
||||
WHERE FloorPlanID = @FloorPlanID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
EXEC dbo.FloorPlan_Get @FloorPlanID;
|
||||
|
||||
SELECT FloorPlanFloorID, FloorPlanID, Name, SortOrder, GridWidth, GridHeight, CreatedDate, ModifiedDate
|
||||
FROM dbo.FloorPlanFloors
|
||||
WHERE FloorPlanID = @FloorPlanID
|
||||
ORDER BY SortOrder, 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.FloorPlan_Save
|
||||
@FloorPlanID int,
|
||||
@ProjectID int,
|
||||
@Name nvarchar(200),
|
||||
@Description nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SET @Name = NULLIF(LTRIM(RTRIM(@Name)), N'');
|
||||
IF @Name IS NULL
|
||||
THROW 51000, 'Floor plan name is required.', 1;
|
||||
|
||||
IF @FloorPlanID = 0
|
||||
BEGIN
|
||||
INSERT dbo.FloorPlans (ProjectID, Name, Description)
|
||||
VALUES (@ProjectID, @Name, @Description);
|
||||
|
||||
SELECT CONVERT(int, SCOPE_IDENTITY());
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.FloorPlans
|
||||
SET Name = @Name,
|
||||
Description = @Description,
|
||||
ModifiedDate = SYSUTCDATETIME()
|
||||
WHERE FloorPlanID = @FloorPlanID
|
||||
AND ProjectID = @ProjectID;
|
||||
|
||||
SELECT @FloorPlanID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_Delete
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DELETE dbo.FloorPlans
|
||||
WHERE FloorPlanID = @FloorPlanID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Get
|
||||
@FloorPlanFloorID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT FloorPlanFloorID, FloorPlanID, Name, SortOrder, GridWidth, GridHeight, CreatedDate, ModifiedDate
|
||||
FROM dbo.FloorPlanFloors
|
||||
WHERE FloorPlanFloorID = @FloorPlanFloorID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Save
|
||||
@FloorPlanFloorID int,
|
||||
@FloorPlanID int,
|
||||
@Name nvarchar(100),
|
||||
@SortOrder int,
|
||||
@GridWidth int,
|
||||
@GridHeight int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
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 @FloorPlanFloorID = 0
|
||||
BEGIN
|
||||
INSERT dbo.FloorPlanFloors (FloorPlanID, Name, SortOrder, GridWidth, GridHeight)
|
||||
VALUES (@FloorPlanID, @Name, @SortOrder, @GridWidth, @GridHeight);
|
||||
|
||||
SELECT CONVERT(int, SCOPE_IDENTITY());
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.FloorPlanFloors
|
||||
SET 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_Delete
|
||||
@FloorPlanFloorID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DELETE dbo.FloorPlanFloors
|
||||
WHERE FloorPlanFloorID = @FloorPlanFloorID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_Save
|
||||
@FloorPlanBlockID int,
|
||||
@FloorPlanFloorID int,
|
||||
@LocationID int,
|
||||
@X int,
|
||||
@Y int,
|
||||
@WidthCells int,
|
||||
@HeightCells int,
|
||||
@BlockType nvarchar(40),
|
||||
@LabelOverride nvarchar(200) = NULL,
|
||||
@Notes nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @GridWidth int, @GridHeight int, @ProjectID int;
|
||||
|
||||
SELECT
|
||||
@GridWidth = fpf.GridWidth,
|
||||
@GridHeight = fpf.GridHeight,
|
||||
@ProjectID = fp.ProjectID
|
||||
FROM dbo.FloorPlanFloors fpf
|
||||
INNER JOIN dbo.FloorPlans fp ON fp.FloorPlanID = fpf.FloorPlanID
|
||||
WHERE fpf.FloorPlanFloorID = @FloorPlanFloorID;
|
||||
|
||||
IF @ProjectID IS NULL
|
||||
THROW 51003, 'Floor not found.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID = @LocationID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
THROW 51004, 'Location must belong to this project.', 1;
|
||||
|
||||
IF @BlockType NOT IN (N'Room', N'Corridor', N'OpenSpace', N'Exterior', N'Other')
|
||||
THROW 51005, 'Choose a valid block type.', 1;
|
||||
|
||||
IF @X < 0 OR @Y < 0 OR @WidthCells < 1 OR @HeightCells < 1 OR @X + @WidthCells > @GridWidth OR @Y + @HeightCells > @GridHeight
|
||||
THROW 51006, 'Block is outside the floor grid.', 1;
|
||||
|
||||
IF @FloorPlanBlockID = 0
|
||||
BEGIN
|
||||
INSERT dbo.FloorPlanBlocks (FloorPlanFloorID, LocationID, X, Y, WidthCells, HeightCells, BlockType, LabelOverride, Notes)
|
||||
VALUES (@FloorPlanFloorID, @LocationID, @X, @Y, @WidthCells, @HeightCells, @BlockType, NULLIF(LTRIM(RTRIM(@LabelOverride)), N''), @Notes);
|
||||
|
||||
SELECT CONVERT(int, SCOPE_IDENTITY());
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.FloorPlanBlocks
|
||||
SET FloorPlanFloorID = @FloorPlanFloorID,
|
||||
LocationID = @LocationID,
|
||||
X = @X,
|
||||
Y = @Y,
|
||||
WidthCells = @WidthCells,
|
||||
HeightCells = @HeightCells,
|
||||
BlockType = @BlockType,
|
||||
LabelOverride = NULLIF(LTRIM(RTRIM(@LabelOverride)), N''),
|
||||
Notes = @Notes,
|
||||
ModifiedDate = SYSUTCDATETIME()
|
||||
WHERE FloorPlanBlockID = @FloorPlanBlockID;
|
||||
|
||||
SELECT @FloorPlanBlockID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_Delete
|
||||
@FloorPlanBlockID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DELETE dbo.FloorPlanBlocks
|
||||
WHERE FloorPlanBlockID = @FloorPlanBlockID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectAccess_UserCanAccessEntity
|
||||
@EntityType nvarchar(100),
|
||||
@EntityID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ProjectID int;
|
||||
|
||||
IF @EntityType = N'Project'
|
||||
SET @ProjectID = @EntityID;
|
||||
ELSE IF @EntityType = N'Book'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.Books WHERE BookID = @EntityID;
|
||||
ELSE IF @EntityType = N'Chapter'
|
||||
SELECT @ProjectID = b.ProjectID FROM dbo.Chapters c INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE c.ChapterID = @EntityID;
|
||||
ELSE IF @EntityType = N'Scene'
|
||||
SELECT @ProjectID = b.ProjectID FROM dbo.Scenes s INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE s.SceneID = @EntityID;
|
||||
ELSE IF @EntityType = N'Character'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.Characters WHERE CharacterID = @EntityID;
|
||||
ELSE IF @EntityType = N'StoryAsset'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.StoryAssets WHERE StoryAssetID = @EntityID;
|
||||
ELSE IF @EntityType = N'Location'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.Locations WHERE LocationID = @EntityID;
|
||||
ELSE IF @EntityType = N'FloorPlan'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.FloorPlans WHERE FloorPlanID = @EntityID;
|
||||
ELSE IF @EntityType = N'FloorPlanFloor'
|
||||
SELECT @ProjectID = fp.ProjectID FROM dbo.FloorPlanFloors fpf INNER JOIN dbo.FloorPlans fp ON fp.FloorPlanID = fpf.FloorPlanID WHERE fpf.FloorPlanFloorID = @EntityID;
|
||||
ELSE IF @EntityType = N'FloorPlanBlock'
|
||||
SELECT @ProjectID = fp.ProjectID FROM dbo.FloorPlanBlocks fpb INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID INNER JOIN dbo.FloorPlans fp ON fp.FloorPlanID = fpf.FloorPlanID WHERE fpb.FloorPlanBlockID = @EntityID;
|
||||
ELSE IF @EntityType = N'PlotLine'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.PlotLines WHERE PlotLineID = @EntityID;
|
||||
ELSE IF @EntityType = N'PlotThread'
|
||||
SELECT @ProjectID = pl.ProjectID FROM dbo.PlotThreads pt INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID WHERE pt.PlotThreadID = @EntityID;
|
||||
ELSE IF @EntityType = N'CharacterRelationship'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.CharacterRelationships WHERE CharacterRelationshipID = @EntityID;
|
||||
ELSE IF @EntityType = N'LocationRelationship'
|
||||
SELECT @ProjectID = l.ProjectID FROM dbo.LocationRelationships lr INNER JOIN dbo.Locations l ON l.LocationID = lr.FromLocationID WHERE lr.LocationRelationshipID = @EntityID;
|
||||
ELSE IF @EntityType = N'TimelineViewPreset'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.TimelineViewPresets WHERE TimelineViewPresetID = @EntityID;
|
||||
ELSE IF @EntityType = N'ProjectRestorePoint'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.ProjectRestorePoints WHERE ProjectRestorePointID = @EntityID;
|
||||
ELSE IF @EntityType = N'Scenario'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.Scenarios WHERE ScenarioID = @EntityID;
|
||||
ELSE IF @EntityType = N'ThreadEvent'
|
||||
SELECT @ProjectID = pl.ProjectID FROM dbo.ThreadEvents te INNER JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID WHERE te.ThreadEventID = @EntityID;
|
||||
ELSE IF @EntityType = N'AssetEvent'
|
||||
SELECT @ProjectID = sa.ProjectID FROM dbo.AssetEvents ae INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ae.StoryAssetID WHERE ae.AssetEventID = @EntityID;
|
||||
ELSE IF @EntityType = N'SceneAssetLocation'
|
||||
SELECT @ProjectID = sa.ProjectID FROM dbo.SceneAssetLocations sal INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = sal.StoryAssetID WHERE sal.SceneAssetLocationID = @EntityID;
|
||||
ELSE IF @EntityType = N'AssetCustodyEvent'
|
||||
SELECT @ProjectID = sa.ProjectID FROM dbo.AssetCustodyEvents ace INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID WHERE ace.AssetCustodyEventID = @EntityID;
|
||||
ELSE IF @EntityType = N'SceneCharacter'
|
||||
SELECT @ProjectID = ch.ProjectID FROM dbo.SceneCharacters sc INNER JOIN dbo.Characters ch ON ch.CharacterID = sc.CharacterID WHERE sc.SceneCharacterID = @EntityID;
|
||||
ELSE IF @EntityType = N'SceneDependency'
|
||||
SELECT @ProjectID = ProjectID FROM dbo.SceneDependencies WHERE SceneDependencyID = @EntityID;
|
||||
ELSE IF @EntityType = N'CharacterKnowledge'
|
||||
SELECT @ProjectID = c.ProjectID FROM dbo.CharacterKnowledge ck INNER JOIN dbo.Characters c ON c.CharacterID = ck.CharacterID WHERE ck.CharacterKnowledgeID = @EntityID;
|
||||
ELSE IF @EntityType = N'RelationshipEvent'
|
||||
SELECT @ProjectID = cr.ProjectID FROM dbo.RelationshipEvents re INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID WHERE re.RelationshipEventID = @EntityID;
|
||||
ELSE IF @EntityType = N'SceneNote'
|
||||
SELECT @ProjectID = b.ProjectID FROM dbo.SceneNotes sn INNER JOIN dbo.Scenes s ON s.SceneID = sn.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sn.SceneNoteID = @EntityID;
|
||||
ELSE IF @EntityType = N'SceneChecklistItem'
|
||||
SELECT @ProjectID = b.ProjectID FROM dbo.SceneChecklistItems sci INNER JOIN dbo.Scenes s ON s.SceneID = sci.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sci.SceneChecklistItemID = @EntityID;
|
||||
ELSE IF @EntityType = N'SceneAttachment'
|
||||
SELECT @ProjectID = b.ProjectID FROM dbo.SceneAttachments sa INNER JOIN dbo.Scenes s ON s.SceneID = sa.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sa.SceneAttachmentID = @EntityID;
|
||||
|
||||
SELECT CONVERT(bit, CASE WHEN EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.ProjectUserAccess
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND UserID = @UserID
|
||||
AND IsActive = 1
|
||||
) THEN 1 ELSE 0 END);
|
||||
END;
|
||||
GO
|
||||
@ -1816,6 +1816,86 @@ public sealed class LocationRelationshipEditViewModel
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FloorPlanListViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public IReadOnlyList<FloorPlan> FloorPlans { get; set; } = [];
|
||||
public FloorPlanEditViewModel NewFloorPlan { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class FloorPlanEditViewModel
|
||||
{
|
||||
public int FloorPlanID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
|
||||
[Required, StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FloorPlanEditorViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public FloorPlanEditViewModel FloorPlan { get; set; } = new();
|
||||
public List<FloorPlanFloorEditViewModel> Floors { get; set; } = [];
|
||||
public List<FloorPlanBlockEditViewModel> Blocks { get; set; } = [];
|
||||
public FloorPlanFloorEditViewModel NewFloor { get; set; } = new();
|
||||
public FloorPlanBlockEditViewModel NewBlock { get; set; } = new();
|
||||
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> BlockTypeOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class FloorPlanFloorEditViewModel
|
||||
{
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
|
||||
[Required, StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[Range(8, 80)]
|
||||
public int GridWidth { get; set; } = 24;
|
||||
|
||||
[Range(8, 80)]
|
||||
public int GridHeight { get; set; } = 16;
|
||||
}
|
||||
|
||||
public sealed class FloorPlanBlockEditViewModel
|
||||
{
|
||||
public int FloorPlanBlockID { get; set; }
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue, ErrorMessage = "Choose a location.")]
|
||||
public int LocationID { get; set; }
|
||||
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string LocationPath { get; set; } = string.Empty;
|
||||
|
||||
[Range(0, 79)]
|
||||
public int X { get; set; }
|
||||
|
||||
[Range(0, 79)]
|
||||
public int Y { get; set; }
|
||||
|
||||
[Range(1, 80)]
|
||||
public int WidthCells { get; set; } = 2;
|
||||
|
||||
[Range(1, 80)]
|
||||
public int HeightCells { get; set; } = 1;
|
||||
|
||||
[Required, StringLength(40)]
|
||||
public string BlockType { get; set; } = FloorPlanBlockTypes.Room;
|
||||
|
||||
[StringLength(200)]
|
||||
public string? LabelOverride { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public string DisplayLabel => string.IsNullOrWhiteSpace(LabelOverride) ? LocationName : LabelOverride;
|
||||
}
|
||||
|
||||
public sealed class WarningDashboardViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
|
||||
457
PlotLine/Views/FloorPlans/Edit.cshtml
Normal file
457
PlotLine/Views/FloorPlans/Edit.cshtml
Normal file
@ -0,0 +1,457 @@
|
||||
@model FloorPlanEditorViewModel
|
||||
@{
|
||||
ViewData["Title"] = Model.FloorPlan.FloorPlanID == 0 ? "New Floor Plan" : Model.FloorPlan.Name;
|
||||
ViewData["ProjectSection"] = "Floor Plans";
|
||||
var activeFloor = Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).FirstOrDefault();
|
||||
var blocksByFloor = Model.Blocks.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList());
|
||||
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Floor Plans</a>
|
||||
<span>@Model.FloorPlan.Name</span>
|
||||
</nav>
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Location floor plan</p>
|
||||
<h1>@Model.FloorPlan.Name</h1>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["FloorPlanMessage"] is string message)
|
||||
{
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
@if (TempData["FloorPlanError"] is string error)
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel floor-plan-meta">
|
||||
<form asp-action="SavePlan" method="post" class="row g-3">
|
||||
<input asp-for="FloorPlan.FloorPlanID" type="hidden" name="FloorPlanID" />
|
||||
<input asp-for="FloorPlan.ProjectID" type="hidden" name="ProjectID" />
|
||||
<div class="col-md-5">
|
||||
<label asp-for="FloorPlan.Name" class="form-label"></label>
|
||||
<input asp-for="FloorPlan.Name" class="form-control" name="Name" />
|
||||
<span asp-validation-for="FloorPlan.Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="FloorPlan.Description" class="form-label"></label>
|
||||
<input asp-for="FloorPlan.Description" class="form-control" name="Description" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-outline-primary w-100" type="submit">Save details</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel floor-plan-tools">
|
||||
<form asp-action="AddFloor" method="post" class="row g-2 align-items-end">
|
||||
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" />
|
||||
<input asp-for="NewFloor.SortOrder" type="hidden" name="SortOrder" />
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">New floor</label>
|
||||
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Width</label>
|
||||
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Height</label>
|
||||
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-primary btn-sm w-100" type="submit">Add floor</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (Model.Floors.Any() && Model.LocationOptions.Any())
|
||||
{
|
||||
<form asp-action="AddBlock" method="post" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Floor</label>
|
||||
<select asp-for="NewBlock.FloorPlanFloorID" asp-items="@(Model.Floors.OrderBy(x => x.SortOrder).Select(x => new SelectListItem(x.Name, x.FloorPlanFloorID.ToString(), x.FloorPlanFloorID == activeFloor?.FloorPlanFloorID)))" class="form-select form-select-sm" name="FloorPlanFloorID"></select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Location</label>
|
||||
<select asp-for="NewBlock.LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm" name="LocationID"></select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Type</label>
|
||||
<select asp-for="NewBlock.BlockType" asp-items="Model.BlockTypeOptions" class="form-select form-select-sm" name="BlockType"></select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label">W</label>
|
||||
<input asp-for="NewBlock.WidthCells" class="form-control form-control-sm" name="WidthCells" min="1" max="80" />
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label">H</label>
|
||||
<input asp-for="NewBlock.HeightCells" class="form-control form-control-sm" name="HeightCells" min="1" max="80" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary btn-sm w-100" type="submit">Add block</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (!Model.Floors.Any())
|
||||
{
|
||||
<section class="empty-panel">
|
||||
<h2>No floors yet</h2>
|
||||
<p>Add a floor to start arranging linked locations.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form asp-action="SaveLayout" method="post" class="floor-plan-editor" data-floor-plan-editor>
|
||||
<input asp-for="FloorPlan.FloorPlanID" type="hidden" />
|
||||
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||
<input asp-for="FloorPlan.ProjectID" type="hidden" />
|
||||
<input asp-for="FloorPlan.Name" type="hidden" />
|
||||
<input asp-for="FloorPlan.Description" type="hidden" />
|
||||
|
||||
<section class="floor-plan-tabs" role="tablist" aria-label="Floors">
|
||||
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
|
||||
{
|
||||
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID">@floor.Name</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="floor-plan-layout">
|
||||
<section class="floor-plan-stage">
|
||||
@for (var i = 0; i < Model.Floors.Count; i++)
|
||||
{
|
||||
var floor = Model.Floors[i];
|
||||
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||
var floorBlocks = blocksByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
|
||||
<div class="floor-plan-floor @(isActive ? "active" : string.Empty)" data-floor-panel="@floor.FloorPlanFloorID">
|
||||
<div class="floor-plan-floor-header">
|
||||
<div>
|
||||
<input asp-for="Floors[i].FloorPlanFloorID" type="hidden" />
|
||||
<input asp-for="Floors[i].FloorPlanID" type="hidden" />
|
||||
<input asp-for="Floors[i].SortOrder" type="hidden" />
|
||||
<label class="form-label">Floor name</label>
|
||||
<input asp-for="Floors[i].Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Grid width</label>
|
||||
<input asp-for="Floors[i].GridWidth" class="form-control form-control-sm" min="8" max="80" data-floor-width="@floor.FloorPlanFloorID" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Grid height</label>
|
||||
<input asp-for="Floors[i].GridHeight" class="form-control form-control-sm" min="8" max="80" data-floor-height="@floor.FloorPlanFloorID" />
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
type="button"
|
||||
data-delete-floor="@floor.FloorPlanFloorID"
|
||||
data-confirm-message="Delete this floor and its placed blocks? Linked locations are not deleted.">Delete floor</button>
|
||||
</div>
|
||||
|
||||
<div class="floor-plan-grid-wrap">
|
||||
<div class="floor-plan-grid"
|
||||
style="--grid-width:@floor.GridWidth;--grid-height:@floor.GridHeight;"
|
||||
data-floor-grid="@floor.FloorPlanFloorID"
|
||||
data-grid-width="@floor.GridWidth"
|
||||
data-grid-height="@floor.GridHeight">
|
||||
@foreach (var block in floorBlocks)
|
||||
{
|
||||
var blockIndex = Model.Blocks.FindIndex(x => x.FloorPlanBlockID == block.FloorPlanBlockID);
|
||||
<button type="button"
|
||||
class="@BlockClass(block.BlockType)"
|
||||
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
|
||||
data-block="@block.FloorPlanBlockID"
|
||||
data-block-index="@blockIndex"
|
||||
data-floor-id="@block.FloorPlanFloorID">
|
||||
<span>@block.DisplayLabel</span>
|
||||
<span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<aside class="floor-plan-side-panel">
|
||||
<h2>Selected block</h2>
|
||||
<p class="muted" data-no-selection>Select a block on the grid to edit its details.</p>
|
||||
<div class="floor-plan-overlap-warning d-none" data-overlap-warning>Some blocks overlap on this floor.</div>
|
||||
|
||||
@for (var i = 0; i < Model.Blocks.Count; i++)
|
||||
{
|
||||
<div class="floor-plan-block-editor d-none" data-block-editor="@Model.Blocks[i].FloorPlanBlockID">
|
||||
<input asp-for="Blocks[i].FloorPlanBlockID" type="hidden" />
|
||||
<input asp-for="Blocks[i].FloorPlanFloorID" type="hidden" data-block-field="floor" />
|
||||
<input asp-for="Blocks[i].LocationName" type="hidden" />
|
||||
<input asp-for="Blocks[i].LocationPath" type="hidden" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Location</label>
|
||||
<select asp-for="Blocks[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"></select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Type</label>
|
||||
<select asp-for="Blocks[i].BlockType" asp-items="Model.BlockTypeOptions" class="form-select form-select-sm" data-block-field="type"></select>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">X</label>
|
||||
<input asp-for="Blocks[i].X" class="form-control form-control-sm" min="0" max="79" data-block-field="x" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Y</label>
|
||||
<input asp-for="Blocks[i].Y" class="form-control form-control-sm" min="0" max="79" data-block-field="y" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Width</label>
|
||||
<input asp-for="Blocks[i].WidthCells" class="form-control form-control-sm" min="1" max="80" data-block-field="w" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Height</label>
|
||||
<input asp-for="Blocks[i].HeightCells" class="form-control form-control-sm" min="1" max="80" data-block-field="h" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Label override</label>
|
||||
<input asp-for="Blocks[i].LabelOverride" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea asp-for="Blocks[i].Notes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
type="button"
|
||||
data-delete-block="@Model.Blocks[i].FloorPlanBlockID">Delete block</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Save layout</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>
|
||||
(() => {
|
||||
const editor = document.querySelector("[data-floor-plan-editor]");
|
||||
if (!editor) return;
|
||||
|
||||
const tabs = [...editor.querySelectorAll("[data-floor-tab]")];
|
||||
const panels = [...editor.querySelectorAll("[data-floor-panel]")];
|
||||
const blocks = [...editor.querySelectorAll("[data-block]")];
|
||||
const noSelection = editor.querySelector("[data-no-selection]");
|
||||
const warning = editor.querySelector("[data-overlap-warning]");
|
||||
let selectedBlock = null;
|
||||
let pointerState = null;
|
||||
|
||||
function setActiveFloor(floorId) {
|
||||
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
|
||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
|
||||
checkOverlaps();
|
||||
}
|
||||
|
||||
function selectBlock(block) {
|
||||
selectedBlock = block;
|
||||
blocks.forEach(item => item.classList.toggle("selected", item === block));
|
||||
editor.querySelectorAll("[data-block-editor]").forEach(panel => panel.classList.add("d-none"));
|
||||
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
|
||||
if (panel) panel.classList.remove("d-none");
|
||||
if (noSelection) noSelection.classList.add("d-none");
|
||||
}
|
||||
|
||||
function fieldsFor(block) {
|
||||
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
|
||||
return {
|
||||
panel,
|
||||
floor: panel?.querySelector("[data-block-field='floor']"),
|
||||
x: panel?.querySelector("[data-block-field='x']"),
|
||||
y: panel?.querySelector("[data-block-field='y']"),
|
||||
w: panel?.querySelector("[data-block-field='w']"),
|
||||
h: panel?.querySelector("[data-block-field='h']"),
|
||||
type: panel?.querySelector("[data-block-field='type']")
|
||||
};
|
||||
}
|
||||
|
||||
function gridFor(block) {
|
||||
return editor.querySelector(`[data-floor-grid="${block.dataset.floorId}"]`);
|
||||
}
|
||||
|
||||
function clampBlock(block, values) {
|
||||
const grid = gridFor(block);
|
||||
const gridWidth = Number(grid?.dataset.gridWidth || 24);
|
||||
const gridHeight = Number(grid?.dataset.gridHeight || 16);
|
||||
const w = Math.max(1, Math.min(Number(values.w), gridWidth));
|
||||
const h = Math.max(1, Math.min(Number(values.h), gridHeight));
|
||||
const x = Math.max(0, Math.min(Number(values.x), gridWidth - w));
|
||||
const y = Math.max(0, Math.min(Number(values.y), gridHeight - h));
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
function readBlock(block) {
|
||||
const fields = fieldsFor(block);
|
||||
return {
|
||||
x: Number(fields.x?.value || block.style.getPropertyValue("--x") || 0),
|
||||
y: Number(fields.y?.value || block.style.getPropertyValue("--y") || 0),
|
||||
w: Number(fields.w?.value || block.style.getPropertyValue("--w") || 1),
|
||||
h: Number(fields.h?.value || block.style.getPropertyValue("--h") || 1)
|
||||
};
|
||||
}
|
||||
|
||||
function writeBlock(block, values) {
|
||||
const next = clampBlock(block, values);
|
||||
block.style.setProperty("--x", next.x);
|
||||
block.style.setProperty("--y", next.y);
|
||||
block.style.setProperty("--w", next.w);
|
||||
block.style.setProperty("--h", next.h);
|
||||
const fields = fieldsFor(block);
|
||||
if (fields.x) fields.x.value = next.x;
|
||||
if (fields.y) fields.y.value = next.y;
|
||||
if (fields.w) fields.w.value = next.w;
|
||||
if (fields.h) fields.h.value = next.h;
|
||||
checkOverlaps();
|
||||
}
|
||||
|
||||
function cellFromPointer(grid, event) {
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const gridWidth = Number(grid.dataset.gridWidth || 24);
|
||||
const gridHeight = Number(grid.dataset.gridHeight || 16);
|
||||
return {
|
||||
x: Math.floor((event.clientX - rect.left) / (rect.width / gridWidth)),
|
||||
y: Math.floor((event.clientY - rect.top) / (rect.height / gridHeight))
|
||||
};
|
||||
}
|
||||
|
||||
function checkOverlaps() {
|
||||
const activePanel = panels.find(panel => panel.classList.contains("active"));
|
||||
if (!activePanel) return;
|
||||
const visibleBlocks = [...activePanel.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) }));
|
||||
let overlaps = false;
|
||||
visibleBlocks.forEach(a => {
|
||||
a.block.classList.remove("overlap");
|
||||
visibleBlocks.forEach(b => {
|
||||
if (a === b) return;
|
||||
const separated = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
|
||||
if (!separated) {
|
||||
overlaps = true;
|
||||
a.block.classList.add("overlap");
|
||||
b.block.classList.add("overlap");
|
||||
}
|
||||
});
|
||||
});
|
||||
warning?.classList.toggle("d-none", !overlaps);
|
||||
}
|
||||
|
||||
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
|
||||
|
||||
blocks.forEach(block => {
|
||||
block.addEventListener("pointerdown", event => {
|
||||
selectBlock(block);
|
||||
const grid = gridFor(block);
|
||||
if (!grid) return;
|
||||
const start = cellFromPointer(grid, event);
|
||||
const current = readBlock(block);
|
||||
const resizing = event.target.matches("[data-resize-handle]");
|
||||
pointerState = { block, grid, start, current, resizing };
|
||||
block.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
editor.addEventListener("pointermove", event => {
|
||||
if (!pointerState) return;
|
||||
const currentCell = cellFromPointer(pointerState.grid, event);
|
||||
const dx = currentCell.x - pointerState.start.x;
|
||||
const dy = currentCell.y - pointerState.start.y;
|
||||
if (pointerState.resizing) {
|
||||
writeBlock(pointerState.block, {
|
||||
...pointerState.current,
|
||||
w: pointerState.current.w + dx,
|
||||
h: pointerState.current.h + dy
|
||||
});
|
||||
} else {
|
||||
writeBlock(pointerState.block, {
|
||||
...pointerState.current,
|
||||
x: pointerState.current.x + dx,
|
||||
y: pointerState.current.y + dy
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editor.addEventListener("pointerup", () => pointerState = null);
|
||||
editor.addEventListener("pointercancel", () => pointerState = null);
|
||||
|
||||
editor.querySelectorAll("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h']").forEach(input => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!selectedBlock) return;
|
||||
writeBlock(selectedBlock, readBlock(selectedBlock));
|
||||
});
|
||||
});
|
||||
|
||||
editor.querySelectorAll("[data-block-field='type']").forEach(select => {
|
||||
select.addEventListener("change", () => {
|
||||
if (!selectedBlock) return;
|
||||
selectedBlock.className = selectedBlock.className
|
||||
.split(" ")
|
||||
.filter(name => !name.startsWith("floor-plan-block--"))
|
||||
.concat(`floor-plan-block--${select.value.toLowerCase()}`)
|
||||
.join(" ");
|
||||
});
|
||||
});
|
||||
|
||||
async function postDelete(url, fields) {
|
||||
const token = editor.querySelector("input[name='__RequestVerificationToken']")?.value;
|
||||
const body = new URLSearchParams({ ...fields, __RequestVerificationToken: token || "" });
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body
|
||||
});
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
editor.querySelectorAll("[data-delete-block]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
postDelete("@Url.Action("DeleteBlock", "FloorPlans")", {
|
||||
floorPlanId: "@Model.FloorPlan.FloorPlanID",
|
||||
floorPlanBlockId: button.dataset.deleteBlock
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
editor.querySelectorAll("[data-delete-floor]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
const message = button.dataset.confirmMessage || "Delete this floor?";
|
||||
if (window.confirm(message)) {
|
||||
postDelete("@Url.Action("DeleteFloor", "FloorPlans")", {
|
||||
floorPlanId: "@Model.FloorPlan.FloorPlanID",
|
||||
floorPlanFloorId: button.dataset.deleteFloor
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (blocks[0]) selectBlock(blocks[0]);
|
||||
checkOverlaps();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
79
PlotLine/Views/FloorPlans/Index.cshtml
Normal file
79
PlotLine/Views/FloorPlans/Index.cshtml
Normal file
@ -0,0 +1,79 @@
|
||||
@model FloorPlanListViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Floor Plans";
|
||||
ViewData["ProjectSection"] = "Floor Plans";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<span>Floor Plans</span>
|
||||
</nav>
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Location plans</p>
|
||||
<h1>@Model.Project.ProjectName</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["FloorPlanMessage"] is string message)
|
||||
{
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>Create floor plan</h2>
|
||||
<form asp-action="Create" method="post" class="row g-3">
|
||||
<input asp-for="NewFloorPlan.ProjectID" type="hidden" name="ProjectID" />
|
||||
<div class="col-md-5">
|
||||
<label asp-for="NewFloorPlan.Name" class="form-label"></label>
|
||||
<input asp-for="NewFloorPlan.Name" class="form-control" name="Name" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="NewFloorPlan.Description" class="form-label"></label>
|
||||
<input asp-for="NewFloorPlan.Description" class="form-control" name="Description" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (!Model.FloorPlans.Any())
|
||||
{
|
||||
<section class="empty-panel">
|
||||
<h2>No floor plans yet</h2>
|
||||
<p>Create a plan, then arrange existing locations as rooms, corridors, halls, gardens, or other spaces.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="edit-panel">
|
||||
<div class="floor-plan-list">
|
||||
@foreach (var floorPlan in Model.FloorPlans)
|
||||
{
|
||||
<article class="floor-plan-list-item">
|
||||
<div>
|
||||
<h2><a asp-action="Edit" asp-route-id="@floorPlan.FloorPlanID">@floorPlan.Name</a></h2>
|
||||
@if (!string.IsNullOrWhiteSpace(floorPlan.Description))
|
||||
{
|
||||
<p>@floorPlan.Description</p>
|
||||
}
|
||||
<p class="muted">@floorPlan.FloorCount floor(s), @floorPlan.BlockCount placed block(s)</p>
|
||||
</div>
|
||||
<div class="button-row compact-buttons">
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@floorPlan.FloorPlanID">Open editor</a>
|
||||
<form asp-action="DeletePlan" method="post" data-confirm-message="Delete this floor plan? This deletes its floors and placed blocks. Linked locations are not deleted.">
|
||||
<input type="hidden" name="id" value="@floorPlan.FloorPlanID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@ -16,6 +16,7 @@
|
||||
<a class="pw-project-nav__link @(ActiveClass("Relationships"))" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@projectId">Relationships</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Assets"))" asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@projectId">Assets</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Locations"))" asp-controller="Locations" asp-action="Index" asp-route-projectId="@projectId">Locations</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Floor Plans"))" asp-controller="FloorPlans" asp-action="Index" asp-route-projectId="@projectId">Floor Plans</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Threads"))" asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@projectId">Threads</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Metrics"))" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@projectId">Metrics</a>
|
||||
<a class="pw-project-nav__link @(ActiveClass("Scenarios"))" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@projectId">Scenarios</a>
|
||||
|
||||
@ -4449,3 +4449,234 @@ body.dragging-location [data-drag-type="location"] {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.floor-plan-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.floor-plan-list-item {
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.floor-plan-list-item h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.floor-plan-list-item p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.floor-plan-tools {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.floor-plan-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.floor-plan-tab {
|
||||
background: var(--panel-bg, #fff);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 999px;
|
||||
color: inherit;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.floor-plan-tab.active {
|
||||
background: var(--bs-primary, #2f6f63);
|
||||
border-color: var(--bs-primary, #2f6f63);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.floor-plan-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.floor-plan-floor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floor-plan-floor.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.floor-plan-floor-header {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(180px, 1fr) 120px 120px auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.floor-plan-grid-wrap {
|
||||
overflow: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.floor-plan-grid {
|
||||
--cell-size: 34px;
|
||||
background-color: var(--panel-bg, #fff);
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(93, 111, 101, .18) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(93, 111, 101, .18) 1px, transparent 1px);
|
||||
background-size: var(--cell-size) var(--cell-size);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
height: calc(var(--grid-height) * var(--cell-size));
|
||||
min-width: calc(var(--grid-width) * var(--cell-size));
|
||||
position: relative;
|
||||
width: calc(var(--grid-width) * var(--cell-size));
|
||||
}
|
||||
|
||||
.floor-plan-block {
|
||||
align-items: center;
|
||||
border: 2px solid rgba(44, 84, 75, .55);
|
||||
border-radius: 6px;
|
||||
color: #12332c;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
font-weight: 650;
|
||||
height: calc(var(--h) * var(--cell-size));
|
||||
justify-content: center;
|
||||
left: calc(var(--x) * var(--cell-size));
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: calc(var(--y) * var(--cell-size));
|
||||
touch-action: none;
|
||||
width: calc(var(--w) * var(--cell-size));
|
||||
}
|
||||
|
||||
.floor-plan-block span:first-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.floor-plan-block.selected {
|
||||
box-shadow: 0 0 0 3px rgba(47, 111, 99, .28);
|
||||
outline: 2px solid var(--bs-primary, #2f6f63);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.floor-plan-block.overlap {
|
||||
border-color: #b45544;
|
||||
box-shadow: inset 0 0 0 2px rgba(180, 85, 68, .18);
|
||||
}
|
||||
|
||||
.floor-plan-block--room {
|
||||
background: #d9eee7;
|
||||
}
|
||||
|
||||
.floor-plan-block--corridor {
|
||||
background: #e7e2f4;
|
||||
}
|
||||
|
||||
.floor-plan-block--openspace {
|
||||
background: #e5efd4;
|
||||
}
|
||||
|
||||
.floor-plan-block--exterior {
|
||||
background: #d9ecf3;
|
||||
}
|
||||
|
||||
.floor-plan-block--other {
|
||||
background: #ece9e1;
|
||||
}
|
||||
|
||||
.floor-plan-resize-handle {
|
||||
border-bottom: 10px solid rgba(18, 51, 44, .45);
|
||||
border-left: 10px solid transparent;
|
||||
bottom: 4px;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.floor-plan-side-panel {
|
||||
align-self: start;
|
||||
background: var(--panel-bg, #fff);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.floor-plan-side-panel h2 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.floor-plan-overlap-warning {
|
||||
background: rgba(180, 85, 68, .12);
|
||||
border: 1px solid rgba(180, 85, 68, .35);
|
||||
border-radius: 6px;
|
||||
color: #8f3f31;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-grid,
|
||||
.dark .floor-plan-grid {
|
||||
background-color: #15201d;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(214, 224, 218, .12) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(214, 224, 218, .12) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block,
|
||||
.dark .floor-plan-block {
|
||||
color: #f1f6f3;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block--room,
|
||||
.dark .floor-plan-block--room {
|
||||
background: #29584f;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block--corridor,
|
||||
.dark .floor-plan-block--corridor {
|
||||
background: #514876;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block--openspace,
|
||||
.dark .floor-plan-block--openspace {
|
||||
background: #4f6133;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block--exterior,
|
||||
.dark .floor-plan-block--exterior {
|
||||
background: #315d6f;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block--other,
|
||||
.dark .floor-plan-block--other {
|
||||
background: #5b564c;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.floor-plan-layout,
|
||||
.floor-plan-floor-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.floor-plan-side-panel {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user