From cb04adcc2e12d70ccf17530ce66555d2ded44d2f Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 6 Apr 2026 12:40:11 -0400 Subject: [PATCH] feat: implement web frontend with full task management UI (#39) - Protected (app) layout with sidebar, header, theme toggle - List detail page with tasks, filters, sorting - Inline task editing (title, notes, priority, due date, recurrence) - Subtask creation and nested display - Dark mode (class-based, persisted to localStorage) - WebSocket sync hook (connects via ticket auth, auto-refresh) - Responsive sidebar (hamburger on mobile) - French UI strings throughout - Components: Sidebar, TaskList, TaskItem, TaskForm, FilterBar, ThemeToggle, Header, AppShell Co-Authored-By: Claude Opus 4.6 (1M context) --- web/package-lock.json | 10 + web/package.json | 1 + web/src/app/(app)/layout.tsx | 44 ++++ web/src/app/(app)/lists/[id]/page.tsx | 108 +++++++++ web/src/app/(app)/page.tsx | 36 +++ web/src/app/globals.css | 32 ++- web/src/app/layout.tsx | 11 +- web/src/app/page.tsx | 65 ------ web/src/components/AppShell.tsx | 8 + web/src/components/FilterBar.tsx | 83 +++++++ web/src/components/Header.tsx | 64 ++++++ web/src/components/Sidebar.tsx | 188 ++++++++++++++++ web/src/components/TaskForm.tsx | 164 ++++++++++++++ web/src/components/TaskItem.tsx | 303 ++++++++++++++++++++++++++ web/src/components/TaskList.tsx | 52 +++++ web/src/components/ThemeScript.tsx | 14 ++ web/src/components/ThemeToggle.tsx | 40 ++++ web/src/components/useSync.ts | 44 ++++ web/src/lib/types.ts | 41 ++++ 19 files changed, 1230 insertions(+), 78 deletions(-) create mode 100644 web/src/app/(app)/layout.tsx create mode 100644 web/src/app/(app)/lists/[id]/page.tsx create mode 100644 web/src/app/(app)/page.tsx delete mode 100644 web/src/app/page.tsx create mode 100644 web/src/components/AppShell.tsx create mode 100644 web/src/components/FilterBar.tsx create mode 100644 web/src/components/Header.tsx create mode 100644 web/src/components/Sidebar.tsx create mode 100644 web/src/components/TaskForm.tsx create mode 100644 web/src/components/TaskItem.tsx create mode 100644 web/src/components/TaskList.tsx create mode 100644 web/src/components/ThemeScript.tsx create mode 100644 web/src/components/ThemeToggle.tsx create mode 100644 web/src/components/useSync.ts create mode 100644 web/src/lib/types.ts diff --git a/web/package-lock.json b/web/package-lock.json index fb792f5..fd80da0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@types/pg": "^8.20.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.45.2", + "lucide-react": "^1.7.0", "next": "16.2.2", "pg": "^8.20.0", "react": "19.2.4", @@ -6100,6 +6101,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/web/package.json b/web/package.json index 97c9b76..af4f558 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "@types/pg": "^8.20.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.45.2", + "lucide-react": "^1.7.0", "next": "16.2.2", "pg": "^8.20.0", "react": "19.2.4", diff --git a/web/src/app/(app)/layout.tsx b/web/src/app/(app)/layout.tsx new file mode 100644 index 0000000..c0f4ed1 --- /dev/null +++ b/web/src/app/(app)/layout.tsx @@ -0,0 +1,44 @@ +export const dynamic = "force-dynamic"; + +import { redirect } from "next/navigation"; +import { getAuthenticatedUser } from "@/lib/auth"; +import { db } from "@/db/client"; +import { slLists, slTags } from "@/db/schema"; +import { eq, isNull, and, asc } from "drizzle-orm"; +import { Sidebar } from "@/components/Sidebar"; +import { Header } from "@/components/Header"; +import { AppShell } from "@/components/AppShell"; + +export default async function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getAuthenticatedUser(); + if (!user) redirect("/auth"); + + const [lists, tags] = await Promise.all([ + db + .select() + .from(slLists) + .where(and(eq(slLists.userId, user.userId), isNull(slLists.deletedAt))) + .orderBy(asc(slLists.position)), + db + .select() + .from(slTags) + .where(and(eq(slTags.userId, user.userId), isNull(slTags.deletedAt))) + .orderBy(asc(slTags.name)), + ]); + + return ( + +
+ +
+
+
{children}
+
+
+
+ ); +} diff --git a/web/src/app/(app)/lists/[id]/page.tsx b/web/src/app/(app)/lists/[id]/page.tsx new file mode 100644 index 0000000..b89875a --- /dev/null +++ b/web/src/app/(app)/lists/[id]/page.tsx @@ -0,0 +1,108 @@ +export const dynamic = "force-dynamic"; + +import { notFound } from "next/navigation"; +import { getAuthenticatedUser } from "@/lib/auth"; +import { db } from "@/db/client"; +import { slLists, slTasks } from "@/db/schema"; +import { eq, and, isNull, asc, desc } from "drizzle-orm"; +import { TaskList } from "@/components/TaskList"; +import type { Task } from "@/lib/types"; +import type { SQL } from "drizzle-orm"; + +export default async function ListPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const user = await getAuthenticatedUser(); + if (!user) notFound(); + + const { id: listId } = await params; + const search = await searchParams; + + // Verify list belongs to user + const [list] = await db + .select() + .from(slLists) + .where( + and( + eq(slLists.id, listId), + eq(slLists.userId, user.userId), + isNull(slLists.deletedAt) + ) + ); + + if (!list) notFound(); + + // Build conditions + const conditions: SQL[] = [ + eq(slTasks.listId, listId), + eq(slTasks.userId, user.userId), + isNull(slTasks.deletedAt), + isNull(slTasks.parentId), + ]; + + const completed = typeof search.completed === "string" ? search.completed : undefined; + if (completed === "true" || completed === "false") { + conditions.push(eq(slTasks.completed, completed === "true")); + } + + const sortBy = (typeof search.sortBy === "string" ? search.sortBy : "position") as string; + const sortOrder = (typeof search.sortOrder === "string" ? search.sortOrder : "asc") as string; + + const sortColumn = + sortBy === "priority" + ? slTasks.priority + : sortBy === "dueDate" + ? slTasks.dueDate + : sortBy === "createdAt" + ? slTasks.createdAt + : sortBy === "title" + ? slTasks.title + : slTasks.position; + + const orderFn = sortOrder === "desc" ? desc : asc; + + const tasks = await db + .select() + .from(slTasks) + .where(and(...conditions)) + .orderBy(orderFn(sortColumn)); + + // Fetch subtasks for all parent tasks + const parentIds = tasks.map((t) => t.id); + let subtasksMap: Record = {}; + + if (parentIds.length > 0) { + const allSubtasks = await db + .select() + .from(slTasks) + .where( + and( + eq(slTasks.userId, user.userId), + isNull(slTasks.deletedAt) + ) + ) + .orderBy(asc(slTasks.position)); + + // Filter subtasks whose parentId is in our task list + const parentIdSet = new Set(parentIds); + for (const sub of allSubtasks) { + if (sub.parentId && parentIdSet.has(sub.parentId)) { + if (!subtasksMap[sub.parentId]) subtasksMap[sub.parentId] = []; + subtasksMap[sub.parentId].push(sub as Task); + } + } + } + + return ( + + ); +} diff --git a/web/src/app/(app)/page.tsx b/web/src/app/(app)/page.tsx new file mode 100644 index 0000000..8e60904 --- /dev/null +++ b/web/src/app/(app)/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import { getAuthenticatedUser } from "@/lib/auth"; +import { db } from "@/db/client"; +import { slLists } from "@/db/schema"; +import { eq, isNull, and, asc } from "drizzle-orm"; + +export const dynamic = "force-dynamic"; + +export default async function AppHome() { + const user = await getAuthenticatedUser(); + if (!user) redirect("/auth"); + + const lists = await db + .select() + .from(slLists) + .where(and(eq(slLists.userId, user.userId), isNull(slLists.deletedAt))) + .orderBy(asc(slLists.position)); + + // Redirect to inbox, or first list, or show empty state + const inbox = lists.find((l) => l.isInbox); + if (inbox) redirect(`/lists/${inbox.id}`); + if (lists.length > 0) redirect(`/lists/${lists[0].id}`); + + // No lists at all — show a message (the sidebar will show "Nouvelle liste" button) + return ( +
+
+

