diff --git a/PlotLine/Controllers/ImportsController.cs b/PlotLine/Controllers/ImportsController.cs new file mode 100644 index 0000000..58d1037 --- /dev/null +++ b/PlotLine/Controllers/ImportsController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using PlotLine.Services; +using PlotLine.ViewModels; + +namespace PlotLine.Controllers; + +public sealed class ImportsController(IImportService imports) : Controller +{ + public IActionResult Index() => View(new ImportIndexViewModel + { + JsonText = ImportIndexViewModel.SampleJson + }); + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Preview(ImportIndexViewModel model) + { + model = await imports.PreviewAsync(model); + return View("Index", model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Import(ImportIndexViewModel model) + { + var result = await imports.ImportAsync(model); + if (result.Succeeded && result.ProjectID.HasValue) + { + TempData["ImportMessage"] = result.Message; + return RedirectToAction("Details", "Projects", new { id = result.ProjectID.Value }); + } + + model.Preview ??= (await imports.PreviewAsync(model)).Preview; + model.StatusMessage = result.Message; + return View("Index", model); + } +} diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs new file mode 100644 index 0000000..de3ef03 --- /dev/null +++ b/PlotLine/Data/ImportRepository.cs @@ -0,0 +1,197 @@ +using System.Data; +using System.Text; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IImportRepository +{ + Task ProjectTitleExistsAsync(string projectTitle); + Task ImportAsync(PlotLineImportPackage package); +} + +public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository +{ + private const string ImportMarker = "Imported via JSON Import Phase 1A"; + + public async Task ProjectTitleExistsAsync(string projectTitle) + { + using var connection = connectionFactory.CreateConnection(); + var count = await connection.ExecuteScalarAsync( + """ + SELECT COUNT(1) + FROM dbo.Projects + WHERE IsArchived = 0 AND ProjectName = @ProjectName; + """, + new { ProjectName = projectTitle }); + + return count > 0; + } + + public async Task ImportAsync(PlotLineImportPackage package) + { + using var connection = connectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + + try + { + var lookupIds = await GetLookupIdsAsync(connection, transaction); + var projectId = await connection.QuerySingleAsync( + "dbo.Project_Save", + new + { + ProjectID = 0, + ProjectName = Clean(package.Project.Title), + Description = CombineText(package.Project.Description, package.Project.Notes, ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + + foreach (var book in package.Books.OrderBy(x => x.SeriesOrder)) + { + var bookId = await connection.QuerySingleAsync( + "dbo.Book_Save", + new + { + BookID = 0, + ProjectID = projectId, + BookTitle = Clean(FormatBookTitle(book)), + BookNumber = book.SeriesOrder, + Description = CombineText( + book.Description, + book.DependsOnPreviousBook ? "Depends on previous book." : null, + book.Notes, + ImportMarker) + }, + transaction, + commandType: CommandType.StoredProcedure); + + foreach (var chapter in book.Chapters.OrderBy(x => x.Order)) + { + var chapterId = await connection.QuerySingleAsync( + "dbo.Chapter_Save", + new + { + ChapterID = 0, + BookID = bookId, + chapter.ChapterNumber, + ChapterTitle = Clean(chapter.Title), + Summary = CombineText(chapter.Summary, chapter.Notes, ImportMarker), + RevisionStatusID = lookupIds.RevisionStatusID + }, + transaction, + commandType: CommandType.StoredProcedure); + + foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) + { + await connection.QuerySingleAsync( + "dbo.Scene_Save", + new + { + SceneID = 0, + ChapterID = chapterId, + scene.SceneNumber, + SceneTitle = Clean(scene.Title), + Summary = CombineText( + scene.Summary, + MetadataLine("POV", scene.PovCharacterName), + MetadataLine("Location", scene.LocationName), + scene.Notes, + ImportMarker), + TimeModeID = lookupIds.TimeModeID, + StartDateTime = (DateTime?)null, + EndDateTime = (DateTime?)null, + DurationAmount = (decimal?)null, + DurationUnitID = (int?)null, + RelativeTimeText = Truncate(scene.DateTimeText, 200), + TimeConfidenceID = lookupIds.TimeConfidenceID, + ScenePurposeNotes = CombineText(scene.PurposeText), + SceneOutcomeNotes = CombineText(scene.OutcomeText), + RevisionStatusID = lookupIds.RevisionStatusID + }, + transaction, + commandType: CommandType.StoredProcedure); + } + } + } + + transaction.Commit(); + return new ImportResult + { + Succeeded = true, + ProjectID = projectId, + Message = "Import complete." + }; + } + catch + { + transaction.Rollback(); + throw; + } + } + + private static async Task GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction) + { + var revisionStatusId = await connection.QuerySingleOrDefaultAsync( + "SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder;", + transaction: transaction); + var timeModeId = await connection.QuerySingleOrDefaultAsync( + "SELECT TOP (1) TimeModeID FROM dbo.TimeModes WHERE TimeModeName = N'Relative' ORDER BY SortOrder;", + transaction: transaction); + var timeConfidenceId = await connection.QuerySingleOrDefaultAsync( + "SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences WHERE TimeConfidenceName = N'Unknown' ORDER BY SortOrder;", + transaction: transaction); + + if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue) + { + throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing."); + } + + return new ImportLookupIds(revisionStatusId.Value, timeModeId.Value, timeConfidenceId.Value); + } + + private static string FormatBookTitle(ImportBookDto book) + => string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}"; + + private static string Clean(string? value) => (value ?? string.Empty).Trim(); + + private static string? MetadataLine(string label, string? value) + => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}"; + + private static string? Truncate(string? value, int maxLength) + { + var clean = Clean(value); + return clean.Length == 0 ? null : clean[..Math.Min(clean.Length, maxLength)]; + } + + private static string? CombineText(params string?[] values) + { + var parts = values + .Select(Clean) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (parts.Count == 0) + { + return null; + } + + var builder = new StringBuilder(); + foreach (var part in parts) + { + if (builder.Length > 0) + { + builder.AppendLine().AppendLine(); + } + + builder.Append(part); + } + + return builder.ToString(); + } + + private sealed record ImportLookupIds(int RevisionStatusID, int TimeModeID, int TimeConfidenceID); +} diff --git a/PlotLine/Models/ImportModels.cs b/PlotLine/Models/ImportModels.cs new file mode 100644 index 0000000..13863b0 --- /dev/null +++ b/PlotLine/Models/ImportModels.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; + +namespace PlotLine.Models; + +public sealed class PlotLineImportPackage +{ + public string? PackageVersion { get; set; } + public string? Source { get; set; } + public ImportProjectDto Project { get; set; } = new(); + public List Books { get; set; } = []; + + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +public sealed class ImportProjectDto +{ + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Notes { get; set; } +} + +public sealed class ImportBookDto +{ + public string Title { get; set; } = string.Empty; + public string? Subtitle { get; set; } + public int SeriesOrder { get; set; } + public bool DependsOnPreviousBook { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } + public List Chapters { get; set; } = []; +} + +public sealed class ImportChapterDto +{ + public decimal ChapterNumber { get; set; } + public string Title { get; set; } = string.Empty; + public int Order { get; set; } + public string? Summary { get; set; } + public string? Notes { get; set; } + public List Scenes { get; set; } = []; +} + +public sealed class ImportSceneDto +{ + public decimal SceneNumber { get; set; } + public string Title { get; set; } = string.Empty; + public int Order { get; set; } + public string? Summary { get; set; } + public string? PovCharacterName { get; set; } + public string? LocationName { get; set; } + public string? DateTimeText { get; set; } + public string? PurposeText { get; set; } + public string? OutcomeText { get; set; } + public string? Notes { get; set; } +} + +public sealed class ImportValidationResult +{ + public bool IsValid => Errors.Count == 0; + public List Errors { get; } = []; + public List Warnings { get; } = []; +} + +public sealed class ImportPreview +{ + public PlotLineImportPackage? Package { get; init; } + public ImportValidationResult Validation { get; init; } = new(); + public bool HasDuplicateProjectTitle { get; init; } + public int BookCount => Package?.Books.Count ?? 0; + public int ChapterCount => Package?.Books.Sum(book => book.Chapters.Count) ?? 0; + public int SceneCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)) ?? 0; +} + +public sealed class ImportResult +{ + public bool Succeeded { get; init; } + public int? ProjectID { get; init; } + public string Message { get; init; } = string.Empty; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 53d0e18..97e9a24 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -39,6 +39,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -57,6 +58,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs new file mode 100644 index 0000000..4e6d127 --- /dev/null +++ b/PlotLine/Services/ImportServices.cs @@ -0,0 +1,201 @@ +using System.Text; +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IImportService +{ + Task PreviewAsync(ImportIndexViewModel model); + Task ImportAsync(ImportIndexViewModel model); +} + +public sealed class ImportService(IImportRepository imports) : IImportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public async Task PreviewAsync(ImportIndexViewModel model) + { + model.JsonText = await ReadJsonTextAsync(model); + model.Preview = await BuildPreviewAsync(model.JsonText); + return model; + } + + public async Task ImportAsync(ImportIndexViewModel model) + { + model.JsonText = await ReadJsonTextAsync(model); + var preview = await BuildPreviewAsync(model.JsonText); + model.Preview = preview; + + if (!preview.Validation.IsValid || preview.Package is null) + { + return new ImportResult + { + Succeeded = false, + Message = "The JSON needs to pass validation before it can be imported." + }; + } + + if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle) + { + return new ImportResult + { + Succeeded = false, + Message = "A project with this title already exists. Confirm that you want to import another project with the same title." + }; + } + + try + { + return await imports.ImportAsync(preview.Package); + } + catch (Exception ex) + { + return new ImportResult + { + Succeeded = false, + Message = $"The import could not be completed, so no data was saved. {ex.Message}" + }; + } + } + + private async Task BuildPreviewAsync(string jsonText) + { + if (string.IsNullOrWhiteSpace(jsonText)) + { + var emptyValidation = new ImportValidationResult(); + emptyValidation.Errors.Add("Paste JSON or upload a JSON file before previewing."); + return new ImportPreview { Validation = emptyValidation }; + } + + PlotLineImportPackage? package; + try + { + package = JsonSerializer.Deserialize(jsonText, JsonOptions); + } + catch (JsonException ex) + { + var invalidJson = new ImportValidationResult(); + invalidJson.Errors.Add($"The JSON could not be read: {ex.Message}"); + return new ImportPreview { Validation = invalidJson }; + } + + if (package is null) + { + var invalidPackage = new ImportValidationResult(); + invalidPackage.Errors.Add("The JSON import package is empty."); + return new ImportPreview { Validation = invalidPackage }; + } + + var validation = Validate(package); + AddMetadataWarnings(package, validation); + var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title) + && await imports.ProjectTitleExistsAsync(package.Project.Title.Trim()); + + if (duplicate) + { + validation.Warnings.Add("A project with the same title already exists. Import is blocked until you confirm that duplicate title."); + } + + return new ImportPreview + { + Package = package, + Validation = validation, + HasDuplicateProjectTitle = duplicate + }; + } + + private static ImportValidationResult Validate(PlotLineImportPackage package) + { + var result = new ImportValidationResult(); + + if (string.IsNullOrWhiteSpace(package.Project.Title)) + { + result.Errors.Add("Project title is required."); + } + + if (package.Books.Count == 0) + { + result.Warnings.Add("No books were supplied. The import will create an empty project."); + } + + foreach (var group in package.Books.GroupBy(x => x.SeriesOrder).Where(x => x.Count() > 1)) + { + result.Errors.Add($"Book order {group.Key} is used more than once."); + } + + for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++) + { + var book = package.Books[bookIndex]; + var bookLabel = string.IsNullOrWhiteSpace(book.Title) ? $"Book {bookIndex + 1}" : book.Title; + if (string.IsNullOrWhiteSpace(book.Title)) + { + result.Errors.Add($"Book {bookIndex + 1} is missing a title."); + } + + foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1)) + { + result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}."); + } + + for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++) + { + var chapter = book.Chapters[chapterIndex]; + var chapterLabel = string.IsNullOrWhiteSpace(chapter.Title) ? $"chapter {chapterIndex + 1}" : chapter.Title; + if (string.IsNullOrWhiteSpace(chapter.Title)) + { + result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title."); + } + + foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1)) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}."); + } + + for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++) + { + if (string.IsNullOrWhiteSpace(chapter.Scenes[sceneIndex].Title)) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title."); + } + } + } + } + + return result; + } + + private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation) + { + var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName)))); + var hasLocation = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName)))); + + if (hasPov) + { + validation.Warnings.Add("POV character names will be kept as imported text notes in scene summaries. Character records are not created in Phase 1A."); + } + + if (hasLocation) + { + validation.Warnings.Add("Location names will be kept as imported text notes in scene summaries. Location records are not created in Phase 1A."); + } + } + + private static async Task ReadJsonTextAsync(ImportIndexViewModel model) + { + if (model.JsonFile is { Length: > 0 }) + { + using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return await reader.ReadToEndAsync(); + } + + return model.JsonText ?? string.Empty; + } +} diff --git a/PlotLine/ViewModels/ImportViewModels.cs b/PlotLine/ViewModels/ImportViewModels.cs new file mode 100644 index 0000000..23db167 --- /dev/null +++ b/PlotLine/ViewModels/ImportViewModels.cs @@ -0,0 +1,180 @@ +using Microsoft.AspNetCore.Http; +using PlotLine.Models; + +namespace PlotLine.ViewModels; + +public sealed class ImportIndexViewModel +{ + public string JsonText { get; set; } = string.Empty; + public IFormFile? JsonFile { get; set; } + public bool AllowDuplicateProjectTitle { get; set; } + public ImportPreview? Preview { get; set; } + public string? StatusMessage { get; set; } + public bool CanImport => Preview is { Validation.IsValid: true } && (!Preview.HasDuplicateProjectTitle || AllowDuplicateProjectTitle); + + public static string SampleJson => """ + { + "packageVersion": "1.0", + "source": "JSON Import Phase 1A sample", + "project": { + "title": "The Glass Meridian", + "description": "A placeholder two-book fantasy outline.", + "notes": "Imported sample data for testing." + }, + "books": [ + { + "title": "Book One", + "subtitle": "The Map Beneath the City", + "seriesOrder": 1, + "dependsOnPreviousBook": false, + "description": "The opening movement of the story.", + "notes": "Keep this book focused on discovery.", + "chapters": [ + { + "chapterNumber": 1, + "title": "A Door in the Rain", + "order": 1, + "summary": "The protagonist finds a hidden route under the old district.", + "notes": "Establish the central mystery.", + "scenes": [ + { + "sceneNumber": 1, + "title": "The Locked Arcade", + "order": 1, + "summary": "A late delivery leads to an impossible doorway.", + "povCharacterName": "Mara", + "locationName": "Old Arcade", + "dateTimeText": "Rainy evening", + "purposeText": "Introduce the mystery", + "outcomeText": "Mara keeps the brass key", + "notes": "Quiet, tense opening." + }, + { + "sceneNumber": 2, + "title": "Footsteps Below", + "order": 2, + "summary": "Mara hears someone moving beneath the street.", + "povCharacterName": "Mara", + "locationName": "Service stair", + "dateTimeText": "Same evening", + "purposeText": "Escalate the discovery", + "outcomeText": "She chooses to descend", + "notes": "End on a decision." + } + ] + }, + { + "chapterNumber": 2, + "title": "The Lantern Court", + "order": 2, + "summary": "The hidden route opens into a forgotten civic space.", + "notes": "Add wonder, but keep danger near.", + "scenes": [ + { + "sceneNumber": 1, + "title": "A City Under Glass", + "order": 1, + "summary": "Mara sees the underground court for the first time.", + "povCharacterName": "Mara", + "locationName": "Lantern Court", + "dateTimeText": "Later that night", + "purposeText": "Reveal the hidden setting", + "outcomeText": "Mara realizes the map is incomplete", + "notes": "Use strong visual contrast." + }, + { + "sceneNumber": 2, + "title": "The Watchman's Offer", + "order": 2, + "summary": "A stranger offers help at a cost.", + "povCharacterName": "Mara", + "locationName": "Lantern Court", + "dateTimeText": "Later that night", + "purposeText": "Introduce an uneasy ally", + "outcomeText": "Mara accepts a temporary guide", + "notes": "Trust should feel provisional." + } + ] + } + ] + }, + { + "title": "Book Two", + "subtitle": "The Clockwork Shore", + "seriesOrder": 2, + "dependsOnPreviousBook": true, + "description": "The story widens beyond the city.", + "notes": "Seed consequences from book one.", + "chapters": [ + { + "chapterNumber": 1, + "title": "Tide Tables", + "order": 1, + "summary": "The route points toward a mechanical coastline.", + "notes": "Reorient the cast after the first book.", + "scenes": [ + { + "sceneNumber": 1, + "title": "Departure Bell", + "order": 1, + "summary": "The expedition leaves before dawn.", + "povCharacterName": "Ilen", + "locationName": "East Station", + "dateTimeText": "Early morning", + "purposeText": "Start the next journey", + "outcomeText": "The group leaves the city", + "notes": "Keep momentum high." + }, + { + "sceneNumber": 2, + "title": "Salt in the Gears", + "order": 2, + "summary": "The first coastal machine fails in a revealing way.", + "povCharacterName": "Ilen", + "locationName": "Clockwork Shore", + "dateTimeText": "Midday", + "purposeText": "Show the new world's rules", + "outcomeText": "They find a damaged signal plate", + "notes": "Make the mechanism readable." + } + ] + }, + { + "chapterNumber": 2, + "title": "The Signal Plate", + "order": 2, + "summary": "The signal points to a rival expedition.", + "notes": "Bring in pressure from outside the group.", + "scenes": [ + { + "sceneNumber": 1, + "title": "A Message in Brass", + "order": 1, + "summary": "The plate reveals a warning.", + "povCharacterName": "Mara", + "locationName": "Shore workshop", + "dateTimeText": "Afternoon", + "purposeText": "Translate the clue", + "outcomeText": "The warning names a missing captain", + "notes": "Make the clue actionable." + }, + { + "sceneNumber": 2, + "title": "Smoke on the Causeway", + "order": 2, + "summary": "A rival camp appears across the water.", + "povCharacterName": "Mara", + "locationName": "Tidal causeway", + "dateTimeText": "Sunset", + "purposeText": "Introduce opposition", + "outcomeText": "The group prepares to cross", + "notes": "End with forward motion." + } + ] + } + ] + } + ] + } + """; +} diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml new file mode 100644 index 0000000..a305f5b --- /dev/null +++ b/PlotLine/Views/Imports/Index.cshtml @@ -0,0 +1,137 @@ +@model ImportIndexViewModel +@{ + ViewData["Title"] = "JSON Import"; + var preview = Model.Preview; +} + +
+
+

Tools

+

JSON Import

+

Paste or upload a Phase 1A package, preview it, then import a new project when validation passes.

+
+ Back to projects +
+ +@if (!string.IsNullOrWhiteSpace(Model.StatusMessage)) +{ +
@Model.StatusMessage
+} + +
+
+
+
+ + +
Uploading a file replaces the pasted text for this preview.
+
+
+ + +
+
+
+ + +
+
+
+ +@if (preview is not null) +{ +
+
+
+

Preview

+

@(preview.Package?.Project.Title ?? "Import package")

+
+
+ @preview.BookCount books + @preview.ChapterCount chapters + @preview.SceneCount scenes +
+
+ + @if (preview.Validation.Errors.Any()) + { +
+ Validation needs attention +
    + @foreach (var error in preview.Validation.Errors) + { +
  • @error
  • + } +
+
+ } + + @if (preview.Validation.Warnings.Any()) + { +
+ Import notes +
    + @foreach (var warning in preview.Validation.Warnings) + { +
  • @warning
  • + } +
+
+ } + + @if (preview.Package is not null) + { +
+ @foreach (var book in preview.Package.Books.OrderBy(x => x.SeriesOrder)) + { +
+

Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")

+ @foreach (var chapter in book.Chapters.OrderBy(x => x.Order)) + { +
+ Chapter @chapter.ChapterNumber: @chapter.Title +
    + @foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) + { +
  • Scene @scene.SceneNumber: @scene.Title
  • + } +
+
+ } +
+ } +
+ +
+ + @if (preview.HasDuplicateProjectTitle) + { +
+ + +
+ } + +
+ } +
+} + + + +@section Scripts { + +} diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index 1478b10..45312ae 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -8,7 +8,10 @@

Series planning

Projects

- New project + @if (!Model.Any()) diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 7f550c2..ea33941 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -24,6 +24,9 @@ + @@ -45,6 +48,10 @@ {
@archiveError
} + @if (TempData["ImportMessage"] is string importMessage) + { +
@importMessage
+ } @RenderBody() diff --git a/PlotLine/wwwroot/css/plotline-theme.css b/PlotLine/wwwroot/css/plotline-theme.css index f71dd8c..a3a4fa0 100644 --- a/PlotLine/wwwroot/css/plotline-theme.css +++ b/PlotLine/wwwroot/css/plotline-theme.css @@ -105,7 +105,8 @@ a:hover { .warning-card, .warning-mini-card, .scenario-chapter, -.drag-drop-panel { +.drag-drop-panel, +.import-tree-book { background: var(--plotline-surface); border-color: var(--plotline-border-soft); border-radius: var(--plotline-radius); diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index 0e3580b..0f4b8d2 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -169,6 +169,85 @@ td.text-end { max-width: 980px; } +.import-panel, +.import-preview-section { + max-width: 1180px; +} + +.import-json-input { + font-family: Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.88rem; + line-height: 1.45; + min-height: 34rem; +} + +.import-preview-heading, +.import-counts { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.import-preview-heading { + justify-content: space-between; + margin-bottom: 16px; +} + +.import-counts { + flex-wrap: wrap; +} + +.import-counts span { + display: inline-flex; + align-items: baseline; + gap: 5px; + border: 1px solid var(--plotline-line); + border-radius: 999px; + padding: 5px 11px; + background: var(--plotline-soft); + color: var(--plotline-muted); + font-weight: 700; +} + +.import-counts strong { + color: var(--plotline-ink); +} + +.import-tree { + display: grid; + gap: 12px; +} + +.import-tree-book { + border: 1px solid var(--plotline-line); + border-radius: 8px; + padding: 14px; + background: rgba(255, 255, 255, 0.54); +} + +.import-tree-book h3 { + margin: 0 0 10px; + font-size: 1.08rem; +} + +.import-tree-chapter { + border-top: 1px solid var(--plotline-line); + padding-top: 10px; + margin-top: 10px; +} + +.import-tree-chapter ul { + margin: 8px 0 0; + color: var(--plotline-muted); +} + +.import-duplicate-confirm { + border: 1px solid rgba(184, 118, 43, 0.34); + border-radius: 8px; + padding: 12px 12px 12px 38px; + background: rgba(251, 236, 211, 0.72); +} + .scene-editor { max-width: 1180px; }