feat(kanban): choose a colour per column
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

"Spalten anpassen" gets a colour dot per column that opens the choice: grey, anthracite (theme
text colour), green, yellow, blue, red, purple or the accent. columnDotClass reads the column's
colour (server defaults emerald/amber count as green/yellow; columns without one keep the colour
of their role) and drives the Kanban header, the status dot in the list view and the task detail.
Blue and purple come from new theme variables --color-info / --color-purple with fallbacks for
light and dark themes. Docs and changelog updated; v0.31.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 11:00:17 +01:00
1 parent b883315914
commit 08081e6c1d
11 files changed
+219 -77

No files matched your search

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
X,
Plus,
@@ -28,17 +28,32 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
];
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
// Dot colour of a status column (Kanban header, list view); fixed by column role for now
// Colours a column can get; all come from the theme (blue and purple via --color-info / --color-purple)
export const COLUMN_COLORS: { id: string; label: string; cls: string }[] = [
{ id: 'gray', label: 'Grau', cls: 'bg-faint' },
{ id: 'slate', label: 'Anthrazit', cls: 'bg-main' },
{ id: 'green', label: 'Grün', cls: 'bg-status-ok' },
{ id: 'yellow', label: 'Gelb', cls: 'bg-status-warn' },
{ id: 'blue', label: 'Blau', cls: 'bg-tone-blue' },
{ id: 'red', label: 'Rot', cls: 'bg-status-err' },
{ id: 'purple', label: 'Lila', cls: 'bg-tone-purple' },
{ id: 'accent', label: 'Akzentfarbe', cls: 'bg-accent' },
];
// Names the server's default columns use
const COLOR_ALIASES: Record<string, string> = { emerald: 'green', amber: 'yellow' };
const columnColorId = (col: KanbanColumn): string => {
if (col.color) return COLOR_ALIASES[col.color] || col.color;
// Columns without a colour (older boards) keep the colour of their role
if (col.isDone) return 'green';
if (col.id === 'in_progress') return 'accent';
if (col.id === 'waiting') return 'yellow';
return 'gray';
};
// Dot colour of a status column (Kanban header, list view, task detail)
export const columnDotClass = (col: KanbanColumn | undefined): string =>
!col
? 'bg-faint'
: col.isDone
? 'bg-status-ok'
: col.id === 'in_progress'
? 'bg-accent'
: col.id === 'waiting'
? 'bg-status-warn'
: 'bg-faint';
(col && COLUMN_COLORS.find((c) => c.id === columnColorId(col))?.cls) || 'bg-faint';
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
@@ -56,6 +71,44 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
const [newColName, setNewColName] = useState('');
const [newColIsDone, setNewColIsDone] = useState(false);
const [saving, setSaving] = useState(false);
// Colour menu: opens at the column's dot like a context menu (fixed, so the scrolling list cannot clip it)
const [colorMenu, setColorMenu] = useState<{ id: string; left: number; top?: number; bottom?: number } | null>(null);
const colorMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorMenu) return;
const close = (e: Event) => {
const target = e.target as HTMLElement;
if (colorMenuRef.current?.contains(target) || target.closest?.('[data-color-trigger]')) return;
setColorMenu(null);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setColorMenu(null);
const dismiss = () => setColorMenu(null);
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', dismiss);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', dismiss);
};
}, [colorMenu]);
const openColorMenu = (id: string, trigger: HTMLElement) => {
if (colorMenu?.id === id) {
setColorMenu(null);
return;
}
const rect = trigger.getBoundingClientRect();
const menuHeight = COLUMN_COLORS.length * 36 + 12;
const left = Math.min(rect.left, window.innerWidth - 200);
// Below the dot if there is room, else above it
setColorMenu(
rect.bottom + menuHeight + 8 < window.innerHeight
? { id, left, top: rect.bottom + 4 }
: { id, left, bottom: window.innerHeight - rect.top + 4 }
);
};
useEffect(() => {
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
@@ -78,6 +131,12 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
);
};
const handleColor = (id: string, color: string) => {
triggerHaptic(10);
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, color } : c)));
setColorMenu(null);
};
const handleToggleDone = (id: string) => {
triggerHaptic(10);
setColumns((prev) =>
@@ -172,65 +231,77 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
{/* Columns List */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
<div className="space-y-1.5 max-h-[45vh] overflow-y-auto pr-1" onScroll={() => setColorMenu(null)}>
{columns.map((col, idx) => (
<div
key={col.id}
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
>
<div className="flex flex-col gap-0.5">
<div key={col.id} className="p-2 bg-canvas border border-border rounded-card space-y-2">
<div className="flex items-center gap-2">
<div className="flex flex-col gap-0.5">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMove(idx, 'up')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach oben"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
type="button"
disabled={idx === columns.length - 1}
onClick={() => handleMove(idx, 'down')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach unten"
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
<button
type="button"
disabled={idx === 0}
onClick={() => handleMove(idx, 'up')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach oben"
data-color-trigger
onClick={(e) => openColorMenu(col.id, e.currentTarget)}
className="w-7 h-7 flex items-center justify-center rounded-btn border border-border bg-panel hover:border-strong flex-shrink-0"
title="Farbe der Spalte"
aria-label={`Farbe der Spalte ${col.name}`}
aria-haspopup="menu"
aria-expanded={colorMenu?.id === col.id}
>
<ArrowUp className="w-3 h-3" />
<span className={`w-3 h-3 rounded-full ${columnDotClass(col)}`} />
</button>
<input
type="text"
value={col.name}
onChange={(e) => handleRename(col.id, e.target.value)}
className="flex-1 h-7 px-2 text-xs input-field"
placeholder="Spaltenname"
/>
{/* IsDone Toggle */}
<button
type="button"
disabled={idx === columns.length - 1}
onClick={() => handleMove(idx, 'down')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach unten"
onClick={() => handleToggleDone(col.id)}
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
col.isDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-faint hover:text-muted'
}`}
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
>
<ArrowDown className="w-3 h-3" />
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
<span>Erledigt</span>
</button>
<button
type="button"
onClick={() => handleDelete(col.id)}
disabled={columns.length <= 1}
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
title="Spalte löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={col.name}
onChange={(e) => handleRename(col.id, e.target.value)}
className="flex-1 h-7 px-2 text-xs input-field"
placeholder="Spaltenname"
/>
{/* IsDone Toggle */}
<button
type="button"
onClick={() => handleToggleDone(col.id)}
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
col.isDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-faint hover:text-muted'
}`}
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
>
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
<span>Erledigt</span>
</button>
<button
type="button"
onClick={() => handleDelete(col.id)}
disabled={columns.length <= 1}
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
title="Spalte löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
@@ -303,6 +374,42 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
</button>
</div>
</div>
{colorMenu &&
(() => {
const col = columns.find((c) => c.id === colorMenu.id);
if (!col) return null;
return (
<div
ref={colorMenuRef}
role="menu"
aria-label={`Farbe der Spalte ${col.name}`}
style={{ left: colorMenu.left, top: colorMenu.top, bottom: colorMenu.bottom }}
className="fixed z-[60] w-44 p-1 bg-panel border border-border rounded-card shadow-panel"
onClick={(e) => e.stopPropagation()}
>
{COLUMN_COLORS.map((c) => {
const active = columnColorId(col) === c.id;
return (
<button
key={c.id}
type="button"
role="menuitemradio"
aria-checked={active}
onClick={() => handleColor(col.id, c.id)}
className={`w-full h-11 sm:h-8 px-2 rounded-btn text-xs flex items-center gap-2 text-left transition-colors ${
active ? 'bg-accent/10 text-main' : 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${c.cls}`} />
<span className="flex-1">{c.label}</span>
{active && <Check className="w-3 h-3 text-accent stroke-[3]" />}
</button>
);
})}
</div>
);
})()}
</div>
);
};