Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 181 additions & 12 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import { NameInput } from "./components/name-input";
import { Header } from "./components/header";
import { Card } from "./components/card";
import { CardName } from "./components/card-name";
import { BulkOperationsToolbar } from "./components/bulk-operations-toolbar";
import { makePersisted } from "@solid-primitives/storage";
import { DragAndDrop } from "./components/drag-and-drop";
import { useLocation, useNavigate } from "@solidjs/router";
import { v7 } from "uuid";
import { addTagToContent, removeTagFromContent, setDueDateInContent, getTagsFromContent } from "./card-content-utils";
import "./stylesheets/index.css";

function App() {
Expand Down Expand Up @@ -49,6 +51,8 @@ function App() {
name: "viewMode",
});
const [renderUID, setRenderUID] = createSignal(v7());
const [selectionMode, setSelectionMode] = createSignal(false);
const [selectedCards, setSelectedCards] = createSignal(new Set());
const location = useLocation();
const navigate = useNavigate();

Expand Down Expand Up @@ -284,12 +288,8 @@ function App() {
navigate(`${basePath()}${board()}/${encodeURIComponent(newCard.name)}.md`);
}

function getTagsByCardContent(text) {
const tags = [...text.matchAll(/\[tag:(.*?)\]/g)]
.map((tagMatch) => tagMatch[1].trim())
.filter((tag) => tag !== "");
return tags;
}
// Use shared utility function for getting tags
const getTagsByCardContent = getTagsFromContent;

function handleSortSelectOnChange(e) {
const value = e.target.value;
Expand Down Expand Up @@ -449,6 +449,146 @@ function App() {
setCards(cardsToKeep);
}

// Bulk operations functions
function toggleCardSelection(cardKey, isSelected) {
const newSelected = new Set(selectedCards());
if (isSelected) {
newSelected.add(cardKey);
} else {
newSelected.delete(cardKey);
}
setSelectedCards(newSelected);
}

function clearSelection() {
setSelectedCards(new Set());
}

function getCardKey(card) {
return `${card.lane}/${card.name}`;
}

// Get tags that exist on selected cards (for remove tags dropdown)
const tagsOnSelectedCards = createMemo(() => {
const selectedCardsList = cards().filter((card) =>
selectedCards().has(getCardKey(card))
);

const allTagsOnSelected = new Set();
selectedCardsList.forEach((card) => {
const cardTags = getTagsFromContent(card.content || "");
cardTags.forEach((tag) => allTagsOnSelected.add(tag));
});

return Array.from(allTagsOnSelected);
});

async function bulkDeleteCards() {
const cardsToDelete = cards().filter((card) =>
selectedCards().has(getCardKey(card))
);

// Delete all selected cards using existing API
const deletePromises = cardsToDelete.map((card) =>
fetch(`${api}/resource${board()}/${card.lane}/${card.name}.md`, {
method: "DELETE",
mode: "cors",
})
);

await Promise.all(deletePromises);

// Update local state
const remainingCards = cards().filter(
(card) => !selectedCards().has(getCardKey(card))
);
setCards(remainingCards);
clearSelection(); // Clear after delete since cards are gone
}

async function bulkAddTags(tagName) {
const cardsToUpdate = cards().filter((card) =>
selectedCards().has(getCardKey(card))
);

// Add tag to each selected card using shared utility function
const updatePromises = cardsToUpdate.map(async (card) => {
const content = card.content || "";
const currentTags = getTagsFromContent(content);

// Skip if card already has this tag
if (currentTags.some((t) => t.toLowerCase() === tagName.toLowerCase())) {
return;
}

const newContent = addTagToContent(content, tagName);

return fetch(`${api}/resource${board()}/${card.lane}/${card.name}.md`, {
method: "PATCH",
mode: "cors",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: newContent }),
});
});

await Promise.all(updatePromises);
await fetchData();
// Keep selection to allow chaining operations
}

async function bulkRemoveTags(tagName) {
const cardsToUpdate = cards().filter((card) =>
selectedCards().has(getCardKey(card))
);

// Remove tag from each selected card using shared utility function
const updatePromises = cardsToUpdate.map(async (card) => {
const content = card.content || "";
const currentTags = getTagsFromContent(content);

// Skip if card doesn't have this tag
if (!currentTags.some((t) => t.toLowerCase() === tagName.toLowerCase())) {
return;
}

const newContent = removeTagFromContent(content, tagName);

return fetch(`${api}/resource${board()}/${card.lane}/${card.name}.md`, {
method: "PATCH",
mode: "cors",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: newContent }),
});
});

await Promise.all(updatePromises);
await fetchData();
// Keep selection to allow chaining operations
}

async function bulkSetDueDate(dueDate) {
const cardsToUpdate = cards().filter((card) =>
selectedCards().has(getCardKey(card))
);

// Set due date for each selected card using shared utility function
const updatePromises = cardsToUpdate.map(async (card) => {
const content = card.content || "";
const newContent = setDueDateInContent(content, dueDate);

return fetch(`${api}/resource${board()}/${card.lane}/${card.name}.md`, {
method: "PATCH",
mode: "cors",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: newContent }),
});
});

await Promise.all(updatePromises);
await fetchData();
// Keep selection to allow chaining operations
}

