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:
parent
aece7049c0
commit
ca7911c7ef
@ -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": {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user