feat(kanban): choose a colour per column
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
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:
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user