fix(web): address code review findings from Razor Pages migration
- Remove legacy wwwroot files (old index.html, app.css, api.js, page scripts) - Add Index page that redirects / to /board - Fix mapping delete button missing handler=Delete in URL - Add [IgnoreAntiforgeryToken] to AnalyticsModel for consistency - Remove duplicate JS from _TaskDetail.cshtml (already in app.js) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ using TaskTracker.Core.Interfaces;
|
|||||||
|
|
||||||
namespace TaskTracker.Api.Pages;
|
namespace TaskTracker.Api.Pages;
|
||||||
|
|
||||||
|
[IgnoreAntiforgeryToken]
|
||||||
public class AnalyticsModel : PageModel
|
public class AnalyticsModel : PageModel
|
||||||
{
|
{
|
||||||
private readonly ITaskRepository _taskRepo;
|
private readonly ITaskRepository _taskRepo;
|
||||||
|
|||||||
2
TaskTracker.Api/Pages/Index.cshtml
Normal file
2
TaskTracker.Api/Pages/Index.cshtml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@page
|
||||||
|
@model TaskTracker.Api.Pages.IndexModel
|
||||||
9
TaskTracker.Api/Pages/Index.cshtml.cs
Normal file
9
TaskTracker.Api/Pages/Index.cshtml.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace TaskTracker.Api.Pages;
|
||||||
|
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
public IActionResult OnGet() => RedirectToPage("/Board");
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--ghost btn--sm"
|
<button class="btn btn--ghost btn--sm"
|
||||||
hx-delete="/mappings?id=@Model.Id"
|
hx-delete="/mappings?handler=Delete&id=@Model.Id"
|
||||||
hx-target="#mapping-row-@Model.Id"
|
hx-target="#mapping-row-@Model.Id"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-confirm="Delete this mapping rule?"
|
hx-confirm="Delete this mapping rule?"
|
||||||
|
|||||||
@@ -259,65 +259,4 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Panel open/close, inline editing, and keyboard handling are in app.js -->
|
||||||
// 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>
|
|
||||||
|
|||||||
@@ -1,619 +0,0 @@
|
|||||||
/* ── Variables ── */
|
|
||||||
:root {
|
|
||||||
--bg-primary: #1a1b23;
|
|
||||||
--bg-secondary: #22232e;
|
|
||||||
--bg-card: #2a2b38;
|
|
||||||
--bg-input: #32333f;
|
|
||||||
--bg-hover: #353647;
|
|
||||||
--border: #3a3b4a;
|
|
||||||
--text-primary: #e4e4e8;
|
|
||||||
--text-secondary: #9d9db0;
|
|
||||||
--text-muted: #6b6b80;
|
|
||||||
--accent: #6c8cff;
|
|
||||||
--accent-hover: #8ba3ff;
|
|
||||||
--success: #4caf7c;
|
|
||||||
--warning: #e0a040;
|
|
||||||
--danger: #e05555;
|
|
||||||
--info: #50b0d0;
|
|
||||||
--sidebar-width: 220px;
|
|
||||||
--radius: 8px;
|
|
||||||
--radius-sm: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reset ── */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Layout ── */
|
|
||||||
#app {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar {
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-brand {
|
|
||||||
padding: 20px 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
list-style: none;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: var(--sidebar-width);
|
|
||||||
padding: 24px 32px;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page { animation: fadeIn 0.15s ease; }
|
|
||||||
.hidden { display: none !important; }
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Typography ── */
|
|
||||||
.page-title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cards ── */
|
|
||||||
.card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Stats Grid ── */
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status Badges ── */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-pending { background: #3a3b4a; color: var(--text-secondary); }
|
|
||||||
.badge-active { background: rgba(76, 175, 124, 0.2); color: var(--success); }
|
|
||||||
.badge-paused { background: rgba(224, 160, 64, 0.2); color: var(--warning); }
|
|
||||||
.badge-completed { background: rgba(108, 140, 255, 0.2); color: var(--accent); }
|
|
||||||
.badge-abandoned { background: rgba(224, 85, 85, 0.2); color: var(--danger); }
|
|
||||||
|
|
||||||
/* ── Buttons ── */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 7px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-input);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
|
|
||||||
|
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
|
||||||
|
|
||||||
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
|
|
||||||
.btn-success:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
.btn-warning { background: var(--warning); border-color: var(--warning); color: #1a1b23; }
|
|
||||||
.btn-warning:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
|
||||||
.btn-danger:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
|
|
||||||
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* ── Forms ── */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input, .form-select, .form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-textarea { resize: vertical; min-height: 60px; }
|
|
||||||
|
|
||||||
.form-inline {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-inline .form-group { margin-bottom: 0; flex: 1; }
|
|
||||||
|
|
||||||
/* ── Tables ── */
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover td { background: var(--bg-hover); }
|
|
||||||
|
|
||||||
/* ── Tabs / Filters ── */
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover { border-color: var(--text-muted); color: var(--text-primary); }
|
|
||||||
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
||||||
|
|
||||||
/* ── Active Task Card ── */
|
|
||||||
.active-task-card {
|
|
||||||
border-left: 3px solid var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-task-card .card-title {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-active-task {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Task List ── */
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item:hover { border-color: var(--accent); }
|
|
||||||
|
|
||||||
.task-item-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item-title {
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Task Detail ── */
|
|
||||||
.task-detail { margin-top: 16px; }
|
|
||||||
|
|
||||||
.task-detail-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-detail-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-meta-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-item {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-label {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-item {
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-item-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-type {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-time {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Context Summary ── */
|
|
||||||
.summary-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.summary-app {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-category {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-count {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
min-width: 50px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ── */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 24px;
|
|
||||||
min-width: 400px;
|
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
animation: modalIn 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Utilities ── */
|
|
||||||
.text-muted { color: var(--text-muted); }
|
|
||||||
.text-sm { font-size: 12px; }
|
|
||||||
.mt-8 { margin-top: 8px; }
|
|
||||||
.mt-16 { margin-top: 16px; }
|
|
||||||
.mb-8 { margin-bottom: 8px; }
|
|
||||||
.mb-16 { margin-bottom: 16px; }
|
|
||||||
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Breadcrumbs ── */
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-link:hover {
|
|
||||||
color: var(--accent-hover);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-sep {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-current {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-parent {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Subtasks ── */
|
|
||||||
.subtask-list {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtask-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtask-item:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtask-item-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtask-item-title {
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtask-item-title:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtask-count {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
|
||||||
::-webkit-scrollbar { width: 8px; }
|
|
||||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>TaskTracker</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<nav id="sidebar">
|
|
||||||
<div class="sidebar-brand">TaskTracker</div>
|
|
||||||
<ul class="nav-links">
|
|
||||||
<li><a href="#/" data-page="dashboard" class="nav-link active">
|
|
||||||
<span class="nav-icon">[D]</span> Dashboard
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#/tasks" data-page="tasks" class="nav-link">
|
|
||||||
<span class="nav-icon">[T]</span> Tasks
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#/context" data-page="context" class="nav-link">
|
|
||||||
<span class="nav-icon">[C]</span> Context
|
|
||||||
</a></li>
|
|
||||||
<li><a href="#/mappings" data-page="mappings" class="nav-link">
|
|
||||||
<span class="nav-icon">[M]</span> Mappings
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<main id="content">
|
|
||||||
<div id="page-dashboard" class="page"></div>
|
|
||||||
<div id="page-tasks" class="page hidden"></div>
|
|
||||||
<div id="page-context" class="page hidden"></div>
|
|
||||||
<div id="page-mappings" class="page hidden"></div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<div id="modal-overlay" class="modal-overlay hidden"></div>
|
|
||||||
<script type="module" src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
const BASE = '/api';
|
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
|
||||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (!json.success) throw new Error(json.error || 'Request failed');
|
|
||||||
return json.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tasks = {
|
|
||||||
list: (status, { parentId, includeSubTasks } = {}) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (status) params.set('status', status);
|
|
||||||
if (parentId != null) params.set('parentId', parentId);
|
|
||||||
if (includeSubTasks) params.set('includeSubTasks', 'true');
|
|
||||||
const qs = params.toString();
|
|
||||||
return request(`/tasks${qs ? `?${qs}` : ''}`);
|
|
||||||
},
|
|
||||||
subtasks: (parentId) => request(`/tasks?parentId=${parentId}`),
|
|
||||||
active: () => request('/tasks/active'),
|
|
||||||
get: (id) => request(`/tasks/${id}`),
|
|
||||||
create: (body) => request('/tasks', { method: 'POST', body: JSON.stringify(body) }),
|
|
||||||
start: (id) => request(`/tasks/${id}/start`, { method: 'PUT' }),
|
|
||||||
pause: (id, note) => request(`/tasks/${id}/pause`, { method: 'PUT', body: JSON.stringify({ note }) }),
|
|
||||||
resume: (id, note) => request(`/tasks/${id}/resume`, { method: 'PUT', body: JSON.stringify({ note }) }),
|
|
||||||
complete: (id) => request(`/tasks/${id}/complete`, { method: 'PUT' }),
|
|
||||||
abandon: (id) => request(`/tasks/${id}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const notes = {
|
|
||||||
list: (taskId) => request(`/tasks/${taskId}/notes`),
|
|
||||||
create: (taskId, body) => request(`/tasks/${taskId}/notes`, { method: 'POST', body: JSON.stringify(body) }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const context = {
|
|
||||||
recent: (minutes = 30) => request(`/context/recent?minutes=${minutes}`),
|
|
||||||
summary: () => request('/context/summary'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const mappings = {
|
|
||||||
list: () => request('/mappings'),
|
|
||||||
create: (body) => request('/mappings', { method: 'POST', body: JSON.stringify(body) }),
|
|
||||||
update: (id, body) => request(`/mappings/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
|
||||||
remove: (id) => request(`/mappings/${id}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const overlay = document.getElementById('modal-overlay');
|
|
||||||
|
|
||||||
export function showModal(title, contentHtml, actions = []) {
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-title">${title}</div>
|
|
||||||
<div class="modal-body">${contentHtml}</div>
|
|
||||||
<div class="modal-actions" id="modal-actions"></div>
|
|
||||||
</div>`;
|
|
||||||
const actionsEl = document.getElementById('modal-actions');
|
|
||||||
actions.forEach(({ label, cls, onClick }) => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = `btn ${cls || ''}`;
|
|
||||||
btn.textContent = label;
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
await onClick(overlay.querySelector('.modal'));
|
|
||||||
closeModal();
|
|
||||||
});
|
|
||||||
actionsEl.appendChild(btn);
|
|
||||||
});
|
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
overlay.addEventListener('click', onOverlayClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeModal() {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
overlay.innerHTML = '';
|
|
||||||
overlay.removeEventListener('click', onOverlayClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOverlayClick(e) {
|
|
||||||
if (e.target === overlay) closeModal();
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import * as api from '../api.js';
|
|
||||||
|
|
||||||
const el = () => document.getElementById('page-context');
|
|
||||||
|
|
||||||
export async function initContext() {
|
|
||||||
el().innerHTML = `
|
|
||||||
<h1 class="page-title">Context</h1>
|
|
||||||
<div class="section-title">App Summary (8 hours)</div>
|
|
||||||
<div id="ctx-summary" class="card mb-16"></div>
|
|
||||||
<div class="flex-between mb-8">
|
|
||||||
<div class="section-title" style="margin-bottom:0">Recent Events</div>
|
|
||||||
<select class="form-select" id="ctx-minutes" style="width:auto">
|
|
||||||
<option value="15">Last 15 min</option>
|
|
||||||
<option value="30" selected>Last 30 min</option>
|
|
||||||
<option value="60">Last hour</option>
|
|
||||||
<option value="120">Last 2 hours</option>
|
|
||||||
<option value="480">Last 8 hours</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="ctx-events" class="card table-wrap"></div>`;
|
|
||||||
|
|
||||||
document.getElementById('ctx-minutes').addEventListener('change', loadEvents);
|
|
||||||
await Promise.all([loadSummary(), loadEvents()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSummary() {
|
|
||||||
try {
|
|
||||||
const summary = await api.context.summary();
|
|
||||||
const container = document.getElementById('ctx-summary');
|
|
||||||
if (!summary || !summary.length) {
|
|
||||||
container.innerHTML = `<div class="empty-state">No activity recorded</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = `
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Application</th><th>Category</th><th>Events</th><th>First Seen</th><th>Last Seen</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${summary.map(s => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(s.appName)}</td>
|
|
||||||
<td>${esc(s.category)}</td>
|
|
||||||
<td>${s.eventCount}</td>
|
|
||||||
<td>${formatTime(s.firstSeen)}</td>
|
|
||||||
<td>${formatTime(s.lastSeen)}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>`;
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('ctx-summary').innerHTML = `<div class="empty-state">Failed to load summary</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEvents() {
|
|
||||||
const minutes = parseInt(document.getElementById('ctx-minutes').value);
|
|
||||||
try {
|
|
||||||
const events = await api.context.recent(minutes);
|
|
||||||
const container = document.getElementById('ctx-events');
|
|
||||||
if (!events || !events.length) {
|
|
||||||
container.innerHTML = `<div class="empty-state">No recent events</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = `
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Source</th><th>App</th><th>Window Title</th><th>URL</th><th>Time</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${events.map(e => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(e.source)}</td>
|
|
||||||
<td>${esc(e.appName)}</td>
|
|
||||||
<td class="truncate">${esc(e.windowTitle)}</td>
|
|
||||||
<td class="truncate">${e.url ? esc(e.url) : '-'}</td>
|
|
||||||
<td>${formatTime(e.timestamp)}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>`;
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('ctx-events').innerHTML = `<div class="empty-state">Failed to load events</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(iso) {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import * as api from '../api.js';
|
|
||||||
|
|
||||||
const el = () => document.getElementById('page-dashboard');
|
|
||||||
|
|
||||||
export function initDashboard() {
|
|
||||||
el().innerHTML = `
|
|
||||||
<h1 class="page-title">Dashboard</h1>
|
|
||||||
<div id="dash-active-task"></div>
|
|
||||||
<div class="section-title mt-16">Task Summary</div>
|
|
||||||
<div id="dash-stats" class="stats-grid"></div>
|
|
||||||
<div class="section-title mt-16">Recent Activity (8 hours)</div>
|
|
||||||
<div id="dash-context" class="card"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshDashboard() {
|
|
||||||
try {
|
|
||||||
const [active, allTasks, summary] = await Promise.all([
|
|
||||||
api.tasks.active(),
|
|
||||||
api.tasks.list(null, { includeSubTasks: true }),
|
|
||||||
api.context.summary(),
|
|
||||||
]);
|
|
||||||
await renderActiveTask(active);
|
|
||||||
renderStats(allTasks);
|
|
||||||
renderContextSummary(summary);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Dashboard refresh failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildParentTrail(task) {
|
|
||||||
const trail = [];
|
|
||||||
let current = task;
|
|
||||||
while (current.parentTaskId) {
|
|
||||||
try {
|
|
||||||
current = await api.tasks.get(current.parentTaskId);
|
|
||||||
trail.unshift({ id: current.id, title: current.title });
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trail;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderActiveTask(task) {
|
|
||||||
const container = document.getElementById('dash-active-task');
|
|
||||||
if (!task) {
|
|
||||||
container.innerHTML = `<div class="card"><div class="no-active-task">No active task</div></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentTrail = await buildParentTrail(task);
|
|
||||||
const breadcrumbHtml = parentTrail.length > 0
|
|
||||||
? `<div class="breadcrumb text-sm mt-8">${parentTrail.map(p => `<span class="breadcrumb-parent">${esc(p.title)}</span><span class="breadcrumb-sep">/</span>`).join('')}<span class="breadcrumb-current">${esc(task.title)}</span></div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const elapsed = task.startedAt ? formatElapsed(new Date(task.startedAt)) : '';
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="card active-task-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<div>
|
|
||||||
<div class="card-title">${esc(task.title)}</div>
|
|
||||||
${breadcrumbHtml}
|
|
||||||
${task.description ? `<div class="text-sm text-muted mt-8">${esc(task.description)}</div>` : ''}
|
|
||||||
${task.category ? `<div class="text-sm text-muted">Category: ${esc(task.category)}</div>` : ''}
|
|
||||||
${elapsed ? `<div class="text-sm text-muted">Active for ${elapsed}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-warning btn-sm" data-action="pause" data-id="${task.id}">Pause</button>
|
|
||||||
<button class="btn btn-success btn-sm" data-action="complete" data-id="${task.id}">Complete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
container.querySelectorAll('[data-action]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const action = btn.dataset.action;
|
|
||||||
const id = btn.dataset.id;
|
|
||||||
try {
|
|
||||||
if (action === 'pause') await api.tasks.pause(id);
|
|
||||||
else if (action === 'complete') await api.tasks.complete(id);
|
|
||||||
refreshDashboard();
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats(allTasks) {
|
|
||||||
const counts = { Pending: 0, Active: 0, Paused: 0, Completed: 0, Abandoned: 0 };
|
|
||||||
allTasks.forEach(t => counts[t.status] = (counts[t.status] || 0) + 1);
|
|
||||||
const container = document.getElementById('dash-stats');
|
|
||||||
container.innerHTML = Object.entries(counts).map(([status, count]) => `
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">${count}</div>
|
|
||||||
<div class="stat-label">${status}</div>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContextSummary(summary) {
|
|
||||||
const container = document.getElementById('dash-context');
|
|
||||||
if (!summary || summary.length === 0) {
|
|
||||||
container.innerHTML = `<div class="empty-state">No recent activity</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = summary.slice(0, 10).map(item => `
|
|
||||||
<div class="summary-item">
|
|
||||||
<div>
|
|
||||||
<div class="summary-app">${esc(item.appName)}</div>
|
|
||||||
<div class="summary-category">${esc(item.category)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-count">${item.eventCount}</div>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsed(since) {
|
|
||||||
const diff = Math.floor((Date.now() - since.getTime()) / 1000);
|
|
||||||
if (diff < 60) return `${diff}s`;
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
|
||||||
const h = Math.floor(diff / 3600);
|
|
||||||
const m = Math.floor((diff % 3600) / 60);
|
|
||||||
return `${h}h ${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import * as api from '../api.js';
|
|
||||||
import { showModal } from '../components/modal.js';
|
|
||||||
|
|
||||||
const el = () => document.getElementById('page-mappings');
|
|
||||||
|
|
||||||
export async function initMappings() {
|
|
||||||
el().innerHTML = `
|
|
||||||
<h1 class="page-title">App Mappings</h1>
|
|
||||||
<div class="flex-between mb-16">
|
|
||||||
<div class="text-muted text-sm">Map process names, window titles, or URLs to categories</div>
|
|
||||||
<button class="btn btn-primary" id="btn-new-mapping">+ New Mapping</button>
|
|
||||||
</div>
|
|
||||||
<div id="mapping-list" class="card table-wrap"></div>`;
|
|
||||||
|
|
||||||
document.getElementById('btn-new-mapping').addEventListener('click', () => showMappingForm());
|
|
||||||
await loadMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMappings() {
|
|
||||||
try {
|
|
||||||
const mappings = await api.mappings.list();
|
|
||||||
const container = document.getElementById('mapping-list');
|
|
||||||
if (!mappings || !mappings.length) {
|
|
||||||
container.innerHTML = `<div class="empty-state">No mappings configured</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = `
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Pattern</th><th>Match Type</th><th>Category</th><th>Friendly Name</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${mappings.map(m => `
|
|
||||||
<tr>
|
|
||||||
<td><code>${esc(m.pattern)}</code></td>
|
|
||||||
<td><span class="badge badge-pending">${m.matchType}</span></td>
|
|
||||||
<td>${esc(m.category)}</td>
|
|
||||||
<td>${esc(m.friendlyName) || '<span class="text-muted">-</span>'}</td>
|
|
||||||
<td>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-sm" data-edit="${m.id}">Edit</button>
|
|
||||||
<button class="btn btn-sm btn-danger" data-delete="${m.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>`;
|
|
||||||
container.querySelectorAll('[data-edit]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const m = mappings.find(x => x.id === parseInt(btn.dataset.edit));
|
|
||||||
if (m) showMappingForm(m);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
container.querySelectorAll('[data-delete]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => confirmDelete(parseInt(btn.dataset.delete)));
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('mapping-list').innerHTML = `<div class="empty-state">Failed to load mappings</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMappingForm(existing = null) {
|
|
||||||
const title = existing ? 'Edit Mapping' : 'New Mapping';
|
|
||||||
showModal(title, `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Pattern *</label>
|
|
||||||
<input type="text" class="form-input" id="map-pattern" value="${esc(existing?.pattern || '')}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Match Type *</label>
|
|
||||||
<select class="form-select" id="map-match-type">
|
|
||||||
<option value="ProcessName" ${existing?.matchType === 'ProcessName' ? 'selected' : ''}>Process Name</option>
|
|
||||||
<option value="TitleContains" ${existing?.matchType === 'TitleContains' ? 'selected' : ''}>Title Contains</option>
|
|
||||||
<option value="UrlContains" ${existing?.matchType === 'UrlContains' ? 'selected' : ''}>URL Contains</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Category *</label>
|
|
||||||
<input type="text" class="form-input" id="map-category" value="${esc(existing?.category || '')}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Friendly Name</label>
|
|
||||||
<input type="text" class="form-input" id="map-friendly" value="${esc(existing?.friendlyName || '')}">
|
|
||||||
</div>`,
|
|
||||||
[
|
|
||||||
{ label: 'Cancel', onClick: () => {} },
|
|
||||||
{
|
|
||||||
label: existing ? 'Save' : 'Create', cls: 'btn-primary', onClick: async (modal) => {
|
|
||||||
const pattern = modal.querySelector('#map-pattern').value.trim();
|
|
||||||
const matchType = modal.querySelector('#map-match-type').value;
|
|
||||||
const category = modal.querySelector('#map-category').value.trim();
|
|
||||||
const friendlyName = modal.querySelector('#map-friendly').value.trim() || null;
|
|
||||||
if (!pattern || !category) { alert('Pattern and Category are required'); throw new Error('cancel'); }
|
|
||||||
const body = { pattern, matchType, category, friendlyName };
|
|
||||||
if (existing) await api.mappings.update(existing.id, body);
|
|
||||||
else await api.mappings.create(body);
|
|
||||||
loadMappings();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setTimeout(() => document.getElementById('map-pattern')?.focus(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(id) {
|
|
||||||
showModal('Delete Mapping', `<p>Are you sure you want to delete this mapping?</p>`, [
|
|
||||||
{ label: 'Cancel', onClick: () => {} },
|
|
||||||
{
|
|
||||||
label: 'Delete', cls: 'btn-danger', onClick: async () => {
|
|
||||||
await api.mappings.remove(id);
|
|
||||||
loadMappings();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
import * as api from '../api.js';
|
|
||||||
import { showModal, closeModal } from '../components/modal.js';
|
|
||||||
|
|
||||||
const el = () => document.getElementById('page-tasks');
|
|
||||||
let currentFilter = null;
|
|
||||||
let selectedTaskId = null;
|
|
||||||
|
|
||||||
export function initTasks() {
|
|
||||||
el().innerHTML = `
|
|
||||||
<h1 class="page-title">Tasks</h1>
|
|
||||||
<div class="flex-between mb-16">
|
|
||||||
<div id="task-filters" class="filter-bar"></div>
|
|
||||||
<button class="btn btn-primary" id="btn-new-task">+ New Task</button>
|
|
||||||
</div>
|
|
||||||
<div id="task-list"></div>
|
|
||||||
<div id="task-detail" class="hidden"></div>`;
|
|
||||||
|
|
||||||
renderFilters();
|
|
||||||
document.getElementById('btn-new-task').addEventListener('click', () => showNewTaskModal());
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFilters() {
|
|
||||||
const statuses = [null, 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
|
|
||||||
const labels = ['All', 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
|
|
||||||
const container = document.getElementById('task-filters');
|
|
||||||
container.innerHTML = statuses.map((s, i) => `
|
|
||||||
<button class="filter-btn ${s === currentFilter ? 'active' : ''}" data-status="${s || ''}">${labels[i]}</button>`).join('');
|
|
||||||
container.querySelectorAll('.filter-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
currentFilter = btn.dataset.status || null;
|
|
||||||
renderFilters();
|
|
||||||
loadTasks();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTasks() {
|
|
||||||
try {
|
|
||||||
const tasks = await api.tasks.list(currentFilter);
|
|
||||||
renderTaskList(tasks);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('task-list').innerHTML = `<div class="empty-state">Failed to load tasks</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTaskList(tasks) {
|
|
||||||
const container = document.getElementById('task-list');
|
|
||||||
document.getElementById('task-detail').classList.add('hidden');
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
if (!tasks.length) {
|
|
||||||
container.innerHTML = `<div class="empty-state">No tasks found</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = tasks.map(t => {
|
|
||||||
const subCount = t.subTasks ? t.subTasks.length : 0;
|
|
||||||
return `
|
|
||||||
<div class="task-item" data-id="${t.id}">
|
|
||||||
<div class="task-item-left">
|
|
||||||
<span class="badge badge-${t.status.toLowerCase()}">${t.status}</span>
|
|
||||||
<span class="task-item-title">${esc(t.title)}</span>
|
|
||||||
${subCount > 0 ? `<span class="subtask-count">${subCount} subtask${subCount !== 1 ? 's' : ''}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="task-item-meta">${t.category ? esc(t.category) + ' · ' : ''}${formatDate(t.createdAt)}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
container.querySelectorAll('.task-item').forEach(item => {
|
|
||||||
item.addEventListener('click', () => showTaskDetail(parseInt(item.dataset.id)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildBreadcrumbs(task) {
|
|
||||||
const trail = [{ id: task.id, title: task.title }];
|
|
||||||
let current = task;
|
|
||||||
while (current.parentTaskId) {
|
|
||||||
try {
|
|
||||||
current = await api.tasks.get(current.parentTaskId);
|
|
||||||
trail.unshift({ id: current.id, title: current.title });
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trail;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showTaskDetail(id) {
|
|
||||||
selectedTaskId = id;
|
|
||||||
try {
|
|
||||||
const task = await api.tasks.get(id);
|
|
||||||
const container = document.getElementById('task-detail');
|
|
||||||
document.getElementById('task-list').classList.add('hidden');
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Build breadcrumb trail
|
|
||||||
const breadcrumbs = await buildBreadcrumbs(task);
|
|
||||||
const breadcrumbHtml = breadcrumbs.length > 1
|
|
||||||
? `<div class="breadcrumb">${breadcrumbs.map((b, i) =>
|
|
||||||
i < breadcrumbs.length - 1
|
|
||||||
? `<a href="#" class="breadcrumb-link" data-id="${b.id}">${esc(b.title)}</a><span class="breadcrumb-sep">/</span>`
|
|
||||||
: `<span class="breadcrumb-current">${esc(b.title)}</span>`
|
|
||||||
).join('')}</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<button class="btn btn-sm mb-16" id="btn-back-tasks">← ${task.parentTaskId ? 'Back to parent' : 'Back to list'}</button>
|
|
||||||
${breadcrumbHtml}
|
|
||||||
<div class="task-detail">
|
|
||||||
<div class="task-detail-header">
|
|
||||||
<div>
|
|
||||||
<div class="task-detail-title">${esc(task.title)}</div>
|
|
||||||
<span class="badge badge-${task.status.toLowerCase()}">${task.status}</span>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group" id="task-actions"></div>
|
|
||||||
</div>
|
|
||||||
${task.description ? `<p class="text-muted mb-16">${esc(task.description)}</p>` : ''}
|
|
||||||
<div class="task-meta-grid card">
|
|
||||||
<div class="meta-item"><div class="meta-label">Category</div>${esc(task.category) || 'None'}</div>
|
|
||||||
<div class="meta-item"><div class="meta-label">Created</div>${formatDateTime(task.createdAt)}</div>
|
|
||||||
<div class="meta-item"><div class="meta-label">Started</div>${task.startedAt ? formatDateTime(task.startedAt) : 'Not started'}</div>
|
|
||||||
<div class="meta-item"><div class="meta-label">Completed</div>${task.completedAt ? formatDateTime(task.completedAt) : '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="section-title mt-16">Subtasks</div>
|
|
||||||
<div id="task-subtasks" class="subtask-list"></div>
|
|
||||||
${task.status !== 'Completed' && task.status !== 'Abandoned' ? `<button class="btn btn-sm btn-primary mt-8" id="btn-add-subtask">+ Add Subtask</button>` : ''}
|
|
||||||
<div class="section-title mt-16">Notes</div>
|
|
||||||
<div id="task-notes"></div>
|
|
||||||
<div class="form-inline mt-8">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" class="form-input" id="note-input" placeholder="Add a note...">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" id="btn-add-note">Add</button>
|
|
||||||
</div>
|
|
||||||
${task.contextEvents && task.contextEvents.length ? `
|
|
||||||
<div class="section-title mt-16">Linked Context Events</div>
|
|
||||||
<div class="table-wrap card">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>App</th><th>Title</th><th>Time</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${task.contextEvents.slice(0, 50).map(e => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(e.appName)}</td>
|
|
||||||
<td class="truncate">${esc(e.windowTitle)}</td>
|
|
||||||
<td>${formatTime(e.timestamp)}</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Render action buttons based on status
|
|
||||||
renderActions(task);
|
|
||||||
|
|
||||||
// Render subtasks
|
|
||||||
renderSubTasks(task.subTasks || []);
|
|
||||||
|
|
||||||
// Render notes
|
|
||||||
renderNotes(task.notes || []);
|
|
||||||
|
|
||||||
// Breadcrumb navigation
|
|
||||||
container.querySelectorAll('.breadcrumb-link').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showTaskDetail(parseInt(link.dataset.id));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Back button
|
|
||||||
document.getElementById('btn-back-tasks').addEventListener('click', () => {
|
|
||||||
if (task.parentTaskId) {
|
|
||||||
showTaskDetail(task.parentTaskId);
|
|
||||||
} else {
|
|
||||||
container.classList.add('hidden');
|
|
||||||
document.getElementById('task-list').classList.remove('hidden');
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add subtask button
|
|
||||||
const addSubBtn = document.getElementById('btn-add-subtask');
|
|
||||||
if (addSubBtn) {
|
|
||||||
addSubBtn.addEventListener('click', () => showNewTaskModal(task.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add note
|
|
||||||
const noteInput = document.getElementById('note-input');
|
|
||||||
document.getElementById('btn-add-note').addEventListener('click', () => addNote(task.id, noteInput));
|
|
||||||
noteInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') addNote(task.id, noteInput);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
alert('Failed to load task: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSubTasks(subTasks) {
|
|
||||||
const container = document.getElementById('task-subtasks');
|
|
||||||
if (!subTasks.length) {
|
|
||||||
container.innerHTML = `<div class="text-muted text-sm">No subtasks</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = subTasks.map(st => {
|
|
||||||
const subCount = st.subTasks ? st.subTasks.length : 0;
|
|
||||||
const canStart = st.status === 'Pending' || st.status === 'Paused';
|
|
||||||
const canComplete = st.status === 'Active' || st.status === 'Paused';
|
|
||||||
return `
|
|
||||||
<div class="subtask-item" data-id="${st.id}">
|
|
||||||
<div class="subtask-item-left">
|
|
||||||
<span class="badge badge-${st.status.toLowerCase()}">${st.status}</span>
|
|
||||||
<a href="#" class="subtask-item-title" data-id="${st.id}">${esc(st.title)}</a>
|
|
||||||
${subCount > 0 ? `<span class="subtask-count">${subCount}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
${canStart ? `<button class="btn btn-sm btn-success subtask-action" data-action="start" data-id="${st.id}">${st.status === 'Paused' ? 'Resume' : 'Start'}</button>` : ''}
|
|
||||||
${canComplete ? `<button class="btn btn-sm btn-success subtask-action" data-action="complete" data-id="${st.id}">Complete</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Navigate to subtask detail
|
|
||||||
container.querySelectorAll('.subtask-item-title').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showTaskDetail(parseInt(link.dataset.id));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inline subtask actions
|
|
||||||
container.querySelectorAll('.subtask-action').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const action = btn.dataset.action;
|
|
||||||
const stId = parseInt(btn.dataset.id);
|
|
||||||
try {
|
|
||||||
if (action === 'start') await api.tasks.start(stId);
|
|
||||||
else if (action === 'complete') await api.tasks.complete(stId);
|
|
||||||
showTaskDetail(selectedTaskId);
|
|
||||||
} catch (err) { alert(err.message); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActions(task) {
|
|
||||||
const container = document.getElementById('task-actions');
|
|
||||||
const actions = [];
|
|
||||||
switch (task.status) {
|
|
||||||
case 'Pending':
|
|
||||||
actions.push({ label: 'Start', cls: 'btn-success', action: () => api.tasks.start(task.id) });
|
|
||||||
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
|
|
||||||
break;
|
|
||||||
case 'Active':
|
|
||||||
actions.push({ label: 'Pause', cls: 'btn-warning', action: () => api.tasks.pause(task.id) });
|
|
||||||
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
|
|
||||||
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
|
|
||||||
break;
|
|
||||||
case 'Paused':
|
|
||||||
actions.push({ label: 'Resume', cls: 'btn-success', action: () => api.tasks.resume(task.id) });
|
|
||||||
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
|
|
||||||
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
container.innerHTML = '';
|
|
||||||
actions.forEach(({ label, cls, action }) => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = `btn btn-sm ${cls}`;
|
|
||||||
btn.textContent = label;
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
showTaskDetail(task.id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
});
|
|
||||||
container.appendChild(btn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNotes(notes) {
|
|
||||||
const container = document.getElementById('task-notes');
|
|
||||||
if (!notes.length) {
|
|
||||||
container.innerHTML = `<div class="text-muted text-sm">No notes yet</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = notes.map(n => `
|
|
||||||
<div class="note-item">
|
|
||||||
<div class="note-item-header">
|
|
||||||
<span class="note-type">${n.type}</span>
|
|
||||||
<span class="note-time">${formatDateTime(n.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div>${esc(n.content)}</div>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addNote(taskId, input) {
|
|
||||||
const content = input.value.trim();
|
|
||||||
if (!content) return;
|
|
||||||
try {
|
|
||||||
await api.notes.create(taskId, { content, type: 'General' });
|
|
||||||
input.value = '';
|
|
||||||
showTaskDetail(taskId);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNewTaskModal(parentTaskId = null) {
|
|
||||||
const title = parentTaskId ? 'New Subtask' : 'New Task';
|
|
||||||
showModal(title, `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Title *</label>
|
|
||||||
<input type="text" class="form-input" id="new-task-title">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea class="form-textarea" id="new-task-desc"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Category</label>
|
|
||||||
<input type="text" class="form-input" id="new-task-cat">
|
|
||||||
</div>`,
|
|
||||||
[
|
|
||||||
{ label: 'Cancel', onClick: () => {} },
|
|
||||||
{
|
|
||||||
label: 'Create', cls: 'btn-primary', onClick: async (modal) => {
|
|
||||||
const taskTitle = modal.querySelector('#new-task-title').value.trim();
|
|
||||||
if (!taskTitle) { alert('Title is required'); throw new Error('cancel'); }
|
|
||||||
const description = modal.querySelector('#new-task-desc').value.trim() || null;
|
|
||||||
const category = modal.querySelector('#new-task-cat').value.trim() || null;
|
|
||||||
const body = { title: taskTitle, description, category };
|
|
||||||
if (parentTaskId) body.parentTaskId = parentTaskId;
|
|
||||||
await api.tasks.create(body);
|
|
||||||
if (parentTaskId) {
|
|
||||||
showTaskDetail(parentTaskId);
|
|
||||||
} else {
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setTimeout(() => document.getElementById('new-task-title')?.focus(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso) {
|
|
||||||
return new Date(iso).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso) {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(iso) {
|
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user