Implemented the Projects listing redesign.
This commit is contained in:
parent
c9a9605cd2
commit
54fdc034ea
@ -51,6 +51,7 @@ public interface IProjectActivityRepository
|
|||||||
public interface IBookRepository
|
public interface IBookRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
||||||
|
Task<IReadOnlyList<Book>> ListByProjectsAsync(IEnumerable<int> projectIds);
|
||||||
Task<Book?> GetAsync(int bookId);
|
Task<Book?> GetAsync(int bookId);
|
||||||
Task<Book?> GetCoverPathsAsync(int bookId);
|
Task<Book?> GetCoverPathsAsync(int bookId);
|
||||||
Task<int> SaveAsync(Book book);
|
Task<int> SaveAsync(Book book);
|
||||||
@ -2374,6 +2375,67 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
|
|||||||
return rows.ToList();
|
return rows.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Book>> ListByProjectsAsync(IEnumerable<int> projectIds)
|
||||||
|
{
|
||||||
|
var ids = projectIds.Distinct().ToArray();
|
||||||
|
if (ids.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<Book>(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
b.BookID,
|
||||||
|
b.ProjectID,
|
||||||
|
b.BookTitle,
|
||||||
|
b.Subtitle,
|
||||||
|
b.Tagline,
|
||||||
|
b.ShortDescription,
|
||||||
|
b.Status,
|
||||||
|
b.BookNumber,
|
||||||
|
b.Description,
|
||||||
|
b.TargetWordCount,
|
||||||
|
b.WritingStartDate,
|
||||||
|
b.TargetCompletionDate,
|
||||||
|
b.PublicationDate,
|
||||||
|
b.CoverThumbnailPath,
|
||||||
|
b.CoverStandardPath,
|
||||||
|
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
|
||||||
|
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
|
||||||
|
ISNULL(counts.SceneCount, 0) AS SceneCount,
|
||||||
|
b.SortOrder,
|
||||||
|
b.CreatedDate,
|
||||||
|
b.UpdatedDate,
|
||||||
|
b.IsArchived,
|
||||||
|
b.ArchivedDate,
|
||||||
|
b.ArchivedReason
|
||||||
|
FROM dbo.Books b
|
||||||
|
OUTER APPLY
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT c.ChapterID) AS ChapterCount,
|
||||||
|
COUNT(s.SceneID) AS SceneCount
|
||||||
|
FROM dbo.Chapters c
|
||||||
|
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
|
||||||
|
WHERE c.BookID = b.BookID AND c.IsArchived = 0
|
||||||
|
) counts
|
||||||
|
OUTER APPLY
|
||||||
|
(
|
||||||
|
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
|
||||||
|
FROM dbo.Chapters c
|
||||||
|
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
|
||||||
|
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
|
||||||
|
WHERE c.BookID = b.BookID AND c.IsArchived = 0
|
||||||
|
) wordCounts
|
||||||
|
WHERE b.ProjectID IN @ProjectIDs AND b.IsArchived = 0
|
||||||
|
ORDER BY b.ProjectID, b.SortOrder, b.BookNumber, b.BookTitle;
|
||||||
|
""",
|
||||||
|
new { ProjectIDs = ids });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Book?> GetAsync(int bookId)
|
public async Task<Book?> GetAsync(int bookId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -411,14 +411,50 @@ public sealed class ProjectService(
|
|||||||
return new ProjectIndexViewModel();
|
return new ProjectIndexViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var activeProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value);
|
||||||
|
var archivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value);
|
||||||
|
var projectBooks = await books.ListByProjectsAsync(activeProjects.Concat(archivedProjects).Select(project => project.ProjectID));
|
||||||
|
var booksByProject = projectBooks
|
||||||
|
.GroupBy(book => book.ProjectID)
|
||||||
|
.ToDictionary(group => group.Key, group => group.ToList());
|
||||||
|
|
||||||
return new ProjectIndexViewModel
|
return new ProjectIndexViewModel
|
||||||
{
|
{
|
||||||
ActiveProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value),
|
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||||
ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value),
|
ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||||
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow)
|
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ProjectIndexCardViewModel ToIndexCard(Project project, IReadOnlyDictionary<int, List<Book>> booksByProject)
|
||||||
|
{
|
||||||
|
booksByProject.TryGetValue(project.ProjectID, out var projectBooks);
|
||||||
|
projectBooks ??= [];
|
||||||
|
|
||||||
|
return new ProjectIndexCardViewModel
|
||||||
|
{
|
||||||
|
ProjectID = project.ProjectID,
|
||||||
|
ProjectName = project.ProjectName,
|
||||||
|
Description = project.Description,
|
||||||
|
CreatedDate = project.CreatedDate,
|
||||||
|
UpdatedDate = project.UpdatedDate,
|
||||||
|
IsArchived = project.IsArchived,
|
||||||
|
ArchivedDate = project.ArchivedDate,
|
||||||
|
AccessRole = project.AccessRole,
|
||||||
|
BookCount = projectBooks.Count,
|
||||||
|
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
|
||||||
|
LastBookUpdatedDate = projectBooks.Count == 0 ? null : projectBooks.Max(book => book.UpdatedDate),
|
||||||
|
CoverThumbnailPaths = projectBooks
|
||||||
|
.Where(book => !string.IsNullOrWhiteSpace(book.CoverThumbnailPath))
|
||||||
|
.OrderBy(book => book.SortOrder)
|
||||||
|
.ThenBy(book => book.BookNumber)
|
||||||
|
.ThenBy(book => book.BookTitle)
|
||||||
|
.Select(book => book.CoverThumbnailPath!)
|
||||||
|
.Take(3)
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
|
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
|
||||||
{
|
{
|
||||||
if (!currentUser.UserId.HasValue)
|
if (!currentUser.UserId.HasValue)
|
||||||
|
|||||||
@ -23,11 +23,28 @@ public sealed class ProjectEditViewModel
|
|||||||
|
|
||||||
public sealed class ProjectIndexViewModel
|
public sealed class ProjectIndexViewModel
|
||||||
{
|
{
|
||||||
public IReadOnlyList<Project> ActiveProjects { get; init; } = [];
|
public IReadOnlyList<ProjectIndexCardViewModel> ActiveProjects { get; init; } = [];
|
||||||
public IReadOnlyList<Project> ArchivedProjects { get; init; } = [];
|
public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { get; init; } = [];
|
||||||
public TrialVisibilityViewModel? Trial { get; init; }
|
public TrialVisibilityViewModel? Trial { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ProjectIndexCardViewModel
|
||||||
|
{
|
||||||
|
public int ProjectID { get; set; }
|
||||||
|
public string ProjectName { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime CreatedDate { get; set; }
|
||||||
|
public DateTime UpdatedDate { get; set; }
|
||||||
|
public bool IsArchived { get; set; }
|
||||||
|
public DateTime? ArchivedDate { get; set; }
|
||||||
|
public string AccessRole { get; set; } = "Owner";
|
||||||
|
public bool IsOwner => string.Equals(AccessRole, "Owner", StringComparison.OrdinalIgnoreCase);
|
||||||
|
public int BookCount { get; set; }
|
||||||
|
public int TotalWords { get; set; }
|
||||||
|
public DateTime? LastBookUpdatedDate { get; set; }
|
||||||
|
public IReadOnlyList<string> CoverThumbnailPaths { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ProjectDetailViewModel
|
public sealed class ProjectDetailViewModel
|
||||||
{
|
{
|
||||||
public Project Project { get; set; } = new();
|
public Project Project { get; set; } = new();
|
||||||
|
|||||||
@ -56,62 +56,173 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<section class="list-section">
|
<section class="project-gallery" aria-label="Project lists">
|
||||||
<h2>Active Projects</h2>
|
<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())
|
@if (!Model.ActiveProjects.Any())
|
||||||
{
|
{
|
||||||
<p class="muted">No active projects.</p>
|
<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
|
else
|
||||||
{
|
{
|
||||||
<div class="content-grid">
|
<div class="project-card-grid">
|
||||||
@foreach (var project in Model.ActiveProjects)
|
@foreach (var project in Model.ActiveProjects)
|
||||||
{
|
{
|
||||||
<article class="plain-card">
|
<article class="project-card">
|
||||||
<h3>@project.ProjectName</h3>
|
<div class="project-card__visual">
|
||||||
<p class="eyebrow">@project.AccessRole</p>
|
@if (project.CoverThumbnailPaths.Any())
|
||||||
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
{
|
||||||
<div class="button-row">
|
<div class="project-cover-stack" aria-label="Book covers for @project.ProjectName">
|
||||||
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@project.ProjectID">Open</a>
|
@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>
|
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@project.ProjectID">Edit</a>
|
||||||
@if (project.IsOwner)
|
@if (project.IsOwner)
|
||||||
{
|
{
|
||||||
<a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a>
|
<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.">
|
<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" />
|
<input type="hidden" name="id" value="@project.ProjectID" />
|
||||||
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
<button class="dropdown-item text-danger" type="submit">Archive</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section class="list-section">
|
<div class="tab-pane fade"
|
||||||
<h2>Archived Projects</h2>
|
id="archived-projects-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="archived-projects-tab"
|
||||||
|
tabindex="0">
|
||||||
@if (!Model.ArchivedProjects.Any())
|
@if (!Model.ArchivedProjects.Any())
|
||||||
{
|
{
|
||||||
<p class="muted">No archived projects.</p>
|
<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
|
else
|
||||||
{
|
{
|
||||||
<div class="content-grid">
|
<div class="project-card-grid">
|
||||||
@foreach (var project in Model.ArchivedProjects)
|
@foreach (var project in Model.ArchivedProjects)
|
||||||
{
|
{
|
||||||
var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");
|
var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");
|
||||||
<article class="plain-card">
|
<article class="project-card project-card--archived">
|
||||||
<h3>@project.ProjectName</h3>
|
<div class="project-card__visual">
|
||||||
<p class="eyebrow">
|
@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
|
Archived
|
||||||
@if (!string.IsNullOrWhiteSpace(archivedDateLabel))
|
@if (!string.IsNullOrWhiteSpace(archivedDateLabel))
|
||||||
{
|
{
|
||||||
<span> / @archivedDateLabel</span>
|
<span> / @archivedDateLabel</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
<h2>@project.ProjectName</h2>
|
||||||
<div class="button-row">
|
</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>
|
<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.">
|
<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" />
|
<input type="hidden" name="id" value="@project.ProjectID" />
|
||||||
@ -132,6 +243,8 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="modal fade" id="hardDeleteProjectModal" tabindex="-1" aria-labelledby="hardDeleteProjectModalTitle" aria-hidden="true">
|
<div class="modal fade" id="hardDeleteProjectModal" tabindex="-1" aria-labelledby="hardDeleteProjectModalTitle" aria-hidden="true">
|
||||||
@ -165,6 +278,20 @@ else
|
|||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
|
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 {
|
@section Scripts {
|
||||||
|
|||||||
@ -420,6 +420,217 @@ a:hover {
|
|||||||
background: var(--plotline-danger);
|
background: var(--plotline-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-gallery {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tabs {
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid var(--plotline-border-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 250, 241, 0.72);
|
||||||
|
box-shadow: var(--plotline-shadow-subtle);
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tabs .nav-link {
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--plotline-text-muted);
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 0.48rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tabs .nav-link:hover,
|
||||||
|
.project-tabs .nav-link:focus {
|
||||||
|
color: var(--plotline-accent-dark);
|
||||||
|
background: rgba(122, 77, 44, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tabs .nav-link.active {
|
||||||
|
color: #fff8ec;
|
||||||
|
background: linear-gradient(180deg, var(--plotline-accent), var(--plotline-accent-dark));
|
||||||
|
box-shadow: 0 8px 16px rgba(62, 45, 28, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tab-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100%;
|
||||||
|
border: 1px solid var(--plotline-border-soft);
|
||||||
|
border-radius: var(--plotline-radius);
|
||||||
|
background: var(--plotline-surface);
|
||||||
|
box-shadow: var(--plotline-shadow-subtle);
|
||||||
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
border-color: rgba(122, 77, 44, 0.24);
|
||||||
|
box-shadow: var(--plotline-shadow-card);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card--archived {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__visual {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 11rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.64), transparent 11rem),
|
||||||
|
linear-gradient(135deg, rgba(242, 226, 207, 0.94), rgba(223, 236, 239, 0.78));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card--archived .project-card__visual {
|
||||||
|
filter: saturate(0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__body {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__heading h2 a {
|
||||||
|
color: var(--plotline-text);
|
||||||
|
text-decoration-color: rgba(122, 77, 44, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__description {
|
||||||
|
display: -webkit-box;
|
||||||
|
min-height: 3.2rem;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--plotline-text-muted);
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__stats span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px solid rgba(84, 68, 48, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28rem 0.6rem;
|
||||||
|
color: var(--plotline-text-muted);
|
||||||
|
background: rgba(255, 250, 241, 0.74);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__stats strong {
|
||||||
|
color: var(--plotline-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border-top: 1px solid rgba(84, 68, 48, 0.1);
|
||||||
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__actions form {
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-stack {
|
||||||
|
position: relative;
|
||||||
|
width: min(11.5rem, 70%);
|
||||||
|
height: 9.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-stack__cover {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 4.65rem;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border: 3px solid rgba(255, 253, 248, 0.86);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--plotline-soft);
|
||||||
|
box-shadow: 0 12px 28px rgba(62, 45, 28, 0.22);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-stack__cover--0 {
|
||||||
|
z-index: 3;
|
||||||
|
transform: translate(-50%, -50%) rotate(-1deg) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-stack__cover--1 {
|
||||||
|
z-index: 2;
|
||||||
|
transform: translate(-88%, -47%) rotate(-8deg) scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-stack__cover--2 {
|
||||||
|
z-index: 1;
|
||||||
|
transform: translate(-12%, -47%) rotate(8deg) scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: min(9.5rem, 62%);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border: 1px solid rgba(84, 68, 48, 0.18);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 253, 248, 0.34), transparent 32%),
|
||||||
|
linear-gradient(135deg, var(--plotline-accent), var(--plotline-accent-dark));
|
||||||
|
box-shadow: 0 14px 30px rgba(62, 45, 28, 0.2);
|
||||||
|
color: #fff8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-fallback span {
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: clamp(2.2rem, 7vw, 3.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-empty-state {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.pw-page-heading {
|
.pw-page-heading {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@ -433,6 +644,21 @@ a:hover {
|
|||||||
.pw-project-nav {
|
.pw-project-nav {
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-tabs {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--plotline-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tabs .nav-item,
|
||||||
|
.project-tabs .nav-link {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__actions .btn,
|
||||||
|
.project-card__actions .dropdown {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-workspace {
|
.timeline-workspace {
|
||||||
@ -1830,6 +2056,79 @@ a:focus-visible {
|
|||||||
color: #ffb2aa !important;
|
color: #ffb2aa !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-tabs {
|
||||||
|
border-color: rgba(241, 195, 143, 0.18);
|
||||||
|
background: rgba(35, 29, 25, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-tabs .nav-link {
|
||||||
|
color: #ead8c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-tabs .nav-link:hover,
|
||||||
|
[data-theme="dark"] .project-tabs .nav-link:focus {
|
||||||
|
color: #ffd5a6;
|
||||||
|
background: rgba(241, 195, 143, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-tabs .nav-link.active {
|
||||||
|
color: #21160f;
|
||||||
|
background: linear-gradient(180deg, #f1c38f, var(--plotline-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card {
|
||||||
|
color: var(--app-text);
|
||||||
|
border-color: var(--app-border);
|
||||||
|
background: var(--app-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card:hover {
|
||||||
|
border-color: rgba(241, 195, 143, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card__visual {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 28% 24%, rgba(241, 195, 143, 0.22), transparent 11rem),
|
||||||
|
linear-gradient(135deg, rgba(58, 42, 32, 0.96), rgba(33, 56, 61, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card__heading h2,
|
||||||
|
[data-theme="dark"] .project-card__heading h2 a {
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card__description {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card__stats span {
|
||||||
|
color: #ead8c4;
|
||||||
|
border-color: rgba(231, 214, 190, 0.2);
|
||||||
|
background: rgba(231, 214, 190, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card__stats strong {
|
||||||
|
color: #ffd5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-card__actions {
|
||||||
|
border-top-color: rgba(231, 214, 190, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-cover-stack__cover {
|
||||||
|
border-color: rgba(42, 35, 30, 0.92);
|
||||||
|
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-cover-fallback {
|
||||||
|
color: #21160f;
|
||||||
|
border-color: rgba(241, 195, 143, 0.34);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 244, 232, 0.3), transparent 32%),
|
||||||
|
linear-gradient(135deg, #f1c38f, var(--plotline-accent));
|
||||||
|
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .timeline-filter,
|
[data-theme="dark"] .timeline-filter,
|
||||||
[data-theme="dark"] .workspace-heading .button-row,
|
[data-theme="dark"] .workspace-heading .button-row,
|
||||||
[data-theme="dark"] .timeline-control-panel {
|
[data-theme="dark"] .timeline-control-panel {
|
||||||
|
|||||||
2
PlotLine/wwwroot/css/plotline-theme.min.css
vendored
2
PlotLine/wwwroot/css/plotline-theme.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user