feat: improve project management with emoji icons and better UI

- Add emoji icon picker for projects (40 icons to choose from)
- Redesign project list in settings with color left-border, icon display
- Show project icons on task cards, project picker, and kanban cards
- Add New Project button to desktop header, tablet overflow menu, and mobile FAB
- Remove duplicate unused /api/tasks/projects route

v1.43.0
This commit is contained in:
mARTin 2026-03-17 11:14:39 +01:00
parent 0248cfbb6c
commit 7e343b29e4
3 changed files with 173 additions and 291 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.42.0", "version": "1.43.0",
"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": {

View File

@ -1,204 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET - List all projects for authenticated user
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const projects = await prisma.project.findMany({
where: { userId },
orderBy: { order: 'asc' },
include: {
_count: {
select: { tasks: true },
},
},
});
return NextResponse.json({ projects });
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json(
{ error: 'Failed to fetch projects' },
{ status: 500 }
);
}
}
// POST - Create a new project
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const body = await request.json();
const { name, icon, color, description } = body;
if (!name) {
return NextResponse.json(
{ error: 'Project name is required' },
{ status: 400 }
);
}
// Set order to be after the last project
const lastProject = await prisma.project.findFirst({
where: { userId },
orderBy: { order: 'desc' },
select: { order: true },
});
const project = await prisma.project.create({
data: {
name,
icon: icon || null,
color: color || null,
description: description || null,
order: (lastProject?.order ?? -1) + 1,
userId,
},
});
return NextResponse.json({ project });
} catch (error) {
console.error('Error creating project:', error);
return NextResponse.json(
{ error: 'Failed to create project' },
{ status: 500 }
);
}
}
// PATCH - Update a project
export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Project ID is required' },
{ status: 400 }
);
}
// Validate ownership
const existingProject = await prisma.project.findFirst({
where: { id, userId },
});
if (!existingProject) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
const body = await request.json();
const { name, icon, color, description, order } = body;
const project = await prisma.project.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(icon !== undefined && { icon: icon || null }),
...(color !== undefined && { color: color || null }),
...(description !== undefined && { description: description || null }),
...(order !== undefined && { order: parseInt(order) }),
},
});
return NextResponse.json({ project });
} catch (error) {
console.error('Error updating project:', error);
return NextResponse.json(
{ error: 'Failed to update project' },
{ status: 500 }
);
}
}
// DELETE - Delete a project (tasks remain, their projectId becomes null)
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Project ID is required' },
{ status: 400 }
);
}
// Validate ownership
const existingProject = await prisma.project.findFirst({
where: { id, userId },
});
if (!existingProject) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
// Nullify projectId on all tasks belonging to this project
await prisma.task.updateMany({
where: { projectId: id },
data: { projectId: null },
});
await prisma.project.delete({
where: { id },
});
return NextResponse.json({ message: 'Project deleted' });
} catch (error) {
console.error('Error deleting project:', error);
return NextResponse.json(
{ error: 'Failed to delete project' },
{ status: 500 }
);
}
}

View File