function renameCard(oldName, newName) {
const newCards = structuredClone(cards());
const newCardIndex = newCards.findIndex((card) => card.name === oldName);
Expand Down Expand Up @@ -655,14 +795,21 @@ function App() {
setCards(newCards);
}

const disableCardsDrag = createMemo(() => sort() !== "none");
const disableCardsDrag = createMemo(() => sort() !== "none" || selectionMode());

createEffect((prev) => {
document.body.classList.remove(`view-mode-${prev}`);
document.body.classList.add(`view-mode-${viewMode()}`);
return viewMode();
});

// Clear selection when exiting selection mode
createEffect(() => {
if (!selectionMode()) {
setSelectedCards(new Set());
}
});

return (
<>
<Header
Expand All @@ -676,7 +823,21 @@ function App() {
onNewLaneBtnClick={createNewLane}
viewMode={viewMode()}
onViewModeChange={(e) => setViewMode(e.target.value)}
selectionMode={selectionMode()}
onSelectionModeChange={setSelectionMode}
/>
<Show when={selectionMode()}>
<BulkOperationsToolbar
selectedCount={selectedCards().size}
onDelete={bulkDeleteCards}
onAddTags={bulkAddTags}
onRemoveTags={bulkRemoveTags}
onSetDueDate={bulkSetDueDate}
onClearSelection={clearSelection}
tagsOptions={tagsOptions().map((option) => option.name)}
tagsOnSelectedCards={tagsOnSelectedCards()}
/>
</Show>
{title() ? <h1 class="app-title">{title()}</h1> : <></>}
<DragAndDrop.Provider>
<DragAndDrop.Container class={`lanes`} onChange={handleLanesSortChange}>
Expand Down Expand Up @@ -725,13 +886,21 @@ function App() {
tags={card.tags}
dueDate={card.dueDate}
content={card.content}
disableDrag={disableCardsDrag()}
selectionMode={selectionMode()}
isSelected={selectedCards().has(getCardKey(card))}
onSelectionChange={(isSelected) =>
toggleCardSelection(getCardKey(card), isSelected)
}
onClick={() => {
let cardUrl = basePath();
if (board()) {
cardUrl += `${board()}`;
if (!selectionMode()) {
let cardUrl = basePath();
if (board()) {
cardUrl += `${board()}`;
}
cardUrl += `/${encodeURIComponent(card.name)}.md`;
navigate(cardUrl);
}
cardUrl += `/${encodeURIComponent(card.name)}.md`;
navigate(cardUrl);
}}
headerSlot={
cardBeingRenamed()?.name === card.name ? (
Expand Down
100 changes: 100 additions & 0 deletions frontend/src/card-content-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Utility functions for manipulating card content (tags, due dates, etc.)
* These functions are shared between single-card editor and bulk operations
*/

/**
* Add a tag to card content
* @param {string} content - Current card content
* @param {string} tagName - Tag name to add
* @returns {string} Updated content with tag added
*/
export function addTagToContent(content, tagName) {
const actualContent = content || "";
const emptyLineIfFirstTag = [...actualContent.matchAll(/\[tag:(.*?)\]/g)]
.length
? ""
: "\n\n";
const newTag = tagName.trim();
return `[tag:${newTag}] ${emptyLineIfFirstTag}${actualContent}`;
}

/**
* Remove a tag from card content
* @param {string} content - Current card content
* @param {string} tagName - Tag name to remove
* @returns {string} Updated content with tag removed
*/
export function removeTagFromContent(content, tagName) {
const currentContent = content || "";
const tagWithBrackets = `[tag:${tagName}]`;
const tagWithBracketsAndSpace = `${tagWithBrackets} `;
let tagLength = tagWithBracketsAndSpace.length;
let indexOfTag = currentContent
.toLowerCase()
.indexOf(tagWithBracketsAndSpace.toLowerCase());

if (indexOfTag === -1) {
indexOfTag = currentContent.toLowerCase().indexOf(tagWithBrackets.toLowerCase());
tagLength = tagWithBrackets.length;
}

if (indexOfTag === -1) {
return currentContent; // Tag not found
}

return `${currentContent.substring(0, indexOfTag)}${currentContent.substring(indexOfTag + tagLength, currentContent.length)}`;
}

/**
* Set or update due date in card content
* @param {string} content - Current card content
* @param {string} newDueDate - New due date (YYYY-MM-DD format)
* @returns {string} Updated content with due date set/updated
*/
export function setDueDateInContent(content, newDueDate) {
const currentContent = content || "";

// Check if card already has a due date
const dueDateStringMatch = currentContent.match(/\[due:(.*?)\]/);
const existingDueDate = dueDateStringMatch?.[1];

const newDueDateTag = `[due:${newDueDate}]`;

if (existingDueDate) {
// Replace existing due date
return currentContent.replace(`[due:${existingDueDate}]`, newDueDateTag);
} else {
// Add new due date at the beginning
return `${newDueDateTag}\n\n${currentContent}`;
}
}

/**
* Extract tags from card content
* @param {string} content - Card content
* @returns {string[]} Array of tag names
*/
export function getTagsFromContent(content) {
const text = content || "";
const tags = [...text.matchAll(/\[tag:(.*?)\]/g)]
.map((tagMatch) => tagMatch[1].trim())
.filter((tag) => tag !== "");
return tags;
}

/**
* Extract due date from card content
* @param {string} content - Card content
* @returns {string|null} Due date string or null if not found
*/
export function getDueDateFromContent(content) {
if (!content) {
return null;
}
const dueDateStringMatch = content.match(/\[due:(.*?)\]/);
if (!dueDateStringMatch?.length) {
return null;
}
return dueDateStringMatch[1];
}
Loading
Loading