refactor: ProjectIcon, ProjectsSidebar und NotesSidebar in eigene Dateien
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

Beim Vermessen der Struktur zeigte sich: In WeeklyView.tsx stecken acht
eigenstaendige Komponenten mit zusammen rund 1.870 Zeilen, die gar nichts
mit der Wochenansicht zu tun haben. Sie haben bereits eigene Props -- der
Schnitt braucht also kein Props-Design, nur die richtigen Importe.

Drei davon sind jetzt heraus, unter src/components/weekly/. Die Symbolliste
projectIconsGlobal ist zu ProjectIcon gewandert, wo sie als einzige
gebraucht wird.

11.556 -> 11.148 Zeilen.

Der scharfe Typecheck aus v1.118.1 hat sich hier zum ersten Mal richtig
ausgezahlt: Er nannte in 19 Sekunden praezise jeden fehlenden Import, in
mehreren schnellen Runden. Vor C1 haette der Build all das stumm
durchgewunken -- und die Fehler waeren erst im Browser aufgeschlagen.

Verifiziert: Typecheck 0 Fehler, npm run build Exit 0, 63 Unit-Tests gruen.

v1.119.1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-07 14:26:08 +02:00
parent 6fe4b69514
commit 7db4dfbf47
8 changed files with 488 additions and 424 deletions

View File

@ -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

View File

