kanbanqube 1.0.12 → 1.0.13
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 +13 -1
- package/app.js +6 -2
- package/controllers/configController.js +2 -9
- package/controllers/userController.js +15 -0
- package/package.json +1 -1
- package/public/app.js +156 -2
- package/public/index.html +11 -1
- package/public/styles.css +176 -22
- package/routes/apiRoutes.js +3 -0
- package/services/userService.js +63 -0
- package/utils/userUtils.js +29 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
KanbanQube is a local-first Kanban board app for solo users and very small teams. A board lives as normal files in a regular vault folder on your machine and can optionally be placed inside a Git repository, so changes can be versioned and synced with tools you already use.
|
|
11
11
|
|
|
12
|
-
The app provides lanes, cards, labels, checklists, comments, card covers, file attachments, archived cards, search, and a card-detail view. Uploaded files are stored in an `uploads/` folder, while board data is split into per-object JSON files under `board/` so Git can merge independent card and checklist edits more cleanly.
|
|
12
|
+
The app provides lanes, cards, labels, checklists, comments, due dates, assignees, card covers, file attachments, archived cards, search, keyboard navigation, and a card-detail view. Uploaded files are stored in an `uploads/` folder, while board data is split into per-object JSON files under `board/` so Git can merge independent card and checklist edits more cleanly.
|
|
13
13
|
|
|
14
14
|

