kanban-lite 1.0.21 → 1.0.23
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/{CLAUDE.md → AGENTS.md} +13 -0
- package/CHANGELOG.md +68 -0
- package/README.md +10 -0
- package/dist/cli.js +168 -102
- package/dist/extension.js +178 -104
- package/dist/mcp-server.js +145 -95
- package/dist/sdk/index.cjs +126 -93
- package/dist/sdk/index.mjs +126 -93
- package/dist/sdk/sdk/KanbanSDK.d.ts +39 -7
- package/dist/sdk/shared/config.d.ts +4 -0
- package/dist/sdk/shared/types.d.ts +4 -0
- package/dist/standalone-webview/index.js +58 -58
- package/dist/standalone-webview/index.js.map +1 -1
- package/dist/standalone-webview/style.css +1 -1
- package/dist/standalone.js +606 -364
- package/dist/webview/index.js +57 -57
- package/dist/webview/index.js.map +1 -1
- package/dist/webview/style.css +1 -1
- package/docs/plans/2026-02-26-settings-tabs-design.md +40 -0
- package/docs/plans/2026-02-26-settings-tabs.md +166 -0
- package/docs/plans/2026-02-27-zoom-settings-design.md +82 -0
- package/docs/plans/2026-02-27-zoom-settings.md +395 -0
- package/docs/sdk.md +3 -6
- package/package.json +1 -1
- package/src/cli/index.ts +12 -2
- package/src/extension/KanbanPanel.ts +25 -5
- package/src/mcp-server/index.ts +20 -2
- package/src/sdk/KanbanSDK.ts +64 -7
- package/src/sdk/__tests__/KanbanSDK.test.ts +17 -1
- package/src/sdk/__tests__/metadata.test.ts +3 -1
- package/src/sdk/__tests__/multi-board.test.ts +2 -0
- package/src/sdk/parser.ts +50 -83
- package/src/shared/config.ts +14 -2
- package/src/shared/types.ts +4 -0
- package/src/standalone/__tests__/server.integration.test.ts +2 -2
- package/src/standalone/index.ts +7 -4
- package/src/standalone/server.ts +31 -6
- package/src/webview/App.tsx +42 -3
- package/src/webview/assets/main.css +31 -2
- package/src/webview/components/KanbanBoard.tsx +35 -3
- package/src/webview/components/KanbanColumn.tsx +40 -4
- package/src/webview/components/SettingsPanel.tsx +179 -77
- package/src/webview/components/Toolbar.tsx +127 -32
- package/src/webview/store/index.ts +26 -28
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from 'react'
|
|
2
|
-
import { Plus, MoreVertical, Pencil, Trash2 } from 'lucide-react'
|
|
2
|
+
import { Plus, MoreVertical, Pencil, Trash2, Check } from 'lucide-react'
|
|
3
3
|
import { FeatureCard } from './FeatureCard'
|
|
4
4
|
import type { Feature, KanbanColumn as KanbanColumnType } from '../../shared/types'
|
|
5
|
-
import type { LayoutMode } from '../store'
|
|
5
|
+
import type { LayoutMode, SortOrder } from '../store'
|
|
6
6
|
import type { DropTarget } from './KanbanBoard'
|
|
7
7
|
|
|
8
|
+
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
|
9
|
+
{ value: 'order', label: 'Board Order' },
|
|
10
|
+
{ value: 'created:asc', label: 'Created (oldest)' },
|
|
11
|
+
{ value: 'created:desc', label: 'Created (newest)' },
|
|
12
|
+
{ value: 'modified:asc', label: 'Modified (oldest)' },
|
|
13
|
+
{ value: 'modified:desc', label: 'Modified (newest)' },
|
|
14
|
+
]
|
|
15
|
+
|
|
8
16
|
interface KanbanColumnProps {
|
|
9
17
|
column: KanbanColumnType
|
|
10
18
|
features: Feature[]
|
|
@@ -12,6 +20,7 @@ interface KanbanColumnProps {
|
|
|
12
20
|
onAddFeature: (status: string) => void
|
|
13
21
|
onEditColumn: (columnId: string) => void
|
|
14
22
|
onRemoveColumn: (columnId: string) => void
|
|
23
|
+
onCleanupColumn: (columnId: string) => void
|
|
15
24
|
onDragStart: (e: React.DragEvent, feature: Feature) => void
|
|
16
25
|
onDragOver: (e: React.DragEvent) => void
|
|
17
26
|
onDragOverCard: (e: React.DragEvent, columnId: string, cardIndex: number) => void
|
|
@@ -23,6 +32,8 @@ interface KanbanColumnProps {
|
|
|
23
32
|
isDeletedColumn?: boolean
|
|
24
33
|
onPurgeColumn?: () => void
|
|
25
34
|
selectedFeatureId?: string
|
|
35
|
+
sort: SortOrder
|
|
36
|
+
onSortChange: (sort: SortOrder) => void
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
export function KanbanColumn({
|
|
@@ -32,6 +43,7 @@ export function KanbanColumn({
|
|
|
32
43
|
onAddFeature,
|
|
33
44
|
onEditColumn,
|
|
34
45
|
onRemoveColumn,
|
|
46
|
+
onCleanupColumn,
|
|
35
47
|
onDragStart,
|
|
36
48
|
onDragOver,
|
|
37
49
|
onDragOverCard,
|
|
@@ -42,7 +54,9 @@ export function KanbanColumn({
|
|
|
42
54
|
layout,
|
|
43
55
|
isDeletedColumn,
|
|
44
56
|
onPurgeColumn,
|
|
45
|
-
selectedFeatureId
|
|
57
|
+
selectedFeatureId,
|
|
58
|
+
sort,
|
|
59
|
+
onSortChange
|
|
46
60
|
}: KanbanColumnProps) {
|
|
47
61
|
const isVertical = layout === 'vertical'
|
|
48
62
|
const isDropTarget = dropTarget && dropTarget.columnId === column.id
|
|
@@ -114,7 +128,20 @@ export function KanbanColumn({
|
|
|
114
128
|
<MoreVertical size={16} className="text-zinc-500" />
|
|
115
129
|
</button>
|
|
116
130
|
{menuOpen && (
|
|
117
|
-
<div className="absolute right-0 top-full mt-1 z-50 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-md shadow-lg py-1 min-w-[
|
|
131
|
+
<div className="absolute right-0 top-full mt-1 z-50 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-md shadow-lg py-1 min-w-[170px]">
|
|
132
|
+
<div className="px-3 py-1 text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide">Sort by</div>
|
|
133
|
+
{SORT_OPTIONS.map((opt) => (
|
|
134
|
+
<button
|
|
135
|
+
key={opt.value}
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => { onSortChange(opt.value) }}
|
|
138
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
|
139
|
+
>
|
|
140
|
+
<Check size={14} className={sort === opt.value ? 'text-blue-500' : 'invisible'} />
|
|
141
|
+
{opt.label}
|
|
142
|
+
</button>
|
|
143
|
+
))}
|
|
144
|
+
<div className="border-t border-zinc-200 dark:border-zinc-600 my-1" />
|
|
118
145
|
<button
|
|
119
146
|
type="button"
|
|
120
147
|
onClick={() => { setMenuOpen(false); onEditColumn(column.id) }}
|
|
@@ -131,6 +158,14 @@ export function KanbanColumn({
|
|
|
131
158
|
<Trash2 size={14} />
|
|
132
159
|
Remove List
|
|
133
160
|
</button>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => { setMenuOpen(false); onCleanupColumn(column.id) }}
|
|
164
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-orange-600 dark:text-orange-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
|
165
|
+
>
|
|
166
|
+
<Trash2 size={14} />
|
|
167
|
+
Cleanup List
|
|
168
|
+
</button>
|
|
134
169
|
</div>
|
|
135
170
|
)}
|
|
136
171
|
</div>
|
|
@@ -155,6 +190,7 @@ export function KanbanColumn({
|
|
|
155
190
|
)}
|
|
156
191
|
<div
|
|
157
192
|
draggable
|
|
193
|
+
data-card-id={feature.id}
|
|
158
194
|
onDragStart={(e) => onDragStart(e, feature)}
|
|
159
195
|
onDragOver={(e) => onDragOverCard(e, column.id, index)}
|
|
160
196
|
onDragEnd={onDragEnd}
|
|
@@ -182,6 +182,53 @@ function SettingsDropdown({ label, value, options, onChange }: {
|
|
|
182
182
|
)
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
function SettingsSlider({ label, description, value, min, max, step, unit, onChange }: {
|
|
186
|
+
label: string
|
|
187
|
+
description?: string
|
|
188
|
+
value: number
|
|
189
|
+
min: number
|
|
190
|
+
max: number
|
|
191
|
+
step: number
|
|
192
|
+
unit?: string
|
|
193
|
+
onChange: (v: number) => void
|
|
194
|
+
}) {
|
|
195
|
+
const pct = ((value - min) / (max - min)) * 100
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className="flex items-center justify-between gap-4 px-4 py-2 transition-colors"
|
|
199
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
200
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
201
|
+
>
|
|
202
|
+
<div className="flex-1 min-w-0">
|
|
203
|
+
<div className="text-sm" style={{ color: 'var(--vscode-foreground)' }}>{label}</div>
|
|
204
|
+
{description && (
|
|
205
|
+
<div className="text-xs mt-0.5" style={{ color: 'var(--vscode-descriptionForeground)' }}>{description}</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
209
|
+
<input
|
|
210
|
+
type="range"
|
|
211
|
+
min={min}
|
|
212
|
+
max={max}
|
|
213
|
+
step={step}
|
|
214
|
+
value={value}
|
|
215
|
+
onChange={e => onChange(Number(e.target.value))}
|
|
216
|
+
className="settings-slider"
|
|
217
|
+
style={{
|
|
218
|
+
background: `linear-gradient(to right, var(--vscode-button-background) ${pct}%, var(--vscode-badge-background, #6b7280) ${pct}%)`,
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
<span
|
|
222
|
+
className="text-xs font-mono w-12 text-right tabular-nums"
|
|
223
|
+
style={{ color: 'var(--vscode-foreground)' }}
|
|
224
|
+
>
|
|
225
|
+
{value}{unit || '%'}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
185
232
|
function ColorPicker({ value, onChange }: { value: string; onChange: (color: string) => void }) {
|
|
186
233
|
const [isOpen, setIsOpen] = useState(false)
|
|
187
234
|
const [customHex, setCustomHex] = useState('')
|
|
@@ -509,6 +556,7 @@ function LabelsSection({ onSetLabel, onRenameLabel, onDeleteLabel }: {
|
|
|
509
556
|
|
|
510
557
|
function SettingsPanelContent({ settings, workspace, onClose, onSave, onSetLabel, onRenameLabel, onDeleteLabel }: Omit<SettingsPanelProps, 'isOpen'>) {
|
|
511
558
|
const [local, setLocal] = useState<CardDisplaySettings>(settings)
|
|
559
|
+
const [activeTab, setActiveTab] = useState<'general' | 'defaults' | 'labels'>('general')
|
|
512
560
|
|
|
513
561
|
useEffect(() => { setLocal(settings) }, [settings])
|
|
514
562
|
|
|
@@ -556,90 +604,144 @@ function SettingsPanelContent({ settings, workspace, onClose, onSave, onSetLabel
|
|
|
556
604
|
</button>
|
|
557
605
|
</div>
|
|
558
606
|
|
|
607
|
+
{/* Tab Bar */}
|
|
608
|
+
<div
|
|
609
|
+
className="flex"
|
|
610
|
+
style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
|
|
611
|
+
>
|
|
612
|
+
{(['general', 'defaults', 'labels'] as const).map(tab => (
|
|
613
|
+
<button
|
|
614
|
+
key={tab}
|
|
615
|
+
type="button"
|
|
616
|
+
onClick={() => setActiveTab(tab)}
|
|
617
|
+
className="px-4 py-2.5 text-xs font-medium transition-colors relative"
|
|
618
|
+
style={{
|
|
619
|
+
color: activeTab === tab
|
|
620
|
+
? 'var(--vscode-foreground)'
|
|
621
|
+
: 'var(--vscode-descriptionForeground)',
|
|
622
|
+
background: 'transparent',
|
|
623
|
+
}}
|
|
624
|
+
>
|
|
625
|
+
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
626
|
+
{activeTab === tab && (
|
|
627
|
+
<span
|
|
628
|
+
className="absolute bottom-0 left-0 right-0 h-0.5"
|
|
629
|
+
style={{ background: 'var(--vscode-button-background)' }}
|
|
630
|
+
/>
|
|
631
|
+
)}
|
|
632
|
+
</button>
|
|
633
|
+
))}
|
|
634
|
+
</div>
|
|
635
|
+
|
|
559
636
|
{/* Content */}
|
|
560
637
|
<div className="flex-1 overflow-auto">
|
|
561
|
-
{
|
|
638
|
+
{activeTab === 'general' && (
|
|
562
639
|
<>
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
640
|
+
{workspace && (
|
|
641
|
+
<>
|
|
642
|
+
<SettingsSection title="Workspace">
|
|
643
|
+
<SettingsInfo label="Project Path" value={workspace.projectPath} />
|
|
644
|
+
<SettingsInfo label="Features Directory" value={workspace.featuresDirectory} />
|
|
645
|
+
<SettingsInfo label="Server Port" value={String(workspace.port)} />
|
|
646
|
+
<SettingsInfo label="Config Version" value={String(workspace.configVersion)} />
|
|
647
|
+
</SettingsSection>
|
|
648
|
+
<div style={{ borderTop: '1px solid var(--vscode-panel-border)' }} />
|
|
649
|
+
</>
|
|
650
|
+
)}
|
|
651
|
+
<SettingsSection title="Card Display">
|
|
652
|
+
<SettingsToggle
|
|
653
|
+
label="Show Priority Badges"
|
|
654
|
+
description="Display priority indicators on feature cards"
|
|
655
|
+
checked={local.showPriorityBadges}
|
|
656
|
+
onChange={v => update({ showPriorityBadges: v })}
|
|
657
|
+
/>
|
|
658
|
+
<SettingsToggle
|
|
659
|
+
label="Show Assignee"
|
|
660
|
+
description="Display assigned person on feature cards"
|
|
661
|
+
checked={local.showAssignee}
|
|
662
|
+
onChange={v => update({ showAssignee: v })}
|
|
663
|
+
/>
|
|
664
|
+
<SettingsToggle
|
|
665
|
+
label="Show Due Date"
|
|
666
|
+
description="Display due dates on feature cards"
|
|
667
|
+
checked={local.showDueDate}
|
|
668
|
+
onChange={v => update({ showDueDate: v })}
|
|
669
|
+
/>
|
|
670
|
+
<SettingsToggle
|
|
671
|
+
label="Show Labels"
|
|
672
|
+
description="Display labels on feature cards and in editors"
|
|
673
|
+
checked={local.showLabels}
|
|
674
|
+
onChange={v => update({ showLabels: v })}
|
|
675
|
+
/>
|
|
676
|
+
<SettingsToggle
|
|
677
|
+
label="Show Filename"
|
|
678
|
+
description="Display the source markdown filename on cards"
|
|
679
|
+
checked={local.showFileName}
|
|
680
|
+
onChange={v => update({ showFileName: v })}
|
|
681
|
+
/>
|
|
682
|
+
<SettingsToggle
|
|
683
|
+
label="Compact Mode"
|
|
684
|
+
description="Use compact card layout to show more features"
|
|
685
|
+
checked={local.compactMode}
|
|
686
|
+
onChange={v => update({ compactMode: v })}
|
|
687
|
+
/>
|
|
688
|
+
<SettingsToggle
|
|
689
|
+
label="Show Deleted Column"
|
|
690
|
+
description="Display the Deleted column to manage soft-deleted cards"
|
|
691
|
+
checked={local.showDeletedColumn}
|
|
692
|
+
onChange={v => update({ showDeletedColumn: v })}
|
|
693
|
+
/>
|
|
568
694
|
</SettingsSection>
|
|
569
695
|
<div style={{ borderTop: '1px solid var(--vscode-panel-border)' }} />
|
|
696
|
+
<SettingsSection title="Zoom">
|
|
697
|
+
<SettingsSlider
|
|
698
|
+
label="Board Zoom"
|
|
699
|
+
description="Scale text size on the board view"
|
|
700
|
+
value={local.boardZoom}
|
|
701
|
+
min={75}
|
|
702
|
+
max={150}
|
|
703
|
+
step={5}
|
|
704
|
+
onChange={v => update({ boardZoom: v })}
|
|
705
|
+
/>
|
|
706
|
+
<SettingsSlider
|
|
707
|
+
label="Card Detail Zoom"
|
|
708
|
+
description="Scale text size in the card detail panel"
|
|
709
|
+
value={local.cardZoom}
|
|
710
|
+
min={75}
|
|
711
|
+
max={150}
|
|
712
|
+
step={5}
|
|
713
|
+
onChange={v => update({ cardZoom: v })}
|
|
714
|
+
/>
|
|
715
|
+
</SettingsSection>
|
|
570
716
|
</>
|
|
571
717
|
)}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
<
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
description="Display the source markdown filename on cards"
|
|
600
|
-
checked={local.showFileName}
|
|
601
|
-
onChange={v => update({ showFileName: v })}
|
|
602
|
-
/>
|
|
603
|
-
<SettingsToggle
|
|
604
|
-
label="Compact Mode"
|
|
605
|
-
description="Use compact card layout to show more features"
|
|
606
|
-
checked={local.compactMode}
|
|
607
|
-
onChange={v => update({ compactMode: v })}
|
|
608
|
-
/>
|
|
609
|
-
<SettingsToggle
|
|
610
|
-
label="Show Deleted Column"
|
|
611
|
-
description="Display the Deleted column to manage soft-deleted cards"
|
|
612
|
-
checked={local.showDeletedColumn}
|
|
613
|
-
onChange={v => update({ showDeletedColumn: v })}
|
|
614
|
-
/>
|
|
615
|
-
</SettingsSection>
|
|
616
|
-
|
|
617
|
-
<div style={{ borderTop: '1px solid var(--vscode-panel-border)' }} />
|
|
618
|
-
|
|
619
|
-
<SettingsSection title="Defaults">
|
|
620
|
-
<SettingsDropdown
|
|
621
|
-
label="Default Priority"
|
|
622
|
-
value={local.defaultPriority}
|
|
623
|
-
options={priorityConfig}
|
|
624
|
-
onChange={v => update({ defaultPriority: v as Priority })}
|
|
625
|
-
/>
|
|
626
|
-
<SettingsDropdown
|
|
627
|
-
label="Default Status"
|
|
628
|
-
value={local.defaultStatus}
|
|
629
|
-
options={statusConfig}
|
|
630
|
-
onChange={v => update({ defaultStatus: v as FeatureStatus })}
|
|
631
|
-
/>
|
|
632
|
-
</SettingsSection>
|
|
633
|
-
|
|
634
|
-
<div style={{ borderTop: '1px solid var(--vscode-panel-border)' }} />
|
|
635
|
-
|
|
636
|
-
<SettingsSection title="Labels">
|
|
637
|
-
<LabelsSection
|
|
638
|
-
onSetLabel={onSetLabel}
|
|
639
|
-
onRenameLabel={onRenameLabel}
|
|
640
|
-
onDeleteLabel={onDeleteLabel}
|
|
641
|
-
/>
|
|
642
|
-
</SettingsSection>
|
|
718
|
+
|
|
719
|
+
{activeTab === 'defaults' && (
|
|
720
|
+
<SettingsSection title="Defaults">
|
|
721
|
+
<SettingsDropdown
|
|
722
|
+
label="Default Priority"
|
|
723
|
+
value={local.defaultPriority}
|
|
724
|
+
options={priorityConfig}
|
|
725
|
+
onChange={v => update({ defaultPriority: v as Priority })}
|
|
726
|
+
/>
|
|
727
|
+
<SettingsDropdown
|
|
728
|
+
label="Default Status"
|
|
729
|
+
value={local.defaultStatus}
|
|
730
|
+
options={statusConfig}
|
|
731
|
+
onChange={v => update({ defaultStatus: v as FeatureStatus })}
|
|
732
|
+
/>
|
|
733
|
+
</SettingsSection>
|
|
734
|
+
)}
|
|
735
|
+
|
|
736
|
+
{activeTab === 'labels' && (
|
|
737
|
+
<SettingsSection title="Labels">
|
|
738
|
+
<LabelsSection
|
|
739
|
+
onSetLabel={onSetLabel}
|
|
740
|
+
onRenameLabel={onRenameLabel}
|
|
741
|
+
onDeleteLabel={onDeleteLabel}
|
|
742
|
+
/>
|
|
743
|
+
</SettingsSection>
|
|
744
|
+
)}
|
|
643
745
|
</div>
|
|
644
746
|
|
|
645
747
|
{/* Footer */}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
|
2
|
-
import { Search, X, Columns, Rows, Settings, Plus, Moon, Sun, ChevronDown, Check } from 'lucide-react'
|
|
3
|
-
import { useStore, type DueDateFilter
|
|
2
|
+
import { Search, X, Columns, Rows, Settings, Plus, Moon, Sun, ChevronDown, Check, Tag } from 'lucide-react'
|
|
3
|
+
import { useStore, type DueDateFilter } from '../store'
|
|
4
4
|
import type { Priority } from '../../shared/types'
|
|
5
5
|
|
|
6
6
|
const priorities: { value: Priority | 'all'; label: string }[] = [
|
|
@@ -34,8 +34,6 @@ export function Toolbar({ onOpenSettings, onAddColumn, onToggleTheme, onSwitchBo
|
|
|
34
34
|
setLabelFilter,
|
|
35
35
|
dueDateFilter,
|
|
36
36
|
setDueDateFilter,
|
|
37
|
-
sortOrder,
|
|
38
|
-
setSortOrder,
|
|
39
37
|
clearAllFilters,
|
|
40
38
|
getUniqueAssignees,
|
|
41
39
|
getUniqueLabels,
|
|
@@ -71,6 +69,21 @@ export function Toolbar({ onOpenSettings, onAddColumn, onToggleTheme, onSwitchBo
|
|
|
71
69
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
72
70
|
const newBoardInputRef = useRef<HTMLInputElement>(null)
|
|
73
71
|
|
|
72
|
+
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false)
|
|
73
|
+
const [labelSearch, setLabelSearch] = useState('')
|
|
74
|
+
const labelDropdownRef = useRef<HTMLDivElement>(null)
|
|
75
|
+
|
|
76
|
+
const filteredGroupedLabels = useMemo(() => {
|
|
77
|
+
if (!labelSearch) return groupedLabels
|
|
78
|
+
const q = labelSearch.toLowerCase()
|
|
79
|
+
const result: Record<string, string[]> = {}
|
|
80
|
+
Object.entries(groupedLabels).forEach(([group, groupLabels]) => {
|
|
81
|
+
const matched = groupLabels.filter(l => l.toLowerCase().includes(q))
|
|
82
|
+
if (matched.length > 0) result[group] = matched
|
|
83
|
+
})
|
|
84
|
+
return result
|
|
85
|
+
}, [groupedLabels, labelSearch])
|
|
86
|
+
|
|
74
87
|
useEffect(() => {
|
|
75
88
|
const handleClickOutside = (e: MouseEvent) => {
|
|
76
89
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
@@ -85,6 +98,19 @@ export function Toolbar({ onOpenSettings, onAddColumn, onToggleTheme, onSwitchBo
|
|
|
85
98
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
86
99
|
}, [boardDropdownOpen])
|
|
87
100
|
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
103
|
+
if (labelDropdownRef.current && !labelDropdownRef.current.contains(e.target as Node)) {
|
|
104
|
+
setLabelDropdownOpen(false)
|
|
105
|
+
setLabelSearch('')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (labelDropdownOpen) {
|
|
109
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
110
|
+
}
|
|
111
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
112
|
+
}, [labelDropdownOpen])
|
|
113
|
+
|
|
88
114
|
useEffect(() => {
|
|
89
115
|
if (creatingBoard && newBoardInputRef.current) {
|
|
90
116
|
newBoardInputRef.current.focus()
|
|
@@ -209,21 +235,103 @@ export function Toolbar({ onOpenSettings, onAddColumn, onToggleTheme, onSwitchBo
|
|
|
209
235
|
|
|
210
236
|
{/* Label Filter */}
|
|
211
237
|
{cardSettings.showLabels && (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
238
|
+
<div className="relative" ref={labelDropdownRef}>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={() => setLabelDropdownOpen(!labelDropdownOpen)}
|
|
242
|
+
className={`flex items-center gap-1.5 text-sm bg-white dark:bg-zinc-800 border rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 text-zinc-900 dark:text-zinc-100 transition-colors ${
|
|
243
|
+
labelFilter.length > 0
|
|
244
|
+
? 'border-blue-500 dark:border-blue-400'
|
|
245
|
+
: 'border-zinc-200 dark:border-zinc-600'
|
|
246
|
+
}`}
|
|
247
|
+
>
|
|
248
|
+
<Tag size={14} className="text-zinc-400 shrink-0" />
|
|
249
|
+
<span className="max-w-[100px] truncate">
|
|
250
|
+
{labelFilter.length === 0
|
|
251
|
+
? 'All Labels'
|
|
252
|
+
: labelFilter.length === 1
|
|
253
|
+
? labelFilter[0]
|
|
254
|
+
: `${labelFilter.length} Labels`}
|
|
255
|
+
</span>
|
|
256
|
+
<ChevronDown size={14} className={`text-zinc-400 transition-transform ${labelDropdownOpen ? 'rotate-180' : ''}`} />
|
|
257
|
+
</button>
|
|
258
|
+
{labelDropdownOpen && (
|
|
259
|
+
<div className="absolute top-full left-0 mt-1 min-w-[200px] max-h-72 overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-md shadow-lg z-50 py-1">
|
|
260
|
+
{/* Search */}
|
|
261
|
+
<div className="px-2 pb-1">
|
|
262
|
+
<input
|
|
263
|
+
type="text"
|
|
264
|
+
value={labelSearch}
|
|
265
|
+
onChange={(e) => setLabelSearch(e.target.value)}
|
|
266
|
+
placeholder="Filter labels..."
|
|
267
|
+
autoFocus
|
|
268
|
+
className="w-full px-2 py-1 text-sm bg-zinc-50 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-500 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400"
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
{/* All Labels option */}
|
|
272
|
+
{!labelSearch && (
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
onClick={() => setLabelFilter([])}
|
|
276
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors ${
|
|
277
|
+
labelFilter.length === 0 ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-zinc-900 dark:text-zinc-100'
|
|
278
|
+
}`}
|
|
279
|
+
>
|
|
280
|
+
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
|
281
|
+
labelFilter.length === 0 ? 'border-blue-500 bg-blue-500' : 'border-zinc-300 dark:border-zinc-500'
|
|
282
|
+
}`}>
|
|
283
|
+
{labelFilter.length === 0 && <Check size={10} className="text-white" />}
|
|
284
|
+
</div>
|
|
285
|
+
<span>All Labels</span>
|
|
286
|
+
</button>
|
|
287
|
+
)}
|
|
288
|
+
{/* Grouped labels */}
|
|
289
|
+
{Object.entries(filteredGroupedLabels).map(([group, groupLabels]) => (
|
|
290
|
+
<div key={group}>
|
|
291
|
+
{Object.keys(filteredGroupedLabels).length > 1 && (
|
|
292
|
+
<div className="px-3 pt-1.5 pb-0.5 text-xs font-semibold text-zinc-400 dark:text-zinc-500 uppercase tracking-wide">
|
|
293
|
+
{group}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
{groupLabels.map((l) => {
|
|
297
|
+
const def = labelDefs[l]
|
|
298
|
+
const checked = labelFilter.includes(l)
|
|
299
|
+
return (
|
|
300
|
+
<button
|
|
301
|
+
key={l}
|
|
302
|
+
type="button"
|
|
303
|
+
onClick={() => {
|
|
304
|
+
if (checked) {
|
|
305
|
+
setLabelFilter(labelFilter.filter(x => x !== l))
|
|
306
|
+
} else {
|
|
307
|
+
setLabelFilter([...labelFilter, l])
|
|
308
|
+
}
|
|
309
|
+
}}
|
|
310
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-900 dark:text-zinc-100 transition-colors"
|
|
311
|
+
>
|
|
312
|
+
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
|
313
|
+
checked ? 'border-blue-500 bg-blue-500' : 'border-zinc-300 dark:border-zinc-500'
|
|
314
|
+
}`}>
|
|
315
|
+
{checked && <Check size={10} className="text-white" />}
|
|
316
|
+
</div>
|
|
317
|
+
{def?.color && (
|
|
318
|
+
<span
|
|
319
|
+
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
320
|
+
style={{ backgroundColor: def.color }}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
<span className="truncate">{l}</span>
|
|
324
|
+
</button>
|
|
325
|
+
)
|
|
326
|
+
})}
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
329
|
+
{Object.keys(filteredGroupedLabels).length === 0 && (
|
|
330
|
+
<div className="px-3 py-2 text-sm text-zinc-400 dark:text-zinc-500">No labels found</div>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
227
335
|
)}
|
|
228
336
|
|
|
229
337
|
{/* Due Date Filter */}
|
|
@@ -241,19 +349,6 @@ export function Toolbar({ onOpenSettings, onAddColumn, onToggleTheme, onSwitchBo
|
|
|
241
349
|
</select>
|
|
242
350
|
)}
|
|
243
351
|
|
|
244
|
-
{/* Sort Order */}
|
|
245
|
-
<select
|
|
246
|
-
value={sortOrder}
|
|
247
|
-
onChange={(e) => setSortOrder(e.target.value as SortOrder)}
|
|
248
|
-
className={selectClassName}
|
|
249
|
-
>
|
|
250
|
-
<option value="order">Board Order</option>
|
|
251
|
-
<option value="created:asc">Created (oldest)</option>
|
|
252
|
-
<option value="created:desc">Created (newest)</option>
|
|
253
|
-
<option value="modified:asc">Modified (oldest)</option>
|
|
254
|
-
<option value="modified:desc">Modified (newest)</option>
|
|
255
|
-
</select>
|
|
256
|
-
|
|
257
352
|
{/* Clear Filters Button */}
|
|
258
353
|
{filtersActive && (
|
|
259
354
|
<button
|