@ -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`

4
package-lock.json generated
View File

@ -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",

View File

@ -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": {

View File

@ -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 <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
// 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 <FontAwesomeIcon icon={found.icon as import("@fortawesome/free-solid-svg-icons").IconDefinition} style={{ fontSize: size, color }} />;
}
return <MdiIcon path={found.icon as string} size={size / 24} color={color} />;
}
// Legacy: check old projectIconsGlobal for backwards compat
const legacy = projectIconsGlobal.find((i) => i.name === normalised || i.name === icon);
if (legacy) return <FontAwesomeIcon icon={legacy.icon} style={{ fontSize: size, color }} />;
// Legacy emoji fallback
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
}
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<string | null>(null);
const [editName, setEditName] = useState("");
const [editColor, setEditColor] = useState("");
const [editIcon, setEditIcon] = useState("folder");
const [showEditIconPicker, setShowEditIconPicker] = useState(false);
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(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<string, Task[]>();
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 (
<>
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 999, background: "rgba(0,0,0,0.3)" }} />
<div style={{
position: "fixed", right: 0, top: 0, bottom: 0, width: "min(380px, 90vw)", zIndex: 1000,
background: darkMode ? "#1e1e2e" : "#fff",
borderLeft: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`,
display: "flex", flexDirection: "column",
boxShadow: "-4px 0 24px rgba(0,0,0,0.12)",
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}` }}>
<h2 style={{ fontSize: "1.1rem", fontWeight: 700, display: "flex", alignItems: "center", gap: "8px", color: darkMode ? "#f0f0f0" : "#333" }}>
<FolderOpen size={20} /> {t.projects}
</h2>
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", padding: "6px", color: darkMode ? "#9ca3af" : "#666", borderRadius: "6px" }}>
<X size={20} />
</button>
</div>
{/* Add project form — at top */}
<div style={{ padding: "16px 20px", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, background: darkMode ? "#1e1e2e" : "#fff" }}>
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#888", marginBottom: "10px" }}>{t.addProject}</p>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowNewIconPicker(!showNewIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={newProjectIcon} size={18} color={darkMode ? "#d1d5db" : "#555"} />
</button>
{showNewIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={newProjectIcon} onSelect={(name) => { setNewProjectIcon(name); setShowNewIconPicker(false); }} darkMode={darkMode} />
</div>
)}
</div>
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
<input type="text" value={newProjectName} onChange={(e) => 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(); }} />
<button onClick={createProject} className="weekly-btn-primary" style={{ padding: "8px 16px", fontSize: "0.85rem", whiteSpace: "nowrap" }}>
<Plus size={14} /> {t.addProject}
</button>
</div>
</div>
{/* Active filter banner */}
{activeProjectFilter && (
<div style={{ padding: "8px 20px", background: darkMode ? "#1e3a5f" : "#dbeafe", display: "flex", alignItems: "center", gap: "8px", fontSize: "0.8rem", color: darkMode ? "#bfdbfe" : "#1e40af" }}>
<Filter size={14} />
<span style={{ flex: 1 }}>
{t.filteringBy} {projects.find((p) => p.id === activeProjectFilter)?.name || ""}
</span>
<button onClick={() => onSetProjectFilter(null)} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#bfdbfe" : "#1e40af", padding: "2px 6px", fontSize: "0.75rem", textDecoration: "underline" }}>
{t.clearFilter}
</button>
</div>
)}
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: "12px" }}>
<p style={{ fontSize: "0.8rem", color: darkMode ? "#9ca3af" : "#888" }}>{t.desc}</p>
{projects.length === 0 ? (
<div style={{ textAlign: "center", padding: "32px 16px" }}>
<div style={{ width: "56px", height: "56px", borderRadius: "50%", background: darkMode ? "#2a2a3a" : "#f3f4f6", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 12px" }}><ProjectIcon icon="folder" size={24} color={darkMode ? "#9ca3af" : "#999"} /></div>
<p style={{ fontSize: "0.9rem", color: "#aaa", fontStyle: "italic" }}>{t.noProjects}</p>
</div>
) : (
projects.map((p) => {
const projectTasks = tasksByProject.get(p.id) || [];
const isExpanded = expandedProjects.has(p.id);
const isFiltered = activeProjectFilter === p.id;
return (
<div key={p.id} style={{ display: "flex", flexDirection: "column", borderRadius: "12px", background: darkMode ? "#2a2a3a" : "#f9fafb", borderLeft: `4px solid ${p.color || "#999"}`, outline: isFiltered ? `2px solid ${p.color || "#3b82f6"}` : "none" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px" }}>
{editingId === p.id ? (
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowEditIconPicker(!showEditIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#1e1e2e" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={editIcon} size={18} color={darkMode ? "#d1d5db" : "#555"} />
</button>
{showEditIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={editIcon} onSelect={(name) => { setEditIcon(name); setShowEditIconPicker(false); }} darkMode={darkMode} />
</div>
)}
</div>
<input type="text" value={editName} onChange={(e) => 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 />
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="color" value={editColor} onChange={(e) => setEditColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
<span style={{ fontSize: "0.8rem", color: darkMode ? "#9ca3af" : "#888" }}>{t.color}</span>
<div style={{ flex: 1 }} />
<button onClick={() => setEditingId(null)} style={{ padding: "6px 14px", fontSize: "0.8rem", background: "none", border: `1px solid ${darkMode ? "#555" : "#ddd"}`, borderRadius: "8px", cursor: "pointer", color: darkMode ? "#9ca3af" : "#666" }}>{t.cancel}</button>
<button onClick={() => updateProject(p.id)} className="weekly-btn-primary" style={{ padding: "6px 14px", fontSize: "0.8rem" }}><Check size={14} /> {t.save}</button>
</div>
</div>
) : (
<>
<button onClick={() => toggleExpanded(p.id)} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280", display: "flex", alignItems: "center" }} title={isExpanded ? t.hideTasks : t.showTasks}>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<ProjectIcon icon={p.icon} size={20} color={p.color || "#999"} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block", color: darkMode ? "#f0f0f0" : "#333" }}>{p.name}</span>
<span style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#888" }}>{projectTasks.length}</span>
</div>
<button onClick={() => onSetProjectFilter(isFiltered ? null : p.id)} style={{ padding: "6px", opacity: isFiltered ? 1 : 0.5, cursor: "pointer", background: isFiltered ? (darkMode ? "#3b3b5f" : "#e0e7ff") : "none", border: "none", borderRadius: "6px", color: isFiltered ? (p.color || "#3b82f6") : (darkMode ? "#ccc" : "#333") }} title={isFiltered ? t.clearFilter : t.filterOnly}>
<Filter size={15} />
</button>
<button onClick={() => { setEditingId(p.id); setEditName(p.name); setEditColor(p.color || "#999"); setEditIcon(p.icon || "folder"); setShowEditIconPicker(false); }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "6px", color: darkMode ? "#ccc" : "#333" }} title="Edit">
<Pencil size={15} />
</button>
<button onClick={() => deleteProject(p.id, p.name)} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }} title="Delete">
<Trash2 size={15} />
</button>
</>
)}
</div>
{isExpanded && editingId !== p.id && (
<div style={{ padding: "0 14px 12px 38px", display: "flex", flexDirection: "column", gap: "4px" }}>
{projectTasks.length === 0 ? (
<span style={{ fontSize: "0.8rem", color: darkMode ? "#6b7280" : "#9ca3af", fontStyle: "italic" }}>{t.noTasks}</span>
) : (
projectTasks.map((task) => (
<div key={task.id} style={{ fontSize: "0.82rem", color: darkMode ? "#d1d5db" : "#374151", padding: "3px 6px", borderRadius: "4px", textDecoration: task.completed ? "line-through" : "none", opacity: task.completed ? 0.6 : 1, display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: p.color || "#999", flexShrink: 0 }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</span>
</div>
))
)}
</div>
)}
</div>
);
})
)}
</div>
</div>
</>
);
}
// 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 (
<>
<div
className={`weekly-modal-overlay ${isVisible ? "show" : ""}`}
onClick={handleClose}
style={{ zIndex: 1999 }}
/>
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
{/* Resize handle */}
<div
onMouseDown={(e) => {
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"
/>
<header className="weekly-notes-sidebar-header">
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
×
</button>
</header>
<div className="weekly-notes-sidebar-content">
{/* URL field */}
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
Link
</label>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<input
type="url"
value={urlValue}
onChange={(e) => 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 && (
<button
onClick={() => { setUrlValue(""); updateTaskUrl(task.id, ""); }}
title="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", color: "#999", padding: "4px" }}
>
<svg viewBox="0 0 24 24" width="13" height="13" 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"
title="Open link"
style={{ color: "#2563eb", display: "inline-flex", padding: "4px" }}
>
<svg viewBox="0 0 24 24" width="14" height="14" 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>
</div>
{/* Notes */}
<div>
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
Notes
</label>
<RichTextEditor
value={task.markdownContent || ""}
onChange={(html) => updateTaskNotes(task.id, html)}
placeholder="Add details, notes, or links..."
minHeight="300px"
/>
</div>
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
<button
className="weekly-btn weekly-btn-secondary"
onClick={handleClose}
style={{ width: '100%' }}
>
Close
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -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 (
<>
<div
className={`weekly-modal-overlay ${isVisible ? "show" : ""}`}
onClick={handleClose}
style={{ zIndex: 1999 }}
/>
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
{/* Resize handle */}
<div
onMouseDown={(e) => {
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"
/>
<header className="weekly-notes-sidebar-header">
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
×
</button>
</header>
<div className="weekly-notes-sidebar-content">
{/* URL field */}
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
Link
</label>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<input
type="url"
value={urlValue}
onChange={(e) => 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 && (
<button
onClick={() => { setUrlValue(""); updateTaskUrl(task.id, ""); }}
title="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", color: "#999", padding: "4px" }}
>
<svg viewBox="0 0 24 24" width="13" height="13" 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"
title="Open link"
style={{ color: "#2563eb", display: "inline-flex", padding: "4px" }}
>
<svg viewBox="0 0 24 24" width="14" height="14" 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>
</div>
{/* Notes */}
<div>
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
Notes
</label>
<RichTextEditor
value={task.markdownContent || ""}
onChange={(html) => updateTaskNotes(task.id, html)}
placeholder="Add details, notes, or links..."
minHeight="300px"
/>
</div>
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
<button
className="weekly-btn weekly-btn-secondary"
onClick={handleClose}
style={{ width: '100%' }}
>
Close
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -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 <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
// 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 <FontAwesomeIcon icon={found.icon as import("@fortawesome/free-solid-svg-icons").IconDefinition} style={{ fontSize: size, color }} />;
}
return <MdiIcon path={found.icon as string} size={size / 24} color={color} />;
}
// Legacy: check old projectIconsGlobal for backwards compat
const legacy = projectIconsGlobal.find((i) => i.name === normalised || i.name === icon);
if (legacy) return <FontAwesomeIcon icon={legacy.icon} style={{ fontSize: size, color }} />;
// Legacy emoji fallback
return <span style={{ fontSize: size, lineHeight: 1 }}>{icon}</span>;
}

View File

@ -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<string | null>(null);
const [editName, setEditName] = useState("");
const [editColor, setEditColor] = useState("");
const [editIcon, setEditIcon] = useState("folder");
const [showEditIconPicker, setShowEditIconPicker] = useState(false);
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(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<string, Task[]>();
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 (
<>
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 999, background: "rgba(0,0,0,0.3)" }} />
<div style={{
position: "fixed", right: 0, top: 0, bottom: 0, width: "min(380px, 90vw)", zIndex: 1000,
background: darkMode ? "#1e1e2e" : "#fff",
borderLeft: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`,
display: "flex", flexDirection: "column",
boxShadow: "-4px 0 24px rgba(0,0,0,0.12)",
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}` }}>
<h2 style={{ fontSize: "1.1rem", fontWeight: 700, display: "flex", alignItems: "center", gap: "8px", color: darkMode ? "#f0f0f0" : "#333" }}>
<FolderOpen size={20} /> {t.projects}
</h2>
<button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", padding: "6px", color: darkMode ? "#9ca3af" : "#666", borderRadius: "6px" }}>
<X size={20} />
</button>
</div>
{/* Add project form — at top */}
<div style={{ padding: "16px 20px", borderBottom: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`, background: darkMode ? "#1e1e2e" : "#fff" }}>
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#888", marginBottom: "10px" }}>{t.addProject}</p>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowNewIconPicker(!showNewIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#2a2a3a" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={newProjectIcon} size={18} color={darkMode ? "#d1d5db" : "#555"} />
</button>
{showNewIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={newProjectIcon} onSelect={(name) => { setNewProjectIcon(name); setShowNewIconPicker(false); }} darkMode={darkMode} />
</div>
)}
</div>
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
<input type="text" value={newProjectName} onChange={(e) => 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(); }} />
<button onClick={createProject} className="weekly-btn-primary" style={{ padding: "8px 16px", fontSize: "0.85rem", whiteSpace: "nowrap" }}>
<Plus size={14} /> {t.addProject}
</button>
</div>
</div>
{/* Active filter banner */}
{activeProjectFilter && (
<div style={{ padding: "8px 20px", background: darkMode ? "#1e3a5f" : "#dbeafe", display: "flex", alignItems: "center", gap: "8px", fontSize: "0.8rem", color: darkMode ? "#bfdbfe" : "#1e40af" }}>
<Filter size={14} />
<span style={{ flex: 1 }}>
{t.filteringBy} {projects.find((p) => p.id === activeProjectFilter)?.name || ""}
</span>
<button onClick={() => onSetProjectFilter(null)} style={{ background: "none", border: "none", cursor: "pointer", color: darkMode ? "#bfdbfe" : "#1e40af", padding: "2px 6px", fontSize: "0.75rem", textDecoration: "underline" }}>
{t.clearFilter}
</button>
</div>
)}
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: "12px" }}>
<p style={{ fontSize: "0.8rem", color: darkMode ? "#9ca3af" : "#888" }}>{t.desc}</p>
{projects.length === 0 ? (
<div style={{ textAlign: "center", padding: "32px 16px" }}>
<div style={{ width: "56px", height: "56px", borderRadius: "50%", background: darkMode ? "#2a2a3a" : "#f3f4f6", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 12px" }}><ProjectIcon icon="folder" size={24} color={darkMode ? "#9ca3af" : "#999"} /></div>
<p style={{ fontSize: "0.9rem", color: "#aaa", fontStyle: "italic" }}>{t.noProjects}</p>
</div>
) : (
projects.map((p) => {
const projectTasks = tasksByProject.get(p.id) || [];
const isExpanded = expandedProjects.has(p.id);
const isFiltered = activeProjectFilter === p.id;
return (
<div key={p.id} style={{ display: "flex", flexDirection: "column", borderRadius: "12px", background: darkMode ? "#2a2a3a" : "#f9fafb", borderLeft: `4px solid ${p.color || "#999"}`, outline: isFiltered ? `2px solid ${p.color || "#3b82f6"}` : "none" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px" }}>
{editingId === p.id ? (
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowEditIconPicker(!showEditIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#1e1e2e" : "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
<ProjectIcon icon={editIcon} size={18} color={darkMode ? "#d1d5db" : "#555"} />
</button>
{showEditIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50 }}>
<IconPicker selectedIcon={editIcon} onSelect={(name) => { setEditIcon(name); setShowEditIconPicker(false); }} darkMode={darkMode} />
</div>
)}
</div>
<input type="text" value={editName} onChange={(e) => 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 />
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="color" value={editColor} onChange={(e) => setEditColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
<span style={{ fontSize: "0.8rem", color: darkMode ? "#9ca3af" : "#888" }}>{t.color}</span>
<div style={{ flex: 1 }} />
<button onClick={() => setEditingId(null)} style={{ padding: "6px 14px", fontSize: "0.8rem", background: "none", border: `1px solid ${darkMode ? "#555" : "#ddd"}`, borderRadius: "8px", cursor: "pointer", color: darkMode ? "#9ca3af" : "#666" }}>{t.cancel}</button>
<button onClick={() => updateProject(p.id)} className="weekly-btn-primary" style={{ padding: "6px 14px", fontSize: "0.8rem" }}><Check size={14} /> {t.save}</button>
</div>
</div>
) : (
<>
<button onClick={() => toggleExpanded(p.id)} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280", display: "flex", alignItems: "center" }} title={isExpanded ? t.hideTasks : t.showTasks}>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<ProjectIcon icon={p.icon} size={20} color={p.color || "#999"} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block", color: darkMode ? "#f0f0f0" : "#333" }}>{p.name}</span>
<span style={{ fontSize: "0.7rem", color: darkMode ? "#9ca3af" : "#888" }}>{projectTasks.length}</span>
</div>
<button onClick={() => onSetProjectFilter(isFiltered ? null : p.id)} style={{ padding: "6px", opacity: isFiltered ? 1 : 0.5, cursor: "pointer", background: isFiltered ? (darkMode ? "#3b3b5f" : "#e0e7ff") : "none", border: "none", borderRadius: "6px", color: isFiltered ? (p.color || "#3b82f6") : (darkMode ? "#ccc" : "#333") }} title={isFiltered ? t.clearFilter : t.filterOnly}>
<Filter size={15} />
</button>
<button onClick={() => { setEditingId(p.id); setEditName(p.name); setEditColor(p.color || "#999"); setEditIcon(p.icon || "folder"); setShowEditIconPicker(false); }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "6px", color: darkMode ? "#ccc" : "#333" }} title="Edit">
<Pencil size={15} />
</button>
<button onClick={() => deleteProject(p.id, p.name)} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }} title="Delete">
<Trash2 size={15} />
</button>
</>
)}
</div>
{isExpanded && editingId !== p.id && (
<div style={{ padding: "0 14px 12px 38px", display: "flex", flexDirection: "column", gap: "4px" }}>
{projectTasks.length === 0 ? (
<span style={{ fontSize: "0.8rem", color: darkMode ? "#6b7280" : "#9ca3af", fontStyle: "italic" }}>{t.noTasks}</span>
) : (
projectTasks.map((task) => (
<div key={task.id} style={{ fontSize: "0.82rem", color: darkMode ? "#d1d5db" : "#374151", padding: "3px 6px", borderRadius: "4px", textDecoration: task.completed ? "line-through" : "none", opacity: task.completed ? 0.6 : 1, display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: p.color || "#999", flexShrink: 0 }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</span>
</div>
))
)}
</div>
)}
</div>
);
})
)}
</div>
</div>
</>
);
}