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.
|
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
|
## [1.119.0] - 2026-09-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
9
TODO.md
9
TODO.md
@ -136,7 +136,7 @@ Betroffen sind genau zwei Dateien:
|
|||||||
|
|
||||||
| Datei | Zeilen |
|
| 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 |
|
| `src/components/SettingsSidebar.tsx` | 5.445 |
|
||||||
|
|
||||||
**Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe
|
**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
|
#### WeeklyView.tsx → Zielstruktur
|
||||||
- [x] Typen nach `src/lib/weekly/types.ts` (7 Typen, 97 Zeilen) — Build verifiziert
|
- [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] 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] Cookie-/Geräteeinstellungen nach `src/lib/weekly/device-settings.ts`
|
||||||
- [x] Formatierungs-Helfer nach `src/lib/weekly/format.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",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.119.0",
|
"version": "1.119.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.119.0",
|
"version": "1.119.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -115,6 +115,9 @@ import SearchModal from "./SearchModal";
|
|||||||
import SimpleDatePicker from "./SimpleDatePicker";
|
import SimpleDatePicker from "./SimpleDatePicker";
|
||||||
import RecurringTasksManager from "./RecurringTasksManager";
|
import RecurringTasksManager from "./RecurringTasksManager";
|
||||||
import WeekPrintModal from "./WeekPrintModal";
|
import WeekPrintModal from "./WeekPrintModal";
|
||||||
|
import { ProjectIcon } from "./weekly/ProjectIcon";
|
||||||
|
import { ProjectsSidebar } from "./weekly/ProjectsSidebar";
|
||||||
|
import { NotesSidebar } from "./weekly/NotesSidebar";
|
||||||
import type {
|
import type {
|
||||||
RecurringTaskException, ViewStyle, KanbanStage, Task,
|
RecurringTaskException, ViewStyle, KanbanStage, Task,
|
||||||
CalendarEvent, SomedayList, CellDuration,
|
CalendarEvent, SomedayList, CellDuration,
|
||||||
@ -11138,427 +11141,8 @@ function TabVisualsPopover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Projects Sidebar Component
|
// 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)
|
// 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
|
// 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