|
|
15
15
|
|
|
@@ -26,6 +26,7 @@ The app provides lanes, cards, labels, checklists, comments, card covers, file a
|
|
|
26
26
|
- [Trello Import](#trello-import)
|
|
27
27
|
- [Demo Board](#demo-board)
|
|
28
28
|
- [Identity](#identity)
|
|
29
|
+
- [Assignees And Due Dates](#assignees-and-due-dates)
|
|
29
30
|
- [Attachments And Covers](#attachments-and-covers)
|
|
30
31
|
- [Keyboard Shortcuts](#keyboard-shortcuts)
|
|
31
32
|
- [Development](#development)
|
|
@@ -144,6 +145,7 @@ Use the board view for quick work:
|
|
|
144
145
|
- Click the board title or lane title to rename it inline.
|
|
145
146
|
- Click a card to open its details.
|
|
146
147
|
- Settings can enable inline editing for existing card titles in board lanes.
|
|
148
|
+
- Set due dates and assignees in card details. Cards show due dates and assigned user avatars directly in the board view.
|
|
147
149
|
|
|
148
150
|
## Git Sync
|
|
149
151
|
|
|
@@ -210,6 +212,16 @@ git config --global user.email
|
|
|
210
212
|
|
|
211
213
|
That name is used for comments and activity entries. The settings dialog shows the detected name and email as read-only values.
|
|
212
214
|
|
|
215
|
+
KanbanQube also reads commit authors from the vault Git repository and makes them available as assignable users. Git can usually provide the author name and email address from existing commits. If the vault has no Git history, KanbanQube still includes your own detected Git identity.
|
|
216
|
+
|
|
217
|
+
User avatars are loaded from Gravatar based on email address. If no Gravatar image exists, the app falls back to initials.
|
|
218
|
+
|
|
219
|
+
## Assignees And Due Dates
|
|
220
|
+
|
|
221
|
+
Open a card to assign users and set a due date. Assigned users are shown as small avatars on the card in board view, with their name available on hover.
|
|
222
|
+
|
|
223
|
+
Due dates are stored on the card and shown below the card title in board view. Completed cards keep the due date visible in a completed state, while overdue open cards are highlighted.
|
|
224
|
+
|
|
213
225
|
## Attachments And Covers
|
|
214
226
|
|
|
215
227
|
Drop files onto a card or into the card details view to attach them. Files are uploaded into the vault `uploads/` folder.
|
package/app.js
CHANGED
|
@@ -10,6 +10,7 @@ const { createBoardService } = require("./services/boardService");
|
|
|
10
10
|
const { createGitSyncService } = require("./services/gitSyncService");
|
|
11
11
|
const { createImportService } = require("./services/importService");
|
|
12
12
|
const { createUploadService } = require("./services/uploadService");
|
|
13
|
+
const { createUserService } = require("./services/userService");
|
|
13
14
|
|
|
14
15
|
function createApp(config) {
|
|
15
16
|
const app = express();
|
|
@@ -17,6 +18,7 @@ function createApp(config) {
|
|
|
17
18
|
const gitSyncService = createGitSyncService(config);
|
|
18
19
|
const importService = createImportService(config);
|
|
19
20
|
const uploadService = createUploadService(config, boardService);
|
|
21
|
+
const userService = createUserService(config);
|
|
20
22
|
|
|
21
23
|
app.disable("x-powered-by");
|
|
22
24
|
app.use(express.json({ limit: "5mb", type: "application/json" }));
|
|
@@ -27,7 +29,8 @@ function createApp(config) {
|
|
|
27
29
|
boardService,
|
|
28
30
|
gitSyncService,
|
|
29
31
|
importService,
|
|
30
|
-
uploadService
|
|
32
|
+
uploadService,
|
|
33
|
+
userService
|
|
31
34
|
}));
|
|
32
35
|
|
|
33
36
|
app.get(`/${config.demoBoardFileName}`, (_request, response) => {
|
|
@@ -62,7 +65,8 @@ function createApp(config) {
|
|
|
62
65
|
boardService,
|
|
63
66
|
gitSyncService,
|
|
64
67
|
importService,
|
|
65
|
-
uploadService
|
|
68
|
+
uploadService,
|
|
69
|
+
userService
|
|
66
70
|
}
|
|
67
71
|
};
|
|
68
72
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { gravatarUrlForEmail } = require("../utils/userUtils");
|
|
4
4
|
|
|
5
5
|
function createConfigController(config, gitService) {
|
|
6
6
|
async function getConfig(_request, response) {
|
|
@@ -22,13 +22,6 @@ function createConfigController(config, gitService) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function gravatarUrlForEmail(email) {
|
|
26
|
-
if (typeof email !== "string" || !email.trim()) return "";
|
|
27
|
-
const hash = crypto.createHash("md5").update(email.trim().toLowerCase()).digest("hex");
|
|
28
|
-
return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
module.exports = {
|
|
32
|
-
createConfigController
|
|
33
|
-
gravatarUrlForEmail
|
|
26
|
+
createConfigController
|
|
34
27
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function createUserController(userService) {
|
|
4
|
+
async function getUsers(_request, response) {
|
|
5
|
+
response.json({ users: await userService.listUsers() });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
getUsers
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
createUserController
|
|
15
|
+
};
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -24,6 +24,7 @@ const SYNC_TIMESTAMP_FORMAT = {
|
|
|
24
24
|
const state = {
|
|
25
25
|
board: null,
|
|
26
26
|
config: null,
|
|
27
|
+
users: [],
|
|
27
28
|
currentUserName: localStorage.getItem(USER_STORAGE_KEY) || "",
|
|
28
29
|
currentUserEmail: localStorage.getItem(USER_EMAIL_STORAGE_KEY) || "",
|
|
29
30
|
showCardDescriptions: localStorage.getItem(SHOW_CARD_DESCRIPTIONS_STORAGE_KEY) === "true",
|
|
@@ -81,6 +82,7 @@ const deleteAllArchivedButton = document.getElementById("deleteAllArchivedButton
|
|
|
81
82
|
|
|
82
83
|
const cardDialog = document.getElementById("cardDialog");
|
|
83
84
|
const cardTitleInput = document.getElementById("cardTitleInput");
|
|
85
|
+
const cardDueInput = document.getElementById("cardDueInput");
|
|
84
86
|
const archivedCardBanner = document.getElementById("archivedCardBanner");
|
|
85
87
|
const cardDetailsCover = document.getElementById("cardDetailsCover");
|
|
86
88
|
const removeCoverButton = document.getElementById("removeCoverButton");
|
|
@@ -92,6 +94,7 @@ const attachmentDropZone = document.getElementById("attachmentDropZone");
|
|
|
92
94
|
const attachmentsContainer = document.getElementById("attachmentsContainer");
|
|
93
95
|
const editDescriptionButton = document.getElementById("editDescriptionButton");
|
|
94
96
|
const checklistsContainer = document.getElementById("checklistsContainer");
|
|
97
|
+
const assigneesContainer = document.getElementById("assigneesContainer");
|
|
95
98
|
const cardLabels = document.getElementById("cardLabels");
|
|
96
99
|
const labelEditorContainer = document.getElementById("labelEditorContainer");
|
|
97
100
|
const activityList = document.getElementById("activityList");
|
|
@@ -169,9 +172,10 @@ try {
|
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
async function bootstrap() {
|
|
172
|
-
const [boardResponse, configResponse] = await Promise.all([
|
|
175
|
+
const [boardResponse, configResponse, usersResponse] = await Promise.all([
|
|
173
176
|
fetch("/api/board"),
|
|
174
|
-
fetch("/api/config")
|
|
177
|
+
fetch("/api/config"),
|
|
178
|
+
fetch("/api/users")
|
|
175
179
|
]);
|
|
176
180
|
|
|
177
181
|
if (!boardResponse.ok) {
|
|
@@ -180,6 +184,7 @@ async function bootstrap() {
|
|
|
180
184
|
|
|
181
185
|
state.board = await boardResponse.json();
|
|
182
186
|
state.config = configResponse.ok ? await configResponse.json() : { boardFile: "board.json", gitRemote: null, gitUserName: null, gitUserEmail: null };
|
|
187
|
+
state.users = usersResponse.ok ? (await usersResponse.json()).users || [] : [];
|
|
183
188
|
hydrateIdentityFromGitConfig();
|
|
184
189
|
applyIconStyle();
|
|
185
190
|
settingsBoardFile.textContent = `Storage: ${state.config.workspacePath || "current folder"}`;
|
|
@@ -319,6 +324,7 @@ function wireEvents() {
|
|
|
319
324
|
renderBoard();
|
|
320
325
|
renderCardDialog();
|
|
321
326
|
});
|
|
327
|
+
cardDueInput.addEventListener("change", updateSelectedCardDueDate);
|
|
322
328
|
|
|
323
329
|
promptCancelButton.addEventListener("click", () => promptDialog.close("cancel"));
|
|
324
330
|
appDialogCloseButton.addEventListener("click", () => appDialog.close("cancel"));
|
|
@@ -570,6 +576,7 @@ function renderCard(card, options = {}) {
|
|
|
570
576
|
for (const badge of buildCardBadges(card)) {
|
|
571
577
|
const badgeNode = document.createElement("span");
|
|
572
578
|
badgeNode.className = "badge";
|
|
579
|
+
if (badge.className) badgeNode.classList.add(badge.className);
|
|
573
580
|
if (badge.symbol) {
|
|
574
581
|
const symbol = document.createElement("span");
|
|
575
582
|
symbol.className = "badge-symbol";
|
|
@@ -585,6 +592,7 @@ function renderCard(card, options = {}) {
|
|
|
585
592
|
}
|
|
586
593
|
footer.append(badgeNode);
|
|
587
594
|
}
|
|
595
|
+
renderBoardCardAssignees(card, footer);
|
|
588
596
|
|
|
589
597
|
node.addEventListener("click", () => {
|
|
590
598
|
setKeyboardCard(card.id, true);
|
|
@@ -615,6 +623,7 @@ function renderCardDialog() {
|
|
|
615
623
|
if (!card) return;
|
|
616
624
|
|
|
617
625
|
cardTitleInput.value = card.name || "";
|
|
626
|
+
cardDueInput.value = dateInputValue(card.due);
|
|
618
627
|
archivedCardBanner.hidden = !isCardArchived(card);
|
|
619
628
|
const coverUrl = coverUrlForCard(card);
|
|
620
629
|
cardDetailsCover.hidden = !coverUrl;
|
|
@@ -628,12 +637,121 @@ function renderCardDialog() {
|
|
|
628
637
|
editDescriptionButton.textContent = state.descriptionEditing ? "Done" : "Edit";
|
|
629
638
|
archiveCardButton.hidden = isCardArchived(card);
|
|
630
639
|
|
|
640
|
+
renderAssignees(card);
|
|
631
641
|
renderLabelsEditor(card);
|
|
632
642
|
renderAttachments(card);
|
|
633
643
|
renderChecklists(card);
|
|
634
644
|
renderActivity(card);
|
|
635
645
|
}
|
|
636
646
|
|
|
647
|
+
function renderBoardCardAssignees(card, footer) {
|
|
648
|
+
const users = assignedUsersForCard(card).slice(0, 4);
|
|
649
|
+
if (users.length === 0) return;
|
|
650
|
+
|
|
651
|
+
const list = document.createElement("div");
|
|
652
|
+
list.className = "card-assignee-list";
|
|
653
|
+
for (const user of users) {
|
|
654
|
+
list.append(createUserAvatar(user, "small-user-avatar"));
|
|
655
|
+
}
|
|
656
|
+
footer.append(list);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function updateSelectedCardDueDate() {
|
|
660
|
+
const card = getSelectedCard();
|
|
661
|
+
if (!card) return;
|
|
662
|
+
|
|
663
|
+
const previousDue = card.due ?? null;
|
|
664
|
+
card.due = cardDueInput.value ? `${cardDueInput.value}T12:00:00.000Z` : null;
|
|
665
|
+
card.dueComplete = card.due ? Boolean(card.dueComplete) : false;
|
|
666
|
+
if (previousDue === card.due) return;
|
|
667
|
+
|
|
668
|
+
touchCard(card);
|
|
669
|
+
pushAction("updateCard", {
|
|
670
|
+
idCard: card.id,
|
|
671
|
+
card: cardActionSnapshot(card),
|
|
672
|
+
list: { id: card.idList, name: listById(card.idList)?.name || "" },
|
|
673
|
+
old: { due: previousDue },
|
|
674
|
+
due: card.due
|
|
675
|
+
});
|
|
676
|
+
queueSave(card.due ? "Due date updated" : "Due date removed");
|
|
677
|
+
renderBoard();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function renderAssignees(card) {
|
|
681
|
+
assigneesContainer.textContent = "";
|
|
682
|
+
if (state.users.length === 0) {
|
|
683
|
+
const empty = document.createElement("div");
|
|
684
|
+
empty.className = "empty-state";
|
|
685
|
+
empty.textContent = "No Git users found yet.";
|
|
686
|
+
assigneesContainer.append(empty);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
card.kanbanQubeAssignees = Array.isArray(card.kanbanQubeAssignees) ? card.kanbanQubeAssignees : [];
|
|
691
|
+
for (const user of state.users) {
|
|
692
|
+
assigneesContainer.append(createAssigneeRow(card, user));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function createAssigneeRow(card, user) {
|
|
697
|
+
const row = document.createElement("label");
|
|
698
|
+
row.className = "assignee-row";
|
|
699
|
+
|
|
700
|
+
const checkbox = document.createElement("input");
|
|
701
|
+
checkbox.type = "checkbox";
|
|
702
|
+
checkbox.checked = card.kanbanQubeAssignees.includes(user.id);
|
|
703
|
+
checkbox.addEventListener("change", () => {
|
|
704
|
+
toggleCardAssignee(card, user.id, checkbox.checked);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const text = document.createElement("span");
|
|
708
|
+
text.className = "assignee-name";
|
|
709
|
+
text.textContent = user.email ? `${user.name || user.email} (${user.email})` : user.name || "Unknown user";
|
|
710
|
+
|
|
711
|
+
row.append(checkbox, createUserAvatar(user, "assignee-avatar"), text);
|
|
712
|
+
return row;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function toggleCardAssignee(card, userId, shouldAssign) {
|
|
716
|
+
const previousAssignees = Array.isArray(card.kanbanQubeAssignees) ? card.kanbanQubeAssignees : [];
|
|
717
|
+
card.kanbanQubeAssignees = shouldAssign
|
|
718
|
+
? [...new Set([...previousAssignees, userId])]
|
|
719
|
+
: previousAssignees.filter((candidate) => candidate !== userId);
|
|
720
|
+
touchCard(card);
|
|
721
|
+
pushAction("updateCard", {
|
|
722
|
+
idCard: card.id,
|
|
723
|
+
card: cardActionSnapshot(card),
|
|
724
|
+
list: { id: card.idList, name: listById(card.idList)?.name || "" },
|
|
725
|
+
old: { kanbanQubeAssignees: previousAssignees },
|
|
726
|
+
kanbanQubeAssignees: card.kanbanQubeAssignees
|
|
727
|
+
});
|
|
728
|
+
queueSave("Assignees updated");
|
|
729
|
+
renderBoard();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function createUserAvatar(user, className) {
|
|
733
|
+
const avatar = document.createElement("span");
|
|
734
|
+
avatar.className = className;
|
|
735
|
+
avatar.dataset.tooltip = user.email ? `${user.name} <${user.email}>` : user.name;
|
|
736
|
+
|
|
737
|
+
const image = document.createElement("img");
|
|
738
|
+
image.alt = "";
|
|
739
|
+
image.hidden = !user.avatarUrl;
|
|
740
|
+
if (user.avatarUrl) image.src = user.avatarUrl;
|
|
741
|
+
|
|
742
|
+
const fallback = document.createElement("span");
|
|
743
|
+
fallback.textContent = user.initials || initialsFor(user.name || user.email || "User");
|
|
744
|
+
fallback.hidden = Boolean(user.avatarUrl);
|
|
745
|
+
|
|
746
|
+
image.addEventListener("error", () => {
|
|
747
|
+
image.hidden = true;
|
|
748
|
+
fallback.hidden = false;
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
avatar.append(image, fallback);
|
|
752
|
+
return avatar;
|
|
753
|
+
}
|
|
754
|
+
|
|
637
755
|
function renderArchiveDialog() {
|
|
638
756
|
archiveList.textContent = "";
|
|
639
757
|
const cards = archivedCards();
|
|
@@ -2377,6 +2495,7 @@ async function reloadBoardAfterSync() {
|
|
|
2377
2495
|
}
|
|
2378
2496
|
|
|
2379
2497
|
state.board = await response.json();
|
|
2498
|
+
state.users = await loadUsers();
|
|
2380
2499
|
state.editingBoardTitle = false;
|
|
2381
2500
|
state.editingBoardTitleValue = "";
|
|
2382
2501
|
state.editingLaneTitleId = null;
|
|
@@ -2393,6 +2512,13 @@ async function reloadBoardAfterSync() {
|
|
|
2393
2512
|
render();
|
|
2394
2513
|
}
|
|
2395
2514
|
|
|
2515
|
+
async function loadUsers() {
|
|
2516
|
+
const response = await fetch("/api/users");
|
|
2517
|
+
if (!response.ok) return state.users || [];
|
|
2518
|
+
const payload = await response.json();
|
|
2519
|
+
return Array.isArray(payload.users) ? payload.users : [];
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2396
2522
|
async function saveBoardNow() {
|
|
2397
2523
|
if (!state.board) return;
|
|
2398
2524
|
state.isSaving = true;
|
|
@@ -2494,6 +2620,13 @@ function labelsForCard(card) {
|
|
|
2494
2620
|
return (card.idLabels || []).map((labelId) => labels.get(labelId)).filter(Boolean);
|
|
2495
2621
|
}
|
|
2496
2622
|
|
|
2623
|
+
function assignedUsersForCard(card) {
|
|
2624
|
+
const users = new Map((state.users || []).map((user) => [user.id, user]));
|
|
2625
|
+
return (Array.isArray(card.kanbanQubeAssignees) ? card.kanbanQubeAssignees : [])
|
|
2626
|
+
.map((userId) => users.get(userId))
|
|
2627
|
+
.filter(Boolean);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2497
2630
|
function checklistsForCard(cardId) {
|
|
2498
2631
|
return (state.board.checklists || []).filter((checklist) => checklist.idCard === cardId);
|
|
2499
2632
|
}
|
|
@@ -2545,6 +2678,25 @@ function isCardArchived(card) {
|
|
|
2545
2678
|
return Boolean(card?.closed);
|
|
2546
2679
|
}
|
|
2547
2680
|
|
|
2681
|
+
function dateInputValue(value) {
|
|
2682
|
+
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : "";
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
function formatDueDate(value) {
|
|
2686
|
+
const input = dateInputValue(value);
|
|
2687
|
+
if (!input) return "";
|
|
2688
|
+
const date = new Date(`${input}T12:00:00`);
|
|
2689
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function dueBadgeClass(card) {
|
|
2693
|
+
if (card.dueComplete) return "badge-due-complete";
|
|
2694
|
+
const input = dateInputValue(card.due);
|
|
2695
|
+
if (!input) return "";
|
|
2696
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2697
|
+
return input < today ? "badge-due-overdue" : "";
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2548
2700
|
function archivedCards() {
|
|
2549
2701
|
return [...(state.board?.cards || [])]
|
|
2550
2702
|
.filter((card) => isCardArchived(card))
|
|
@@ -2730,6 +2882,7 @@ function cardElementAfter(container, y) {
|
|
|
2730
2882
|
|
|
2731
2883
|
function buildCardBadges(card) {
|
|
2732
2884
|
const badges = [];
|
|
2885
|
+
if (card.due) badges.push({ icon: "clock", text: formatDueDate(card.due), className: dueBadgeClass(card) });
|
|
2733
2886
|
if (card.badges?.description) badges.push({ icon: "description", text: "" });
|
|
2734
2887
|
if (card.badges?.comments) badges.push({ icon: "comment", text: String(card.badges.comments) });
|
|
2735
2888
|
if (card.badges?.checkItems) badges.push({ symbol: "☑", text: `${card.badges.checkItemsChecked || 0}/${card.badges.checkItems}` });
|
|
@@ -2739,6 +2892,7 @@ function buildCardBadges(card) {
|
|
|
2739
2892
|
|
|
2740
2893
|
function createIcon(name) {
|
|
2741
2894
|
const paths = {
|
|
2895
|
+
clock: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 1.8"],
|
|
2742
2896
|
description: ["M4 6h16M4 12h12M4 18h9"],
|
|
2743
2897
|
comment: ["M5 5.5h14v9H8.5L5 18V5.5Z"],
|
|
2744
2898
|
checklist: ["M9 7h11M9 12h11M9 17h11M4 7.2l1.2 1.2L7.5 6M4 12.2l1.2 1.2 2.3-2.4M4 17.2l1.2 1.2 2.3-2.4"],
|
package/public/index.html
CHANGED
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
|
|
41
41
|
<section class="board-header">
|
|
42
42
|
<div>
|
|
43
|
-
<p class="eyebrow">Git-synced board</p>
|
|
44
43
|
<div class="board-title-shell">
|
|
45
44
|
<h1 id="boardTitle">KanbanQube Board</h1>
|
|
46
45
|
<input id="boardTitleInlineInput" class="inline-title-input board-title-inline-input" type="text" maxlength="200" hidden />
|
|
@@ -57,6 +56,10 @@
|
|
|
57
56
|
<div>
|
|
58
57
|
<p class="modal-label">Card details</p>
|
|
59
58
|
<input id="cardTitleInput" class="title-input" type="text" maxlength="200" placeholder="Task" />
|
|
59
|
+
<label class="card-due-field">
|
|
60
|
+
<span>Due date</span>
|
|
61
|
+
<input id="cardDueInput" type="date" />
|
|
62
|
+
</label>
|
|
60
63
|
</div>
|
|
61
64
|
<button id="closeCardButton" type="button" class="icon-button" aria-label="Close card dialog">✕</button>
|
|
62
65
|
</header>
|
|
@@ -97,6 +100,13 @@
|
|
|
97
100
|
</section>
|
|
98
101
|
|
|
99
102
|
<aside class="modal-sidebar">
|
|
103
|
+
<section class="sidebar-panel">
|
|
104
|
+
<div class="panel-header">
|
|
105
|
+
<h2>Assignees</h2>
|
|
106
|
+
</div>
|
|
107
|
+
<div id="assigneesContainer" class="assignees-container"></div>
|
|
108
|
+
</section>
|
|
109
|
+
|
|
100
110
|
<section class="sidebar-panel">
|
|
101
111
|
<div class="panel-header">
|
|
102
112
|
<h2>Labels</h2>
|
package/public/styles.css
CHANGED
|
@@ -388,16 +388,15 @@ button {
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
.board-header {
|
|
391
|
-
display:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
391
|
+
display: block;
|
|
392
|
+
padding: 0.75rem 1rem;
|
|
393
|
+
background: rgba(14, 18, 26, 0.34);
|
|
394
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
395
|
+
backdrop-filter: blur(8px);
|
|
396
396
|
}
|
|
397
397
|
|
|
398
398
|
.board-header > div:first-child {
|
|
399
399
|
min-width: 0;
|
|
400
|
-
flex: 1 1 auto;
|
|
401
400
|
}
|
|
402
401
|
|
|
403
402
|
.eyebrow,
|
|
@@ -411,12 +410,29 @@ button {
|
|
|
411
410
|
|
|
412
411
|
#boardTitle {
|
|
413
412
|
margin: 0;
|
|
414
|
-
|
|
413
|
+
max-width: min(52rem, calc(100vw - 3rem));
|
|
414
|
+
overflow: hidden;
|
|
415
|
+
text-overflow: ellipsis;
|
|
416
|
+
white-space: nowrap;
|
|
417
|
+
font-size: clamp(1.05rem, 1.4vw, 1.35rem);
|
|
418
|
+
line-height: 1.25;
|
|
419
|
+
font-weight: 800;
|
|
415
420
|
}
|
|
416
421
|
|
|
417
422
|
.board-title-shell {
|
|
418
423
|
min-width: 0;
|
|
419
|
-
|
|
424
|
+
display: inline-flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
max-width: 100%;
|
|
427
|
+
padding: 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.board-title-shell:has(.board-title-inline-input:not([hidden])) {
|
|
431
|
+
padding: 0.45rem 0.65rem;
|
|
432
|
+
border-radius: calc(10px * var(--ui-scale));
|
|
433
|
+
background: rgba(255, 255, 255, 0.12);
|
|
434
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
435
|
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
|
420
436
|
}
|
|
421
437
|
|
|
422
438
|
.meta-badge {
|
|
@@ -595,8 +611,11 @@ button {
|
|
|
595
611
|
}
|
|
596
612
|
|
|
597
613
|
.board-title-inline-input {
|
|
598
|
-
|
|
599
|
-
|
|
614
|
+
width: min(52rem, calc(100vw - 3rem));
|
|
615
|
+
max-width: 100%;
|
|
616
|
+
font-size: clamp(1.05rem, 1.4vw, 1.35rem);
|
|
617
|
+
line-height: 1.25;
|
|
618
|
+
font-weight: 800;
|
|
600
619
|
}
|
|
601
620
|
|
|
602
621
|
.lane-title-inline-input {
|
|
@@ -779,6 +798,97 @@ button {
|
|
|
779
798
|
margin-top: 0.9rem;
|
|
780
799
|
color: var(--muted);
|
|
781
800
|
font-size: 0.9rem;
|
|
801
|
+
align-items: center;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
.card-assignee-list {
|
|
805
|
+
margin-left: auto;
|
|
806
|
+
display: flex;
|
|
807
|
+
align-items: center;
|
|
808
|
+
gap: 0.25rem;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.small-user-avatar,
|
|
812
|
+
.assignee-avatar {
|
|
813
|
+
position: relative;
|
|
814
|
+
display: inline-grid;
|
|
815
|
+
place-items: center;
|
|
816
|
+
flex: 0 0 auto;
|
|
817
|
+
border-radius: 999px;
|
|
818
|
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
819
|
+
background: rgba(255, 255, 255, 0.1);
|
|
820
|
+
color: var(--text);
|
|
821
|
+
overflow: visible;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
.small-user-avatar {
|
|
825
|
+
width: calc(26px * var(--ui-scale));
|
|
826
|
+
height: calc(26px * var(--ui-scale));
|
|
827
|
+
font-size: 0.68rem;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.assignee-avatar {
|
|
831
|
+
width: calc(30px * var(--ui-scale));
|
|
832
|
+
height: calc(30px * var(--ui-scale));
|
|
833
|
+
font-size: 0.74rem;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.small-user-avatar img,
|
|
837
|
+
.small-user-avatar > span,
|
|
838
|
+
.assignee-avatar img,
|
|
839
|
+
.assignee-avatar > span {
|
|
840
|
+
width: 100%;
|
|
841
|
+
height: 100%;
|
|
842
|
+
border-radius: inherit;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.small-user-avatar img[hidden],
|
|
846
|
+
.small-user-avatar > span[hidden],
|
|
847
|
+
.assignee-avatar img[hidden],
|
|
848
|
+
.assignee-avatar > span[hidden] {
|
|
849
|
+
display: none !important;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.small-user-avatar img,
|
|
853
|
+
.assignee-avatar img {
|
|
854
|
+
display: block;
|
|
855
|
+
object-fit: cover;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.small-user-avatar > span,
|
|
859
|
+
.assignee-avatar > span {
|
|
860
|
+
display: inline-flex;
|
|
861
|
+
align-items: center;
|
|
862
|
+
justify-content: center;
|
|
863
|
+
font-weight: 800;
|
|
864
|
+
background: rgba(255, 255, 255, 0.1);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.small-user-avatar::after,
|
|
868
|
+
.assignee-avatar::after {
|
|
869
|
+
content: attr(data-tooltip);
|
|
870
|
+
position: absolute;
|
|
871
|
+
right: 0;
|
|
872
|
+
bottom: calc(100% + 0.5rem);
|
|
873
|
+
padding: 0.32rem 0.5rem;
|
|
874
|
+
border-radius: calc(10px * var(--ui-scale));
|
|
875
|
+
background: rgba(12, 15, 21, 0.96);
|
|
876
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
877
|
+
color: var(--text);
|
|
878
|
+
font-size: 0.82rem;
|
|
879
|
+
font-weight: 600;
|
|
880
|
+
line-height: 1.2;
|
|
881
|
+
white-space: nowrap;
|
|
882
|
+
opacity: 0;
|
|
883
|
+
pointer-events: none;
|
|
884
|
+
z-index: 20;
|
|
885
|
+
box-shadow: 0 calc(10px * var(--ui-scale)) calc(24px * var(--ui-scale)) rgba(0, 0, 0, 0.32);
|
|
886
|
+
transition: opacity 90ms ease;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.small-user-avatar:hover::after,
|
|
890
|
+
.assignee-avatar:hover::after {
|
|
891
|
+
opacity: 1;
|
|
782
892
|
}
|
|
783
893
|
|
|
784
894
|
.badge {
|
|
@@ -788,6 +898,14 @@ button {
|
|
|
788
898
|
line-height: 1;
|
|
789
899
|
}
|
|
790
900
|
|
|
901
|
+
.badge-due-overdue {
|
|
902
|
+
color: #fecaca;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.badge-due-complete {
|
|
906
|
+
color: #bbf7d0;
|
|
907
|
+
}
|
|
908
|
+
|
|
791
909
|
.badge-symbol {
|
|
792
910
|
font-size: 0.95rem;
|
|
793
911
|
line-height: 1;
|
|
@@ -1011,6 +1129,24 @@ button {
|
|
|
1011
1129
|
width: 100%;
|
|
1012
1130
|
}
|
|
1013
1131
|
|
|
1132
|
+
.card-due-field {
|
|
1133
|
+
display: inline-flex;
|
|
1134
|
+
align-items: center;
|
|
1135
|
+
gap: 0.5rem;
|
|
1136
|
+
margin-top: 0.45rem;
|
|
1137
|
+
color: var(--muted);
|
|
1138
|
+
font-size: 0.9rem;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
.card-due-field input {
|
|
1142
|
+
min-height: calc(32px * var(--ui-scale));
|
|
1143
|
+
padding: 0.25rem 0.45rem;
|
|
1144
|
+
border-radius: calc(10px * var(--ui-scale));
|
|
1145
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1146
|
+
background: rgba(255, 255, 255, 0.05);
|
|
1147
|
+
color: var(--text);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1014
1150
|
.archived-card-banner {
|
|
1015
1151
|
margin-bottom: 1rem;
|
|
1016
1152
|
padding: 0.85rem 1rem;
|
|
@@ -1092,7 +1228,10 @@ button {
|
|
|
1092
1228
|
}
|
|
1093
1229
|
|
|
1094
1230
|
.modal-large .modal-sidebar {
|
|
1095
|
-
grid-
|
|
1231
|
+
grid-auto-rows: max-content;
|
|
1232
|
+
align-content: start;
|
|
1233
|
+
overflow: auto;
|
|
1234
|
+
padding-right: 0.2rem;
|
|
1096
1235
|
}
|
|
1097
1236
|
|
|
1098
1237
|
.sidebar-panel,
|
|
@@ -1110,12 +1249,6 @@ button {
|
|
|
1110
1249
|
min-height: 0;
|
|
1111
1250
|
}
|
|
1112
1251
|
|
|
1113
|
-
.modal-large .modal-sidebar .sidebar-panel:nth-of-type(2) {
|
|
1114
|
-
display: grid;
|
|
1115
|
-
overflow: hidden;
|
|
1116
|
-
grid-template-rows: auto auto minmax(0, 1fr);
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
1252
|
.modal-large .attachments-panel,
|
|
1120
1253
|
.modal-large .checklists-panel {
|
|
1121
1254
|
display: grid;
|
|
@@ -1303,6 +1436,32 @@ button {
|
|
|
1303
1436
|
gap: 0.7rem;
|
|
1304
1437
|
}
|
|
1305
1438
|
|
|
1439
|
+
.assignees-container {
|
|
1440
|
+
display: grid;
|
|
1441
|
+
gap: 0.55rem;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
.assignee-row {
|
|
1445
|
+
display: grid;
|
|
1446
|
+
grid-template-columns: auto auto minmax(0, 1fr);
|
|
1447
|
+
align-items: center;
|
|
1448
|
+
gap: 0.65rem;
|
|
1449
|
+
padding: 0.45rem 0.5rem;
|
|
1450
|
+
border-radius: calc(12px * var(--ui-scale));
|
|
1451
|
+
background: rgba(255, 255, 255, 0.035);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
.assignee-row input {
|
|
1455
|
+
margin: 0;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
.assignee-name {
|
|
1459
|
+
min-width: 0;
|
|
1460
|
+
overflow: hidden;
|
|
1461
|
+
text-overflow: ellipsis;
|
|
1462
|
+
white-space: nowrap;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1306
1465
|
.attachment-row {
|
|
1307
1466
|
display: grid;
|
|
1308
1467
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
|
@@ -1657,11 +1816,6 @@ button {
|
|
|
1657
1816
|
grid-template-columns: 1fr;
|
|
1658
1817
|
}
|
|
1659
1818
|
|
|
1660
|
-
.board-header {
|
|
1661
|
-
flex-direction: column;
|
|
1662
|
-
align-items: start;
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
1819
|
.modal-grid {
|
|
1666
1820
|
grid-template-columns: 1fr;
|
|
1667
1821
|
}
|
package/routes/apiRoutes.js
CHANGED
|
@@ -6,6 +6,7 @@ const { createConfigController } = require("../controllers/configController");
|
|
|
6
6
|
const { createImportController } = require("../controllers/importController");
|
|
7
7
|
const { createSyncController } = require("../controllers/syncController");
|
|
8
8
|
const { createUploadController } = require("../controllers/uploadController");
|
|
9
|
+
const { createUserController } = require("../controllers/userController");
|
|
9
10
|
const { asyncHandler } = require("../middleware/asyncHandler");
|
|
10
11
|
|
|
11
12
|
function createApiRoutes(services) {
|
|
@@ -15,10 +16,12 @@ function createApiRoutes(services) {
|
|
|
15
16
|
const importController = createImportController(services.boardService, services.importService);
|
|
16
17
|
const syncController = createSyncController(services.gitSyncService);
|
|
17
18
|
const uploadController = createUploadController(services.uploadService);
|
|
19
|
+
const userController = createUserController(services.userService);
|
|
18
20
|
|
|
19
21
|
router.get("/board", asyncHandler(boardController.getBoard));
|
|
20
22
|
router.put("/board", asyncHandler(boardController.saveBoard));
|
|
21
23
|
router.get("/config", asyncHandler(configController.getConfig));
|
|
24
|
+
router.get("/users", asyncHandler(userController.getUsers));
|
|
22
25
|
router.post("/uploads", asyncHandler(uploadController.uploadFiles));
|
|
23
26
|
router.delete("/uploads/:fileName", asyncHandler(uploadController.deleteUpload));
|
|
24
27
|
router.post("/import", asyncHandler(importController.importBoard));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { createGitService } = require("./gitService");
|
|
4
|
+
const { gravatarUrlForEmail, initialsForUser, userIdForIdentity } = require("../utils/userUtils");
|
|
5
|
+
const { nonEmptyString } = require("../utils/stringUtils");
|
|
6
|
+
|
|
7
|
+
function createUserService(config) {
|
|
8
|
+
const gitService = createGitService(config);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
listUsers: () => listUsers(config, gitService)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function listUsers(config, gitService) {
|
|
16
|
+
const usersByKey = new Map();
|
|
17
|
+
await addCurrentGitUser(config, gitService, usersByKey);
|
|
18
|
+
await addCommitAuthors(config, gitService, usersByKey);
|
|
19
|
+
return [...usersByKey.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function addCurrentGitUser(config, gitService, usersByKey) {
|
|
23
|
+
const [name, email] = await Promise.all([
|
|
24
|
+
gitService.gitUserName(config.workspaceDir),
|
|
25
|
+
gitService.gitUserEmail(config.workspaceDir)
|
|
26
|
+
]);
|
|
27
|
+
addUser(usersByKey, name, email, true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function addCommitAuthors(config, gitService, usersByKey) {
|
|
31
|
+
if (!(await gitService.hasGitRepository(config.workspaceDir))) return;
|
|
32
|
+
const result = await gitService.runGit(config.workspaceDir, ["log", "--format=%aN%x00%aE%x00%ct"]);
|
|
33
|
+
if (result.code !== 0 || !result.stdout.trim()) return;
|
|
34
|
+
|
|
35
|
+
for (const line of result.stdout.split("\n")) {
|
|
36
|
+
const [name, email, timestamp] = line.split("\0");
|
|
37
|
+
addUser(usersByKey, name, email, false, Number(timestamp) || 0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function addUser(usersByKey, nameValue, emailValue, isCurrentUser = false, lastCommitTime = 0) {
|
|
42
|
+
const name = nonEmptyString(nameValue);
|
|
43
|
+
const email = nonEmptyString(emailValue).toLowerCase();
|
|
44
|
+
if (!name && !email) return;
|
|
45
|
+
|
|
46
|
+
const key = email || `name:${name.toLowerCase()}`;
|
|
47
|
+
const existing = usersByKey.get(key);
|
|
48
|
+
const next = {
|
|
49
|
+
id: userIdForIdentity(name, email),
|
|
50
|
+
name: name || email,
|
|
51
|
+
email,
|
|
52
|
+
initials: initialsForUser(name, email),
|
|
53
|
+
avatarUrl: gravatarUrlForEmail(email),
|
|
54
|
+
isCurrentUser: Boolean(existing?.isCurrentUser || isCurrentUser),
|
|
55
|
+
lastCommitTime: Math.max(existing?.lastCommitTime || 0, lastCommitTime)
|
|
56
|
+
};
|
|
57
|
+
usersByKey.set(key, next);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
createUserService,
|
|
62
|
+
listUsers
|
|
63
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
function gravatarUrlForEmail(email) {
|
|
6
|
+
if (typeof email !== "string" || !email.trim()) return "";
|
|
7
|
+
const hash = crypto.createHash("md5").update(email.trim().toLowerCase()).digest("hex");
|
|
8
|
+
return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function initialsForUser(name, email) {
|
|
12
|
+
const source = String(name || email || "User").trim();
|
|
13
|
+
return source
|
|
14
|
+
.split(/\s+/)
|
|
15
|
+
.slice(0, 2)
|
|
16
|
+
.map((part) => part[0]?.toUpperCase() || "")
|
|
17
|
+
.join("") || "U";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function userIdForIdentity(name, email) {
|
|
21
|
+
const source = email ? `email:${email.trim().toLowerCase()}` : `name:${String(name || "user").trim().toLowerCase()}`;
|
|
22
|
+
return crypto.createHash("sha1").update(source).digest("hex").slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
gravatarUrlForEmail,
|
|
27
|
+
initialsForUser,
|
|
28
|
+
userIdForIdentity
|
|
29
|
+
};
|