trekoon 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/docs/commands.md +9 -11
- package/docs/quickstart.md +10 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +469 -1377
- package/src/board/assets/components/ClampedText.js +1 -1
- package/src/board/assets/components/Component.js +271 -0
- package/src/board/assets/components/ConfirmDialog.js +81 -0
- package/src/board/assets/components/EpicRow.js +43 -26
- package/src/board/assets/components/EpicsOverview.js +52 -11
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +87 -0
- package/src/board/assets/components/SubtaskModal.js +100 -0
- package/src/board/assets/components/TaskCard.js +82 -0
- package/src/board/assets/components/TaskModal.js +99 -0
- package/src/board/assets/components/TopBar.js +167 -0
- package/src/board/assets/components/Workspace.js +319 -0
- package/src/board/assets/components/assetMap.js +29 -14
- package/src/board/assets/components/helpers.js +261 -0
- package/src/board/assets/fonts/inter-latin.woff2 +0 -0
- package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
- package/src/board/assets/index.html +20 -57
- package/src/board/assets/main.js +2 -18
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +342 -0
- package/src/board/assets/state/actions.js +204 -16
- package/src/board/assets/state/api.js +201 -46
- package/src/board/assets/state/store.js +418 -117
- package/src/board/assets/state/url.js +184 -0
- package/src/board/assets/state/utils.js +222 -0
- package/src/board/assets/styles/board.css +933 -129
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/routes.ts +15 -6
- package/src/board/server.ts +1 -0
- package/src/board/assets/components/AppShell.js +0 -17
- package/src/board/assets/components/BoardTopbar.js +0 -78
- package/src/board/assets/components/WorkspaceHeader.js +0 -70
- package/src/board/assets/utils/dom.js +0 -308
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { preserveFormState, preserveDetailsState } from "./Component.js";
|
|
2
|
+
import {
|
|
3
|
+
buttonClasses,
|
|
4
|
+
escapeHtml,
|
|
5
|
+
fieldClasses,
|
|
6
|
+
formatDate,
|
|
7
|
+
lookupNode,
|
|
8
|
+
neutralChipClasses,
|
|
9
|
+
readNodeLabel,
|
|
10
|
+
readStatusLabel,
|
|
11
|
+
renderDescriptionPreview,
|
|
12
|
+
renderDescriptionSection,
|
|
13
|
+
renderEmptyState,
|
|
14
|
+
renderStatusBadge,
|
|
15
|
+
renderStatusSelect,
|
|
16
|
+
secondaryPanelClasses,
|
|
17
|
+
sectionLabelClasses,
|
|
18
|
+
} from "./helpers.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Sub-render helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function renderDependencyOptions(task, snapshot) {
|
|
25
|
+
const existing = new Set(task.blockedBy);
|
|
26
|
+
return [
|
|
27
|
+
...snapshot.tasks.map((c) => ({ id: c.id, kind: "task", title: c.title })),
|
|
28
|
+
...snapshot.subtasks.map((c) => ({ id: c.id, kind: "subtask", title: c.title })),
|
|
29
|
+
]
|
|
30
|
+
.filter((c) => c.id !== task.id && !existing.has(c.id))
|
|
31
|
+
.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(readNodeLabel(c.kind, c.title))}</option>`)
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderDependencyItems(task, snapshot, isMutating, dependencyIds) {
|
|
36
|
+
if (dependencyIds.length === 0) {
|
|
37
|
+
return renderEmptyState("No dependencies", "Add blockers here to keep task transitions honest.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return dependencyIds.map((depId) => {
|
|
41
|
+
const dep = lookupNode(snapshot, depId);
|
|
42
|
+
return `
|
|
43
|
+
<article class="board-related-item grid gap-3 rounded-3xl border border-[var(--board-border)] bg-white/[0.03] px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
|
|
44
|
+
<div class="min-w-0">
|
|
45
|
+
<strong class="block text-sm font-semibold text-[var(--board-text)]">${escapeHtml(readNodeLabel(dep?.kind ?? "task", dep?.title ?? depId))}</strong>
|
|
46
|
+
${renderDescriptionPreview(dep?.description ?? "", "board-related-item__description mt-2 text-sm leading-6 text-[var(--board-text-muted)]")}
|
|
47
|
+
</div>
|
|
48
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
49
|
+
${renderStatusBadge(dep?.status ?? "todo", readStatusLabel(dep?.status ?? "Unknown"))}
|
|
50
|
+
<button type="button" class="${buttonClasses()}" data-remove-dependency-source="${escapeHtml(task.id)}" data-remove-dependency-target="${escapeHtml(depId)}" ${isMutating ? "disabled" : ""} aria-label="Remove dependency ${escapeHtml(dep?.title ?? depId)}">Remove</button>
|
|
51
|
+
</div>
|
|
52
|
+
</article>
|
|
53
|
+
`;
|
|
54
|
+
}).join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderDependencySection(task, snapshot, isMutating) {
|
|
58
|
+
const visible = task.blockedBy.slice(0, 3);
|
|
59
|
+
const hidden = task.blockedBy.slice(3);
|
|
60
|
+
return `
|
|
61
|
+
<details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${task.blockedBy.length <= 2 ? "open" : ""}>
|
|
62
|
+
<summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
|
|
63
|
+
<span>Dependencies</span>
|
|
64
|
+
<span class="${neutralChipClasses()}">${task.blockedBy.length}</span>
|
|
65
|
+
</summary>
|
|
66
|
+
<form class="mt-4 grid gap-4" data-form-id="task-dependency:${escapeHtml(task.id)}" data-dependency-form="${escapeHtml(task.id)}">
|
|
67
|
+
<label class="grid gap-2">
|
|
68
|
+
<span class="${sectionLabelClasses()}">Add dependency</span>
|
|
69
|
+
<select class="${fieldClasses()}" name="dependsOnId" data-control-id="dependency-target" required ${isMutating ? "disabled" : ""}>
|
|
70
|
+
<option value="">Select a task or subtask\u2026</option>
|
|
71
|
+
${renderDependencyOptions(task, snapshot)}
|
|
72
|
+
</select>
|
|
73
|
+
</label>
|
|
74
|
+
<div class="flex justify-end">
|
|
75
|
+
<button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Add dependency</button>
|
|
76
|
+
</div>
|
|
77
|
+
</form>
|
|
78
|
+
<div class="board-inline-list mt-4 space-y-3">
|
|
79
|
+
${renderDependencyItems(task, snapshot, isMutating, visible)}
|
|
80
|
+
</div>
|
|
81
|
+
${hidden.length > 0 ? `
|
|
82
|
+
<details class="board-disclosure board-detail-nested mt-4 ${secondaryPanelClasses("p-3")}">
|
|
83
|
+
<summary class="cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">Show ${hidden.length} more ${hidden.length === 1 ? "dependency" : "dependencies"}</summary>
|
|
84
|
+
<div class="board-inline-list mt-3 space-y-3">
|
|
85
|
+
${renderDependencyItems(task, snapshot, isMutating, hidden)}
|
|
86
|
+
</div>
|
|
87
|
+
</details>
|
|
88
|
+
` : ""}
|
|
89
|
+
</details>
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderSubtaskItems(subtasks) {
|
|
94
|
+
if (subtasks.length === 0) {
|
|
95
|
+
return renderEmptyState("No subtasks", "This task does not have subtasks in the current snapshot.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return `
|
|
99
|
+
<div class="space-y-3">
|
|
100
|
+
${subtasks.map((subtask) => `
|
|
101
|
+
<article class="board-related-item grid gap-3 rounded-3xl border border-[var(--board-border)] bg-white/[0.03] px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
|
|
102
|
+
<div class="min-w-0">
|
|
103
|
+
<strong class="block text-sm font-semibold text-[var(--board-text)]">${escapeHtml(subtask.title)}</strong>
|
|
104
|
+
${renderDescriptionPreview(subtask.description, "board-related-item__description mt-2 text-sm leading-6 text-[var(--board-text-muted)]")}
|
|
105
|
+
</div>
|
|
106
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
107
|
+
${renderStatusBadge(subtask.status)}
|
|
108
|
+
<button type="button" class="${buttonClasses()}" data-open-subtask="${escapeHtml(subtask.id)}" aria-label="Open subtask ${escapeHtml(subtask.title)}">Open</button>
|
|
109
|
+
<button type="button" class="${buttonClasses()}" data-delete-subtask="${escapeHtml(subtask.id)}" aria-label="Remove subtask ${escapeHtml(subtask.title)}">Remove</button>
|
|
110
|
+
</div>
|
|
111
|
+
</article>
|
|
112
|
+
`).join("")}
|
|
113
|
+
</div>
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderCreateSubtaskForm(task, isMutating) {
|
|
118
|
+
return `
|
|
119
|
+
<form class="grid gap-4 rounded-3xl border border-[var(--board-border)] bg-white/[0.03] p-4" data-form-id="task-create-subtask:${escapeHtml(task.id)}" data-create-subtask-form="${escapeHtml(task.id)}">
|
|
120
|
+
<div>
|
|
121
|
+
<span class="${sectionLabelClasses()}">Add subtask</span>
|
|
122
|
+
<p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">Create a new subtask directly from the task detail panel.</p>
|
|
123
|
+
</div>
|
|
124
|
+
<label class="grid gap-2">
|
|
125
|
+
<span class="${sectionLabelClasses()}">Title</span>
|
|
126
|
+
<input class="${fieldClasses()}" name="title" data-control-id="subtask-title" placeholder="Write tests\u2026" required ${isMutating ? "disabled" : ""} />
|
|
127
|
+
</label>
|
|
128
|
+
<label class="grid gap-2">
|
|
129
|
+
<span class="${sectionLabelClasses()}">Description</span>
|
|
130
|
+
<textarea class="${fieldClasses()} min-h-[96px]" name="description" data-control-id="subtask-description" rows="3" placeholder="Optional context for this subtask\u2026" ${isMutating ? "disabled" : ""}></textarea>
|
|
131
|
+
</label>
|
|
132
|
+
<label class="grid gap-2">
|
|
133
|
+
<span class="${sectionLabelClasses()}">Status</span>
|
|
134
|
+
${renderStatusSelect("status", "todo", isMutating)}
|
|
135
|
+
</label>
|
|
136
|
+
<div class="flex justify-end">
|
|
137
|
+
<button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Add subtask</button>
|
|
138
|
+
</div>
|
|
139
|
+
</form>
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderSubtaskSection(task, isMutating) {
|
|
144
|
+
const visible = task.subtasks.slice(0, 4);
|
|
145
|
+
const hidden = task.subtasks.slice(4);
|
|
146
|
+
const shouldOpen = task.subtasks.length <= 3;
|
|
147
|
+
return `
|
|
148
|
+
<details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${shouldOpen ? "open" : ""}>
|
|
149
|
+
<summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
|
|
150
|
+
<span>Subtasks</span>
|
|
151
|
+
<span class="${neutralChipClasses()}">${task.subtasks.length}</span>
|
|
152
|
+
</summary>
|
|
153
|
+
<div class="mt-4 space-y-4">
|
|
154
|
+
<details class="board-disclosure board-detail-nested ${secondaryPanelClasses("p-3")}" ${task.subtasks.length === 0 ? "open" : ""}>
|
|
155
|
+
<summary class="cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">Add subtask</summary>
|
|
156
|
+
<div class="mt-3">
|
|
157
|
+
${renderCreateSubtaskForm(task, isMutating)}
|
|
158
|
+
</div>
|
|
159
|
+
</details>
|
|
160
|
+
${renderSubtaskItems(visible)}
|
|
161
|
+
${hidden.length > 0 ? `
|
|
162
|
+
<details class="board-disclosure board-detail-nested ${secondaryPanelClasses("p-3")}">
|
|
163
|
+
<summary class="cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">Show ${hidden.length} more subtask${hidden.length === 1 ? "" : "s"}</summary>
|
|
164
|
+
<div class="mt-3">
|
|
165
|
+
${renderSubtaskItems(hidden)}
|
|
166
|
+
</div>
|
|
167
|
+
</details>
|
|
168
|
+
` : ""}
|
|
169
|
+
</div>
|
|
170
|
+
</details>
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Task surface (shared between Inspector drawer and TaskModal)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export function renderTaskSurface(props) {
|
|
179
|
+
const { task, epics, snapshot, isMutating = false, options = {} } = props;
|
|
180
|
+
const epic = epics.find((c) => c.id === task.epicId) ?? null;
|
|
181
|
+
const {
|
|
182
|
+
titleId = "",
|
|
183
|
+
closeLabel = "Close",
|
|
184
|
+
containerClassName = "board-detail-surface",
|
|
185
|
+
detailEyebrow = "Task detail",
|
|
186
|
+
scrollSurface = "inspector",
|
|
187
|
+
} = options;
|
|
188
|
+
|
|
189
|
+
return `
|
|
190
|
+
<div class="${containerClassName} grid h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden">
|
|
191
|
+
<header class="board-detail-surface__header board-drawer__header border-b border-[var(--board-border)] pb-5">
|
|
192
|
+
<div class="board-detail-surface__hero flex flex-col gap-4">
|
|
193
|
+
<div class="board-detail-surface__title-row flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
194
|
+
<div class="min-w-0">
|
|
195
|
+
<span class="${sectionLabelClasses()}">${escapeHtml(detailEyebrow)}</span>
|
|
196
|
+
<h3 ${titleId ? `id="${escapeHtml(titleId)}"` : ""} class="mt-2 text-2xl font-semibold tracking-tight text-[var(--board-text)]">${escapeHtml(task.title)}</h3>
|
|
197
|
+
</div>
|
|
198
|
+
<button type="button" class="${buttonClasses()} shrink-0" data-close-task aria-label="${escapeHtml(closeLabel)}">${escapeHtml(closeLabel)}</button>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="board-detail-surface__meta flex flex-wrap gap-2">
|
|
201
|
+
<span class="${neutralChipClasses()}">Epic ${escapeHtml(epic?.title ?? "Unknown")}</span>
|
|
202
|
+
${renderStatusBadge(task.status)}
|
|
203
|
+
<span class="${neutralChipClasses()}">${task.subtasks.length} subtask${task.subtasks.length === 1 ? "" : "s"}</span>
|
|
204
|
+
<span class="${neutralChipClasses()}">${task.blockedBy.length} blocker${task.blockedBy.length === 1 ? "" : "s"}</span>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</header>
|
|
208
|
+
<div class="board-detail-surface__body board-drawer__body min-h-0 overflow-y-auto overscroll-contain pt-5 pr-1" data-scroll-surface="${escapeHtml(scrollSurface)}">
|
|
209
|
+
<div class="board-detail-surface__stack space-y-4">
|
|
210
|
+
<section class="${secondaryPanelClasses("board-detail-card p-4")}">
|
|
211
|
+
<div class="board-detail-summary-grid grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
212
|
+
<div>
|
|
213
|
+
<p class="${sectionLabelClasses()}">Updated</p>
|
|
214
|
+
<p class="mt-2 text-sm font-medium text-[var(--board-text)]">${escapeHtml(formatDate(task.updatedAt))}</p>
|
|
215
|
+
</div>
|
|
216
|
+
<div>
|
|
217
|
+
<p class="${sectionLabelClasses()}">Dependencies</p>
|
|
218
|
+
<p class="mt-2 text-sm font-medium text-[var(--board-text)]">${task.blockedBy.length} blocking item${task.blockedBy.length === 1 ? "" : "s"}</p>
|
|
219
|
+
</div>
|
|
220
|
+
<div>
|
|
221
|
+
<p class="${sectionLabelClasses()}">Outgoing</p>
|
|
222
|
+
<p class="mt-2 text-sm font-medium text-[var(--board-text)]">${task.blocks.length} dependent item${task.blocks.length === 1 ? "" : "s"}</p>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
${renderDescriptionSection("Description", task.description, { open: false, compact: true })}
|
|
227
|
+
<details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" open>
|
|
228
|
+
<summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
|
|
229
|
+
<span>Edit task</span>
|
|
230
|
+
${renderStatusBadge(task.status)}
|
|
231
|
+
</summary>
|
|
232
|
+
<form class="mt-4 grid gap-4" data-form-id="task-edit:${escapeHtml(task.id)}" data-task-form="${escapeHtml(task.id)}">
|
|
233
|
+
<label class="grid gap-2">
|
|
234
|
+
<span class="${sectionLabelClasses()}">Title</span>
|
|
235
|
+
<input class="${fieldClasses()}" name="title" data-control-id="task-title" value="${escapeHtml(task.title)}" placeholder="Task title\u2026" required ${isMutating ? "disabled" : ""} />
|
|
236
|
+
</label>
|
|
237
|
+
<label class="grid gap-2">
|
|
238
|
+
<span class="${sectionLabelClasses()}">Description</span>
|
|
239
|
+
<textarea class="${fieldClasses()} min-h-[180px]" name="description" data-control-id="task-description" rows="7" placeholder="Task description\u2026" ${isMutating ? "disabled" : ""}>${escapeHtml(task.description)}</textarea>
|
|
240
|
+
</label>
|
|
241
|
+
<label class="grid gap-2">
|
|
242
|
+
<span class="${sectionLabelClasses()}">Status</span>
|
|
243
|
+
${renderStatusSelect("status", task.status, isMutating)}
|
|
244
|
+
</label>
|
|
245
|
+
<div class="board-detail-surface__actions flex flex-wrap justify-end gap-3">
|
|
246
|
+
<button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Save task</button>
|
|
247
|
+
</div>
|
|
248
|
+
</form>
|
|
249
|
+
</details>
|
|
250
|
+
${renderDependencySection(task, snapshot, isMutating)}
|
|
251
|
+
${renderSubtaskSection(task, isMutating)}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Inspector component — task detail drawer, preserves form state across updates.
|
|
260
|
+
*/
|
|
261
|
+
export function createInspector() {
|
|
262
|
+
let container = null;
|
|
263
|
+
let currentTaskId = null;
|
|
264
|
+
let previousTask = null;
|
|
265
|
+
|
|
266
|
+
function getResetFormIds(nextTask) {
|
|
267
|
+
if (!previousTask || previousTask.id !== nextTask.id) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const resetFormIds = [];
|
|
272
|
+
if (nextTask.subtasks.length > previousTask.subtasks.length) {
|
|
273
|
+
resetFormIds.push(`form:task-create-subtask:${nextTask.id}`);
|
|
274
|
+
}
|
|
275
|
+
if (nextTask.blockedBy.length > previousTask.blockedBy.length) {
|
|
276
|
+
resetFormIds.push(`form:task-dependency:${nextTask.id}`);
|
|
277
|
+
}
|
|
278
|
+
return resetFormIds;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
mount(el) {
|
|
283
|
+
container = el;
|
|
284
|
+
return this;
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @param {{ task: object|null, epics: object[], snapshot: object, isMutating: boolean } | null} props
|
|
289
|
+
*/
|
|
290
|
+
update(props) {
|
|
291
|
+
if (!container) return;
|
|
292
|
+
|
|
293
|
+
if (!props || !props.task) {
|
|
294
|
+
if (currentTaskId) {
|
|
295
|
+
container.innerHTML = "";
|
|
296
|
+
currentTaskId = null;
|
|
297
|
+
previousTask = null;
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { task } = props;
|
|
303
|
+
const surfaceOptions = {
|
|
304
|
+
closeLabel: "Close inspector",
|
|
305
|
+
containerClassName: "board-detail-surface board-detail-surface--inspector",
|
|
306
|
+
detailEyebrow: "Task inspector",
|
|
307
|
+
scrollSurface: "inspector",
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const resetFormIds = getResetFormIds(task);
|
|
311
|
+
|
|
312
|
+
if (currentTaskId === task.id) {
|
|
313
|
+
// Same task — preserve form state and details open/closed state
|
|
314
|
+
preserveDetailsState(container, () => {
|
|
315
|
+
preserveFormState(container, () => {
|
|
316
|
+
container.innerHTML = renderTaskSurface({ ...props, options: surfaceOptions });
|
|
317
|
+
}, { resetFormIds });
|
|
318
|
+
});
|
|
319
|
+
} else {
|
|
320
|
+
// Different task — full re-render
|
|
321
|
+
container.innerHTML = renderTaskSurface({ ...props, options: surfaceOptions });
|
|
322
|
+
currentTaskId = task.id;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
previousTask = task;
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
unmount() {
|
|
329
|
+
if (container) container.innerHTML = "";
|
|
330
|
+
container = null;
|
|
331
|
+
currentTaskId = null;
|
|
332
|
+
previousTask = null;
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeHtml,
|
|
3
|
+
renderCheckIcon,
|
|
4
|
+
renderIcon,
|
|
5
|
+
} from "./helpers.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Notice component — auto-dismiss after 4 s, aria-live polite.
|
|
9
|
+
*/
|
|
10
|
+
export function createNotice() {
|
|
11
|
+
let container = null;
|
|
12
|
+
let dismissTimer = null;
|
|
13
|
+
let lastNotice = null;
|
|
14
|
+
|
|
15
|
+
function clearTimer() {
|
|
16
|
+
if (dismissTimer !== null) {
|
|
17
|
+
clearTimeout(dismissTimer);
|
|
18
|
+
dismissTimer = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
mount(el) {
|
|
24
|
+
container = el;
|
|
25
|
+
return this;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ notice: { type: string, message: string, title?: string } | null, onDismiss?: () => void }} props
|
|
30
|
+
*/
|
|
31
|
+
update(props) {
|
|
32
|
+
if (!container) return;
|
|
33
|
+
const { notice, onDismiss } = props;
|
|
34
|
+
|
|
35
|
+
if (!notice) {
|
|
36
|
+
if (lastNotice) {
|
|
37
|
+
container.innerHTML = "";
|
|
38
|
+
lastNotice = null;
|
|
39
|
+
clearTimer();
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Same notice — skip
|
|
45
|
+
if (lastNotice && lastNotice.type === notice.type && lastNotice.message === notice.message) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const noticeTitle = typeof notice.title === "string" && notice.title.trim().length > 0
|
|
50
|
+
? notice.title.trim()
|
|
51
|
+
: notice.type === "error"
|
|
52
|
+
? "Action blocked"
|
|
53
|
+
: "Saved";
|
|
54
|
+
|
|
55
|
+
container.innerHTML = `
|
|
56
|
+
<div class="board-toast-region" role="presentation">
|
|
57
|
+
<section class="board-toast board-toast--${notice.type === "error" ? "error" : "success"}" role="${notice.type === "error" ? "alert" : "status"}" aria-live="${notice.type === "error" ? "assertive" : "polite"}" aria-atomic="true">
|
|
58
|
+
<div class="board-toast__icon ${notice.type === "error" ? "board-toast__icon--error" : "board-toast__icon--success"}">
|
|
59
|
+
${notice.type === "error" ? renderIcon("warning") : renderCheckIcon()}
|
|
60
|
+
</div>
|
|
61
|
+
<div class="board-toast__content">
|
|
62
|
+
<p class="board-toast__title" id="board-notice-title">${escapeHtml(noticeTitle)}</p>
|
|
63
|
+
<p class="board-toast__message">${escapeHtml(notice.message)}</p>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
</div>
|
|
67
|
+
`;
|
|
68
|
+
lastNotice = { type: notice.type, message: notice.message };
|
|
69
|
+
|
|
70
|
+
// Auto-dismiss after 4 s
|
|
71
|
+
clearTimer();
|
|
72
|
+
if (typeof onDismiss === "function") {
|
|
73
|
+
dismissTimer = setTimeout(() => {
|
|
74
|
+
onDismiss();
|
|
75
|
+
dismissTimer = null;
|
|
76
|
+
}, 4000);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
unmount() {
|
|
81
|
+
clearTimer();
|
|
82
|
+
if (container) container.innerHTML = "";
|
|
83
|
+
container = null;
|
|
84
|
+
lastNotice = null;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { preserveFormState } from "./Component.js";
|
|
2
|
+
import {
|
|
3
|
+
buttonClasses,
|
|
4
|
+
escapeHtml,
|
|
5
|
+
fieldClasses,
|
|
6
|
+
panelClasses,
|
|
7
|
+
renderStatusSelect,
|
|
8
|
+
sectionLabelClasses,
|
|
9
|
+
} from "./helpers.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render the subtask modal HTML.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} props
|
|
15
|
+
* @param {object} props.subtask
|
|
16
|
+
* @param {boolean} props.isMutating
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function render(props) {
|
|
20
|
+
const { subtask, isMutating = false } = props;
|
|
21
|
+
|
|
22
|
+
return `
|
|
23
|
+
<div class="board-modal-backdrop fixed inset-0 z-40 grid place-items-center bg-slate-950/70 p-4 backdrop-blur-md" data-close-subtask>
|
|
24
|
+
<section class="board-modal board-modal--sheet ${panelClasses("grid max-h-[calc(100dvh-2rem)] w-full grid-rows-[auto_1fr] overflow-hidden p-5 sm:p-6")}" role="dialog" aria-modal="true" aria-labelledby="board-subtask-modal-title" data-overlay-root tabindex="-1">
|
|
25
|
+
<header class="board-modal__header board-detail-surface__header border-b border-[var(--board-border)] pb-5">
|
|
26
|
+
<div>
|
|
27
|
+
<span class="${sectionLabelClasses()}">Subtask editor</span>
|
|
28
|
+
<h3 id="board-subtask-modal-title" class="mt-2 text-xl font-semibold tracking-tight text-[var(--board-text)]">${escapeHtml(subtask.title)}</h3>
|
|
29
|
+
</div>
|
|
30
|
+
<button type="button" class="${buttonClasses()} mt-4 sm:mt-0" data-close-subtask aria-label="Close subtask editor" data-overlay-initial-focus>Close</button>
|
|
31
|
+
</header>
|
|
32
|
+
<div class="board-modal__body board-detail-surface__body min-h-0 pt-5" data-scroll-surface="subtask-modal">
|
|
33
|
+
<form class="grid gap-4" data-subtask-form="${escapeHtml(subtask.id)}">
|
|
34
|
+
<label class="grid gap-2">
|
|
35
|
+
<span class="${sectionLabelClasses()}">Title</span>
|
|
36
|
+
<input class="${fieldClasses()}" name="title" value="${escapeHtml(subtask.title)}" placeholder="Subtask title\u2026" required ${isMutating ? "disabled" : ""} />
|
|
37
|
+
</label>
|
|
38
|
+
<label class="grid gap-2">
|
|
39
|
+
<span class="${sectionLabelClasses()}">Description</span>
|
|
40
|
+
<textarea class="${fieldClasses()} min-h-[144px]" name="description" rows="5" placeholder="Subtask description\u2026" ${isMutating ? "disabled" : ""}>${escapeHtml(subtask.description)}</textarea>
|
|
41
|
+
</label>
|
|
42
|
+
<label class="grid gap-2">
|
|
43
|
+
<span class="${sectionLabelClasses()}">Status</span>
|
|
44
|
+
${renderStatusSelect("status", subtask.status, isMutating)}
|
|
45
|
+
</label>
|
|
46
|
+
<div class="board-modal__actions mt-2 flex flex-wrap justify-end gap-3">
|
|
47
|
+
<button type="button" class="${buttonClasses()}" data-close-subtask aria-label="Cancel editing">Cancel</button>
|
|
48
|
+
<button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Save subtask</button>
|
|
49
|
+
</div>
|
|
50
|
+
</form>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
</div>
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* SubtaskModal component — preserves form state across updates.
|
|
59
|
+
*/
|
|
60
|
+
export function createSubtaskModal() {
|
|
61
|
+
let container = null;
|
|
62
|
+
let currentSubtaskId = null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
mount(el) {
|
|
66
|
+
container = el;
|
|
67
|
+
return this;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{ subtask: object|null, isMutating: boolean } | null} props
|
|
72
|
+
*/
|
|
73
|
+
update(props) {
|
|
74
|
+
if (!container) return;
|
|
75
|
+
|
|
76
|
+
if (!props || !props.subtask) {
|
|
77
|
+
if (currentSubtaskId) {
|
|
78
|
+
container.innerHTML = "";
|
|
79
|
+
currentSubtaskId = null;
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (currentSubtaskId === props.subtask.id) {
|
|
85
|
+
preserveFormState(container, () => {
|
|
86
|
+
container.innerHTML = render(props);
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
container.innerHTML = render(props);
|
|
90
|
+
currentSubtaskId = props.subtask.id;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
unmount() {
|
|
95
|
+
if (container) container.innerHTML = "";
|
|
96
|
+
container = null;
|
|
97
|
+
currentSubtaskId = null;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cx,
|
|
3
|
+
escapeHtml,
|
|
4
|
+
formatDate,
|
|
5
|
+
hasLongTaskTitle,
|
|
6
|
+
neutralChipClasses,
|
|
7
|
+
renderStatusBadge,
|
|
8
|
+
renderTaskMeta,
|
|
9
|
+
} from "./helpers.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a kanban task card.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} props
|
|
15
|
+
* @param {object} props.task
|
|
16
|
+
* @param {boolean} props.selected
|
|
17
|
+
* @param {boolean} props.isMutating
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function renderTaskCard(props) {
|
|
21
|
+
const { task, selected = false, isMutating = false } = props;
|
|
22
|
+
const longTitle = hasLongTaskTitle(task.title);
|
|
23
|
+
|
|
24
|
+
return `
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
class="board-task-card ${cx(
|
|
28
|
+
"w-full text-left rounded-[22px] border p-3.5 transition duration-200 lg:p-4",
|
|
29
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--board-border-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--board-surface)]",
|
|
30
|
+
selected
|
|
31
|
+
? "border-[var(--board-border-strong)] bg-[var(--board-accent-soft)] shadow-focus"
|
|
32
|
+
: "border-[var(--board-border)] bg-[var(--board-surface-2)] shadow-[0_10px_30px_rgba(0,0,0,0.18)] hover:-translate-y-0.5 hover:border-[var(--board-border-strong)] hover:shadow-lift",
|
|
33
|
+
)}"
|
|
34
|
+
draggable="${isMutating ? "false" : "true"}"
|
|
35
|
+
data-task-id="${escapeHtml(task.id)}"
|
|
36
|
+
data-draggable-task="true"
|
|
37
|
+
aria-pressed="${selected}"
|
|
38
|
+
aria-label="${escapeHtml(task.title)}"
|
|
39
|
+
>
|
|
40
|
+
<div class="board-task-card__header flex items-start justify-between gap-3">
|
|
41
|
+
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
|
42
|
+
${renderStatusBadge(task.status)}
|
|
43
|
+
<span class="board-task-card__eyebrow text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]">${escapeHtml(formatDate(task.updatedAt))}</span>
|
|
44
|
+
</div>
|
|
45
|
+
${longTitle ? `<span class="board-task-card__cue ${neutralChipClasses()}">Open for full title</span>` : ""}
|
|
46
|
+
</div>
|
|
47
|
+
<div class="board-task-card__body mt-3 grid gap-3">
|
|
48
|
+
<strong class="board-task-card__title block text-sm font-semibold leading-5 text-[var(--board-text)] sm:text-[0.95rem]">${escapeHtml(task.title)}</strong>
|
|
49
|
+
${task.description?.trim() ? `<p class="board-task-card__description text-sm leading-5 text-[var(--board-text-muted)] board-clamped-text__preview board-clamped-text__preview--2">${escapeHtml(task.description.trim())}</p>` : ""}
|
|
50
|
+
</div>
|
|
51
|
+
<div class="board-task-card__footer mt-3 flex flex-wrap items-center gap-2.5">${renderTaskMeta(task)}</div>
|
|
52
|
+
</button>
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* TaskCard component with mount/update/unmount lifecycle.
|
|
58
|
+
*/
|
|
59
|
+
export function createTaskCard() {
|
|
60
|
+
let container = null;
|
|
61
|
+
let lastHtml = null;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
mount(el) {
|
|
65
|
+
container = el;
|
|
66
|
+
return this;
|
|
67
|
+
},
|
|
68
|
+
update(props) {
|
|
69
|
+
if (!container) return;
|
|
70
|
+
const html = renderTaskCard(props);
|
|
71
|
+
if (html !== lastHtml) {
|
|
72
|
+
container.innerHTML = html;
|
|
73
|
+
lastHtml = html;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
unmount() {
|
|
77
|
+
if (container) container.innerHTML = "";
|
|
78
|
+
container = null;
|
|
79
|
+
lastHtml = null;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|