Implemented the Projects listing redesign.
This commit is contained in:
parent
c9a9605cd2
commit
54fdc034ea
@ -51,6 +51,7 @@ public interface IProjectActivityRepository
|
||||
public interface IBookRepository
|
||||
{
|
||||
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
||||
Task<IReadOnlyList<Book>> ListByProjectsAsync(IEnumerable<int> projectIds);
|
||||
Task<Book?> GetAsync(int bookId);
|
||||
Task<Book?> GetCoverPathsAsync(int bookId);
|
||||
Task<int> SaveAsync(Book book);
|
||||
@ -2374,6 +2375,67 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -411,14 +411,50 @@ public sealed class ProjectService(
|
||||
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
|
||||
{
|
||||
ActiveProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value),
|
||||
ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value),
|
||||
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||
ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||
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)
|
||||
{
|
||||
if (!currentUser.UserId.HasValue)
|
||||
|
||||
@ -23,11 +23,28 @@ public sealed class ProjectEditViewModel
|
||||
|
||||
public sealed class ProjectIndexViewModel
|
||||
{
|
||||
public IReadOnlyList<Project> ActiveProjects { get; init; } = [];
|
||||
public IReadOnlyList<Project> ArchivedProjects { get; init; } = [];
|
||||
public IReadOnlyList<ProjectIndexCardViewModel> ActiveProjects { get; init; } = [];
|
||||
public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { 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 Project Project { get; set; } = new();
|
||||
|
||||
@ -56,82 +56,195 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="list-section">
|
||||
<h2>Active Projects</h2>
|
||||
@if (!Model.ActiveProjects.Any())
|
||||
{
|
||||
<p class="muted">No active projects.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="content-grid">
|
||||
@foreach (var project in Model.ActiveProjects)
|
||||
{
|
||||
<article class="plain-card">
|
||||
<h3>@project.ProjectName</h3>
|
||||
<p class="eyebrow">@project.AccessRole</p>
|
||||
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-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)
|
||||
{
|
||||
<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="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="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Archived Projects</h2>
|
||||
@if (!Model.ArchivedProjects.Any())
|
||||
{
|
||||
<p class="muted">No archived projects.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="content-grid">
|
||||
@foreach (var project in Model.ArchivedProjects)
|
||||
<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())
|
||||
{
|
||||
var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");
|
||||
<article class="plain-card">
|
||||
<h3>@project.ProjectName</h3>
|
||||
<p class="eyebrow">
|
||||
Archived
|
||||
@if (!string.IsNullOrWhiteSpace(archivedDateLabel))
|
||||
{
|
||||
<span> / @archivedDateLabel</span>
|
||||
}
|
||||
</p>
|
||||
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
||||
<div class="button-row">
|
||||
<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 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">
|
||||
@ -165,6 +278,20 @@ else
|
||||
|
||||
@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 {
|
||||
|
||||
@ -420,6 +420,217 @@ a:hover {
|
||||
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) {
|
||||
.pw-page-heading {
|
||||
align-items: stretch;
|
||||
@ -433,6 +644,21 @@ a:hover {
|
||||
.pw-project-nav {
|
||||
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 {
|
||||
@ -1830,6 +2056,79 @@ a:focus-visible {
|
||||
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"] .workspace-heading .button-row,
|
||||
[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