@ -67,6 +67,7 @@ import {
CalendarDays, CalendarDays,
ListTodo, ListTodo,
Filter, Filter,
Pencil,
} from "lucide-react"; } from "lucide-react";
// Types // Types
@ -6074,13 +6075,15 @@ export default function WeeklyView() {
<button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button> <button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button>
<button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{language === "de" ? "Fokus" : "Focus"}</span></button> <button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{language === "de" ? "Fokus" : "Focus"}</span></button>
<button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button> <button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button>
<button onClick={() => { setShowSettings(true); setShowHeaderMore(false); }}><FolderPlus size={16} /> <span>{language === "de" ? "Neues Projekt" : "New Project"}</span></button>
</div> </div>
</> </>
)} )}
</div> </div>
{/* Recurring Tasks — desktop only */} {/* Desktop only: Recurring Tasks + New Project */}
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button> <button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowSettings(true)} title="New Project"><FolderPlus size={17} /></button>
{/* User Menu */} {/* User Menu */}
<UserMenu <UserMenu
@ -8291,6 +8294,10 @@ export default function WeeklyView() {
<div className="mobile-fab-menu-icon" style={{ background: "#10b981" }}><Search size={18} /></div> <div className="mobile-fab-menu-icon" style={{ background: "#10b981" }}><Search size={18} /></div>
<span>{language === "de" ? "Suche" : "Search"}</span> <span>{language === "de" ? "Suche" : "Search"}</span>
</button> </button>
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowSettings(true); }}>
<div className="mobile-fab-menu-icon" style={{ background: "#6366f1" }}><FolderPlus size={18} /></div>
<span>{language === "de" ? "Projekt" : "Project"}</span>
</button>
</div> </div>
)} )}
<button <button
@ -9113,12 +9120,16 @@ function TaskItem({
}} }}
title={task.project ? task.project.name : "Assign project"} title={task.project ? task.project.name : "Assign project"}
> >
<Circle {task.project?.icon ? (
size={12} <span style={{ fontSize: "12px", lineHeight: 1 }}>{task.project.icon}</span>
fill={task.project?.color || "none"} ) : (
stroke={task.project?.color || "currentColor"} <Circle
strokeWidth={2} size={12}
/> fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
)}
</button> </button>
{showProjectPicker && ( {showProjectPicker && (
<div className="absolute z-50 top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}> <div className="absolute z-50 top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
@ -9144,7 +9155,8 @@ function TaskItem({
setShowProjectPicker(false); setShowProjectPicker(false);
}} }}
> >
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} /> <span style={{ fontSize: "12px" }}>{p.icon || "📁"}</span>
<Circle size={8} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{p.name} {p.name}
</button> </button>
))} ))}
@ -9822,9 +9834,20 @@ function SettingsSidebar({
); );
const [newProjectName, setNewProjectName] = useState(""); const [newProjectName, setNewProjectName] = useState("");
const [newProjectColor, setNewProjectColor] = useState("#3b82f6"); const [newProjectColor, setNewProjectColor] = useState("#3b82f6");
const [newProjectIcon, setNewProjectIcon] = useState("📁");
const [showNewProjectIconPicker, setShowNewProjectIconPicker] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null); const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editProjectName, setEditProjectName] = useState(""); const [editProjectName, setEditProjectName] = useState("");
const [editProjectColor, setEditProjectColor] = useState(""); const [editProjectColor, setEditProjectColor] = useState("");
const [editProjectIcon, setEditProjectIcon] = useState("");
const [showEditProjectIconPicker, setShowEditProjectIconPicker] = useState(false);
const projectEmojis = [
"📁", "📂", "💼", "🎯", "🚀", "⭐", "💡", "🔥", "🎨", "🎵",
"📱", "💻", "🌐", "🏠", "🏢", "📊", "📈", "🔧", "⚡", "🎮",
"📝", "📖", "🎓", "🧪", "🔬", "🏋️", "🍽️", "✈️", "🌿", "❤️",
"🛒", "💰", "🎁", "📸", "🎬", "🧹", "🐾", "🌍", "🔒", "✅",
];
// Fetch lists when the calendar tab is selected // Fetch lists when the calendar tab is selected
useEffect(() => { useEffect(() => {
@ -11061,57 +11084,93 @@ function SettingsSidebar({
)} )}
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
{projects.map((p) => ( {projects.map((p) => (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}> <div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "8px 12px", borderRadius: "10px", background: "var(--bg-secondary, #f9fafb)", borderLeft: `3px solid ${p.color || "#999"}` }}>
{editingProjectId === p.id ? ( {editingProjectId === p.id ? (
<> <div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
<input <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
type="color" <div style={{ position: "relative" }}>
value={editProjectColor} <button
onChange={(e) => setEditProjectColor(e.target.value)} onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)}
style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }} style={{ width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "18px", display: "flex", alignItems: "center", justifyContent: "center" }}
/> title="Change icon"
<input >
type="text" {editProjectIcon || "📁"}
value={editProjectName} </button>
onChange={(e) => setEditProjectName(e.target.value)} {showEditProjectIconPicker && (
className="weekly-input" <div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "10px", padding: "8px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "220px" }}>
style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }} <div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px" }}>
onKeyDown={(e) => { {projectEmojis.map((emoji) => (
if (e.key === "Enter") { <button
key={emoji}
onClick={() => { setEditProjectIcon(emoji); setShowEditProjectIconPicker(false); }}
style={{ width: "24px", height: "24px", border: "none", background: editProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "4px", cursor: "pointer", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
<input
type="text"
value={editProjectName}
onChange={(e) => setEditProjectName(e.target.value)}
className="weekly-input"
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
onKeyDown={(e) => {
if (e.key === "Enter") {
fetch("/api/projects", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }),
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
}
if (e.key === "Escape") setEditingProjectId(null);
}}
autoFocus
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
type="color"
value={editProjectColor}
onChange={(e) => setEditProjectColor(e.target.value)}
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
/>
<span style={{ fontSize: "0.75rem", color: "#888" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
<div style={{ flex: 1 }} />
<button
onClick={() => setEditingProjectId(null)}
style={{ padding: "4px 10px", fontSize: "0.8rem", background: "none", border: "1px solid var(--border-color, #ddd)", borderRadius: "6px", cursor: "pointer", color: "var(--text-secondary, #666)" }}
>
{profile.language === "de" ? "Abbrechen" : "Cancel"}
</button>
<button
onClick={() => {
fetch("/api/projects", { fetch("/api/projects", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor }), body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }),
}).then(() => { onProjectsChanged(); setEditingProjectId(null); }); }).then(() => { onProjectsChanged(); setEditingProjectId(null); });
} }}
if (e.key === "Escape") setEditingProjectId(null); className="weekly-btn-primary"
}} style={{ padding: "4px 12px", fontSize: "0.8rem" }}
autoFocus >
/> <Check size={12} />
<button </button>
onClick={() => { </div>
fetch("/api/projects", { </div>
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor }),
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
}}
style={{ padding: "2px 6px", fontSize: "0.8rem" }}
className="weekly-btn-primary"
>
<Check size={12} />
</button>
</>
) : ( ) : (
<> <>
<Circle size={14} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} /> <span style={{ fontSize: "18px", lineHeight: 1 }}>{p.icon || "📁"}</span>
<span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 500 }}>{p.name}</span> <span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 600 }}>{p.name}</span>
<button <button
onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); }} onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); setEditProjectIcon(p.icon || "📁"); setShowEditProjectIconPicker(false); }}
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", background: "none", border: "none" }} style={{ padding: "4px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "4px" }}
title="Edit" title="Edit"
> >
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> <Pencil size={13} />
</button> </button>
<button <button
onClick={() => { onClick={() => {
@ -11119,54 +11178,81 @@ function SettingsSidebar({
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged()); fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
} }
}} }}
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }} style={{ padding: "4px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "4px" }}
title="Delete" title="Delete"
> >
<Trash2 size={12} /> <Trash2 size={13} />
</button> </button>
</> </>
)} )}
</div> </div>
))} ))}
</div> </div>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}> {/* Add new project */}
<input <div style={{ padding: "10px 12px", borderRadius: "10px", border: "1px dashed var(--border-color, #d1d5db)", background: "var(--bg-secondary, #f9fafb)" }}>
type="color" <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
value={newProjectColor} <div style={{ position: "relative" }}>
onChange={(e) => setNewProjectColor(e.target.value)} <button
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0 }} onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)}
/> style={{ width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "18px", display: "flex", alignItems: "center", justifyContent: "center" }}
<input title="Choose icon"
type="text" >
value={newProjectName} {newProjectIcon}
onChange={(e) => setNewProjectName(e.target.value)} </button>
placeholder={t.projectName} {showNewProjectIconPicker && (
className="weekly-input" <div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "10px", padding: "8px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "220px" }}>
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }} <div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px" }}>
onKeyDown={(e) => { {projectEmojis.map((emoji) => (
if (e.key === "Enter" && newProjectName.trim()) { <button
key={emoji}
onClick={() => { setNewProjectIcon(emoji); setShowNewProjectIconPicker(false); }}
style={{ width: "24px", height: "24px", border: "none", background: newProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "4px", cursor: "pointer", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
<input
type="color"
value={newProjectColor}
onChange={(e) => setNewProjectColor(e.target.value)}
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
/>
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder={t.projectName}
className="weekly-input"
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
onKeyDown={(e) => {
if (e.key === "Enter" && newProjectName.trim()) {
fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }),
}).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); });
}
}}
/>
<button
onClick={() => {
if (!newProjectName.trim()) return;
fetch("/api/projects", { fetch("/api/projects", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor }), body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }),
}).then(() => { onProjectsChanged(); setNewProjectName(""); }); }).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); });
} }}
}} className="weekly-btn-primary"
/> style={{ padding: "6px 12px", fontSize: "0.8rem", whiteSpace: "nowrap" }}
<button >
onClick={() => { <Plus size={14} /> {t.addProject}
if (!newProjectName.trim()) return; </button>
fetch("/api/projects", { </div>
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor }),
}).then(() => { onProjectsChanged(); setNewProjectName(""); });
}}
className="weekly-btn-primary"
style={{ padding: "6px 12px", fontSize: "0.8rem", whiteSpace: "nowrap" }}
>
<Plus size={14} /> {t.addProject}
</button>
</div> </div>
</div> </div>