364 lines
20 KiB
Plaintext
364 lines
20 KiB
Plaintext
@model ProjectIndexViewModel
|
|
@{
|
|
ViewData["Title"] = "Projects";
|
|
var trial = Model.Trial;
|
|
}
|
|
|
|
<div class="page-heading">
|
|
<div>
|
|
<p class="eyebrow">Series planning</p>
|
|
<h1>Projects <help-icon key="projects.overview" /></h1>
|
|
</div>
|
|
<div class="button-row">
|
|
<a class="btn btn-outline-primary" asp-controller="Imports" asp-action="Index">Import JSON</a>
|
|
<a class="btn btn-primary" asp-action="Create">New project</a>
|
|
</div>
|
|
</div>
|
|
|
|
@if (trial is not null)
|
|
{
|
|
<section class="trial-dashboard-banner @trial.WarningCssClass"
|
|
data-trial-banner
|
|
data-trial-banner-key="projects:@trial.ExpiryDateUtc.ToString("yyyyMMdd")"
|
|
aria-label="Trial status">
|
|
<div class="trial-dashboard-banner__body">
|
|
<p class="eyebrow">Free trial status</p>
|
|
<h2>@trial.StatusLabel on @trial.PlanName</h2>
|
|
<p>Your 30-day trial expires on @FormatDate(trial.ExpiryDateUtc). Upgrade whenever you are ready.</p>
|
|
</div>
|
|
<div class="trial-dashboard-banner__actions">
|
|
<a class="btn btn-primary btn-sm" asp-controller="ManageAccount" asp-action="Subscription">Upgrade</a>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-trial-banner-dismiss>Dismiss</button>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@if (Model.OnboardingNudge.ShouldShow)
|
|
{
|
|
<section class="onboarding-dashboard-nudge @(Model.OnboardingNudge.CompanionConnected ? "is-connected" : string.Empty)"
|
|
data-word-companion-presence="@(Model.OnboardingNudge.IsCompanionPresence.ToString().ToLowerInvariant())"
|
|
aria-label="PlotDirector setup">
|
|
<div>
|
|
<p class="eyebrow">First setup</p>
|
|
<h2 data-word-companion-dashboard-title>@Model.OnboardingNudge.Title</h2>
|
|
<p data-word-companion-dashboard-description>@Model.OnboardingNudge.Description</p>
|
|
</div>
|
|
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.OnboardingNudge.ButtonText</a>
|
|
</section>
|
|
}
|
|
|
|
@if (Model.StoryIntelligence.ShouldShow)
|
|
{
|
|
<section class="story-intelligence-dashboard-card"
|
|
aria-label="Story Intelligence">
|
|
<div>
|
|
<p class="eyebrow">Story Intelligence</p>
|
|
<h2>@Model.StoryIntelligence.Title</h2>
|
|
<p>@Model.StoryIntelligence.Description</p>
|
|
</div>
|
|
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.StoryIntelligence.ButtonText</a>
|
|
</section>
|
|
}
|
|
|
|
@if (TempData["ArchiveMessage"] is string archiveMessage)
|
|
{
|
|
<div class="alert alert-success">@archiveMessage</div>
|
|
}
|
|
@if (TempData["ArchiveError"] is string archiveError)
|
|
{
|
|
<div class="alert alert-danger">@archiveError</div>
|
|
}
|
|
@if (TempData["ProjectExportMessage"] is string projectExportMessage)
|
|
{
|
|
<div class="alert alert-warning">@projectExportMessage</div>
|
|
}
|
|
|
|
@if (!Model.ActiveProjects.Any() && !Model.ArchivedProjects.Any())
|
|
{
|
|
<section class="empty-panel">
|
|
<h2>Start with a series or standalone novel</h2>
|
|
<p>Create a project, then add books, chapters and scenes in narrative order.</p>
|
|
<a class="btn btn-primary" asp-action="Create">Create your first project</a>
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<section class="project-gallery" aria-label="Project lists">
|
|
<ul class="nav nav-pills project-tabs" id="projectTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active"
|
|
id="active-projects-tab"
|
|
data-bs-toggle="tab"
|
|
data-bs-target="#active-projects-panel"
|
|
type="button"
|
|
role="tab"
|
|
aria-controls="active-projects-panel"
|
|
aria-selected="true">
|
|
Active Projects (@Model.ActiveProjects.Count)
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link"
|
|
id="archived-projects-tab"
|
|
data-bs-toggle="tab"
|
|
data-bs-target="#archived-projects-panel"
|
|
type="button"
|
|
role="tab"
|
|
aria-controls="archived-projects-panel"
|
|
aria-selected="false">
|
|
Archived Projects (@Model.ArchivedProjects.Count)
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="tab-content project-tab-content" id="projectTabsContent">
|
|
<div class="tab-pane fade show active"
|
|
id="active-projects-panel"
|
|
role="tabpanel"
|
|
aria-labelledby="active-projects-tab"
|
|
tabindex="0">
|
|
@if (!Model.ActiveProjects.Any())
|
|
{
|
|
<div class="empty-panel project-empty-state">
|
|
<h2>No active projects</h2>
|
|
<p class="muted mb-0">Restored projects will appear here, ready to open again.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="project-card-grid">
|
|
@foreach (var project in Model.ActiveProjects)
|
|
{
|
|
<article class="project-card">
|
|
<div class="project-card__visual">
|
|
@if (project.CoverThumbnailPaths.Any())
|
|
{
|
|
<div class="project-cover-stack" aria-label="Book covers for @project.ProjectName">
|
|
@foreach (var cover in project.CoverThumbnailPaths.Select((path, index) => new { path, index }))
|
|
{
|
|
<img src="@cover.path"
|
|
alt=""
|
|
class="project-cover-stack__cover project-cover-stack__cover--@cover.index"
|
|
loading="lazy" />
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="project-cover-fallback" aria-hidden="true">
|
|
<span>@Initials(project.ProjectName)</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="project-card__body">
|
|
<div class="project-card__heading">
|
|
<div>
|
|
<p class="eyebrow mb-1">@project.AccessRole</p>
|
|
<h2><a asp-action="Details" asp-route-id="@project.ProjectID">@project.ProjectName</a></h2>
|
|
</div>
|
|
</div>
|
|
<p class="project-card__description">@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
|
<div class="project-card__stats" aria-label="Project summary">
|
|
<span><strong>@project.BookCount.ToString("N0")</strong> books</span>
|
|
<span><strong>@project.TotalWords.ToString("N0")</strong> words</span>
|
|
<span>Modified @FormatDate(ProjectLastUpdated(project))</span>
|
|
</div>
|
|
</div>
|
|
<div class="project-card__actions">
|
|
<a class="btn btn-primary btn-sm" asp-action="Details" asp-route-id="@project.ProjectID">Open</a>
|
|
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@project.ProjectID">Edit</a>
|
|
@if (project.IsOwner)
|
|
{
|
|
<div class="dropdown pw-actions">
|
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
More
|
|
</button>
|
|
<div class="dropdown-menu dropdown-menu-end">
|
|
<a class="dropdown-item" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a>
|
|
<div class="dropdown-divider"></div>
|
|
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project? Archived projects are hidden from your active workspace but remain stored within PlotDirector. Export your project first if you wish to maintain an external backup.">
|
|
<input type="hidden" name="id" value="@project.ProjectID" />
|
|
<button class="dropdown-item text-danger" type="submit">Archive</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="tab-pane fade"
|
|
id="archived-projects-panel"
|
|
role="tabpanel"
|
|
aria-labelledby="archived-projects-tab"
|
|
tabindex="0">
|
|
@if (!Model.ArchivedProjects.Any())
|
|
{
|
|
<div class="empty-panel project-empty-state">
|
|
<h2>No archived projects</h2>
|
|
<p class="muted mb-0">Archived projects will live here, recoverable but out of your active workspace.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="project-card-grid">
|
|
@foreach (var project in Model.ArchivedProjects)
|
|
{
|
|
var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");
|
|
<article class="project-card project-card--archived">
|
|
<div class="project-card__visual">
|
|
@if (project.CoverThumbnailPaths.Any())
|
|
{
|
|
<div class="project-cover-stack" aria-label="Book covers for @project.ProjectName">
|
|
@foreach (var cover in project.CoverThumbnailPaths.Select((path, index) => new { path, index }))
|
|
{
|
|
<img src="@cover.path"
|
|
alt=""
|
|
class="project-cover-stack__cover project-cover-stack__cover--@cover.index"
|
|
loading="lazy" />
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="project-cover-fallback" aria-hidden="true">
|
|
<span>@Initials(project.ProjectName)</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="project-card__body">
|
|
<div class="project-card__heading">
|
|
<div>
|
|
<p class="eyebrow mb-1">
|
|
Archived
|
|
@if (!string.IsNullOrWhiteSpace(archivedDateLabel))
|
|
{
|
|
<span> / @archivedDateLabel</span>
|
|
}
|
|
</p>
|
|
<h2>@project.ProjectName</h2>
|
|
</div>
|
|
</div>
|
|
<p class="project-card__description">@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
|
<div class="project-card__stats" aria-label="Project summary">
|
|
<span><strong>@project.BookCount.ToString("N0")</strong> books</span>
|
|
<span><strong>@project.TotalWords.ToString("N0")</strong> words</span>
|
|
<span>Modified @FormatDate(ProjectLastUpdated(project))</span>
|
|
</div>
|
|
</div>
|
|
<div class="project-card__actions">
|
|
<a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a>
|
|
<form asp-action="Restore" method="post" class="archive-button-form" data-confirm-message="Restore this project? It will return to your active project list.">
|
|
<input type="hidden" name="id" value="@project.ProjectID" />
|
|
<button class="btn btn-outline-primary btn-sm" type="submit">Restore</button>
|
|
</form>
|
|
@if (project.IsOwner)
|
|
{
|
|
<button class="btn btn-outline-danger btn-sm"
|
|
type="button"
|
|
data-hard-delete-project
|
|
data-project-id="@project.ProjectID"
|
|
data-project-name="@project.ProjectName">
|
|
Delete Permanently
|
|
</button>
|
|
}
|
|
</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="modal fade" id="hardDeleteProjectModal" tabindex="-1" aria-labelledby="hardDeleteProjectModalTitle" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content plotline-confirm-modal">
|
|
<div class="modal-header">
|
|
<h2 class="modal-title fs-5" id="hardDeleteProjectModalTitle">Permanently delete this project?</h2>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
|
</div>
|
|
<form asp-controller="Projects" asp-action="HardDeleteArchivedProject" method="post" asp-antiforgery="true" data-confirm-skip="true" data-hard-delete-project-form>
|
|
<div class="modal-body">
|
|
<p>This will permanently delete <strong data-hard-delete-project-name></strong> and all associated data, including books, chapters, scenes, characters, locations, assets, plot threads, notes, metrics, attachments and uploaded files.</p>
|
|
<p class="mb-3">This action cannot be undone.</p>
|
|
<input type="hidden" name="projectId" data-hard-delete-project-id />
|
|
<label class="form-label" for="hardDeleteProjectConfirmation">Type the project name to continue</label>
|
|
<input class="form-control"
|
|
id="hardDeleteProjectConfirmation"
|
|
name="confirmationName"
|
|
autocomplete="off"
|
|
data-hard-delete-project-confirmation />
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-danger" disabled data-hard-delete-project-submit>Delete Permanently</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@functions {
|
|
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
|
|
|
|
private static DateTime ProjectLastUpdated(ProjectIndexCardViewModel project) =>
|
|
new[] { project.UpdatedDate, project.LastBookUpdatedDate ?? project.UpdatedDate }.Max();
|
|
|
|
private static string Initials(string value)
|
|
{
|
|
var words = value
|
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Take(2)
|
|
.Select(word => char.ToUpperInvariant(word[0]));
|
|
|
|
var initials = string.Concat(words);
|
|
return string.IsNullOrWhiteSpace(initials) ? "PD" : initials;
|
|
}
|
|
}
|
|
|
|
@section Scripts {
|
|
<script>
|
|
(() => {
|
|
const modalElement = document.getElementById("hardDeleteProjectModal");
|
|
if (!modalElement || !window.bootstrap) return;
|
|
|
|
const modal = new bootstrap.Modal(modalElement);
|
|
const projectNameLabel = modalElement.querySelector("[data-hard-delete-project-name]");
|
|
const projectIdInput = modalElement.querySelector("[data-hard-delete-project-id]");
|
|
const confirmationInput = modalElement.querySelector("[data-hard-delete-project-confirmation]");
|
|
const submitButton = modalElement.querySelector("[data-hard-delete-project-submit]");
|
|
let selectedProjectName = "";
|
|
|
|
const updateSubmitState = () => {
|
|
if (!submitButton || !confirmationInput) return;
|
|
submitButton.disabled = confirmationInput.value.trim() !== selectedProjectName;
|
|
};
|
|
|
|
document.querySelectorAll("[data-hard-delete-project]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
selectedProjectName = button.dataset.projectName || "";
|
|
if (projectNameLabel) projectNameLabel.textContent = selectedProjectName;
|
|
if (projectIdInput) projectIdInput.value = button.dataset.projectId || "";
|
|
if (confirmationInput) confirmationInput.value = "";
|
|
updateSubmitState();
|
|
modal.show();
|
|
});
|
|
});
|
|
|
|
confirmationInput?.addEventListener("input", updateSubmitState);
|
|
modalElement.addEventListener("shown.bs.modal", () => confirmationInput?.focus());
|
|
modalElement.addEventListener("hidden.bs.modal", () => {
|
|
selectedProjectName = "";
|
|
if (projectIdInput) projectIdInput.value = "";
|
|
if (confirmationInput) confirmationInput.value = "";
|
|
updateSubmitState();
|
|
});
|
|
})();
|
|
</script>
|
|
}
|