diff --git a/CHANGELOG.md b/CHANGELOG.md index a36af8a..8e314f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project are documented in this file. +## [1.119.1] - 2026-09-07 + +### Changed +- `ProjectIcon`, `ProjectsSidebar` und `NotesSidebar` liegen jetzt als eigene Dateien unter + `src/components/weekly/`. Sie standen in `WeeklyView.tsx`, hatten dort aber bereits eigene + Props und nichts mit der Wochenansicht zu tun. Die Symbolliste `projectIconsGlobal` ist zu + `ProjectIcon` gewandert, wo sie als einzige gebraucht wird. +- `WeeklyView.tsx`: 11.556 → 11.148 Zeilen. + +In `WeeklyView.tsx` stecken insgesamt **acht** solcher eigenständigen Komponenten mit +zusammen rund 1.870 Zeilen — fünf davon warten noch (siehe TODO.md D1). + +Verifiziert: Typecheck 0 Fehler, `npm run build` Exit 0, 63 Unit-Tests grün. + ## [1.119.0] - 2026-09-07 ### Changed diff --git a/TODO.md b/TODO.md index d2dc006..7515ff2 100644 --- a/TODO.md +++ b/TODO.md @@ -136,7 +136,7 @@ Betroffen sind genau zwei Dateien: | Datei | Zeilen | |---|---:| -| `src/components/WeeklyView.tsx` | 11.556 *(war 11.906)* | +| `src/components/WeeklyView.tsx` | 11.148 *(war 11.906)* | | `src/components/SettingsSidebar.tsx` | 5.445 | **Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe @@ -144,7 +144,12 @@ Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt. #### WeeklyView.tsx → Zielstruktur - [x] Typen nach `src/lib/weekly/types.ts` (7 Typen, 97 Zeilen) — Build verifiziert -- [ ] `src/components/weekly/` anlegen +- [x] `src/components/weekly/` angelegt +- [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] 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` diff --git a/package-lock.json b/package-lock.json index e387714..159a583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.119.0", + "version": "1.119.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.119.0", + "version": "1.119.1", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index a98349f..aaca392 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.119.0", + "version": "1.119.1", "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": { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 7b45c7f..7d35f10 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -115,6 +115,9 @@ import SearchModal from "./SearchModal"; import SimpleDatePicker from "./SimpleDatePicker"; import RecurringTasksManager from "./RecurringTasksManager"; import WeekPrintModal from "./WeekPrintModal"; +import { ProjectIcon } from "./weekly/ProjectIcon"; +import { ProjectsSidebar } from "./weekly/ProjectsSidebar"; +import { NotesSidebar } from "./weekly/NotesSidebar"; import type { RecurringTaskException, ViewStyle, KanbanStage, Task, CalendarEvent, SomedayList, CellDuration, @@ -11138,427 +11141,8 @@ function TabVisualsPopover({ } // Projects Sidebar Component -const projectIconsGlobal: { name: string; icon: IconDefinition }[] = [ - { name: "folder", icon: faFolder }, { name: "briefcase", icon: faBriefcase }, - { name: "bullseye", icon: faBullseye }, { name: "rocket", icon: faRocket }, - { name: "star", icon: faStar }, { name: "lightbulb", icon: faLightbulb }, - { name: "fire", icon: faFire }, { name: "palette", icon: faPalette }, - { name: "music", icon: faMusic }, { name: "mobile", icon: faMobileScreen }, - { name: "laptop", icon: faLaptop }, { name: "globe", icon: faGlobe }, - { name: "house", icon: faHouse }, { name: "building", icon: faBuilding }, - { name: "chart-bar", icon: faChartBar }, { name: "chart-line", icon: faChartLine }, - { name: "wrench", icon: faWrench }, { name: "bolt", icon: faBolt }, - { name: "gamepad", icon: faGamepad }, { name: "pen", icon: faPen }, - { name: "book", icon: faBook }, { name: "graduation-cap", icon: faGraduationCap }, - { name: "flask", icon: faFlask }, { name: "microscope", icon: faMicroscope }, - { name: "dumbbell", icon: faDumbbell }, { name: "utensils", icon: faUtensils }, - { name: "plane", icon: faPlane }, { name: "leaf", icon: faLeaf }, - { name: "heart", icon: faHeart }, { name: "cart-shopping", icon: faCartShopping }, - { name: "coins", icon: faCoins }, { name: "gift", icon: faGift }, - { name: "camera", icon: faCamera }, { name: "film", icon: faFilm }, - { name: "broom", icon: faBroom }, { name: "paw", icon: faPaw }, - { name: "earth", icon: faEarthAmericas }, { name: "lock", icon: faLock }, - { name: "check", icon: faCheck }, { name: "code", icon: faCode }, - { name: "cube", icon: faCube }, { name: "users", icon: faUsers }, - { name: "car", icon: faCar }, { name: "mountain", icon: faMountain }, - { name: "umbrella", icon: faUmbrella }, { name: "clock", icon: faClock }, - { name: "tag", icon: faTag }, -]; // Helper to render a project icon (FA name, MDI name, or legacy emoji) -function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) { - if (!icon) return ; - // Normalise legacy "faHeart" → "heart" prefix style - const normalised = icon.startsWith("fa") && icon.length > 2 && icon[2] === icon[2].toUpperCase() - ? icon.slice(2, 3).toLowerCase() + icon.slice(3) - : icon; - // Check unified icon registry (FA + MDI) - const found = allIcons.find((i) => i.name === normalised || i.name === icon); - if (found) { - if (found.type === "fa") { - return ; - } - return ; - } - // Legacy: check old projectIconsGlobal for backwards compat - const legacy = projectIconsGlobal.find((i) => i.name === normalised || i.name === icon); - if (legacy) return ; - // Legacy emoji fallback - return {icon}; -} -function ProjectsSidebar({ darkMode, language, projects, onProjectsChanged, onClose, tasks, activeProjectFilter, onSetProjectFilter }: { - darkMode: boolean; - language: string; - projects: { id: string; name: string; icon?: string | null; color?: string | null }[]; - onProjectsChanged: () => void; - onClose: () => void; - tasks: Task[]; - activeProjectFilter: string | null; - onSetProjectFilter: (id: string | null) => void; -}) { - const [newProjectName, setNewProjectName] = useState(""); - const [newProjectColor, setNewProjectColor] = useState("#3b82f6"); - const [newProjectIcon, setNewProjectIcon] = useState("folder"); - const [showNewIconPicker, setShowNewIconPicker] = useState(false); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(""); - const [editColor, setEditColor] = useState(""); - const [editIcon, setEditIcon] = useState("folder"); - const [showEditIconPicker, setShowEditIconPicker] = useState(false); - const [expandedProjects, setExpandedProjects] = useState>(new Set()); - - const t = { - projects: language === "de" ? "Projekte" : "Projects", - desc: language === "de" ? "Aufgaben mit farbcodierten Projekten organisieren" : "Organize tasks with color-coded projects", - noProjects: language === "de" ? "Noch keine Projekte erstellt" : "No projects yet", - addProject: language === "de" ? "Projekt hinzufügen" : "Add Project", - name: language === "de" ? "Name" : "Name", - cancel: language === "de" ? "Abbrechen" : "Cancel", - save: language === "de" ? "Speichern" : "Save", - color: language === "de" ? "Farbe" : "Color", - deleteConfirm: (name: string) => language === "de" ? `Projekt "${name}" löschen?` : `Delete project "${name}"?`, - filterOnly: language === "de" ? "Nur dieses Projekt anzeigen" : "Show only this project", - clearFilter: language === "de" ? "Filter aufheben" : "Clear filter", - showTasks: language === "de" ? "Aufgaben einblenden" : "Show tasks", - hideTasks: language === "de" ? "Aufgaben ausblenden" : "Hide tasks", - noTasks: language === "de" ? "Keine Aufgaben" : "No tasks", - filteringBy: language === "de" ? "Filter aktiv:" : "Filtering by:", - }; - - const tasksByProject = useMemo(() => { - const map = new Map(); - tasks.forEach((task) => { - if (!task.projectId || task.parentTaskId) return; - const arr = map.get(task.projectId) || []; - arr.push(task); - map.set(task.projectId, arr); - }); - return map; - }, [tasks]); - - const toggleExpanded = (id: string) => { - setExpandedProjects((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const createProject = () => { - if (!newProjectName.trim()) return; - fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) }) - .then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); }); - }; - - const updateProject = (id: string) => { - fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, name: editName, color: editColor, icon: editIcon }) }) - .then(() => { onProjectsChanged(); setEditingId(null); }); - }; - - const deleteProject = (id: string, name: string) => { - if (confirm(t.deleteConfirm(name))) { - fetch(`/api/projects?id=${id}`, { method: "DELETE" }).then(() => onProjectsChanged()); - } - }; - - return ( - <> -
-
- {/* Header */} -
-