Bienvenue sur Simpl-Liste

+

+ Créez votre première liste en utilisant le bouton dans la barre + latérale. +

+
+
+ ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a2dc41e..3347a3a 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,26 +1,38 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:where(.dark, .dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-bleu: #4A90A4; + --color-creme: #FFF8F0; + --color-terracotta: #C17767; + --color-vert: #8BA889; + --color-sable: #D4A574; + --color-violet: #7B68EE; + --color-rouge: #E57373; + --color-teal: #4DB6AC; + --color-surface-light: #FFFFFF; + --color-surface-dark: #2A2A2A; + --color-border-light: #E5E7EB; + --color-border-dark: #3A3A3A; --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + --background: #FFF8F0; + --foreground: #1A1A1A; +} + +.dark { + --background: #1A1A1A; + --foreground: #F5F5F5; } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), Arial, Helvetica, sans-serif; } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 976eb90..4d808d9 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeScript } from "@/components/ThemeScript"; import "./globals.css"; const geistSans = Geist({ @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Simpl-Liste", + description: "Gestion de tâches minimaliste par La Compagnie Maximus", }; export default function RootLayout({ @@ -24,9 +25,13 @@ export default function RootLayout({ }>) { return ( + + + {children} ); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx deleted file mode 100644 index 3f36f7c..0000000 --- a/web/src/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); -} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..1cf75ef --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useSync } from "./useSync"; + +export function AppShell({ children }: { children: React.ReactNode }) { + useSync(); + return <>{children}; +} diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx new file mode 100644 index 0000000..26620eb --- /dev/null +++ b/web/src/components/FilterBar.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { Filter, ArrowUpDown } from "lucide-react"; + +const STATUS_OPTIONS = [ + { value: "", label: "Toutes" }, + { value: "false", label: "Actives" }, + { value: "true", label: "Complétées" }, +]; + +const SORT_OPTIONS = [ + { value: "position", label: "Position" }, + { value: "priority", label: "Priorité" }, + { value: "dueDate", label: "Échéance" }, + { value: "title", label: "Titre" }, + { value: "createdAt", label: "Date de création" }, +]; + +export function FilterBar() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const completed = searchParams.get("completed") ?? ""; + const sortBy = searchParams.get("sortBy") ?? "position"; + const sortOrder = searchParams.get("sortOrder") ?? "asc"; + + const updateParam = (key: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + router.push(`${pathname}?${params.toString()}`); + }; + + return ( +
+ {/* Status filter */} +
+ + +
+ + {/* Sort */} +
+ + + +
+
+ ); +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..a50f025 --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { ThemeToggle } from "./ThemeToggle"; +import { User, LogOut } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +interface HeaderProps { + userName: string; +} + +export function Header({ userName }: HeaderProps) { + const [menuOpen, setMenuOpen] = useState(false); + + return ( +
+ {/* Spacer for mobile hamburger */} +
+ +
+ Simpl-Liste +
+ +
+ + + {/* User menu */} +
+ + + {menuOpen && ( + <> +
setMenuOpen(false)} + /> +
+
+ {userName} +
+ setMenuOpen(false)} + > + + Se déconnecter + +
+ + )} +
+
+
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..014f854 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { + Inbox, + List, + Plus, + Tag, + Menu, + X, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import type { List as ListType, Tag as TagType } from "@/lib/types"; + +interface SidebarProps { + lists: ListType[]; + tags: TagType[]; +} + +export function Sidebar({ lists, tags }: SidebarProps) { + const pathname = usePathname(); + const router = useRouter(); + const [mobileOpen, setMobileOpen] = useState(false); + const [showNewList, setShowNewList] = useState(false); + const [newListName, setNewListName] = useState(""); + const [tagsExpanded, setTagsExpanded] = useState(false); + + const handleCreateList = async () => { + const name = newListName.trim(); + if (!name) return; + await fetch("/api/lists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + setNewListName(""); + setShowNewList(false); + router.refresh(); + }; + + const sidebarContent = ( +
+ {/* Header */} +
+

Simpl-Liste

+
+ + {/* Lists */} + + + {/* Sign out */} +
+ + + Se déconnecter + +
+
+ ); + + return ( + <> + {/* Mobile hamburger */} + + + {/* Mobile overlay */} + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + {/* Mobile sidebar */} + + + {/* Desktop sidebar */} + + + ); +} diff --git a/web/src/components/TaskForm.tsx b/web/src/components/TaskForm.tsx new file mode 100644 index 0000000..ecf017a --- /dev/null +++ b/web/src/components/TaskForm.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, X } from "lucide-react"; + +const PRIORITY_LABELS = [ + { value: 0, label: "Aucune", color: "" }, + { value: 1, label: "Basse", color: "text-vert" }, + { value: 2, label: "Moyenne", color: "text-sable" }, + { value: 3, label: "Haute", color: "text-rouge" }, +]; + +interface TaskFormProps { + listId: string; + parentId?: string; + onClose?: () => void; +} + +export function TaskForm({ listId, parentId, onClose }: TaskFormProps) { + const router = useRouter(); + const [title, setTitle] = useState(""); + const [notes, setNotes] = useState(""); + const [priority, setPriority] = useState(0); + const [dueDate, setDueDate] = useState(""); + const [recurrence, setRecurrence] = useState(""); + const [expanded, setExpanded] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || submitting) return; + + setSubmitting(true); + try { + await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + notes: notes || undefined, + priority, + dueDate: dueDate || undefined, + recurrence: recurrence || undefined, + listId, + parentId: parentId || undefined, + }), + }); + setTitle(""); + setNotes(""); + setPriority(0); + setDueDate(""); + setRecurrence(""); + setExpanded(false); + router.refresh(); + onClose?.(); + } finally { + setSubmitting(false); + } + }; + + if (!expanded && !parentId) { + return ( + + ); + } + + return ( +
+
+ setTitle(e.target.value)} + placeholder={parentId ? "Nouvelle sous-tâche..." : "Titre de la tâche..."} + className="flex-1 bg-transparent text-sm focus:outline-none placeholder:text-foreground/40" + /> + {!parentId && ( + + )} +
+ +