feat(web): add task detail panel with inline editing, subtasks, and notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,113 @@ public class BoardModel : PageModel
|
||||
return await ReturnBoardContentAsync();
|
||||
}
|
||||
|
||||
// Load task detail panel
|
||||
public async Task<IActionResult> OnGetTaskDetailAsync(int id)
|
||||
{
|
||||
var task = await _taskRepo.GetByIdAsync(id);
|
||||
if (task is null) return NotFound();
|
||||
return Partial("Partials/_TaskDetail", task);
|
||||
}
|
||||
|
||||
// Update task fields (inline edit)
|
||||
public async Task<IActionResult> OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes)
|
||||
{
|
||||
var task = await _taskRepo.GetByIdAsync(id);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
if (title is not null) task.Title = title;
|
||||
if (description is not null) task.Description = description;
|
||||
if (category is not null) task.Category = category;
|
||||
if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes;
|
||||
|
||||
await _taskRepo.UpdateAsync(task);
|
||||
return Partial("Partials/_TaskDetail", task);
|
||||
}
|
||||
|
||||
// Add subtask
|
||||
public async Task<IActionResult> OnPostAddSubtaskAsync(int id, string title)
|
||||
{
|
||||
var parent = await _taskRepo.GetByIdAsync(id);
|
||||
if (parent is null) return NotFound();
|
||||
|
||||
var subtask = new WorkTask
|
||||
{
|
||||
Title = title,
|
||||
ParentTaskId = id,
|
||||
Status = WorkTaskStatus.Pending
|
||||
};
|
||||
await _taskRepo.CreateAsync(subtask);
|
||||
|
||||
// Reload parent to get updated subtask list
|
||||
parent = await _taskRepo.GetByIdAsync(id);
|
||||
return Partial("Partials/_SubtaskList", parent);
|
||||
}
|
||||
|
||||
// Complete subtask
|
||||
public async Task<IActionResult> OnPutCompleteSubtaskAsync(int id)
|
||||
{
|
||||
var task = await _taskRepo.GetByIdAsync(id);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
task.Status = WorkTaskStatus.Completed;
|
||||
task.CompletedAt = DateTime.UtcNow;
|
||||
await _taskRepo.UpdateAsync(task);
|
||||
|
||||
// Return parent's subtask list
|
||||
if (task.ParentTaskId.HasValue)
|
||||
{
|
||||
var parent = await _taskRepo.GetByIdAsync(task.ParentTaskId.Value);
|
||||
if (parent is not null)
|
||||
return Partial("Partials/_SubtaskList", parent);
|
||||
}
|
||||
return Content("");
|
||||
}
|
||||
|
||||
// Add note
|
||||
public async Task<IActionResult> OnPostAddNoteAsync(int id, string content)
|
||||
{
|
||||
var task = await _taskRepo.GetByIdAsync(id);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
task.Notes.Add(new TaskNote
|
||||
{
|
||||
Content = content,
|
||||
Type = NoteType.General,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _taskRepo.UpdateAsync(task);
|
||||
|
||||
return Partial("Partials/_NotesList", task);
|
||||
}
|
||||
|
||||
// Search tasks (for Ctrl+K modal)
|
||||
public async Task<IActionResult> OnGetSearchAsync(string? q)
|
||||
{
|
||||
var allTasks = await _taskRepo.GetAllAsync();
|
||||
List<WorkTask> results;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
results = allTasks
|
||||
.Where(t => t.Status is WorkTaskStatus.Active or WorkTaskStatus.Paused or WorkTaskStatus.Pending)
|
||||
.OrderByDescending(t => t.Status == WorkTaskStatus.Active)
|
||||
.Take(8)
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
results = allTasks
|
||||
.Where(t =>
|
||||
t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
(t.Description?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(t.Category?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
.Take(10)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Partial("Partials/_SearchResults", results);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ReturnBoardContentAsync()
|
||||
{
|
||||
await LoadBoardDataAsync();
|
||||
|
||||
64
TaskTracker.Api/Pages/Partials/_NotesList.cshtml
Normal file
64
TaskTracker.Api/Pages/Partials/_NotesList.cshtml
Normal file
@@ -0,0 +1,64 @@
|
||||
@using TaskTracker.Core.Enums
|
||||
@model TaskTracker.Core.Entities.WorkTask
|
||||
|
||||
@functions {
|
||||
static string FormatRelativeTime(DateTime dt)
|
||||
{
|
||||
var diff = DateTime.UtcNow - dt;
|
||||
if (diff.TotalMinutes < 1) return "just now";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
|
||||
if (diff.TotalDays < 2) return "yesterday";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
|
||||
if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)}w ago";
|
||||
return dt.ToLocalTime().ToString("MMM d, yyyy");
|
||||
}
|
||||
|
||||
static string GetNoteTypeCssClass(NoteType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
NoteType.PauseNote => "note-type-badge--pause",
|
||||
NoteType.ResumeNote => "note-type-badge--resume",
|
||||
_ => "note-type-badge--general"
|
||||
};
|
||||
}
|
||||
|
||||
static string GetNoteTypeLabel(NoteType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
NoteType.PauseNote => "Pause",
|
||||
NoteType.ResumeNote => "Resume",
|
||||
_ => "General"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
<div id="notes-list-@Model.Id">
|
||||
<h3 class="detail-section-label">Notes</h3>
|
||||
|
||||
@foreach (var note in Model.Notes.OrderBy(n => n.CreatedAt))
|
||||
{
|
||||
<div class="note">
|
||||
<div class="note-header">
|
||||
<span class="note-type-badge @GetNoteTypeCssClass(note.Type)">
|
||||
@GetNoteTypeLabel(note.Type)
|
||||
</span>
|
||||
<span class="note-time">@FormatRelativeTime(note.CreatedAt)</span>
|
||||
</div>
|
||||
<div class="note-content">@note.Content</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add note form -->
|
||||
<form hx-post="/board?handler=AddNote&id=@Model.Id"
|
||||
hx-target="#notes-list-@Model.Id"
|
||||
hx-swap="outerHTML"
|
||||
class="note-add-form"
|
||||
style="margin-top: 8px;">
|
||||
<input type="text" name="content" placeholder="Add a note..." class="input"
|
||||
style="font-size: 13px; padding: 6px 10px;"
|
||||
autocomplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
33
TaskTracker.Api/Pages/Partials/_SearchResults.cshtml
Normal file
33
TaskTracker.Api/Pages/Partials/_SearchResults.cshtml
Normal file
@@ -0,0 +1,33 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@using TaskTracker.Core.Enums
|
||||
@model List<TaskTracker.Core.Entities.WorkTask>
|
||||
|
||||
@* Placeholder — full implementation in Task 6 (Search Modal) *@
|
||||
<div class="search-results">
|
||||
@if (Model.Count == 0)
|
||||
{
|
||||
<div style="padding: 16px; text-align: center; color: var(--color-text-secondary); font-size: 13px;">
|
||||
No results found
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var task in Model)
|
||||
{
|
||||
<div class="search-result-item"
|
||||
hx-get="/board?handler=TaskDetail&id=@task.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML"
|
||||
style="padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--color-border);">
|
||||
<span class="badge badge--@task.Status.ToString().ToLower()" style="flex-shrink: 0;">
|
||||
@task.Status
|
||||
</span>
|
||||
<span style="font-size: 14px; color: var(--color-text-primary);">@task.Title</span>
|
||||
@if (!string.IsNullOrEmpty(task.Category))
|
||||
{
|
||||
<span style="margin-left: auto; font-size: 11px; color: var(--color-text-tertiary);">@task.Category</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
46
TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml
Normal file
46
TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml
Normal file
@@ -0,0 +1,46 @@
|
||||
@using TaskTracker.Core.Enums
|
||||
@model TaskTracker.Core.Entities.WorkTask
|
||||
|
||||
<div id="subtask-list-@Model.Id">
|
||||
<h3 class="detail-section-label">Subtasks</h3>
|
||||
|
||||
@foreach (var sub in Model.SubTasks)
|
||||
{
|
||||
<div class="subtask-row">
|
||||
@if (sub.Status == WorkTaskStatus.Completed)
|
||||
{
|
||||
<span class="subtask-check subtask-check--done">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="subtask-title subtask-title--done">@sub.Title</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="subtask-check"
|
||||
hx-put="/board?handler=CompleteSubtask&id=@sub.Id"
|
||||
hx-target="#subtask-list-@Model.Id"
|
||||
hx-swap="outerHTML">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" ry="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="subtask-title">@sub.Title</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add subtask form -->
|
||||
<form hx-post="/board?handler=AddSubtask&id=@Model.Id"
|
||||
hx-target="#subtask-list-@Model.Id"
|
||||
hx-swap="outerHTML"
|
||||
class="subtask-add-form"
|
||||
style="margin-top: 8px;">
|
||||
<input type="text" name="title" placeholder="Add subtask..." class="input"
|
||||
style="font-size: 13px; padding: 6px 10px;"
|
||||
autocomplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
323
TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml
Normal file
323
TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml
Normal file
@@ -0,0 +1,323 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@using TaskTracker.Core.Enums
|
||||
@model TaskTracker.Core.Entities.WorkTask
|
||||
|
||||
@{
|
||||
var statusColors = new Dictionary<WorkTaskStatus, (string Color, string Label)>
|
||||
{
|
||||
[WorkTaskStatus.Pending] = ("#64748b", "Pending"),
|
||||
[WorkTaskStatus.Active] = ("#3b82f6", "Active"),
|
||||
[WorkTaskStatus.Paused] = ("#eab308", "Paused"),
|
||||
[WorkTaskStatus.Completed] = ("#22c55e", "Completed"),
|
||||
[WorkTaskStatus.Abandoned] = ("#ef4444", "Abandoned"),
|
||||
};
|
||||
var (statusColor, statusLabel) = statusColors[Model.Status];
|
||||
var catColor = BoardModel.GetCategoryColor(Model.Category);
|
||||
var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt);
|
||||
|
||||
double? progressPercent = null;
|
||||
if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue)
|
||||
{
|
||||
var start = Model.StartedAt.Value;
|
||||
var end = Model.CompletedAt ?? DateTime.UtcNow;
|
||||
var elapsedMins = (end - start).TotalMinutes;
|
||||
progressPercent = Math.Min(100, (elapsedMins / Model.EstimatedMinutes.Value) * 100);
|
||||
}
|
||||
|
||||
var isTerminal = Model.Status is WorkTaskStatus.Completed or WorkTaskStatus.Abandoned;
|
||||
}
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="detail-overlay" onclick="closeDetailPanel()"></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="detail-panel">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;">
|
||||
<div class="inline-edit" id="edit-title" style="flex: 1; min-width: 0;">
|
||||
<h2 class="detail-title inline-edit-display inline-edit-display--title"
|
||||
onclick="startEdit('title')">@Model.Title</h2>
|
||||
<form class="inline-edit-form" style="display:none"
|
||||
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML">
|
||||
<input type="text" name="title" value="@Model.Title"
|
||||
class="inline-edit-input"
|
||||
onblur="this.form.requestSubmit()"
|
||||
onkeydown="if(event.key==='Escape'){cancelEdit('title');event.stopPropagation()}"
|
||||
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
|
||||
</form>
|
||||
</div>
|
||||
<button class="detail-close" onclick="closeDetailPanel()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status badge + Category -->
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 12px;">
|
||||
<span class="badge badge--@statusLabel.ToLower()">@statusLabel</span>
|
||||
|
||||
<div class="inline-edit" id="edit-category">
|
||||
@if (!string.IsNullOrEmpty(Model.Category))
|
||||
{
|
||||
<span class="inline-edit-display inline-edit-display--field"
|
||||
onclick="startEdit('category')"
|
||||
style="font-size: 12px; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 8px; height: 8px; border-radius: 50%; background: @catColor; flex-shrink: 0;"></span>
|
||||
@Model.Category
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="inline-edit-display inline-edit-display--field"
|
||||
onclick="startEdit('category')"
|
||||
style="font-size: 12px; color: var(--color-text-tertiary);">
|
||||
+ category
|
||||
</span>
|
||||
}
|
||||
<form class="inline-edit-form" style="display:none"
|
||||
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML">
|
||||
<input type="text" name="category" value="@(Model.Category ?? "")"
|
||||
class="inline-edit-input" placeholder="Category"
|
||||
onblur="this.form.requestSubmit()"
|
||||
onkeydown="if(event.key==='Escape'){cancelEdit('category');event.stopPropagation()}"
|
||||
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="detail-body">
|
||||
<!-- Description -->
|
||||
<div class="detail-section">
|
||||
<h3 class="detail-section-label">Description</h3>
|
||||
<div class="inline-edit" id="edit-description">
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<div class="inline-edit-display inline-edit-display--field"
|
||||
onclick="startEdit('description')"
|
||||
style="font-size: 13px; line-height: 1.6; white-space: pre-wrap;">@Model.Description</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="inline-edit-display inline-edit-display--field"
|
||||
onclick="startEdit('description')"
|
||||
style="font-size: 13px; color: var(--color-text-tertiary);">
|
||||
Click to add description...
|
||||
</div>
|
||||
}
|
||||
<form class="inline-edit-form" style="display:none"
|
||||
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML">
|
||||
<textarea name="description" rows="4"
|
||||
class="inline-edit-input"
|
||||
onkeydown="if(event.key==='Escape'){cancelEdit('description');event.stopPropagation()}"
|
||||
style="resize: vertical;">@(Model.Description ?? "")</textarea>
|
||||
<div style="display: flex; gap: 6px; margin-top: 6px;">
|
||||
<button type="submit" class="btn btn--sm btn--primary">Save</button>
|
||||
<button type="button" class="btn btn--sm btn--ghost" onclick="cancelEdit('description')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time section -->
|
||||
<div class="detail-section">
|
||||
<h3 class="detail-section-label">Time</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 13px;">
|
||||
<span style="color: var(--color-text-secondary);">Elapsed</span>
|
||||
<span style="color: var(--color-text-primary); font-variant-numeric: tabular-nums;">@elapsed</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
|
||||
<span style="color: var(--color-text-secondary);">Estimate</span>
|
||||
<div class="inline-edit" id="edit-estimate" style="text-align: right;">
|
||||
<span class="inline-edit-display inline-edit-display--field"
|
||||
onclick="startEdit('estimate')"
|
||||
style="color: var(--color-text-primary); font-variant-numeric: tabular-nums; padding: 2px 8px;">
|
||||
@(Model.EstimatedMinutes.HasValue ? $"{Model.EstimatedMinutes}m" : "--")
|
||||
</span>
|
||||
<form class="inline-edit-form" style="display:none"
|
||||
hx-put="/board?handler=UpdateTask&id=@Model.Id"
|
||||
hx-target="#detail-panel"
|
||||
hx-swap="innerHTML">
|
||||
<input type="number" name="estimatedMinutes"
|
||||
value="@(Model.EstimatedMinutes?.ToString() ?? "")"
|
||||
class="inline-edit-input" placeholder="minutes"
|
||||
style="width: 100px; text-align: right;"
|
||||
onblur="this.form.requestSubmit()"
|
||||
onkeydown="if(event.key==='Escape'){cancelEdit('estimate');event.stopPropagation()}"
|
||||
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (progressPercent.HasValue)
|
||||
{
|
||||
var isOver = progressPercent.Value >= 100;
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill @(isOver ? "progress-bar-fill--over" : "")"
|
||||
style="width: @progressPercent.Value.ToString("F0")%"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="detail-section">
|
||||
<partial name="Partials/_SubtaskList" model="Model" />
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="detail-section">
|
||||
<partial name="Partials/_NotesList" model="Model" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (sticky bottom) -->
|
||||
@if (!isTerminal)
|
||||
{
|
||||
<div class="detail-actions">
|
||||
@switch (Model.Status)
|
||||
{
|
||||
case WorkTaskStatus.Pending:
|
||||
<button hx-put="/board?handler=Start&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--primary btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Start
|
||||
</button>
|
||||
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--danger btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Abandon
|
||||
</button>
|
||||
break;
|
||||
|
||||
case WorkTaskStatus.Active:
|
||||
<button hx-put="/board?handler=Pause&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--amber btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Pause
|
||||
</button>
|
||||
<button hx-put="/board?handler=Complete&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--emerald btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Complete
|
||||
</button>
|
||||
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--danger btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Abandon
|
||||
</button>
|
||||
break;
|
||||
|
||||
case WorkTaskStatus.Paused:
|
||||
<button hx-put="/board?handler=Resume&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--primary btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Resume
|
||||
</button>
|
||||
<button hx-put="/board?handler=Complete&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--emerald btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Complete
|
||||
</button>
|
||||
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
|
||||
hx-target="#kanban-board"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn--danger btn--full"
|
||||
onclick="closeDetailPanel()">
|
||||
Abandon
|
||||
</button>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Open the detail panel when this partial is loaded
|
||||
(function() {
|
||||
var overlay = document.querySelector('.detail-overlay');
|
||||
var panel = document.querySelector('.detail-panel');
|
||||
// Use requestAnimationFrame to ensure the DOM is painted before adding classes
|
||||
requestAnimationFrame(function() {
|
||||
if (overlay) overlay.classList.add('detail-overlay--open');
|
||||
if (panel) panel.classList.add('detail-panel--open');
|
||||
});
|
||||
})();
|
||||
|
||||
function closeDetailPanel() {
|
||||
var overlay = document.querySelector('.detail-overlay');
|
||||
var panel = document.querySelector('.detail-panel');
|
||||
if (overlay) overlay.classList.remove('detail-overlay--open');
|
||||
if (panel) panel.classList.remove('detail-panel--open');
|
||||
// Clear the panel content after the transition
|
||||
setTimeout(function() {
|
||||
var container = document.getElementById('detail-panel');
|
||||
if (container) container.innerHTML = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function startEdit(field) {
|
||||
var wrapper = document.getElementById('edit-' + field);
|
||||
if (!wrapper) return;
|
||||
var display = wrapper.querySelector('.inline-edit-display');
|
||||
var form = wrapper.querySelector('.inline-edit-form');
|
||||
if (display) display.style.display = 'none';
|
||||
if (form) {
|
||||
form.style.display = '';
|
||||
var input = form.querySelector('input, textarea');
|
||||
if (input) {
|
||||
input.focus();
|
||||
if (input.type === 'text' || input.tagName === 'TEXTAREA') {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit(field) {
|
||||
var wrapper = document.getElementById('edit-' + field);
|
||||
if (!wrapper) return;
|
||||
var display = wrapper.querySelector('.inline-edit-display');
|
||||
var form = wrapper.querySelector('.inline-edit-form');
|
||||
if (display) display.style.display = '';
|
||||
if (form) form.style.display = 'none';
|
||||
}
|
||||
|
||||
// Close panel on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
var panel = document.querySelector('.detail-panel--open');
|
||||
if (panel) {
|
||||
closeDetailPanel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user