- {t.projects} -

- -
- - {/* Add project form — at top */} -
-

{t.addProject}

-
-
- - {showNewIconPicker && ( -
- { setNewProjectIcon(name); setShowNewIconPicker(false); }} darkMode={darkMode} /> -
- )} -
- setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} /> - setNewProjectName(e.target.value)} placeholder={t.name} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter") createProject(); }} /> - -
-
- - {/* Active filter banner */} - {activeProjectFilter && ( -
- - - {t.filteringBy} {projects.find((p) => p.id === activeProjectFilter)?.name || ""} - - -
- )} - - {/* Scrollable content */} -
-

{t.desc}

- - {projects.length === 0 ? ( -
-
-

{t.noProjects}

-
- ) : ( - projects.map((p) => { - const projectTasks = tasksByProject.get(p.id) || []; - const isExpanded = expandedProjects.has(p.id); - const isFiltered = activeProjectFilter === p.id; - return ( -
-
- {editingId === p.id ? ( -
-
-
- - {showEditIconPicker && ( -
- { setEditIcon(name); setShowEditIconPicker(false); }} darkMode={darkMode} /> -
- )} -
- setEditName(e.target.value)} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter") updateProject(p.id); if (e.key === "Escape") setEditingId(null); }} autoFocus /> -
-
- setEditColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} /> - {t.color} -
- - -
-
- ) : ( - <> - - -
- {p.name} - {projectTasks.length} -
- - - - - )} -
- {isExpanded && editingId !== p.id && ( -
- {projectTasks.length === 0 ? ( - {t.noTasks} - ) : ( - projectTasks.map((task) => ( -
- - {task.title} -
- )) - )} -
- )} -
- ); - }) - )} -
-
- - ); -} // Notes Sidebar Component -interface NotesSidebarProps { - task: Task; - onClose: () => void; - updateTaskNotes: (id: string, notes: string) => void; - updateTaskUrl: (id: string, url: string) => void; -} - -function NotesSidebar({ task, onClose, updateTaskNotes, updateTaskUrl }: NotesSidebarProps) { - const [isVisible, setIsVisible] = useState(false); - const [sidebarWidth, setSidebarWidth] = useState(500); - const [urlValue, setUrlValue] = useState(task.url || ""); - const isResizing = useRef(false); - - useEffect(() => { setUrlValue(task.url || ""); }, [task.url]); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isResizing.current) return; - const newWidth = window.innerWidth - e.clientX; - setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9))); - }; - const handleMouseUp = () => { - if (isResizing.current) { - isResizing.current = false; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - } - }; - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, []); - - useEffect(() => { - const timer = setTimeout(() => setIsVisible(true), 10); - return () => clearTimeout(timer); - }, []); - - const handleClose = () => { - setIsVisible(false); - setTimeout(onClose, 300); - }; - - const handleUrlBlur = () => { - const normalised = urlValue.trim() - ? urlValue.trim().startsWith("http") ? urlValue.trim() : `https://${urlValue.trim()}` - : ""; - if (normalised !== (task.url || "")) { - updateTaskUrl(task.id, normalised); - } - }; - - return ( - <> -
-
- {/* Resize handle */} -
{ - e.preventDefault(); - isResizing.current = true; - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }} - style={{ - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: '6px', - cursor: 'col-resize', - zIndex: 10, - }} - title="Drag to resize" - /> -
-

