feat: kanban card detail modal with full task editing

Click any kanban card to open a Trello-style detail modal with:
- Title editing
- Project assignment with dropdown
- Kanban stage selector
- Due date picker
- Someday list assignment
- Markdown notes editor
- Subtask management (add, toggle, edit, delete)
- Task deletion
Responsive design: slides up as bottom sheet on mobile.

v1.55.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-21 22:31:10 +01:00
parent aece7049c0
commit ca7911c7ef
3 changed files with 460 additions and 3 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.54.1",
"version": "1.55.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -1972,6 +1972,258 @@ h3 {
}
}
/* Kanban Detail Modal */
.kanban-detail-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: kanbanFadeIn 0.15s ease;
}
@keyframes kanbanFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.kanban-detail-modal {
background: var(--weekly-bg, #fff);
border-radius: 12px;
width: 100%;
max-width: 560px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: kanbanSlideUp 0.2s ease;
overflow: hidden;
}
@keyframes kanbanSlideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.kanban-detail-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid var(--weekly-border, #e5e7eb);
}
.kanban-detail-title-input {
flex: 1;
font-size: 1.1rem;
font-weight: 600;
border: none;
background: transparent;
color: var(--weekly-text, #111);
outline: none;
font-family: inherit;
}
.kanban-detail-title-input:focus {
border-bottom: 2px solid var(--weekly-accent, #6366f1);
}
.kanban-detail-close {
background: none;
border: none;
cursor: pointer;
color: var(--weekly-text-muted, #9ca3af);
padding: 4px;
border-radius: 6px;
display: flex;
}
.kanban-detail-close:hover {
background: var(--weekly-hover, rgba(0,0,0,0.05));
color: var(--weekly-text, #111);
}
.kanban-detail-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.kanban-detail-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.kanban-detail-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.kanban-detail-field label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--weekly-text-muted, #9ca3af);
display: flex;
align-items: center;
gap: 4px;
}
.kanban-detail-select,
.kanban-detail-date {
padding: 6px 8px;
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 6px;
background: var(--weekly-bg, #fff);
color: var(--weekly-text, #111);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.kanban-detail-select:focus,
.kanban-detail-date:focus {
outline: none;
border-color: var(--weekly-accent, #6366f1);
}
.kanban-detail-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.kanban-detail-section > label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--weekly-text-muted, #9ca3af);
display: flex;
align-items: center;
gap: 4px;
}
.kanban-detail-notes {
padding: 10px 12px;
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 8px;
background: var(--weekly-bg, #fff);
color: var(--weekly-text, #111);
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
min-height: 80px;
line-height: 1.5;
}
.kanban-detail-notes:focus {
outline: none;
border-color: var(--weekly-accent, #6366f1);
}
.kanban-detail-subtasks {
display: flex;
flex-direction: column;
gap: 4px;
}
.kanban-detail-subtask {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.kanban-detail-subtask input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
flex-shrink: 0;
}
.kanban-detail-subtask-title {
flex: 1;
border: none;
background: transparent;
color: var(--weekly-text, #111);
font-size: 0.85rem;
font-family: inherit;
outline: none;
padding: 2px 4px;
border-radius: 4px;
}
.kanban-detail-subtask-title:focus {
background: var(--weekly-hover, rgba(0,0,0,0.03));
}
.kanban-detail-subtask-title.completed {
text-decoration: line-through;
opacity: 0.5;
}
.kanban-detail-subtask-delete {
background: none;
border: none;
cursor: pointer;
color: var(--weekly-text-muted, #d1d5db);
padding: 2px;
border-radius: 4px;
display: flex;
opacity: 0;
transition: opacity 0.1s;
}
.kanban-detail-subtask:hover .kanban-detail-subtask-delete {
opacity: 1;
}
.kanban-detail-subtask-delete:hover {
color: #ef4444;
background: rgba(239,68,68,0.08);
}
.kanban-detail-subtask-add {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
color: var(--weekly-text-muted, #9ca3af);
}
.kanban-detail-subtask-input {
flex: 1;
border: none;
background: transparent;
color: var(--weekly-text, #111);
font-size: 0.85rem;
font-family: inherit;
outline: none;
padding: 2px 4px;
}
.kanban-detail-subtask-input::placeholder {
color: var(--weekly-text-muted, #d1d5db);
}
.kanban-detail-footer {
padding: 12px 20px;
border-top: 1px solid var(--weekly-border, #e5e7eb);
display: flex;
justify-content: flex-end;
}
.kanban-detail-delete {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
color: #ef4444;
background: rgba(239,68,68,0.08);
}
.kanban-detail-delete:hover {
background: rgba(239,68,68,0.15);
}
@media (max-width: 600px) {
.kanban-detail-modal {
max-width: 100%;
max-height: 95vh;
border-radius: 12px 12px 0 0;
margin-top: auto;
}
.kanban-detail-overlay {
align-items: flex-end;
padding: 0;
}
.kanban-detail-meta {
grid-template-columns: 1fr;
}
}
.preferences-overlay {
position: fixed;
inset: 0;

View File

@ -2077,6 +2077,7 @@ export default function WeeklyView() {
const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState<string | null>(null);
const [kanbanAddingStageId, setKanbanAddingStageId] = useState<string | null>(null);
const [kanbanNewTaskTitle, setKanbanNewTaskTitle] = useState("");
const [kanbanDetailTask, setKanbanDetailTask] = useState<Task | null>(null);
const [protectEventTimes, setProtectEventTimes] = useState(false);
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
@ -6456,10 +6457,10 @@ export default function WeeklyView() {
e.dataTransfer.effectAllowed = "move";
}}
onClick={(e) => {
// Don't open notes if clicking checkbox, contentEditable, or buttons
// Don't open detail if clicking checkbox, contentEditable, or buttons
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.contentEditable === "true" || target.closest("button")) return;
setSelectedTaskForNotes(task);
setKanbanDetailTask(task);
}}
style={{ cursor: "pointer" }}
>
@ -8845,6 +8846,210 @@ export default function WeeklyView() {
/>
)
}
{/* Kanban Detail Modal */}
{kanbanDetailTask && (() => {
const task = kanbanDetailTask;
// Re-resolve the task from state to get latest data
const liveTask = findTaskAnywhere(task.id) || task;
return (
<div className="kanban-detail-overlay" onClick={() => setKanbanDetailTask(null)}>
<div className="kanban-detail-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="kanban-detail-header">
<div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1 }}>
<input
type="checkbox"
checked={liveTask.completed}
onChange={() => { toggleTask(liveTask.id); setKanbanDetailTask({ ...liveTask, completed: !liveTask.completed }); }}
style={{ width: "18px", height: "18px", cursor: "pointer", accentColor: "var(--weekly-accent, #6366f1)" }}
/>
<input
type="text"
defaultValue={liveTask.title}
onBlur={(e) => {
const val = e.target.value.trim();
if (val && val !== liveTask.title) {
updateTask(liveTask.id, val);
setKanbanDetailTask({ ...liveTask, title: val });
}
}}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className="kanban-detail-title-input"
/>
</div>
<button onClick={() => setKanbanDetailTask(null)} className="kanban-detail-close">
<X size={18} />
</button>
</div>
<div className="kanban-detail-body">
{/* Meta row: project, stage, date */}
<div className="kanban-detail-meta">
{/* Project picker */}
<div className="kanban-detail-field">
<label><Tag size={13} /> {t.project || "Project"}</label>
<select
value={liveTask.projectId || ""}
onChange={(e) => {
const pid = e.target.value || null;
assignProject(liveTask.id, pid);
const proj = pid ? projects.find(p => p.id === pid) || null : null;
setKanbanDetailTask({ ...liveTask, projectId: pid, project: proj });
}}
className="kanban-detail-select"
>
<option value="">{t.noProject || "No project"}</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Kanban stage picker */}
<div className="kanban-detail-field">
<label><Kanban size={13} /> {t.stage || "Stage"}</label>
<select
value={liveTask.kanbanStage || ""}
onChange={(e) => {
const stage = e.target.value || null;
updateTaskFields(liveTask.id, { kanbanStage: stage });
setKanbanDetailTask({ ...liveTask, kanbanStage: stage });
}}
className="kanban-detail-select"
>
<option value="">{t.noStage || "No stage"}</option>
{kanbanStages.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
{/* Due date */}
<div className="kanban-detail-field">
<label><CalendarDays size={13} /> {t.dueDate || "Due date"}</label>
<input
type="date"
value={liveTask.scheduledDate ? new Date(liveTask.scheduledDate).toISOString().split("T")[0] : ""}
onChange={(e) => {
const val = e.target.value || null;
updateTaskFields(liveTask.id, { scheduledDate: val });
setKanbanDetailTask({ ...liveTask, scheduledDate: val });
}}
className="kanban-detail-date"
/>
</div>
{/* Someday list */}
<div className="kanban-detail-field">
<label><ListTodo size={13} /> {t.list || "List"}</label>
<select
value={liveTask.somedayListId || ""}
onChange={(e) => {
const lid = e.target.value || null;
updateTaskFields(liveTask.id, { somedayListId: lid });
setKanbanDetailTask({ ...liveTask, somedayListId: lid });
fetchTasks();
}}
className="kanban-detail-select"
>
<option value=""></option>
{somedayLists.map(sl => (
<option key={sl.id} value={sl.id}>{sl.title}</option>
))}
</select>
</div>
</div>
{/* Notes */}
<div className="kanban-detail-section">
<label><FileText size={13} /> {t.notes || "Notes"}</label>
<textarea
defaultValue={liveTask.markdownContent || ""}
onBlur={(e) => {
const val = e.target.value;
if (val !== (liveTask.markdownContent || "")) {
updateTaskNotes(liveTask.id, val);
setKanbanDetailTask({ ...liveTask, markdownContent: val });
}
}}
placeholder={language === "de" ? "Notizen hinzufügen..." : "Add notes..."}
className="kanban-detail-notes"
rows={4}
/>
</div>
{/* Subtasks */}
<div className="kanban-detail-section">
<label><Check size={13} /> {t.subtasks || "Subtasks"}</label>
<div className="kanban-detail-subtasks">
{(liveTask.subTasks || []).map(sub => (
<div key={sub.id} className="kanban-detail-subtask">
<input
type="checkbox"
checked={sub.completed}
onChange={() => toggleSubTask(sub.id)}
style={{ accentColor: "var(--weekly-accent, #6366f1)" }}
/>
<input
type="text"
defaultValue={sub.title}
onBlur={(e) => {
const val = e.target.value.trim();
if (!val) deleteSubTask(sub.id);
else if (val !== sub.title) updateSubTask(sub.id, val);
}}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className={`kanban-detail-subtask-title ${sub.completed ? "completed" : ""}`}
/>
<button onClick={() => deleteSubTask(sub.id)} className="kanban-detail-subtask-delete">
<X size={12} />
</button>
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
const input = (e.target as HTMLFormElement).elements.namedItem("newSub") as HTMLInputElement;
const val = input.value.trim();
if (val) {
addSubTask(liveTask.id, val);
input.value = "";
// Refresh modal task after adding
setTimeout(() => {
const refreshed = findTaskAnywhere(liveTask.id);
if (refreshed) setKanbanDetailTask(refreshed);
}, 300);
}
}}
className="kanban-detail-subtask-add"
>
<Plus size={14} />
<input
name="newSub"
type="text"
placeholder={language === "de" ? "Unteraufgabe hinzufügen..." : "Add subtask..."}
className="kanban-detail-subtask-input"
/>
</form>
</div>
</div>
</div>
{/* Footer */}
<div className="kanban-detail-footer">
<button
onClick={() => { deleteTask(liveTask.id); setKanbanDetailTask(null); }}
className="kanban-detail-delete"
>
<Trash2 size={14} /> {language === "de" ? "Löschen" : "Delete"}
</button>
</div>
</div>
</div>
);
})()}
<ImportListModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}