diff --git a/backend/ws/git/branch-name.ts b/backend/ws/git/branch-name.ts index 5828fa81..e2444087 100644 --- a/backend/ws/git/branch-name.ts +++ b/backend/ws/git/branch-name.ts @@ -148,7 +148,7 @@ export const branchNameHandler = createRouter() const diffResult = await execGit(['diff', '--cached'], cwd); const rawDiff = diffResult.stdout; - if (!rawDiff.trim()) { + if (!rawDiff.trim() && !data.customPrompt) { throw new Error('No staged changes to generate a branch name for'); } 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/git/CommitForm.svelte b/frontend/components/git/CommitForm.svelte index e66e91fb..3ee3c1e1 100644 --- a/frontend/components/git/CommitForm.svelte +++ b/frontend/components/git/CommitForm.svelte @@ -5,6 +5,7 @@ import Icon from '$frontend/components/common/display/Icon.svelte'; import { clickOutside } from '$frontend/utils/click-outside'; import { settings } from '$frontend/stores/features/settings.svelte'; + import { taskClientStore } from '$frontend/stores/features/task-client.svelte'; import { projectState } from '$frontend/stores/core/projects.svelte'; import { showError } from '$frontend/stores/ui/notification.svelte'; import { @@ -87,6 +88,7 @@ let branchNameDraft = $state(''); let showBranchDraft = $state(false); let showGenerateDropdown = $state(false); + let showBranchSubmenu = $state(false); let generateBtnEl = $state(null); let generateMenuStyle = $state(''); @@ -98,11 +100,17 @@ ? `right: ${window.innerWidth - rect.right}px; bottom: ${window.innerHeight - rect.top + 6}px;` : `right: ${window.innerWidth - rect.right}px; top: ${rect.bottom + 6}px;`; showGenerateDropdown = true; + showBranchSubmenu = settings.commitGenerator.ticketSource === 'trello' && !!taskClientStore.selectedBoardId; } function handleGenerateClick() { if (!onCreateBranch) { generateCommitMessage(); return; } - showGenerateDropdown ? (showGenerateDropdown = false) : openGenerateMenu(); + if (showGenerateDropdown) { + showGenerateDropdown = false; + showBranchSubmenu = false; + } else { + openGenerateMenu(); + } } function generateBoth() { @@ -181,11 +189,29 @@ return parts.join('\n'); } - function buildBranchExtra(): string { + function buildBranchExtra(source?: 'card' | 'diff'): string { const config = settings.commitGenerator.branchConfig; - if (!config) return ''; - const { maxWords, allowedPrefixes, context } = config; const parts: string[] = []; + + const useCard = source === 'card' || (source === undefined && settings.commitGenerator.ticketSource === 'trello' && taskClientStore.activeCardId); + + if (useCard) { + const activeCardId = taskClientStore.activeCardId; + if (activeCardId) { + const activeCard = taskClientStore.cards.find(c => c.id === activeCardId); + if (activeCard) { + parts.push(`CRITICAL: The branch description MUST be generated directly from this task card title: "${activeCard.name}".`); + + const ticketLanguage = settings.commitGenerator.ticketLanguage; + if (ticketLanguage === 'en') { + parts.push('Write/translate the branch description in English.'); + } + } + } + } + + if (!config) return parts.join('\n'); + const { maxWords, allowedPrefixes, context } = config; if (maxWords !== 3) parts.push(`Description must be at most ${maxWords} word${maxWords === 1 ? '' : 's'}.`); const prefixes = (allowedPrefixes ?? '').split(',').map(s => s.trim()).filter(Boolean); if (prefixes.length) parts.push(`Prefix must be one of: ${prefixes.join(', ')}.`); @@ -230,17 +256,43 @@ } } - async function generateBranchName() { + async function generateBranchName(source?: 'card' | 'diff') { const projectId = projectState.currentProject?.id; if (!projectId || stagedCount === 0 || isGeneratingBranch || repoBusy) return; setGitOp(projectId, 'isGeneratingBranch', true, repoPath); try { - const { useCustomModel, engine, provider, modelId, branchSeparator, branchConfig } = settings.commitGenerator; + const { useCustomModel, engine, provider, modelId, branchSeparator, branchConfig, ticketSource, ticketPrefix } = settings.commitGenerator; + const msgSep = branchConfig?.branchMessageSeparator !== undefined ? branchConfig.branchMessageSeparator : '-'; + + // 1. Get the ticket ID/prefix if enabled and active + let ticketId = ''; + if (ticketSource === 'trello') { + const activeCardId = taskClientStore.activeCardId; + if (activeCardId) { + const activeCard = taskClientStore.cards.find(c => c.id === activeCardId); + if (activeCard) { + if (ticketPrefix === 'id-short' && activeCard.idShort) { + ticketId = String(activeCard.idShort); + } else if (activeCard.shortLink) { + ticketId = activeCard.shortLink; + } else { + // fallback to parsing shortLink from Trello card URL + const match = activeCard.url?.match(/\/c\/([a-zA-Z0-9]+)/); + if (match) { + ticketId = match[1]; + } + } + } + } + } + + // Fallback to AI generation const resolvedEngine = useCustomModel ? engine : settings.selectedEngine; const resolvedProvider = useCustomModel ? provider : settings.selectedProvider; const resolvedModel = useCustomModel ? modelId : settings.selectedModelId; - const extra = buildBranchExtra(); + const extra = buildBranchExtra(source); + const result = await ws.http('git:generate-branch-name', { projectId, engine: resolvedEngine, @@ -251,7 +303,33 @@ ...(repoPath && { repoPath }), ...(extra && { customPrompt: extra }) }); - branchNameDraft = result.branchName; + + let branchName = result.branchName; + + // Find the separator index that divides the prefix and the description + const sepIdx = branchSeparator ? branchName.indexOf(branchSeparator) : -1; + let prefix = ''; + let description = branchName; + if (sepIdx !== -1) { + prefix = branchName.slice(0, sepIdx + branchSeparator.length); + description = branchName.slice(sepIdx + branchSeparator.length); + } + + // Format the description with the custom branch message separator (defaulting to '-') + let formattedDesc = description.split('-').join(msgSep); + + // 2. Prepends the ticket ID if found + if (ticketId) { + if (formattedDesc) { + formattedDesc = `${ticketId}${msgSep}${formattedDesc}`; + } else { + formattedDesc = ticketId; + } + } + + branchName = `${prefix}${formattedDesc}`; + + branchNameDraft = branchName; showBranchDraft = true; } catch (err) { showError('Generate Branch Failed', err instanceof Error ? err.message : 'Failed to generate branch name'); @@ -275,9 +353,320 @@ setGitOp(projectId, 'isCreatingBranch', false, repoPath); } } + + let showCardSelector = $state(false); + let cardSearchQuery = $state(''); + let selectedListId = $state(null); + let listContainerEl = $state(null); + + $effect(() => { + if (listContainerEl) { + // Trigger on dropdown open, board change, or list change + const _open = showCardSelector; + const _board = taskClientStore.selectedBoardId; + const _list = selectedListId; + listContainerEl.scrollTop = 0; + } + }); + + const filteredCards = $derived( + taskClientStore.cards.filter(c => + c.idList === selectedListId && ( + !cardSearchQuery.trim() || + c.name.toLowerCase().includes(cardSearchQuery.toLowerCase()) || + String(c.idShort || '').includes(cardSearchQuery) || + (c.shortLink || '').toLowerCase().includes(cardSearchQuery.toLowerCase()) + ) + ) + ); + + const filteredLists = $derived( + taskClientStore.lists.filter(l => + !cardSearchQuery.trim() || + l.name.toLowerCase().includes(cardSearchQuery.toLowerCase()) + ) + ); + + function openCardSelector() { + showCardSelector = true; + cardSearchQuery = ''; + if (taskClientStore.activeCardId) { + const activeCard = taskClientStore.cards.find(c => c.id === taskClientStore.activeCardId); + if (activeCard) { + selectedListId = activeCard.idList; + return; + } + } + selectedListId = null; + } + + const sortedBoards = $derived( + taskClientStore.boards.map(board => ({ + ...board, + isStarred: taskClientStore.boardStars.some(s => s.idBoard === board.id) + })).sort((a, b) => { + if (a.isStarred && !b.isStarred) return -1; + if (!a.isStarred && b.isStarred) return 1; + return a.name.localeCompare(b.name); + }) + ); + + const filteredBoards = $derived( + sortedBoards.filter(b => + !cardSearchQuery.trim() || + b.name.toLowerCase().includes(cardSearchQuery.toLowerCase()) + ) + ); + + function selectTrelloCard(cardId: string) { + taskClientStore.activeCardId = cardId; + showCardSelector = false; + } + + function clearActiveCard() { + taskClientStore.activeCardId = null; + }
+ {#if settings.commitGenerator.ticketSource === 'trello'} + {@const activeCard = taskClientStore.cards.find(c => c.id === taskClientStore.activeCardId)} +
+ {#if activeCard} + {@const activeTicketLabel = settings.commitGenerator.ticketPrefix === 'id-short' && activeCard.idShort ? `#${activeCard.idShort}` : (activeCard.shortLink || '')} + +
+
+
+ +
+ {#if activeTicketLabel} + + {activeTicketLabel} + + {/if} + {#if activeCard.url} + + {activeCard.name} + + {:else} + {activeCard.name} + {/if} +
+
+ +
+ +
+
+ {: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}
+
+
+ + +
+
+ + + +
+
+
+ {:else} +
+ handleToggleCheckItem(checklist.id, item.id, item.state)} + class="w-3.5 h-3.5 accent-green-500 rounded bg-slate-950 border border-slate-700 cursor-pointer shrink-0" + /> + +
+ + {/if} +
+ {/each} + + {/if} + {/if} + + +
+ {#if activeAddCheckItemId === checklist.id} +
+ { + if (e.key === 'Enter') handleAddCheckItem(checklist.id); + if (e.key === 'Escape') activeAddCheckItemId = null; + }} + class="w-full px-3 py-1.5 rounded-lg border border-slate-700 bg-slate-950 text-xs text-slate-100 focus:outline-none focus:ring-1 focus:ring-violet-500" + /> +
+ + +
+
+ {:else} + + {/if} +
+ + {/each} + + {/if} + + + +
+ +
+
+ + Comments and activity +
+ +
+ + +
+ + + {#if isWritingComment} +
+ +
+ + + + +
+
+ {:else} + + {/if} + + +
+ {#each filteredActions as action} +
+ + {#if action.memberCreator?.avatarUrl} + + {:else} +
+ {action.memberCreator?.fullName.charAt(0).toUpperCase()} +
+ {/if} + + +
+
+ {action.memberCreator?.fullName} + {#if action.type === 'commentCard'} + commented: + {:else} + {formatActionText(action).replace(action.memberCreator?.fullName || '', '').trim()} + {/if} +
+
+ {new Date(action.date).toLocaleString('id-ID', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ {#if action.type === 'commentCard' && action.data?.text} + {#if editingCommentId === action.id} +
+ +
+ + +
+
+ {:else} +
+ {@html renderMarkdownWithMentions(action.data.text || '', false)} +
+
+ + + +
+ {/if} + {/if} +
+
+ {/each} + + {#if filteredActions.length === 0} +

No recent activity

+ {/if} +
+ +
+ + +
+ Card Actions +
+ + +
+
+
+ + + + + + {#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..ec9dc1dd --- /dev/null +++ b/frontend/components/task-client/TaskClientModal.svelte @@ -0,0 +1,2573 @@ + + + + + + {#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={editingCardId !== card.id} + ondragstart={(e) => handleDragStart(e, card.id)} + ondragend={handleDragEnd} + data-card-id={card.id} + class="relative 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 {editingCardId === card.id ? 'opacity-0 pointer-events-none' : ''}" + > + + {#if editingCardId !== card.id} + + {/if} + + {#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; + initialSection = null; + }} + /> + + {#if editingCardId && editingCardRect} + {@const card = taskClientStore.cards.find(c => c.id === editingCardId)} + + + + +
+
+ + {#if card && 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} + + +
+ + +
+ + + {#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} + + + + + +
+
+
+ +
+ +

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)} />