Notes: {task.title}

- -
-
- {/* URL field */} -
- -
- setUrlValue(e.target.value)} - onBlur={handleUrlBlur} - onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} - placeholder="https://..." - style={{ flex: 1, fontSize: "0.82rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "5px 8px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }} - /> - {urlValue && ( - - )} - {urlValue && ( - - - - - - - )} -
-
- - {/* Notes */} -
- - updateTaskNotes(task.id, html)} - placeholder="Add details, notes, or links..." - minHeight="300px" - /> -
- -
- -
-
-
- - ); -} diff --git a/src/components/weekly/NotesSidebar.tsx b/src/components/weekly/NotesSidebar.tsx new file mode 100644 index 0000000..5148628 --- /dev/null +++ b/src/components/weekly/NotesSidebar.tsx @@ -0,0 +1,168 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import dynamic from "next/dynamic"; + +const RichTextEditor = dynamic(() => import("../RichTextEditor"), { ssr: false }); +import type { Task } from "@/lib/weekly/types"; + +/** + * Seitenleiste für Notizen und Verweis einer Aufgabe. Lag bis v1.119.1 in WeeklyView.tsx. + */ +interface NotesSidebarProps { + task: Task; + onClose: () => void; + updateTaskNotes: (id: string, notes: string) => void; + updateTaskUrl: (id: string, url: string) => void; +} + +export function NotesSidebar({ task, onClose, updateTaskNotes, updateTaskUrl }: NotesSidebarProps) { + const [isVisible, setIsVisible] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(500); + const [urlValue, setUrlValue] = useState(task.url || ""); + const isResizing = useRef(false); + + useEffect(() => { setUrlValue(task.url || ""); }, [task.url]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing.current) return; + const newWidth = window.innerWidth - e.clientX; + setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9))); + }; + const handleMouseUp = () => { + if (isResizing.current) { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 10); + return () => clearTimeout(timer); + }, []); + + const handleClose = () => { + setIsVisible(false); + setTimeout(onClose, 300); + }; + + const handleUrlBlur = () => { + const normalised = urlValue.trim() + ? urlValue.trim().startsWith("http") ? urlValue.trim() : `https://${urlValue.trim()}` + : ""; + if (normalised !== (task.url || "")) { + updateTaskUrl(task.id, normalised); + } + }; + + return ( + <> +
+
+ {/* Resize handle */} +
{ + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }} + style={{ + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: '6px', + cursor: 'col-resize', + zIndex: 10, + }} + title="Drag to resize" + /> +
+

