refactor: ProjectIcon, ProjectsSidebar und NotesSidebar in eigene Dateien
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:
parent
6fe4b69514
commit
7db4dfbf47
14
CHANGELOG.md
14
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
|
||||
|
||||
9
TODO.md
9
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`
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
168
src/components/weekly/NotesSidebar.tsx
Normal file
168
src/components/weekly/NotesSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
src/components/weekly/ProjectIcon.tsx
Normal file
64
src/components/weekly/ProjectIcon.tsx
Normal 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>;
|
||||
}
|
||||
229
src/components/weekly/ProjectsSidebar.tsx
Normal file
229
src/components/weekly/ProjectsSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user