PlotDirector Phase 17F - Attachments Editor Migration

This commit is contained in:
Nick Beckley 2026-06-30 14:37:57 +01:00
parent 0d72dc64d0
commit 7243538b2b
5 changed files with 334 additions and 2 deletions

View File

@ -169,6 +169,15 @@ public sealed class ScenesController(
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneWorkflowEditor.cshtml", model);
}
[HttpGet]
public async Task<IActionResult> SceneAttachmentsEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneAttachmentsEditor.cshtml", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSceneDetails(SceneDetailsSaveViewModel model)
@ -1146,12 +1155,22 @@ public sealed class ScenesController(
{
if (string.IsNullOrWhiteSpace(model.Title))
{
if (IsAjaxRequest())
{
return BadRequest(new { success = false, message = "Enter an attachment title before saving." });
}
TempData["WriterWorkspaceError"] = "Enter an attachment title before saving.";
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
if (model.UploadedFile is not null && model.UploadedFile.Length > MaxAttachmentBytes)
{
if (IsAjaxRequest())
{
return BadRequest(new { success = false, message = "Attachment files must be 25 MB or smaller." });
}
TempData["WriterWorkspaceError"] = "Attachment files must be 25 MB or smaller.";
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -1172,6 +1191,11 @@ public sealed class ScenesController(
if (!storageLimit.Allowed)
{
if (IsAjaxRequest())
{
return BadRequest(new { success = false, message = storageLimit.Message });
}
TempData["WriterWorkspaceError"] = storageLimit.Message;
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -1184,6 +1208,11 @@ public sealed class ScenesController(
if (string.IsNullOrWhiteSpace(model.FilePath) && string.IsNullOrWhiteSpace(model.ExternalUrl))
{
if (IsAjaxRequest())
{
return BadRequest(new { success = false, message = "Upload a file or enter an external link." });
}
TempData["WriterWorkspaceError"] = "Upload a file or enter an external link.";
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -1207,6 +1236,11 @@ public sealed class ScenesController(
}
}
if (IsAjaxRequest())
{
return await SceneWriterRefreshJsonAsync(model.SceneID);
}
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -1217,9 +1251,39 @@ public sealed class ScenesController(
await writerWorkspace.DeleteAttachmentAsync(id);
await storage.MarkDeletedByStoragePathAsync(filePath);
uploadStorage.TryDeleteFile(filePath, "uploads/scenes");
if (IsAjaxRequest())
{
return await SceneWriterRefreshJsonAsync(sceneId);
}
return RedirectForScene(sceneId, projectId, bookId);
}
private bool IsAjaxRequest() =>
string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
private async Task<IActionResult> SceneWriterRefreshJsonAsync(int sceneId)
{
var updated = await scenes.GetEditAsync(sceneId);
if (updated is null)
{
return NotFound();
}
return Json(new
{
success = true,
html = new
{
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated)
},
scene = new
{
updated.SceneID
}
});
}
private IActionResult RedirectForScene(int sceneId, int? projectId, int? bookId)
{
if (TryGetLocalReturnUrl(out var returnUrl))

View File

@ -0,0 +1,210 @@
@model SceneEditViewModel
@{
static string? FileHref(string? filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return null;
}
return filePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? filePath
: filePath.StartsWith("/")
? filePath
: !System.IO.Path.IsPathRooted(filePath)
? "/" + filePath.TrimStart('/', '\\')
: null;
}
static bool IsImageAttachment(string? href, string? filePath)
{
if (string.IsNullOrWhiteSpace(href) || string.IsNullOrWhiteSpace(filePath))
{
return false;
}
var extension = System.IO.Path.GetExtension(filePath);
return new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp" }
.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
}
<div class="scene-inspector-editor-form">
<div class="scene-inspector-editor-fields">
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Current attachments</span>
<strong>@Model.Attachments.Count</strong>
</div>
@if (Model.StorageUsage is not null)
{
<p class="muted">Storage used: @Model.StorageUsage.DisplayLabel</p>
}
@if (!Model.Attachments.Any())
{
<p class="muted">No scene references yet.</p>
}
else
{
<div class="scene-writer-editor-list">
@foreach (var attachment in Model.Attachments)
{
var href = FileHref(attachment.FilePath);
var hasFile = !string.IsNullOrWhiteSpace(attachment.FilePath);
var hasImage = IsImageAttachment(href, attachment.FilePath);
<article class="scene-writer-editor-card scene-attachment-editor-card">
<div class="scene-writer-editor-card-header">
<div>
<h4>@attachment.Title</h4>
<p>@attachment.TypeName / @(hasFile ? "Uploaded file" : "External link")</p>
</div>
</div>
<div class="scene-attachment-preview-row">
@if (hasImage)
{
<a class="scene-attachment-thumb" href="@href" target="_blank" rel="noopener" aria-label="Open @attachment.Title image">
<img src="@href" alt="@attachment.Title preview" loading="lazy" />
</a>
}
else
{
<div class="scene-attachment-thumb scene-attachment-thumb-fallback" aria-hidden="true">
<span>@(hasFile ? "File" : "Link")</span>
</div>
}
<div class="scene-attachment-preview-copy">
@if (!string.IsNullOrWhiteSpace(attachment.Notes))
{
<p>@attachment.Notes</p>
}
@if (hasFile)
{
<p class="muted">@System.IO.Path.GetFileName(attachment.FilePath)</p>
}
<div class="button-row compact-buttons">
@if (!string.IsNullOrWhiteSpace(href))
{
<a class="btn btn-outline-secondary btn-sm" href="@href" target="_blank" rel="noopener">Open</a>
<a class="btn btn-outline-secondary btn-sm" href="@href" download>Download</a>
}
@if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl))
{
<a class="btn btn-outline-secondary btn-sm" href="@attachment.ExternalUrl" target="_blank" rel="noopener">Open link</a>
}
</div>
</div>
</div>
<form asp-controller="Scenes" asp-action="SaveAttachment" method="post" enctype="multipart/form-data" class="scene-attachment-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="SceneAttachmentID" value="@attachment.SceneAttachmentID" />
<input type="hidden" name="SceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<input type="hidden" name="FilePath" value="@attachment.FilePath" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="form-group">
<label for="Attachment_@(attachment.SceneAttachmentID)__SceneAttachmentTypeID">Attachment type</label>
<select class="form-control form-control-sm" id="Attachment_@(attachment.SceneAttachmentID)__SceneAttachmentTypeID" name="SceneAttachmentTypeID">
@foreach (var option in Model.SceneAttachmentTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == attachment.SceneAttachmentTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="form-group">
<label for="Attachment_@(attachment.SceneAttachmentID)__Title">Title</label>
<input class="form-control form-control-sm" id="Attachment_@(attachment.SceneAttachmentID)__Title" name="Title" value="@attachment.Title" />
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="Attachment_@(attachment.SceneAttachmentID)__UploadedFile">Replace file</label>
<input class="form-control form-control-sm" id="Attachment_@(attachment.SceneAttachmentID)__UploadedFile" name="UploadedFile" type="file" />
</div>
<div class="form-group">
<label for="Attachment_@(attachment.SceneAttachmentID)__ExternalUrl">External URL</label>
<input class="form-control form-control-sm" id="Attachment_@(attachment.SceneAttachmentID)__ExternalUrl" name="ExternalUrl" value="@attachment.ExternalUrl" />
</div>
</div>
<div class="form-group">
<label for="Attachment_@(attachment.SceneAttachmentID)__Notes">Notes</label>
<textarea class="form-control form-control-sm" id="Attachment_@(attachment.SceneAttachmentID)__Notes" name="Notes" rows="2">@attachment.Notes</textarea>
</div>
<div class="scene-attachment-action-row">
<button class="btn btn-sm btn-primary" type="submit" data-scene-editor-save>Save attachment</button>
</div>
</form>
<form asp-controller="Scenes" asp-action="DeleteAttachment" method="post" class="scene-attachment-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@attachment.SceneAttachmentID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<input type="hidden" name="projectId" value="@Model.ReturnProjectID" />
<input type="hidden" name="bookId" value="@Model.ReturnBookID" />
<input type="hidden" name="filePath" value="@attachment.FilePath" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-danger" type="submit" data-scene-editor-save>Remove attachment</button>
</form>
</article>
}
</div>
}
</section>
<section class="scene-writer-editor-section">
<div class="scene-writer-editor-heading">
<span>Add attachment</span>
</div>
<form asp-controller="Scenes" asp-action="SaveAttachment" method="post" enctype="multipart/form-data" class="scene-writer-editor-card scene-attachment-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="SceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<div class="form-group">
<label for="NewAttachment_SceneAttachmentTypeID">Attachment type</label>
<select class="form-control form-control-sm" id="NewAttachment_SceneAttachmentTypeID" name="SceneAttachmentTypeID" asp-items="Model.SceneAttachmentTypeOptions"></select>
</div>
<div class="form-group">
<label for="NewAttachment_Title">Title</label>
<input class="form-control form-control-sm" id="NewAttachment_Title" name="Title" placeholder="Reference title" />
</div>
<div class="scene-inspector-editor-grid">
<div class="form-group">
<label for="NewAttachment_UploadedFile">Choose file</label>
<input class="form-control form-control-sm" id="NewAttachment_UploadedFile" name="UploadedFile" type="file" />
</div>
<div class="form-group">
<label for="NewAttachment_ExternalUrl">Or external URL</label>
<input class="form-control form-control-sm" id="NewAttachment_ExternalUrl" name="ExternalUrl" placeholder="https://..." />
</div>
</div>
<div class="form-group">
<label for="NewAttachment_Notes">Notes</label>
<textarea class="form-control form-control-sm" id="NewAttachment_Notes" name="Notes" rows="2" placeholder="Why this reference matters"></textarea>
</div>
<div class="scene-attachment-action-row">
<button class="btn btn-sm btn-primary" type="submit" data-scene-editor-save>Add attachment</button>
</div>
</form>
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Close</button>
</div>
</div>

View File

@ -40,6 +40,6 @@
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="notes" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneNotesEditor", "Scenes", new { sceneId = Model.SceneID })">Manage notes</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="checklist" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneChecklistEditor", "Scenes", new { sceneId = Model.SceneID })">Manage checklist</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="workflow" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneWorkflowEditor", "Scenes", new { sceneId = Model.SceneID })">Manage workflow</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="attachments" data-scene-id="@Model.SceneID">Manage attachments</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="attachments" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneAttachmentsEditor", "Scenes", new { sceneId = Model.SceneID })">Manage attachments</button>
</div>
</article>

View File

@ -5308,6 +5308,60 @@ body.scene-inspector-editor-resizing {
white-space: nowrap;
}
.scene-attachment-editor-card {
align-items: stretch;
}
.scene-attachment-preview-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.scene-attachment-thumb {
display: grid;
place-items: center;
width: 72px;
height: 72px;
overflow: hidden;
border: 1px solid var(--plotline-line);
border-radius: 8px;
background: rgba(47, 111, 99, 0.06);
color: var(--plotline-muted);
font-size: 0.78rem;
font-weight: 800;
text-align: center;
}
.scene-attachment-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.scene-attachment-preview-copy {
display: grid;
gap: 8px;
min-width: 0;
}
.scene-attachment-preview-copy p {
margin: 0;
}
.scene-attachment-editor-form,
.scene-attachment-delete-form {
display: grid;
gap: 10px;
}
.scene-attachment-action-row {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.scene-inspector-v2-timeline .scene-inspector-v2-health {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -5361,6 +5415,10 @@ body.scene-inspector-editor-resizing {
grid-template-columns: 1fr;
align-items: stretch;
}
.scene-attachment-preview-row {
grid-template-columns: 1fr;
}
}
.floor-plan-toolbar-floor-group {

File diff suppressed because one or more lines are too long