Notes: {task.title}

+ +
+
+ {/* URL field */} +
+ +
+ setUrlValue(e.target.value)} + onBlur={handleUrlBlur} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + placeholder="https://..." + style={{ flex: 1, fontSize: "0.82rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "5px 8px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }} + /> + {urlValue && ( + + )} + {urlValue && ( + + + + + + + )} +
+
+ + {/* Notes */} +
+ + updateTaskNotes(task.id, html)} + placeholder="Add details, notes, or links..." + minHeight="300px" + /> +
+ +
+ +
+
+
+ + ); +} diff --git a/src/components/weekly/ProjectIcon.tsx b/src/components/weekly/ProjectIcon.tsx new file mode 100644 index 0000000..d8ea27e --- /dev/null +++ b/src/components/weekly/ProjectIcon.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import MdiIcon from "@mdi/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { + faBolt, faBook, faBriefcase, faBroom, faBuilding, faBullseye, faCamera, faCar, faCartShopping, faChartBar, faChartLine, faCheck, faClock, faCode, faCoins, faCube, faDumbbell, faEarthAmericas, faFilm, faFire, faFlask, faFolder, faGamepad, faGift, faGlobe, faGraduationCap, faHeart, faHouse, faLaptop, faLeaf, faLightbulb, faLock, faMicroscope, faMobileScreen, faMountain, faMusic, faPalette, faPaw, faPen, faPlane, faRocket, faStar, faTag, faUmbrella, faUsers, faUtensils, faWrench, +} from "@fortawesome/free-solid-svg-icons"; +import { allIcons } from "../iconRegistry"; + +// Ältere Projekte speichern Symbolnamen aus dieser Liste. Wanderte zusammen mit der +// Komponente aus WeeklyView.tsx hierher, weil nur sie die Liste benutzt. +const projectIconsGlobal: { name: string; icon: IconDefinition }[] = [ + { name: "folder", icon: faFolder }, { name: "briefcase", icon: faBriefcase }, + { name: "bullseye", icon: faBullseye }, { name: "rocket", icon: faRocket }, + { name: "star", icon: faStar }, { name: "lightbulb", icon: faLightbulb }, + { name: "fire", icon: faFire }, { name: "palette", icon: faPalette }, + { name: "music", icon: faMusic }, { name: "mobile", icon: faMobileScreen }, + { name: "laptop", icon: faLaptop }, { name: "globe", icon: faGlobe }, + { name: "house", icon: faHouse }, { name: "building", icon: faBuilding }, + { name: "chart-bar", icon: faChartBar }, { name: "chart-line", icon: faChartLine }, + { name: "wrench", icon: faWrench }, { name: "bolt", icon: faBolt }, + { name: "gamepad", icon: faGamepad }, { name: "pen", icon: faPen }, + { name: "book", icon: faBook }, { name: "graduation-cap", icon: faGraduationCap }, + { name: "flask", icon: faFlask }, { name: "microscope", icon: faMicroscope }, + { name: "dumbbell", icon: faDumbbell }, { name: "utensils", icon: faUtensils }, + { name: "plane", icon: faPlane }, { name: "leaf", icon: faLeaf }, + { name: "heart", icon: faHeart }, { name: "cart-shopping", icon: faCartShopping }, + { name: "coins", icon: faCoins }, { name: "gift", icon: faGift }, + { name: "camera", icon: faCamera }, { name: "film", icon: faFilm }, + { name: "broom", icon: faBroom }, { name: "paw", icon: faPaw }, + { name: "earth", icon: faEarthAmericas }, { name: "lock", icon: faLock }, + { name: "check", icon: faCheck }, { name: "code", icon: faCode }, + { name: "cube", icon: faCube }, { name: "users", icon: faUsers }, + { name: "car", icon: faCar }, { name: "mountain", icon: faMountain }, + { name: "umbrella", icon: faUmbrella }, { name: "clock", icon: faClock }, + { name: "tag", icon: faTag }, +]; + + +/** + * Zeigt das Symbol eines Projekts. Lag bis v1.119.1 in WeeklyView.tsx. + */ +export function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) { + if (!icon) return ; + // Normalise legacy "faHeart" → "heart" prefix style + const normalised = icon.startsWith("fa") && icon.length > 2 && icon[2] === icon[2].toUpperCase() + ? icon.slice(2, 3).toLowerCase() + icon.slice(3) + : icon; + // Check unified icon registry (FA + MDI) + const found = allIcons.find((i) => i.name === normalised || i.name === icon); + if (found) { + if (found.type === "fa") { + return ; + } + return ; + } + // Legacy: check old projectIconsGlobal for backwards compat + const legacy = projectIconsGlobal.find((i) => i.name === normalised || i.name === icon); + if (legacy) return ; + // Legacy emoji fallback + return {icon}; +} diff --git a/src/components/weekly/ProjectsSidebar.tsx b/src/components/weekly/ProjectsSidebar.tsx new file mode 100644 index 0000000..70db22b --- /dev/null +++ b/src/components/weekly/ProjectsSidebar.tsx @@ -0,0 +1,229 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import dynamic from "next/dynamic"; +import { FolderOpen, X, Plus, Filter, Check, ChevronDown, ChevronRight, Pencil, Trash2 } from "lucide-react"; + +const IconPicker = dynamic(() => import("../IconPicker"), { ssr: false }); +import { ProjectIcon } from "./ProjectIcon"; +import type { Task } from "@/lib/weekly/types"; + +/** + * Seitenleiste zur Verwaltung der Projekte. Lag bis v1.119.1 in WeeklyView.tsx. + */ +export function ProjectsSidebar({ darkMode, language, projects, onProjectsChanged, onClose, tasks, activeProjectFilter, onSetProjectFilter }: { + darkMode: boolean; + language: string; + projects: { id: string; name: string; icon?: string | null; color?: string | null }[]; + onProjectsChanged: () => void; + onClose: () => void; + tasks: Task[]; + activeProjectFilter: string | null; + onSetProjectFilter: (id: string | null) => void; +}) { + const [newProjectName, setNewProjectName] = useState(""); + const [newProjectColor, setNewProjectColor] = useState("#3b82f6"); + const [newProjectIcon, setNewProjectIcon] = useState("folder"); + const [showNewIconPicker, setShowNewIconPicker] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editColor, setEditColor] = useState(""); + const [editIcon, setEditIcon] = useState("folder"); + const [showEditIconPicker, setShowEditIconPicker] = useState(false); + const [expandedProjects, setExpandedProjects] = useState>(new Set()); + + const t = { + projects: language === "de" ? "Projekte" : "Projects", + desc: language === "de" ? "Aufgaben mit farbcodierten Projekten organisieren" : "Organize tasks with color-coded projects", + noProjects: language === "de" ? "Noch keine Projekte erstellt" : "No projects yet", + addProject: language === "de" ? "Projekt hinzufügen" : "Add Project", + name: language === "de" ? "Name" : "Name", + cancel: language === "de" ? "Abbrechen" : "Cancel", + save: language === "de" ? "Speichern" : "Save", + color: language === "de" ? "Farbe" : "Color", + deleteConfirm: (name: string) => language === "de" ? `Projekt "${name}" löschen?` : `Delete project "${name}"?`, + filterOnly: language === "de" ? "Nur dieses Projekt anzeigen" : "Show only this project", + clearFilter: language === "de" ? "Filter aufheben" : "Clear filter", + showTasks: language === "de" ? "Aufgaben einblenden" : "Show tasks", + hideTasks: language === "de" ? "Aufgaben ausblenden" : "Hide tasks", + noTasks: language === "de" ? "Keine Aufgaben" : "No tasks", + filteringBy: language === "de" ? "Filter aktiv:" : "Filtering by:", + }; + + const tasksByProject = useMemo(() => { + const map = new Map(); + tasks.forEach((task) => { + if (!task.projectId || task.parentTaskId) return; + const arr = map.get(task.projectId) || []; + arr.push(task); + map.set(task.projectId, arr); + }); + return map; + }, [tasks]); + + const toggleExpanded = (id: string) => { + setExpandedProjects((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const createProject = () => { + if (!newProjectName.trim()) return; + fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) }) + .then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("folder"); }); + }; + + const updateProject = (id: string) => { + fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, name: editName, color: editColor, icon: editIcon }) }) + .then(() => { onProjectsChanged(); setEditingId(null); }); + }; + + const deleteProject = (id: string, name: string) => { + if (confirm(t.deleteConfirm(name))) { + fetch(`/api/projects?id=${id}`, { method: "DELETE" }).then(() => onProjectsChanged()); + } + }; + + return ( + <> +
+
+ {/* Header */} +
+

