From 73cfe75e67e321abcc2449e8fabbf0ab31d45219 Mon Sep 17 00:00:00 2001 From: Agung Maulana Date: Fri, 26 Jun 2026 09:57:13 +0700 Subject: [PATCH 1/2] feat(task-client): add Trello task client with kanban board, card details, and rich text editor Implement a full Trello integration inside the app, including account management, board browsing, a kanban-style card view with drag-and-drop, and a detailed card modal with checklists, comments, labels, attachments, due dates, and member assignment. A rich text editor with formatting toolbar, code syntax highlighting, mentions, and emoji support replaces Markdown input for descriptions and comments. Also add colorblind-friendly label patterns and wire the task client toggle into both desktop and mobile navigators. --- frontend/app.css | 39 + .../task-client/CardDetailModal.svelte | 3200 +++++++++++++++++ .../task-client/RichTextEditor.svelte | 2241 ++++++++++++ .../task-client/TaskClientButton.svelte | 32 + .../task-client/TaskClientModal.svelte | 1473 ++++++++ .../workspace/DesktopNavigator.svelte | 5 + .../workspace/MobileNavigator.svelte | 5 + .../stores/features/task-client.svelte.ts | 1622 +++++++++ shared/utils/logger.ts | 1 + 9 files changed, 8618 insertions(+) create mode 100644 frontend/components/task-client/CardDetailModal.svelte create mode 100644 frontend/components/task-client/RichTextEditor.svelte create mode 100644 frontend/components/task-client/TaskClientButton.svelte create mode 100644 frontend/components/task-client/TaskClientModal.svelte create mode 100644 frontend/stores/features/task-client.svelte.ts diff --git a/frontend/app.css b/frontend/app.css index 1b0eef04..750af95c 100644 --- a/frontend/app.css +++ b/frontend/app.css @@ -311,3 +311,42 @@ } } +@utility colorblind-pattern-green { + background-image: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(255,255,255,0.3) 4px, rgba(255,255,255,0.3) 8px) !important; +} +@utility colorblind-pattern-yellow { + background-image: radial-gradient(rgba(0,0,0,0.15) 20%, transparent 20%), radial-gradient(rgba(0,0,0,0.15) 20%, transparent 20%) !important; + background-size: 8px 8px !important; + background-position: 0 0, 4px 4px !important; +} +@utility colorblind-pattern-orange { + background-image: repeating-linear-gradient(90deg, transparent, transparent 4px, rgba(255,255,255,0.3) 4px, rgba(255,255,255,0.3) 8px) !important; +} +@utility colorblind-pattern-red { + background-image: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(255,255,255,0.25) 4px, rgba(255,255,255,0.25) 8px), repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255,255,255,0.25) 4px, rgba(255,255,255,0.25) 8px) !important; +} +@utility colorblind-pattern-purple { + background-image: repeating-linear-gradient(0deg, transparent, transparent 4px, rgba(255,255,255,0.3) 4px, rgba(255,255,255,0.3) 8px) !important; +} +@utility colorblind-pattern-blue { + background-image: radial-gradient(circle, rgba(255,255,255,0.3) 30%, transparent 30%) !important; + background-size: 8px 8px !important; +} +@utility colorblind-pattern-sky { + background-image: repeating-linear-gradient(60deg, transparent, transparent 3px, rgba(255,255,255,0.3) 3px, rgba(255,255,255,0.3) 6px) !important; +} +@utility colorblind-pattern-lime { + background-image: linear-gradient(45deg, rgba(0,0,0,0.15) 25%, transparent 25%), linear-gradient(-45deg, rgba(0,0,0,0.15) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.15) 75%), linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.15) 75%) !important; + background-size: 8px 8px !important; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px !important; +} +@utility colorblind-pattern-pink { + background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(255,255,255,0.3) 4px, rgba(255,255,255,0.3) 8px) !important; +} +@utility colorblind-pattern-black { + background-image: linear-gradient(135deg, rgba(255,255,255,0.2) 25%, transparent 25%), linear-gradient(225deg, rgba(255,255,255,0.2) 25%, transparent 25%), linear-gradient(45deg, rgba(255,255,255,0.2) 25%, transparent 25%), linear-gradient(315deg, rgba(255,255,255,0.2) 25%, transparent 25%) !important; + background-size: 10px 10px !important; + background-position: 5px 0, 5px 5px, 0 5px, 0 0 !important; +} + + diff --git a/frontend/components/task-client/CardDetailModal.svelte b/frontend/components/task-client/CardDetailModal.svelte new file mode 100644 index 00000000..600db75e --- /dev/null +++ b/frontend/components/task-client/CardDetailModal.svelte @@ -0,0 +1,3200 @@ + + + + +{#if isOpen && card} + + + + {#if showDatesPopover} + + + +
+ {@render datesPopoverContent()} +
+ {/if} + + {#if previewAttachment} + + + + + + + +
+
+ {#if previewAttachment.mimeType?.startsWith('image/') || previewAttachment.previews?.length > 0} + {previewAttachment.name} + {:else} +
+ There is no preview available for this attachment. + + + Download + +
+ {/if} +
+
+ + +
+
+

{previewAttachment.name}

+
+ Added {formattedPreviewDate} + {#if previewAttachment.bytes} + + {formatFileSize(previewAttachment.bytes)} + {/if} +
+ +
+ + + Open in new tab + + + + Download + + +
+
+
+ {/if} +{/if} + +{#snippet datesPopoverContent()} +
+ +
+

Dates

+ +
+ + +
+
+ + +
+ + {monthNames[calendarMonth]} {calendarYear} + +
+ + +
+
+ + +
+ Su + Mo + Tu + We + Th + Fr + Sa +
+ + +
+ {#each calendarDays as cell} + {@const isSelected = isCellSelected(cell.day, cell.month, cell.year)} + + {/each} +
+ + +
+ Start date +
+ + activeDateField = 'start'} + class="flex-1 px-2.5 py-1.5 rounded bg-slate-900 text-slate-200 text-xs focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all + {activeDateField === 'start' ? 'border border-violet-500 ring-1 ring-violet-500' : 'border border-slate-700'}" + /> +
+
+ + +
+ Due date +
+ + activeDateField = 'due'} + class="flex-1 px-2.5 py-1.5 rounded bg-slate-900 text-slate-200 text-xs focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all + {activeDateField === 'due' ? 'border border-violet-500 ring-1 ring-violet-500' : 'border border-slate-700'}" + /> +
+
+ + +
+ Recurring + +
+ + +
+ Set due date reminder + +
+ + +
+ + +
+
+{/snippet} + +{#snippet labelsPopoverContent()} + +
+ {#if labelsPopoverMode === 'list'} +

Labels

+ {:else if labelsPopoverMode === 'create'} +
+ +

Create label

+
+ {:else if labelsPopoverMode === 'edit'} +
+ +

Change label

+
+ {/if} + +
+ + {#if labelsPopoverMode === 'list'} + +
+ +
+ + +
+ Labels + {#if filteredLabels.length === 0} +
No labels found
+ {:else} + {#each filteredLabels as label} + {@const isAssigned = card?.labels.some(l => l.id === label.id)} + {@const bg = labelColorMap[label.color] ?? label.color ?? '#6b7280'} +
+ + + + + + + + +
+ {/each} + {/if} +
+ + +
+ + +
+ + {:else if labelsPopoverMode === 'create' || labelsPopoverMode === 'edit'} + +
+ + +
+ + +
+ Select a color +
+ {#each labelColors as color} + {@const bg = labelColorMap[color] ?? color} + {@const isSelected = labelFormColor === color} + + {/each} +
+
+ + +
+ {#if labelsPopoverMode === 'create'} + + {:else} + + + {/if} +
+ {/if} +{/snippet} + + + diff --git a/frontend/components/task-client/RichTextEditor.svelte b/frontend/components/task-client/RichTextEditor.svelte new file mode 100644 index 00000000..efd58ba2 --- /dev/null +++ b/frontend/components/task-client/RichTextEditor.svelte @@ -0,0 +1,2241 @@ + + +
+ +
+ +
+ + +
+ + + + + +
+ + {#if showMoreMenu} + + + + +
+ + + + + +
+ + +
+ {/if} +
+
+ +
+ + {#if showListMenu} + + + + +
+ + + + +
+ {/if} +
+
+ {#if showDirectLinkImage} + + + {/if} + + +
+ + {#if showInsertMenu} + + + + +
+ {#if insertSubMenu === 'main'} + +
+ + + ↵ Enter +
+ + +
+ {#if filteredInsertItems.length === 0} +
No options match query
+ {:else} + {#each filteredInsertItems as item, idx} + {@const isSelected = idx === insertSelectedIndex} + + {/each} + {/if} +
+ {:else if insertSubMenu === 'mention'} + +
+ {#each filteredMentionOptions as member, idx} + {@const isSelected = idx === autocompleteSelectedIndex} + + {/each} +
+ {:else if insertSubMenu === 'emoji'} + +
+
+ {#each emojiCategories as cat} + + {/each} +
+
+ {#each emojisData[selectedEmojiCategory] as emoji} + + {/each} +
+ {#if hoveredEmoji} +
+ {hoveredEmoji.char} +
+ {hoveredEmoji.name} + {hoveredEmoji.code} +
+
+ {/if} +
+ {/if} +
+ {/if} +
+
+ + +
+ {#if showAttachmentButton} + + {/if} + + {#if showMarkdownIcon} + + M↓ + + {/if} + + +
+
+ + +
+ + {#if showAutocomplete && filteredAutocompleteOptions.length > 0} +
+
+ {#each filteredAutocompleteOptions as member, idx} + {@const isSelected = idx === autocompleteSelectedIndex} + + {/each} +
+
+ {/if} + + {#if showInsertLinkPopover} + +
+
{ + e.preventDefault(); + if (insertLinkUrl.trim()) { + handleInsertLink(); + } + }} + onmousedown={(e) => e.stopPropagation()} + class="pointer-events-auto w-80 p-4.5 rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl flex flex-col gap-4 text-slate-200 animate-in fade-in zoom-in-95 duration-150" + > +
+ Insert link + +
+ +
+ + +
+
+ + + Give this link a title or description +
+
+ +
+
+
+ {/if} + + {#if showInsertImagePopover} + +
+
{ + e.preventDefault(); + if (insertImageUrl.trim()) { + handleInsertImageUrl(); + } + }} + onmousedown={(e) => e.stopPropagation()} + class="pointer-events-auto w-80 p-4.5 rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl flex flex-col gap-4 text-slate-200 animate-in fade-in zoom-in-95 duration-150" + > +
+ Select image + +
+ +
+ +
+ + +
+
+ +
+ { + const file = e.currentTarget.files?.[0]; + if (file) handleUploadImageFile(file); + }} + /> + +
+
+
+ {/if} + + {#if activeLinkNode} +
{ if (!isEditingLink) e.preventDefault(); }} + style="top: {linkTooltipCoords.top}px; left: {linkTooltipCoords.left}px; transform: translateX(-50%);" + class="absolute z-[60] p-1 rounded-lg border border-slate-800 bg-slate-950 shadow-2xl flex flex-col gap-1 text-xs select-none" + > + {#if isEditingLink} + +
{ + e.preventDefault(); + handleSaveEditLink(); + }} + class="flex flex-col gap-2 p-3.5 w-64 text-slate-200 animate-in fade-in zoom-in-95 duration-150" + > +
+ + +
+
+ + +
+
+ + +
+
+ {:else} + +
+ + +
+ + + + + + + + + + + + +
+ {/if} +
+ {/if} +
+ +{#if activeCodeNode && isCodeActive && enableCodeToolbar} +
+ + + +
+ + + + + + +
+{/if} + +{#if showFormattingHelp} +
+
+ Formatting help + +
+
+
+
# Heading for Header 1
+
## Heading for Header 2
+
**text** for Bold
+
*text* for Italic
+
+
+
~~text~~ for Strikethrough
+
`code` for inline code
+
``` on empty line for code snippet block
+
> text for block quote
+
+
+
+{/if} + + diff --git a/frontend/components/task-client/TaskClientButton.svelte b/frontend/components/task-client/TaskClientButton.svelte new file mode 100644 index 00000000..fc685c64 --- /dev/null +++ b/frontend/components/task-client/TaskClientButton.svelte @@ -0,0 +1,32 @@ + + +{#if collapsed} + +{:else} + +{/if} diff --git a/frontend/components/task-client/TaskClientModal.svelte b/frontend/components/task-client/TaskClientModal.svelte new file mode 100644 index 00000000..bfe9a37a --- /dev/null +++ b/frontend/components/task-client/TaskClientModal.svelte @@ -0,0 +1,1473 @@ + + + + + + {#snippet children()} +
+ + + + +
+ +
+
+
+ +
+
+

{selectedProvider.name}

+

{selectedProvider.description}

+
+
+
+ + + {#if selectedProviderId === 'trello'} +
+ + + + + {#if trelloView === 'boards' || trelloView === 'board'} + + {/if} + + + {#if trelloView === 'board' && taskClientStore.selectedBoard} + + + + + {/if} +
+ {/if} + + + {#if selectedProviderId === 'trello'} + {#if trelloView === 'accounts'} + +
+
+ {#if taskClientStore.recentBoards.length > 0} +
+

Recent Boards

+
+ {#each taskClientStore.recentBoards as board} + + {/each} +
+
+ {/if} + + +
+
+

Connected Accounts

+

Click an account to browse its boards

+
+ {#if taskClientStore.accounts.length > 0} + + {taskClientStore.accounts.length} connected + + {/if} +
+ + {#if taskClientStore.accounts.length > 0} +
+ {#each taskClientStore.accounts as account} +
+ + + + +
+ + + + + +
+
+ {/each} +
+ {:else if !showAddForm} + +
+
+ +
+

No accounts connected

+

Add your Trello account to get started

+
+ {/if} + + + {#if !showAddForm} +
+ +
+ {/if} + + + {#if showAddForm} +
+
+ {editingAccountId ? 'Edit Account' : 'Connect Account'} +
+ +
+

+ 1. Get your API Key from + trello.com/power-ups/admin. +

+

+ 2. After entering your API Key below, click the authorization button to generate a token with Read & Write permissions (required for Trello stars & edit capabilities): +

+
+ + +
+ + +
+ + + {#if editingAccountId} + {#if !isEditingApiKey} +
+ +
+ ••••••••{addApiKey.slice(-4)} + +
+
+ {:else} +
+ +
+ + + +
+
+ {/if} + {:else} +
+ + +
+ {/if} + + {#if addApiKey.trim()} + + + Authorize Token (Read & Write) + + {:else} +
+ + Enter your API Key first to display the token authorization link. +
+ {/if} + + + {#if editingAccountId} + {#if !isEditingToken} +
+ +
+ ••••••••{addToken.slice(-4)} + +
+
+ {:else} +
+ +
+ + + +
+
+ {/if} + {:else} +
+ + +
+ {/if} + {#if addError} +

{addError}

+ {/if} +
+ + +
+
+ {/if} +
+
+ + {:else if trelloView === 'boards'} + + {#if taskClientStore.loadingBoards} +
+ + Loading boards… +
+ {:else} +
+ {#snippet boardCard(board: TrelloBoard)} +
openBoard(board.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openBoard(board.id); } }} + class="relative flex flex-col justify-end w-full h-24 p-3.5 rounded-xl hover:shadow-md transition-all text-left cursor-pointer group overflow-hidden border-none text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500" + style="background-color: {board.prefs?.backgroundColor ?? '#0052CC'}; background-image: {board.prefs?.backgroundImage ? `url(${board.prefs.backgroundImage})` : 'none'}; background-size: cover; background-position: center;" + > +
+ + + + +
+
{board.name}
+ {#if board.desc} +
{board.desc}
+ {/if} +
+
+ {/snippet} + + {#if taskClientStore.boards.length === 0} +
+
+ +
+

No boards found

+
+ {:else} +
+ {#if starredBoards.length > 0} +
+ +
+
+ +
+

Starred Boards

+ {starredBoards.length} +
+ + +
+ {#each starredBoards as board} + {@render boardCard(board)} + {/each} +
+
+ {/if} + + {#each groupedWorkspaces as group} +
+ +
+
+ +
+

{group.name}

+ {group.boards.length} +
+ + +
+ {#each group.boards as board} + {@render boardCard(board)} + {/each} +
+
+ {/each} +
+ {/if} +
+ {/if} + + {:else if trelloView === 'board'} + +
+ {#if taskClientStore.loadingCards} +
+
+ + Loading board… +
+
+ {/if} + +
+
+ {#each taskClientStore.lists as list} + {@const listCards = taskClientStore.getCardsForList(list.id)} + {@const visibleCards = listCards.filter(c => c.id !== draggingId)} +
handleDragOver(e, list.id)} + ondrop={(e) => handleDrop(e, list.id)} + > +
+
+
{list.name}
+ {listCards.length} +
+ + + + {#if activeListActionsMenuId === list.id} + + + +
+ +
+ List actions + +
+ + +
+ + +
+ + + + +
+
+ {/if} +
+ {#if visibleCards.length > 0 || activeDropListId === list.id || addingCardListId === list.id} +
+ {#each visibleCards as card, idx} + {@const cardChecklists = taskClientStore.boardChecklists.filter(cl => cl.idCard === card.id)} + + {#if activeDropListId === list.id && activeDropIndex === idx} +
+ {/if} + + handleCardClick(e, card.id)} + draggable="true" + ondragstart={(e) => handleDragStart(e, card.id)} + ondragend={handleDragEnd} + data-card-id={card.id} + class="block p-2.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-sm transition-all cursor-pointer active:cursor-grabbing group" + > + + {#if card.labels.length > 0} +
+ {#each card.labels as label} + {@const labelColorMap: Record = { + 'green': '#61bd4f', 'yellow': '#f2d600', 'orange': '#ff9f1a', + 'red': '#eb5a46', 'purple': '#c377e0', 'blue': '#0079bf', + 'sky': '#00c2e0', 'lime': '#51e898', 'pink': '#ff78cb', 'black': '#344563' + }} + {@const bg = labelColorMap[label.color] ?? label.color ?? '#6b7280'} + + {/each} +
+ {/if} + + +
+ +

{card.name}

+
+ + +
+
+ + {#if card.due} + + + {formatCardDates(card.start, card.due)} + + {/if} + + + {#if card.badges?.description || card.desc} + + + + {/if} + + + {#if card.badges?.checkItems > 0} + + {/if} + + + {#if card.badges?.subscribed} + + + + {/if} + + + {#if card.badges?.attachments > 0} + + + {card.badges.attachments} + + {/if} + + + {#if card.badges?.comments > 0} + + + {card.badges.comments} + + {/if} +
+ + + {#if card.members?.length > 0} +
+ {#each card.members.slice(0, 3) as member} + {#if member.avatarUrl} + {member.fullName} + {:else} +
+ {member.fullName.charAt(0).toUpperCase()} +
+ {/if} + {/each} + {#if card.members.length > 3} +
+ +{card.members.length - 3} +
+ {/if} +
+ {/if} +
+ + + {#if showCardChecklists[card.id] && cardChecklists.length > 0} +
+ {#each cardChecklists as checklist} + {@const isExpanded = expandedChecklists[checklist.id] ?? false} +
+ + {#if isExpanded && checklist.checkItems && checklist.checkItems.length > 0} +
+ {#each checklist.checkItems as item} + {@const isItemComplete = item.state === 'complete'} + + {/each} +
+ {/if} +
+ {/each} +
+ {/if} +
+ {/each} + + + {#if activeDropListId === list.id && activeDropIndex === visibleCards.length} +
+ {/if} + + + {#if addingCardListId === list.id} +
+ +
+ + +
+
+ {/if} +
+ {/if} + + + {#if addingCardListId !== list.id} +
+ +
+ {/if} +
+ {/each} + {#if taskClientStore.lists.length === 0} +
+

No lists in this board

+
+ {/if} +
+
+
+ {/if} + + {:else} + +
+
+ +
+

{selectedProvider.name}

+

+ {selectedProvider.name} integration is coming soon. +

+ Coming Soon +
+ {/if} +
+
+ + { + isDetailModalOpen = false; + activeDetailCardId = null; + }} + /> + + {#if taskClientStore.error} + + + + + +
+
+
+ +
+ +

An Error Occurred

+ +

+ {taskClientStore.error} +

+ + +
+
+ {/if} + {/snippet} +
diff --git a/frontend/components/workspace/DesktopNavigator.svelte b/frontend/components/workspace/DesktopNavigator.svelte index ac8c49d3..3cb8db31 100644 --- a/frontend/components/workspace/DesktopNavigator.svelte +++ b/frontend/components/workspace/DesktopNavigator.svelte @@ -24,6 +24,8 @@ import TunnelModal from '$frontend/components/tunnel/TunnelModal.svelte'; import DbClientButton from '$frontend/components/db-client/DbClientButton.svelte'; import DbClientModal from '$frontend/components/db-client/DbClientModal.svelte'; + import TaskClientButton from '$frontend/components/task-client/TaskClientButton.svelte'; + import TaskClientModal from '$frontend/components/task-client/TaskClientModal.svelte'; import ProjectUserAvatars from '$frontend/components/common/display/ProjectUserAvatars.svelte'; import ws from '$frontend/utils/ws'; @@ -34,6 +36,7 @@ let searchQuery = $state(''); let showTunnelModal = $state(false); let showDbClientModal = $state(false); + let showTaskClientModal = $state(false); let hoveredProject = $state(null); let tooltipY = $state(0); let tooltipX = $state(0); @@ -363,6 +366,7 @@ (showTunnelModal = true)} /> (showDbClientModal = true)} /> + (showTaskClientModal = true)} /> +
+ + + + {:else} + + + {/if} + + + {#if showCardSelector} + + + +
+ +
+ + +
+ + +
+ {#if !taskClientStore.selectedBoardId} + {#if taskClientStore.accounts.length === 0} +
+ No Trello account connected. +
Please connect your account in the Task Client.
+
+ {:else} + {#if taskClientStore.loadingBoards} +
+ + Loading boards... +
+ {:else if taskClientStore.boards.length === 0} +
+ No boards found. + +
+ {:else if filteredBoards.length === 0} +
No boards match your search.
+ {:else} + {@const starred = filteredBoards.filter(b => b.isStarred)} + {@const unstarred = filteredBoards.filter(b => !b.isStarred)} + + {#if starred.length > 0} +
Starred Boards
+ {#each starred as board} + + {/each} + + {#if unstarred.length > 0} +
+ {/if} + {/if} + + {#if unstarred.length > 0} + {#if starred.length > 0} +
All Boards
+ {/if} + {#each unstarred as board} + + {/each} + {/if} + {/if} + {/if} + {:else} + +
+
+
+ +
+ Board: + {taskClientStore.selectedBoard?.name ?? 'Loading...'} +
+
+ +
+ + {#if selectedListId} + {@const activeList = taskClientStore.lists.find(l => l.id === selectedListId)} +
+
+ +
+ List: + {activeList?.name ?? 'Loading...'} +
+
+ +
+ {/if} +
+ + {#if !selectedListId} + {#if taskClientStore.loadingCards} +
+ + Loading lists... +
+ {:else if filteredLists.length === 0} +
No lists match your search.
+ {:else} +
Select a List:
+ {#each filteredLists as list} + + {/each} + {/if} + {:else} + {#if taskClientStore.loadingCards} +
+ + Loading cards... +
+ {:else if filteredCards.length === 0} +
No cards found.
+ {:else} + {#each filteredCards as card} + {@const isActive = card.id === taskClientStore.activeCardId} + {@const ticketLabel = settings.commitGenerator.ticketPrefix === 'id-short' && card.idShort ? `#${card.idShort}` : (card.shortLink || '')} + + {/each} + {/if} + {/if} + {/if} +
+
+ {/if} + + {/if}
+
+ + + + + + {#if activePopover} + {#if activePopover === 'actions'} + +
+ + + + + + + + + +
+ {:else} + +
+ {#if activePopover === 'labels'} + {@const card = taskClientStore.cards.find(c => c.id === editingCardId)} + {@const filteredLabels = taskClientStore.boardLabels.filter(label => + !searchLabelQuery.trim() || + (label.name || label.color).toLowerCase().includes(searchLabelQuery.toLowerCase()) + )} + +
+
+ + + {#if labelsPopoverMode === 'list'} + Labels + {:else if labelsPopoverMode === 'create'} + Create label + {:else if labelsPopoverMode === 'edit'} + Change label + {/if} + +
+ +
+ + {#if labelsPopoverMode === 'list'} + +
+ +
+ + +
+ {#if filteredLabels.length === 0} +
No labels found
+ {:else} + {#each filteredLabels as label} + {@const isAssigned = card?.labels.some(l => l.id === label.id)} + {@const labelColorMap: Record = { + 'green': '#61bd4f', 'yellow': '#f2d600', 'orange': '#ff9f1a', + 'red': '#eb5a46', 'purple': '#c377e0', 'blue': '#0079bf', + 'sky': '#00c2e0', 'lime': '#51e898', 'pink': '#ff78cb', 'black': '#344563' + }} + {@const bg = labelColorMap[label.color] ?? label.color ?? '#6b7280'} +
+ + + + + + + + +
+ {/each} + {/if} +
+ + +
+ + +
+ {:else if labelsPopoverMode === 'create' || labelsPopoverMode === 'edit'} + +
+ + +
+ + +
+ Select a color +
+ {#each labelColors as color} + {@const labelColorMap: Record = { + 'green': '#61bd4f', 'yellow': '#f2d600', 'orange': '#ff9f1a', + 'red': '#eb5a46', 'purple': '#c377e0', 'blue': '#0079bf', + 'sky': '#00c2e0', 'lime': '#51e898', 'pink': '#ff78cb', 'black': '#344563' + }} + {@const bg = labelColorMap[color] ?? color} + {@const isSelected = labelFormColor === color} + + {/each} +
+
+ + +
+ {#if labelsPopoverMode === 'create'} + + {:else} + + + {/if} +
+ {/if} + {:else if activePopover === 'members'} + {@const card = taskClientStore.cards.find(c => c.id === editingCardId)} + {@const filteredMembers = taskClientStore.boardMembers.filter(m => + !searchMemberQuery.trim() || + m.fullName.toLowerCase().includes(searchMemberQuery.toLowerCase()) || + m.username?.toLowerCase().includes(searchMemberQuery.toLowerCase()) + )} + +
+
+ + Members +
+ +
+ + +
+ +
+ + +
+ {#if filteredMembers.length === 0} +
No members found
+ {:else} + {#each filteredMembers as member} + {@const isAssigned = card?.members.some(m => m.id === member.id)} + + {/each} + {/if} +
+ {:else if activePopover === 'dates'} + +
+
+ + Dates +
+ +
+ + +
+
+ + +
+ + {monthNames[calendarMonth]} {calendarYear} + +
+ + +
+
+ + +
+ Su + Mo + Tu + We + Th + Fr + Sa +
+ + +
+ {#each calendarDays as cell} + {@const isSelected = isCellSelected(cell.day, cell.month, cell.year)} + + {/each} +
+ + +
+ +
+ Start date +
+ + activeDateField = 'start'} + class="flex-1 px-2.5 py-1.5 rounded bg-slate-900 text-slate-200 text-xs focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all + {activeDateField === 'start' ? 'border border-violet-500 ring-1 ring-violet-500' : 'border border-slate-700'}" + /> +
+
+ + +
+ Due date +
+ + activeDateField = 'due'} + class="flex-1 px-2.5 py-1.5 rounded bg-slate-900 text-slate-200 text-xs focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all + {activeDateField === 'due' ? 'border border-violet-500 ring-1 ring-violet-500' : 'border border-slate-700'}" + /> +
+
+ + +
+ Recurring + +
+ + +
+ Set due date reminder + +
+
+ + +
+ + +
+ {/if} +
+ {/if} + {/if} +{/if} + + + {#if taskClientStore.error} diff --git a/frontend/stores/features/settings.svelte.ts b/frontend/stores/features/settings.svelte.ts index c410af72..27eaf1a7 100644 --- a/frontend/stores/features/settings.svelte.ts +++ b/frontend/stores/features/settings.svelte.ts @@ -48,8 +48,11 @@ const defaultSettings: AppSettings = { modelName: 'Haiku 4.5', format: 'single-line', branchSeparator: DEFAULT_BRANCH_SEPARATOR, + ticketSource: 'none', + ticketPrefix: 'short-link', + ticketLanguage: 'auto', commitConfig: { style: 'technical', subjectLength: 72, allowedTypes: '', context: '' }, - branchConfig: { maxWords: 3, allowedPrefixes: '', context: '' } + branchConfig: { maxWords: 3, allowedPrefixes: '', context: '', branchMessageSeparator: '-' } }, pinnedModels: [] }; diff --git a/frontend/stores/features/task-client.svelte.ts b/frontend/stores/features/task-client.svelte.ts index e7e41af1..fe7f10c7 100644 --- a/frontend/stores/features/task-client.svelte.ts +++ b/frontend/stores/features/task-client.svelte.ts @@ -75,6 +75,8 @@ export interface TrelloList { export interface TrelloCard { id: string; + idShort?: number; + shortLink?: string; name: string; desc: string; closed: boolean; @@ -158,13 +160,13 @@ async function trelloFetch( function createTaskClientStore() { // Persisted accounts let accounts = $state(loadAccounts()); - let selectedAccountId = $state(accounts[0]?.id ?? null); + let selectedAccountId = $state(loadSelectedAccountId() ?? accounts[0]?.id ?? null); // Per-account data let boards = $state([]); let organizations = $state([]); let starredBoardIds = $state(selectedAccountId ? loadStarredBoardIdsForAccount(selectedAccountId) : []); - let selectedBoardId = $state(null); + let selectedBoardId = $state(loadSelectedBoardId()); let lists = $state([]); let cards = $state([]); @@ -179,7 +181,7 @@ function createTaskClientStore() { let boardMembers = $state<{ id: string; fullName: string; username?: string; avatarUrl: string | null }[]>([]); // Active card details - let activeCardId = $state(null); + let activeCardId = $state(loadActiveCardId()); const activeCardChecklists = $derived(boardChecklists.filter((cl) => cl.idCard === activeCardId)); let activeCardActions = $state([]); let activeCardAttachments = $state<{ id: string; name: string; url: string; date: string; mimeType: string; bytes?: number; previews: { url: string; width: number; height: number }[] }[]>([]); @@ -236,6 +238,21 @@ function createTaskClientStore() { saveRecentBoards(recentBoards); } + function loadSelectedAccountId(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem('clopen:task-client:selected-account-id'); + } + + function loadSelectedBoardId(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem('clopen:task-client:selected-board-id'); + } + + function loadActiveCardId(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem('clopen:task-client:active-card-id'); + } + function loadAccounts(): TrelloAccount[] { if (typeof localStorage === 'undefined') return []; try { @@ -287,6 +304,9 @@ function createTaskClientStore() { } saveAccounts(accounts); selectedAccountId = account.id; + if (typeof localStorage !== 'undefined') { + localStorage.setItem('clopen:task-client:selected-account-id', account.id); + } starredBoardIds = loadStarredBoardIdsForAccount(account.id); return account; } @@ -307,9 +327,19 @@ function createTaskClientStore() { if (selectedAccountId === id) { selectedAccountId = accounts[0]?.id ?? null; + if (typeof localStorage !== 'undefined') { + if (selectedAccountId) { + localStorage.setItem('clopen:task-client:selected-account-id', selectedAccountId); + } else { + localStorage.removeItem('clopen:task-client:selected-account-id'); + } + localStorage.removeItem('clopen:task-client:selected-board-id'); + localStorage.removeItem('clopen:task-client:active-card-id'); + } starredBoardIds = selectedAccountId ? loadStarredBoardIdsForAccount(selectedAccountId) : []; boards = []; selectedBoardId = null; + activeCardId = null; lists = []; cards = []; } @@ -317,9 +347,15 @@ function createTaskClientStore() { function selectAccount(id: string) { selectedAccountId = id; + if (typeof localStorage !== 'undefined') { + localStorage.setItem('clopen:task-client:selected-account-id', id); + localStorage.removeItem('clopen:task-client:selected-board-id'); + localStorage.removeItem('clopen:task-client:active-card-id'); + } starredBoardIds = loadStarredBoardIdsForAccount(id); boards = []; selectedBoardId = null; + activeCardId = null; lists = []; cards = []; error = null; @@ -354,8 +390,25 @@ function createTaskClientStore() { } } - async function selectBoard(boardId: string): Promise { + async function selectBoard(boardId: string | null): Promise { selectedBoardId = boardId; + if (typeof localStorage !== 'undefined') { + if (boardId) { + localStorage.setItem('clopen:task-client:selected-board-id', boardId); + } else { + localStorage.removeItem('clopen:task-client:selected-board-id'); + localStorage.removeItem('clopen:task-client:active-card-id'); + } + } + if (!boardId) { + lists = []; + cards = []; + boardChecklists = []; + boardLabels = []; + boardMembers = []; + activeCardId = null; + return; + } const account = accounts.find((a) => a.id === selectedAccountId); if (!account) return; loadingCards = true; @@ -370,7 +423,7 @@ function createTaskClientStore() { trelloFetch( account.apiKey, account.token, - `/boards/${boardId}/cards?filter=open&fields=id,name,desc,closed,idList,idBoard,due,dueComplete,dueReminder,start,url,labels,badges,pos&members=true&member_fields=id,fullName,username,avatarUrl` + `/boards/${boardId}/cards?filter=open&fields=id,idShort,shortLink,name,desc,closed,idList,idBoard,due,dueComplete,dueReminder,start,url,labels,badges,pos&members=true&member_fields=id,fullName,username,avatarUrl` ), trelloFetch( account.apiKey, @@ -427,6 +480,10 @@ function createTaskClientStore() { async function selectRecentBoard(boardId: string, accountId: string): Promise { selectedAccountId = accountId; + if (typeof localStorage !== 'undefined') { + localStorage.setItem('clopen:task-client:selected-account-id', accountId); + localStorage.setItem('clopen:task-client:selected-board-id', boardId); + } starredBoardIds = loadStarredBoardIdsForAccount(accountId); selectedBoardId = boardId; const account = accounts.find((a) => a.id === accountId); @@ -443,7 +500,7 @@ function createTaskClientStore() { trelloFetch( account.apiKey, account.token, - `/boards/${boardId}/cards?filter=open&fields=id,name,desc,closed,idList,idBoard,due,dueComplete,dueReminder,start,url,labels,badges,pos&members=true&member_fields=id,fullName,username,avatarUrl` + `/boards/${boardId}/cards?filter=open&fields=id,idShort,shortLink,name,desc,closed,idList,idBoard,due,dueComplete,dueReminder,start,url,labels,badges,pos&members=true&member_fields=id,fullName,username,avatarUrl` ), trelloFetch( account.apiKey, @@ -737,6 +794,38 @@ function createTaskClientStore() { } } + async function updateCardName(cardId: string, name: string): Promise { + const account = accounts.find((a) => a.id === selectedAccountId); + if (!account) return; + + const cardIdx = cards.findIndex((c) => c.id === cardId); + let originalName = ''; + if (cardIdx !== -1) { + originalName = cards[cardIdx].name; + cards[cardIdx].name = name; + } + + try { + await trelloFetch( + account.apiKey, + account.token, + `/cards/${cardId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + } + ); + } catch (e) { + debug.error('task-client', 'updateCardName failed, reverting:', e); + if (cardIdx !== -1) { + cards[cardIdx].name = originalName; + } + error = e instanceof Error ? e.message : String(e); + throw e; + } + } + async function updateCheckItemState( cardId: string, checklistId: string, @@ -1547,6 +1636,23 @@ function createTaskClientStore() { } } + // Auto-load boards and cards if we have saved account/board IDs on start + if (typeof window !== 'undefined') { + setTimeout(() => { + if (selectedAccountId) { + loadBoards().then(() => { + if (selectedBoardId) { + selectBoard(selectedBoardId).catch((e) => { + debug.error('task-client', 'Auto-load cards failed:', e); + }); + } + }).catch((e) => { + debug.error('task-client', 'Auto-load boards failed:', e); + }); + } + }, 0); + } + return { get accounts() { return accounts; }, get selectedAccountId() { return selectedAccountId; }, @@ -1564,6 +1670,16 @@ function createTaskClientStore() { get error() { return error; }, set error(val: string | null) { error = val; }, get activeCardId() { return activeCardId; }, + set activeCardId(val: string | null) { + activeCardId = val; + if (typeof localStorage !== 'undefined') { + if (val) { + localStorage.setItem('clopen:task-client:active-card-id', val); + } else { + localStorage.removeItem('clopen:task-client:active-card-id'); + } + } + }, get activeCardChecklists() { return activeCardChecklists; }, get activeCardActions() { return activeCardActions; }, get activeCardAttachments() { return activeCardAttachments; }, @@ -1588,6 +1704,7 @@ function createTaskClientStore() { archiveAllCards, fetchCardDetails, updateCardDescription, + updateCardName, updateCheckItemState, addCheckItem, addComment, diff --git a/shared/types/stores/settings.ts b/shared/types/stores/settings.ts index e1c0fcdb..d439a2ca 100644 --- a/shared/types/stores/settings.ts +++ b/shared/types/stores/settings.ts @@ -12,6 +12,7 @@ export interface BranchNameConfig { maxWords: 1 | 2 | 3; allowedPrefixes: string; context: string; + branchMessageSeparator?: string; } /** AI commit message generator settings */ @@ -25,6 +26,9 @@ export interface CommitGeneratorSettings { format: CommitMessageFormat; /** Separator between prefix and generated description, e.g. '/', '#', '-'. */ branchSeparator: string; + ticketLanguage?: 'auto' | 'en'; + ticketSource?: 'none' | 'trello'; + ticketPrefix?: 'short-link' | 'id-short'; commitConfig: CommitMessageConfig; branchConfig: BranchNameConfig; }