refactor: restliche fuenf Fremdkomponenten aus WeeklyView.tsx herausloesen
TaskInput, SomedayAddTask, TaskItem (ueber 900 Zeilen), ListVisualsPopover und TabVisualsPopover liegen jetzt unter src/components/weekly/. Dazu die Prioritaets-Abzeichen: GridTaskBlock importierte getPriorityBadge aus WeeklyView.tsx und zog sich damit die gesamte Wochenansicht in seinen Abhaengigkeitsgraphen. WeeklyView.tsx: 11.148 -> 9.711 Zeilen, erstmals unter 10.000. Gegenueber dem Stand vor dem Audit (11.906) sind das 2.195 Zeilen oder 18 Prozent. Der Typecheck hat den Schnitt in mehreren 19-Sekunden-Runden gefuehrt und dabei zwei Dinge gefunden, die sonst erst im Browser aufgeschlagen waeren: ein doppeltes export-Schluesselwort und -- feiner -- dass DragEvent in WeeklyView aus React stammt, in den neuen Dateien ohne Import aber das globale DOM-DragEvent gewesen waere. Verifiziert: Typecheck 0 Fehler, npm run build Exit 0, 63 Unit-Tests gruen. v1.119.2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7db4dfbf47
commit
e8198190fd
15
CHANGELOG.md
15
CHANGELOG.md
@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
## [1.119.2] - 2026-09-07
|
||||
|
||||
### Changed
|
||||
- Die restlichen fünf Fremdkomponenten aus `WeeklyView.tsx` liegen jetzt unter
|
||||
`src/components/weekly/`: `TaskInput`, `SomedayAddTask`, `TaskItem` (mit über 900 Zeilen
|
||||
die grösste), `ListVisualsPopover` und `TabVisualsPopover`.
|
||||
- Die Prioritäts-Abzeichen sind nach `weekly/priority-badge.tsx` gewandert. `GridTaskBlock`
|
||||
importierte `getPriorityBadge` aus `WeeklyView.tsx` und zog sich damit die gesamte
|
||||
Wochenansicht in seinen Abhängigkeitsgraphen; `WeeklyView` re-exportiert die Funktion,
|
||||
bestehende Importe bleiben gültig.
|
||||
- **`WeeklyView.tsx`: 11.148 → 9.711 Zeilen** — erstmals unter 10.000. Gegenüber dem Stand
|
||||
vor dem Audit (11.906) sind das 2.195 Zeilen oder 18 Prozent.
|
||||
|
||||
Verifiziert: Typecheck 0 Fehler, `npm run build` Exit 0, 63 Unit-Tests grün.
|
||||
|
||||
## [1.119.1] - 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
18
TODO.md
18
TODO.md
@ -136,7 +136,7 @@ Betroffen sind genau zwei Dateien:
|
||||
|
||||
| Datei | Zeilen |
|
||||
|---|---:|
|
||||
| `src/components/WeeklyView.tsx` | 11.148 *(war 11.906)* |
|
||||
| `src/components/WeeklyView.tsx` | **9.711** *(war 11.906)* |
|
||||
| `src/components/SettingsSidebar.tsx` | 5.445 |
|
||||
|
||||
**Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe
|
||||
@ -148,8 +148,15 @@ Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt.
|
||||
- [x] **Entdeckung:** In `WeeklyView.tsx` steckten **acht eigenständige Komponenten**, die
|
||||
gar nichts mit der Wochenansicht zu tun haben — zusammen rund 1.870 Zeilen. Drei davon
|
||||
sind heraus: `ProjectIcon`, `ProjectsSidebar`, `NotesSidebar`.
|
||||
- [ ] Restliche fünf herauslösen: `TaskInput`, `SomedayAddTask`, `TaskItem`,
|
||||
`ListVisualsPopover`, `TabVisualsPopover`
|
||||
- [x] Restliche fünf herausgelöst: `TaskInput`, `SomedayAddTask`, `TaskItem`,
|
||||
`ListVisualsPopover`, `TabVisualsPopover` — dazu die Prioritäts-Abzeichen, die
|
||||
`GridTaskBlock` von `WeeklyView` importierte
|
||||
- [ ] Someday-Listen aus dem Haupt-JSX herauslösen (jetzt echtes Props-Design nötig)
|
||||
- [ ] All-Day-Leiste herauslösen
|
||||
- [ ] Zeitraster und Tagesspalten herauslösen
|
||||
- [ ] Einstellungs-Flyouts herauslösen
|
||||
- [ ] Drag & Drop in einen Hook (~15 Abhängigkeiten — erst sinnvoll, wenn der State
|
||||
geordneter ist; ein Hook mit 15 Parametern wäre keine Verbesserung)
|
||||
- [x] Datums-/Zeitraster-Helfer nach `src/lib/weekly/date-utils.ts` (13 Funktionen, 202 Zeilen)
|
||||
- [x] Cookie-/Geräteeinstellungen nach `src/lib/weekly/device-settings.ts`
|
||||
- [x] Formatierungs-Helfer nach `src/lib/weekly/format.ts`
|
||||
@ -158,11 +165,6 @@ Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt.
|
||||
vorher die drei Implementierungen auf Gleichheit prüfen
|
||||
- [ ] `calendar-cache.ts` hat mit `getWeekStart` eine **vierte** eigene Wochenlogik —
|
||||
ebenfalls gegen `date-utils.ts` ersetzen
|
||||
- [ ] Drag & Drop in einen Hook `useTaskDragDrop.ts`
|
||||
- [ ] Someday-Listen in `SomedayLists.tsx`
|
||||
- [ ] All-Day-Leiste in `AllDayBar.tsx`
|
||||
- [ ] Zeitraster + Tagesspalten in `TimeGrid.tsx` / `DayColumn.tsx`
|
||||
- [ ] Einstellungs-Flyouts in `QuickSettingsPanel.tsx`
|
||||
- [ ] Daten-Laden in `useWeeklyData.ts`
|
||||
- [ ] `WeeklyView.tsx` bleibt als Orchestrator übrig
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.119.1",
|
||||
"version": "1.119.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.119.1",
|
||||
"version": "1.119.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.119.1",
|
||||
"version": "1.119.2",
|
||||
"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": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
134
src/components/weekly/SomedayAddTask.tsx
Normal file
134
src/components/weekly/SomedayAddTask.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Eingabezeile für die "Irgendwann"-Listen. Lag bis v1.119.2 in WeeklyView.tsx.
|
||||
*/
|
||||
|
||||
export function SomedayAddTask({
|
||||
listId,
|
||||
onAdd,
|
||||
onCancel,
|
||||
slotIdx,
|
||||
darkMode,
|
||||
showProjectIcons,
|
||||
showTaskCheckboxes,
|
||||
}: {
|
||||
listId: string;
|
||||
onAdd: (title: string) => void;
|
||||
onCancel?: () => void;
|
||||
slotIdx?: number;
|
||||
darkMode?: boolean;
|
||||
showProjectIcons?: boolean;
|
||||
showTaskCheckboxes?: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (title.trim()) {
|
||||
onAdd(title.trim());
|
||||
setTitle("");
|
||||
} else if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (slotIdx !== undefined && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [slotIdx]);
|
||||
|
||||
const isBottomAddInput = slotIdx === undefined;
|
||||
|
||||
if (isBottomAddInput) {
|
||||
// Render as a flex row that mirrors the task item layout so the input
|
||||
// aligns exactly with the task text column (icon spacer + checkbox spacer + input).
|
||||
return (
|
||||
<li
|
||||
className="weekly-task-item minimal"
|
||||
style={{ margin: "0", listStyle: "none", width: "100%", padding: "4px 24px 4px 8px" }}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px", width: "100%" }}
|
||||
>
|
||||
{/* Icon column spacer — matches task item icon column */}
|
||||
{showProjectIcons && <span style={{ width: "18px", minWidth: "18px", flexShrink: 0 }} />}
|
||||
{/* Checkbox spacer — matches task item checkbox column */}
|
||||
{showTaskCheckboxes && <span style={{ width: "16px", minWidth: "16px", flexShrink: 0 }} />}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setTitle("");
|
||||
if (onCancel) onCancel();
|
||||
else e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="weekly-task-text"
|
||||
style={{
|
||||
flex: 1,
|
||||
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
|
||||
background: darkMode ? "#1f2937" : "#ffffff",
|
||||
color: darkMode ? "#f9fafb" : "#111827",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "0.9375rem",
|
||||
outline: "none",
|
||||
height: "26px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
placeholder="Add task..."
|
||||
data-someday-add-input={listId}
|
||||
/>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline slot editor: transparent, no border
|
||||
return (
|
||||
<li
|
||||
className="weekly-task-item minimal"
|
||||
style={{ margin: "0", listStyle: "none", width: "100%" }}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setTitle("");
|
||||
if (onCancel) onCancel();
|
||||
else e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="weekly-task-text"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: "0",
|
||||
fontSize: "0.9375rem",
|
||||
outline: "none",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
58
src/components/weekly/TaskInput.tsx
Normal file
58
src/components/weekly/TaskInput.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, DragEvent } from "react";
|
||||
|
||||
/**
|
||||
* Eingabezeile zum Anlegen einer Aufgabe. Lag bis v1.119.2 in WeeklyView.tsx.
|
||||
*/
|
||||
|
||||
// Task Input Component
|
||||
interface TaskInputProps {
|
||||
onAddTask: (title: string) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
export function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
|
||||
const handleAddTask = () => {
|
||||
if (newTaskTitle.trim()) {
|
||||
onAddTask(newTaskTitle);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="weekly-task-input"
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<textarea
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAddTask();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a to-do..."
|
||||
rows={newTaskTitle.split("\n").length || 1}
|
||||
style={{
|
||||
resize: "none",
|
||||
overflow: "hidden",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: "inherit",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
outline: "none",
|
||||
padding: "inherit",
|
||||
fontSize: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
973
src/components/weekly/TaskItem.tsx
Normal file
973
src/components/weekly/TaskItem.tsx
Normal file
@ -0,0 +1,973 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, DragEvent } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Bot, ChevronDown, Circle, User, X } from "lucide-react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { Task, SomedayList, KanbanStage } from "@/lib/weekly/types";
|
||||
import { getPriorityBadge } from "./priority-badge";
|
||||
import { ProjectIcon } from "./ProjectIcon";
|
||||
|
||||
const RichTextEditor = dynamic(() => import("../RichTextEditor"), { ssr: false });
|
||||
|
||||
/**
|
||||
* Eine Aufgabe in der Listendarstellung. Mit über 900 Zeilen die grösste der acht
|
||||
* Komponenten, die in WeeklyView.tsx steckten.
|
||||
*/
|
||||
|
||||
// Task Item Component
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
isEditing: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onUpdate: (title: string) => void;
|
||||
onDelete: () => void;
|
||||
onNotes: (notes: string) => void;
|
||||
onUrl?: (url: string) => void;
|
||||
onRollToggle: () => void;
|
||||
onRecurrence: () => void;
|
||||
onDragStart: (e: DragEvent, task: Task) => void;
|
||||
onDragEnd: () => void;
|
||||
variant?: "default" | "minimal";
|
||||
isSomeday?: boolean;
|
||||
onAddSubTask?: (parentId: string, title: string) => void;
|
||||
onToggleSubTask?: (subTaskId: string) => void;
|
||||
onDeleteSubTask?: (subTaskId: string) => void;
|
||||
onUpdateSubTask?: (subTaskId: string, title: string) => void;
|
||||
editingTaskId?: string | null;
|
||||
onSetEditingTaskId?: (id: string | null) => void;
|
||||
isSubTask?: boolean;
|
||||
showTaskCheckboxes?: boolean;
|
||||
showProjectIcons?: boolean;
|
||||
showPriorityIcons?: boolean;
|
||||
priorityStyle?: string;
|
||||
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||||
kanbanStages?: KanbanStage[];
|
||||
}
|
||||
|
||||
export function TaskItem({
|
||||
task,
|
||||
isEditing,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onNotes,
|
||||
onUrl,
|
||||
onRollToggle,
|
||||
onRecurrence,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
variant = "default",
|
||||
isSomeday = false,
|
||||
onAddSubTask,
|
||||
onToggleSubTask,
|
||||
onDeleteSubTask,
|
||||
onUpdateSubTask,
|
||||
editingTaskId,
|
||||
onSetEditingTaskId,
|
||||
isSubTask = false,
|
||||
showTaskCheckboxes = false,
|
||||
showProjectIcons = false,
|
||||
showPriorityIcons = true,
|
||||
priorityStyle = "eisenhower",
|
||||
projects = [],
|
||||
onProjectAssign,
|
||||
kanbanStages = [],
|
||||
}: TaskItemProps) {
|
||||
const [editValue, setEditValue] = useState(task.title);
|
||||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||||
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
||||
const [isUrlOpen, setIsUrlOpen] = useState(false);
|
||||
const [urlValue, setUrlValue] = useState(task.url || "");
|
||||
const urlInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
||||
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
|
||||
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
||||
const [showProjectPicker, setShowProjectPicker] = useState(false);
|
||||
const projectPickerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Touch: tap-to-reveal actions
|
||||
const [touchActive, setTouchActive] = useState(false);
|
||||
const taskItemRef = useRef<HTMLLIElement>(null);
|
||||
|
||||
// Touch: swipe gesture state
|
||||
const [swipeX, setSwipeX] = useState(0);
|
||||
const swipeTouchStart = useRef({ x: 0, y: 0, swiping: false });
|
||||
|
||||
// Close touch-active on outside click
|
||||
useEffect(() => {
|
||||
if (!touchActive) return;
|
||||
const handler = (e: Event) => {
|
||||
if (taskItemRef.current && !taskItemRef.current.contains(e.target as Node)) {
|
||||
setTouchActive(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("touchstart", handler);
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", handler);
|
||||
document.removeEventListener("mousedown", handler);
|
||||
};
|
||||
}, [touchActive]);
|
||||
|
||||
// Close project picker on outside click
|
||||
useEffect(() => {
|
||||
if (!showProjectPicker) return;
|
||||
const handler = (e: Event) => {
|
||||
if (projectPickerRef.current && !projectPickerRef.current.contains(e.target as Node)) {
|
||||
setShowProjectPicker(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showProjectPicker]);
|
||||
|
||||
const needsSync = task.externalProvider && (
|
||||
!task.externalId ||
|
||||
!task.lastSyncedAt ||
|
||||
new Date(task.updatedAt) > new Date(task.lastSyncedAt)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Focus URL input when opened; sync when task.url changes
|
||||
useEffect(() => { if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50); }, [isUrlOpen]);
|
||||
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
|
||||
|
||||
const handleUrlSave = (val: string) => {
|
||||
setIsUrlOpen(false);
|
||||
if (val.trim() !== (task.url || "") && onUrl) {
|
||||
onUrl(val.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onUpdate(editValue);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(task.title);
|
||||
onUpdate(task.title);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={taskItemRef}
|
||||
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${showProjectPicker ? "picker-open" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
|
||||
style={(() => {
|
||||
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
||||
if (stageColor) return { borderLeft: `4px solid ${stageColor}`, paddingLeft: "6px" };
|
||||
if (task.project?.color) return { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" };
|
||||
return undefined;
|
||||
})()}
|
||||
draggable={!isEditing && !isNotesOpen && swipeX === 0}
|
||||
onDragStart={(e) => {
|
||||
// If dragging a subtask, don't drag the parent
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.subtask-list')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onDragStart(e as unknown as DragEvent, task);
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => {
|
||||
// Touch: toggle action toolbar on tap
|
||||
if (window.matchMedia("(pointer: coarse)").matches && !isEditing) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".task-actions") || target.closest("button")) return;
|
||||
setTouchActive(!touchActive);
|
||||
return;
|
||||
}
|
||||
if ((variant === "minimal" || isSomeday) && !isEditing) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "BUTTON" ||
|
||||
target.tagName === "INPUT" ||
|
||||
target.closest("button")
|
||||
)
|
||||
return;
|
||||
onEdit();
|
||||
}
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
// No task-level swipe — container handles day navigation
|
||||
}}
|
||||
>
|
||||
{/* Swipe indicators */}
|
||||
{swipeX > 20 && (
|
||||
<div className="task-swipe-indicator complete" style={{ width: Math.abs(swipeX) }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
</div>
|
||||
)}
|
||||
{swipeX < -20 && (
|
||||
<div className="task-swipe-indicator delete" style={{ width: Math.abs(swipeX) }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: "100%", position: "relative", transform: swipeX !== 0 ? `translateX(${swipeX}px)` : undefined, transition: swipeX === 0 ? "transform 0.2s ease" : "none", background: "inherit" }}>
|
||||
{/* Visual Indicator for Rolling Tasks */}
|
||||
{task.isRolling && !task.completed && !isSomeday && (
|
||||
<div className="rolling-icon-indicator" title="Auto-rolling task">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="10"
|
||||
height="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: isSomeday ? "center" : "flex-start",
|
||||
gap: isSomeday ? "4px" : "0.5rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
{/* Someday: match icon + checkbox column widths so textarea aligns with task text */}
|
||||
{isSomeday && showProjectIcons && <span style={{ width: "18px", minWidth: "18px", flexShrink: 0 }} />}
|
||||
{isSomeday && showTaskCheckboxes && <span style={{ width: "16px", minWidth: "16px", flexShrink: 0 }} />}
|
||||
<form onSubmit={handleSubmit} style={{ flex: 1, display: "flex" }}>
|
||||
{variant === "minimal" ? (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="weekly-task-text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onUpdate(editValue)}
|
||||
rows={editValue.split("\n").length || 1}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
padding: "0",
|
||||
resize: "none",
|
||||
overflow: "hidden",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
fontWeight: "inherit",
|
||||
lineHeight: "inherit",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="weekly-task-text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onUpdate(editValue)}
|
||||
rows={editValue.split("\n").length || 1}
|
||||
style={{
|
||||
resize: "none",
|
||||
overflow: "hidden",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: "inherit",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Someday: fixed-width icon column — project icon, priority badge, delegation, or empty placeholder */}
|
||||
{isSomeday && (
|
||||
<span style={{ width: "18px", minWidth: "18px", flexShrink: 0, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
||||
{showProjectIcons && task.project
|
||||
? <ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
|
||||
: (() => {
|
||||
if (showPriorityIcons) { const pb = getPriorityBadge(task, priorityStyle, 11); if (pb) return pb.node; }
|
||||
if (task.delegatedTo) return task.delegatedTo === "AI" ? <Bot size={11} color="#8b5cf6" /> : <User size={11} color="#8b5cf6" />;
|
||||
return null;
|
||||
})()
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{/* Someday: checkbox outside text span so it aligns in its own column */}
|
||||
{isSomeday && showTaskCheckboxes && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.completed}
|
||||
onChange={(e) => { e.stopPropagation(); onToggle(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="task-checkbox flex-shrink-0"
|
||||
style={{ width: "16px", height: "16px", margin: 0, cursor: "pointer", flexShrink: 0, accentColor: "var(--weekly-teal, #009a9a)", WebkitAppearance: "checkbox" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`weekly-task-text flex-1 ${task.completed ? "completed" : ""}`}
|
||||
onClick={(e) => {
|
||||
if (variant === "default" && !showTaskCheckboxes) onToggle();
|
||||
// For minimal/someday, parent onClick handles edit
|
||||
}}
|
||||
onDoubleClick={variant === "default" ? onEdit : undefined}
|
||||
style={
|
||||
variant === "minimal" || isSomeday
|
||||
? { display: "flex", alignItems: "center", gap: "4px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
|
||||
: { display: "flex", alignItems: "center", gap: "6px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
|
||||
}
|
||||
>
|
||||
{/* Non-someday: checkbox stays inside text span */}
|
||||
{!isSomeday && showTaskCheckboxes && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.completed}
|
||||
onChange={(e) => { e.stopPropagation(); onToggle(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="task-checkbox flex-shrink-0"
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
top: "3px",
|
||||
left: "-2px",
|
||||
accentColor: "var(--weekly-teal, #009a9a)",
|
||||
WebkitAppearance: "checkbox"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflow: "visible",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Non-someday only: priority/delegation icons inline. Someday shows them in the leading column. */}
|
||||
{!isSomeday && showPriorityIcons && (() => {
|
||||
const pb = getPriorityBadge(task, priorityStyle, 11);
|
||||
return pb ? (
|
||||
<span title={pb.label} style={{ marginRight: "3px", verticalAlign: "middle", display: "inline-flex", alignItems: "center" }}>
|
||||
{pb.node}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{!isSomeday && task.delegatedTo && (
|
||||
<span
|
||||
title={task.delegatedTo === "AI" ? `AI: ${task.delegationNote || ""}` : task.delegatedTo}
|
||||
style={{ marginRight: "3px", verticalAlign: "middle", display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{task.delegatedTo === "AI"
|
||||
? <Bot size={11} color="#8b5cf6" />
|
||||
: <User size={11} color="#8b5cf6" />}
|
||||
</span>
|
||||
)}
|
||||
{/* Non-someday: inline project icon; someday uses the column above */}
|
||||
{showProjectIcons && task.project && !isSomeday && (
|
||||
<span style={{ marginRight: "4px", verticalAlign: "middle" }}>
|
||||
<ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
|
||||
</span>
|
||||
)}
|
||||
{task.title}
|
||||
</span>
|
||||
{(() => {
|
||||
const provider = task.externalProvider
|
||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||
if (!provider) return null;
|
||||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||||
notion: { icon: faNotion, color: "#000000", label: "Notion" },
|
||||
};
|
||||
const info = iconMap[provider];
|
||||
if (!info) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
title={`Synced with ${info.label}`}
|
||||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, paddingLeft: "4px" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={info.icon} style={{ width: 12, height: 12, color: info.color }} />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
|
||||
{/* Subtask indicator - toggles subtask list */}
|
||||
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (() => {
|
||||
const completed = task.subTasks!.filter(s => s.completed).length;
|
||||
const total = task.subTasks!.length;
|
||||
const allDone = completed === total;
|
||||
const expanded = isSubTasksOpen || isSubTaskInputOpen;
|
||||
const pct = total > 0 ? (completed / total) * 100 : 0;
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSubTasksOpen(!isSubTasksOpen);
|
||||
}}
|
||||
className="subtask-indicator-badge"
|
||||
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "2px 8px 2px 4px",
|
||||
borderRadius: "12px",
|
||||
background: expanded ? "rgba(99,102,241,0.08)" : "transparent",
|
||||
color: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-text-light, #9ca3af)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={12} style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
width: "36px",
|
||||
height: "5px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--weekly-border, #e5e7eb)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: `${pct}%`,
|
||||
borderRadius: "3px",
|
||||
background: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-accent, #6366f1)",
|
||||
transition: "width 0.3s ease",
|
||||
}} />
|
||||
</span>
|
||||
<span style={{ opacity: 0.8 }}>{completed}/{total}</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* URL indicator */}
|
||||
{task.url && (
|
||||
<a
|
||||
href={task.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={task.url}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center",
|
||||
padding: "2px 5px", borderRadius: "4px",
|
||||
background: "rgba(37,99,235,0.10)", color: "#2563eb",
|
||||
textDecoration: "none", flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Note indicator - toggles inline notes */}
|
||||
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsNotesOpen(!isNotesOpen);
|
||||
}}
|
||||
className="focus:outline-none flex-shrink-0"
|
||||
title={isNotesOpen ? "Collapse note" : "Expand note"}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "2px",
|
||||
padding: "2px 5px",
|
||||
borderRadius: "4px",
|
||||
background: isNotesOpen ? "rgba(245, 158, 11, 0.1)" : "transparent",
|
||||
color: isNotesOpen ? "#f59e0b" : "var(--weekly-text-muted, #999)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isNotesOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="task-actions z-20">
|
||||
{/* Complete */}
|
||||
<button
|
||||
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
title={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Edit */}
|
||||
<button
|
||||
className="task-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Add Sub-task */}
|
||||
{!isSubTask && onAddSubTask && (
|
||||
<button
|
||||
className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSubTaskInputOpen(!isSubTaskInputOpen);
|
||||
if (!isSubTaskInputOpen) {
|
||||
setTimeout(() => subTaskInputRef.current?.focus(), 50);
|
||||
}
|
||||
}}
|
||||
title="Add sub-task"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Recurrence (hidden for someday tasks) */}
|
||||
{!isSomeday && (
|
||||
<button
|
||||
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRecurrence();
|
||||
}}
|
||||
title={
|
||||
task.isRecurring ? "Edit recurrence" : "Make recurring"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<button
|
||||
className={`task-action-btn ${isNotesOpen || (task.markdownContent && task.markdownContent.trim().length > 0) ? "active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsNotesOpen(!isNotesOpen);
|
||||
}}
|
||||
title="Notes"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Project Assignment */}
|
||||
{!isSubTask && projects.length > 0 && onProjectAssign && (
|
||||
<div className="relative" ref={projectPickerRef}>
|
||||
<button
|
||||
className={`task-action-btn ${task.project ? "active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowProjectPicker(!showProjectPicker);
|
||||
}}
|
||||
title={task.project ? task.project.name : "Assign project"}
|
||||
>
|
||||
{task.project?.icon ? (
|
||||
<span style={{ fontSize: "12px", lineHeight: 1 }}>{task.project.icon}</span>
|
||||
) : (
|
||||
<Circle
|
||||
size={12}
|
||||
fill={task.project?.color || "none"}
|
||||
stroke={task.project?.color || "currentColor"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{showProjectPicker && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
|
||||
{task.projectId && (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProjectAssign(task.id, null);
|
||||
setShowProjectPicker(false);
|
||||
}}
|
||||
>
|
||||
<X size={10} /> Remove
|
||||
</button>
|
||||
)}
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
|
||||
setShowProjectPicker(false);
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px" }}><ProjectIcon icon={p.icon} size={12} color={p.color || "#999"} /></span>
|
||||
<Circle size={8} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roll Toggle - Active State Colored (hidden for someday tasks) */}
|
||||
{!task.completed && !isSomeday && (
|
||||
<button
|
||||
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRollToggle();
|
||||
}}
|
||||
title={
|
||||
task.isRolling ? "Disable rolling" : "Enable rolling"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="1 4 1 10 7 10"></polyline>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<button
|
||||
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
|
||||
title={task.url || "Add link"}
|
||||
style={task.url ? { color: "#2563eb" } : {}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
className="task-action-btn delete text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL editor */}
|
||||
{isUrlOpen && (
|
||||
<div className="weekly-notes-inline" onClick={(e) => e.stopPropagation()} style={{ padding: "6px 8px", display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#2563eb" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<input
|
||||
ref={urlInputRef}
|
||||
type="url"
|
||||
value={urlValue}
|
||||
onChange={(e) => setUrlValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); handleUrlSave(urlValue); }
|
||||
if (e.key === "Escape") { setIsUrlOpen(false); setUrlValue(task.url || ""); }
|
||||
}}
|
||||
onBlur={() => handleUrlSave(urlValue)}
|
||||
placeholder="https://..."
|
||||
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
|
||||
/>
|
||||
{urlValue && (
|
||||
<button onMouseDown={(e) => { e.preventDefault(); setUrlValue(""); if (onUrl) onUrl(""); setIsUrlOpen(false); }} title="Remove link" style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999", flexShrink: 0 }}>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{urlValue && (
|
||||
<a href={urlValue.startsWith("http") ? urlValue : `https://${urlValue}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title="Open link" style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }}>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Notes Editor */}
|
||||
{isNotesOpen && (
|
||||
<div className="weekly-notes-inline" onClick={(e) => e.stopPropagation()} style={{ padding: "0" }}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: "4px 6px 0" }}>
|
||||
<button onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }} style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }} title="Close">×</button>
|
||||
</div>
|
||||
<div style={{ padding: "0 6px 8px" }}>
|
||||
<RichTextEditor
|
||||
value={notesValue}
|
||||
onChange={(html) => { setNotesValue(html); onNotes(html); }}
|
||||
placeholder="Add notes..."
|
||||
minHeight="80px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-tasks section */}
|
||||
{
|
||||
!isSubTask && (isSubTasksOpen || isSubTaskInputOpen) && task.subTasks && task.subTasks.length > 0 && (
|
||||
<ul className="subtask-list" onClick={(e) => e.stopPropagation()} style={task.project?.color ? { borderLeft: `2px solid ${task.project.color}`, marginLeft: "2px" } : undefined}>
|
||||
{task.subTasks.map((subTask) => (
|
||||
<li
|
||||
key={subTask.id}
|
||||
className={`subtask-item ${subTask.completed ? "completed" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onDragStart) {
|
||||
onDragStart(e as any, { ...subTask, parentTaskId: task.id, scheduledDate: task.scheduledDate } as any);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
|
||||
>
|
||||
<button
|
||||
className="subtask-checkbox"
|
||||
onClick={() => onToggleSubTask?.(subTask.id)}
|
||||
aria-label={subTask.completed ? "Mark incomplete" : "Mark complete"}
|
||||
>
|
||||
{subTask.completed ? (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{editingTaskId === subTask.id ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.querySelector("input");
|
||||
if (input) {
|
||||
onUpdateSubTask?.(subTask.id, input.value);
|
||||
onSetEditingTaskId?.(null);
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={subTask.title}
|
||||
autoFocus
|
||||
className="subtask-edit-input"
|
||||
onBlur={(e) => {
|
||||
onUpdateSubTask?.(subTask.id, e.target.value);
|
||||
onSetEditingTaskId?.(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onSetEditingTaskId?.(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span
|
||||
className={`subtask-title ${subTask.completed ? "completed" : ""}`}
|
||||
onClick={() => onSetEditingTaskId?.(subTask.id)}
|
||||
>
|
||||
{subTask.title}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="subtask-delete-btn"
|
||||
onClick={() => onDeleteSubTask?.(subTask.id)}
|
||||
title="Remove sub-task"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Add sub-task input */}
|
||||
{
|
||||
!isSubTask && isSubTaskInputOpen && (
|
||||
<div className="subtask-add-row" onClick={(e) => e.stopPropagation()}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newSubTaskTitle.trim()) {
|
||||
onAddSubTask?.(task.id, newSubTaskTitle.trim());
|
||||
setNewSubTaskTitle("");
|
||||
}
|
||||
}}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.25rem", flex: 1 }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<input
|
||||
ref={subTaskInputRef}
|
||||
type="text"
|
||||
value={newSubTaskTitle}
|
||||
onChange={(e) => setNewSubTaskTitle(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (!newSubTaskTitle.trim()) {
|
||||
setIsSubTaskInputOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setNewSubTaskTitle("");
|
||||
setIsSubTaskInputOpen(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Add sub-task..."
|
||||
className="subtask-add-input"
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</li >
|
||||
);
|
||||
}
|
||||
251
src/components/weekly/VisualsPopover.tsx
Normal file
251
src/components/weekly/VisualsPopover.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { ProjectIcon } from "./ProjectIcon";
|
||||
|
||||
const IconPicker = dynamic(() => import("../IconPicker"), { ssr: false });
|
||||
|
||||
/**
|
||||
* Auswahl von Farbe und Symbol für Listen und Reiter der "Irgendwann"-Ansicht.
|
||||
* Lagen bis v1.119.2 in WeeklyView.tsx.
|
||||
*/
|
||||
|
||||
// Punkt 7+8 — popover for editing a someday list's icon and color.
|
||||
export function ListVisualsPopover({
|
||||
list,
|
||||
darkMode,
|
||||
language,
|
||||
onChange,
|
||||
onClose,
|
||||
anchorRect,
|
||||
}: {
|
||||
list: { id: string; title: string; color?: string | null; icon?: string | null };
|
||||
darkMode: boolean;
|
||||
language: string;
|
||||
anchorRect?: DOMRect | null;
|
||||
onChange: (updates: { color?: string | null; icon?: string | null }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) onCloseRef.current();
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, []);
|
||||
const de = language === "de";
|
||||
const top = anchorRect ? anchorRect.top + 32 : -9999;
|
||||
const left = anchorRect ? anchorRect.left + 4 : -9999;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top,
|
||||
left,
|
||||
zIndex: 10000,
|
||||
background: darkMode ? "#1e1e2e" : "#fff",
|
||||
border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`,
|
||||
borderRadius: "10px",
|
||||
padding: "12px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||
minWidth: "240px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: darkMode ? "#d1d5db" : "#444" }}>
|
||||
{de ? "Listen-Stil" : "List style"}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#888", padding: "2px" }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#666", flex: 1 }}>{de ? "Icon" : "Icon"}</span>
|
||||
<button
|
||||
onClick={() => setIconPickerOpen((v) => !v)}
|
||||
style={{ width: "32px", height: "32px", borderRadius: "8px", border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<ProjectIcon icon={list.icon} size={16} color={list.color || (darkMode ? "#d1d5db" : "#555")} />
|
||||
</button>
|
||||
{list.icon && (
|
||||
<button
|
||||
onClick={() => onChange({ icon: null })}
|
||||
title={de ? "Icon entfernen" : "Remove icon"}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{iconPickerOpen && (
|
||||
<div style={{ position: "fixed", top: top + 78, left, zIndex: 10001 }}>
|
||||
<IconPicker
|
||||
selectedIcon={list.icon || ""}
|
||||
onSelect={(name) => { onChange({ icon: name }); setIconPickerOpen(false); }}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#666", flex: 1 }}>{de ? "Farbe" : "Color"}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={list.color || "#6366f1"}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
|
||||
/>
|
||||
{list.color && (
|
||||
<button
|
||||
onClick={() => onChange({ color: null })}
|
||||
title={de ? "Farbe entfernen" : "Remove color"}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Punkt 7 — popover for editing a tab's icon and color (stored in viewSettings JSON).
|
||||
export function TabVisualsPopover({
|
||||
tabName,
|
||||
visuals,
|
||||
darkMode,
|
||||
language,
|
||||
onChange,
|
||||
onRename,
|
||||
onDissolve,
|
||||
onClose,
|
||||
anchorRect,
|
||||
}: {
|
||||
tabName: string;
|
||||
visuals: { color?: string; icon?: string };
|
||||
darkMode: boolean;
|
||||
language: string;
|
||||
anchorRect?: DOMRect | null;
|
||||
onChange: (updates: { color?: string | null; icon?: string | null }) => void;
|
||||
onRename: (newName: string) => void;
|
||||
onDissolve: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(tabName);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) onCloseRef.current();
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, []);
|
||||
const de = language === "de";
|
||||
const labelStyle = { fontSize: "0.75rem", color: darkMode ? "#9ca3af" : "#666", flex: 1 } as const;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: anchorRect ? anchorRect.bottom + 6 : -9999,
|
||||
left: anchorRect ? anchorRect.left : -9999,
|
||||
zIndex: 10000,
|
||||
background: darkMode ? "#1e1e2e" : "#fff",
|
||||
border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`,
|
||||
borderRadius: "10px",
|
||||
padding: "12px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||
minWidth: "230px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "0.78rem", fontWeight: 700, color: darkMode ? "#d1d5db" : "#333" }}>
|
||||
{de ? "Tab bearbeiten" : "Edit tab"}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#9ca3af" : "#888", padding: "2px" }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Rename */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={labelStyle}>{de ? "Name" : "Name"}</span>
|
||||
<input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => { if (renameValue.trim() && renameValue.trim() !== tabName) onRename(renameValue.trim()); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { if (renameValue.trim() && renameValue.trim() !== tabName) onRename(renameValue.trim()); (e.target as HTMLInputElement).blur(); } }}
|
||||
style={{ flex: 1, fontSize: "0.8rem", border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`, borderRadius: "6px", padding: "4px 8px", background: darkMode ? "#2a2a3a" : "#f9f9f9", color: darkMode ? "#e5e7eb" : "#222", outline: "none" }}
|
||||
/>
|
||||
</div>
|
||||
{/* Icon */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={labelStyle}>{de ? "Icon" : "Icon"}</span>
|
||||
<button
|
||||
onClick={() => setIconPickerOpen((v) => !v)}
|
||||
style={{ width: "32px", height: "32px", borderRadius: "8px", border: `1px solid ${darkMode ? "#444" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<ProjectIcon icon={visuals.icon} size={16} color={visuals.color || (darkMode ? "#d1d5db" : "#555")} />
|
||||
</button>
|
||||
{visuals.icon && (
|
||||
<button onClick={() => onChange({ icon: null })} title={de ? "Icon entfernen" : "Remove icon"} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{iconPickerOpen && (
|
||||
<div style={{ position: "absolute", top: "110px", left: "10px", zIndex: 110 }}>
|
||||
<IconPicker
|
||||
selectedIcon={visuals.icon || ""}
|
||||
onSelect={(name) => { onChange({ icon: name }); setIconPickerOpen(false); }}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Color */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={labelStyle}>{de ? "Farbe" : "Color"}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={visuals.color || "#6366f1"}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
|
||||
/>
|
||||
{visuals.color && (
|
||||
<button onClick={() => onChange({ color: null })} title={de ? "Farbe entfernen" : "Remove color"} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "2px" }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Dissolve / delete */}
|
||||
<div style={{ borderTop: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`, paddingTop: "8px" }}>
|
||||
<button
|
||||
onClick={() => { onDissolve(); onClose(); }}
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: "8px", padding: "6px 8px", fontSize: "0.8rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", borderRadius: "6px", textAlign: "left" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(239,68,68,0.08)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "none")}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{de ? "Tab auflösen (Listen bleiben)" : "Dissolve tab (lists stay)"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
src/components/weekly/priority-badge.tsx
Normal file
97
src/components/weekly/priority-badge.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Archive, CalendarClock, CornerUpRight, Star, Zap } from "lucide-react";
|
||||
import type { Task } from "@/lib/weekly/types";
|
||||
|
||||
/**
|
||||
* Prioritäts-Abzeichen für Aufgaben.
|
||||
*
|
||||
* Lag bis v1.119.2 in WeeklyView.tsx — obwohl GridTaskBlock `getPriorityBadge` von dort
|
||||
* importierte und sich damit die gesamte Wochenansicht in seinen Abhängigkeitsgraphen zog.
|
||||
*/
|
||||
|
||||
// ── Priority indicator helper ─────────────────────────────────────
|
||||
// Renders a small priority badge for a task. The visual style is selected
|
||||
// by the user's `priorityStyle` setting:
|
||||
// - "eisenhower" → quadrant icons + colors (urgency × importance)
|
||||
// - "abcde" → letter A–E in a colored circle
|
||||
// - "ivylee" → digit 1–6 in a colored circle (rank persisted in priority)
|
||||
// - "pareto" → star for the "vital few" (importance flag)
|
||||
// Returns null when the task has no priority data for the active style.
|
||||
type PriorityBadge = { node: React.ReactNode; label: string };
|
||||
|
||||
const PRIORITY_NUMBER_COLORS: Record<string, string> = {
|
||||
"1": "#ef4444", "2": "#f97316", "3": "#eab308",
|
||||
"4": "#22c55e", "5": "#3b82f6", "6": "#8b5cf6",
|
||||
};
|
||||
|
||||
const PRIORITY_LETTER_COLORS: Record<string, string> = {
|
||||
A: "#ef4444", B: "#3b82f6", C: "#eab308", D: "#f97316", E: "#9ca3af",
|
||||
};
|
||||
|
||||
function makeBadgeCircle(text: string, color: string, size: number, label: string): PriorityBadge {
|
||||
return {
|
||||
label,
|
||||
node: (
|
||||
<span style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
color: "#fff",
|
||||
fontSize: Math.max(8, size - 6),
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}>{text}</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPriorityBadge(task: Task, style: string, size = 12): PriorityBadge | null {
|
||||
// Universal "important" star — shown for any task explicitly marked important
|
||||
// (e.g. Outlook To-Do tasks with the star flag) regardless of the active priority style.
|
||||
const importantStar = (): PriorityBadge => ({
|
||||
label: "Important",
|
||||
node: <Star size={size + 1} fill="#eab308" color="#eab308" style={{ flexShrink: 0 }} />,
|
||||
});
|
||||
|
||||
if (style === "abcde") {
|
||||
if (task.priority && PRIORITY_LETTER_COLORS[task.priority]) {
|
||||
return makeBadgeCircle(task.priority, PRIORITY_LETTER_COLORS[task.priority], size + 2, task.priority);
|
||||
}
|
||||
if (task.importance === true) return importantStar();
|
||||
return null;
|
||||
}
|
||||
if (style === "ivylee") {
|
||||
if (task.priority && PRIORITY_NUMBER_COLORS[task.priority]) {
|
||||
return makeBadgeCircle(task.priority, PRIORITY_NUMBER_COLORS[task.priority], size + 2, `Ivy Lee #${task.priority}`);
|
||||
}
|
||||
if (task.importance === true) return importantStar();
|
||||
return null;
|
||||
}
|
||||
if (style === "pareto") {
|
||||
if (task.importance !== true && task.priority !== "A" && task.priority !== "B") return null;
|
||||
return {
|
||||
label: "Vital Few (80/20)",
|
||||
node: <Star size={size + 1} fill="#eab308" color="#eab308" style={{ flexShrink: 0 }} />,
|
||||
};
|
||||
}
|
||||
// eisenhower (default)
|
||||
if (task.urgency != null && task.importance != null) {
|
||||
if (task.urgency && task.importance)
|
||||
return { label: "Sofort erledigen", node: <Zap size={size} color="#ef4444" style={{ flexShrink: 0 }} /> };
|
||||
if (!task.urgency && task.importance)
|
||||
return { label: "Planen", node: <CalendarClock size={size} color="#3b82f6" style={{ flexShrink: 0 }} /> };
|
||||
if (task.urgency && !task.importance)
|
||||
return { label: "Delegieren", node: <CornerUpRight size={size} color="#f97316" style={{ flexShrink: 0 }} /> };
|
||||
return { label: "Eliminieren", node: <Archive size={size} color="#9ca3af" style={{ flexShrink: 0 }} /> };
|
||||
}
|
||||
// Eisenhower with only importance set (e.g. imported from Outlook with no urgency)
|
||||
if (task.importance === true) return importantStar();
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user