+ {t.projects} +

+ +
+ + {/* Add project form — at top */} +
+

{t.addProject}

+
+
+ + {showNewIconPicker && ( +
+ { setNewProjectIcon(name); setShowNewIconPicker(false); }} darkMode={darkMode} /> +
+ )} +
+ setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} /> + setNewProjectName(e.target.value)} placeholder={t.name} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter") createProject(); }} /> + +
+
+ + {/* Active filter banner */} + {activeProjectFilter && ( +
+ + + {t.filteringBy} {projects.find((p) => p.id === activeProjectFilter)?.name || ""} + + +
+ )} + + {/* Scrollable content */} +
+

{t.desc}

+ + {projects.length === 0 ? ( +
+
+

{t.noProjects}

+
+ ) : ( + projects.map((p) => { + const projectTasks = tasksByProject.get(p.id) || []; + const isExpanded = expandedProjects.has(p.id); + const isFiltered = activeProjectFilter === p.id; + return ( +
+
+ {editingId === p.id ? ( +
+
+
+ + {showEditIconPicker && ( +
+ { setEditIcon(name); setShowEditIconPicker(false); }} darkMode={darkMode} /> +
+ )} +
+ setEditName(e.target.value)} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter") updateProject(p.id); if (e.key === "Escape") setEditingId(null); }} autoFocus /> +
+
+ setEditColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} /> + {t.color} +
+ + +
+
+ ) : ( + <> + + +
+ {p.name} + {projectTasks.length} +
+ + + + + )} +
+ {isExpanded && editingId !== p.id && ( +
+ {projectTasks.length === 0 ? ( + {t.noTasks} + ) : ( + projectTasks.map((task) => ( +
+ + {task.title} +
+ )) + )} +
+ )} +
+ ); + }) + )} +
+
+ + ); +}