Compare commits

...

2 commits

Author SHA1 Message Date
b7a090df71 Merge pull request 'feat: implement web frontend with full task management UI (#39)' (#46) from issue-39-frontend-web into master 2026-04-06 16:58:35 +00:00
le king fu
cb04adcc2e 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) <noreply@anthropic.com>
2026-04-06 12:40:11 -04:00
19 changed files with 1230 additions and 78 deletions

10
web/package-lock.json generated
View file

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

View file

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

View file

@ -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 (
<AppShell>
<div className="flex h-screen overflow-hidden">
<Sidebar lists={lists} tags={tags} />
<div className="flex-1 flex flex-col min-w-0">
<Header userName={user.name || user.email || ""} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
</AppShell>
);
}

View file

@ -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<string, Task[]> = {};
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 (
<TaskList
tasks={tasks as Task[]}
subtasksMap={subtasksMap}
listId={listId}
listName={list.name}
/>
);
}

View file

@ -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 (
<div className="flex items-center justify-center h-full text-foreground/50">
<div className="text-center space-y-2">
<p className="text-lg">Bienvenue sur Simpl-Liste</p>
<p className="text-sm">
Créez votre première liste en utilisant le bouton dans la barre
latérale.
</p>
</div>
</div>
);
}

View file

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

View file

@ -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 (
<html
lang="en"
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<head>
<ThemeScript />
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);

View file

@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,8 @@
"use client";
import { useSync } from "./useSync";
export function AppShell({ children }: { children: React.ReactNode }) {
useSync();
return <>{children}</>;
}

View file

@ -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 (
<div className="flex flex-wrap items-center gap-3 text-sm">
{/* Status filter */}
<div className="flex items-center gap-1.5">
<Filter size={14} className="text-foreground/50" />
<select
value={completed}
onChange={(e) => updateParam("completed", e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Sort */}
<div className="flex items-center gap-1.5">
<ArrowUpDown size={14} className="text-foreground/50" />
<select
value={sortBy}
onChange={(e) => updateParam("sortBy", e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={() =>
updateParam("sortOrder", sortOrder === "asc" ? "desc" : "asc")
}
className="px-1.5 py-1 border border-border-light dark:border-border-dark rounded hover:bg-black/5 dark:hover:bg-white/5"
title={sortOrder === "asc" ? "Croissant" : "Décroissant"}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
);
}

View file

@ -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 (
<header className="h-14 shrink-0 flex items-center justify-between px-4 md:px-6 border-b border-border-light dark:border-border-dark bg-surface-light dark:bg-surface-dark">
{/* Spacer for mobile hamburger */}
<div className="w-10 md:hidden" />
<div className="hidden md:block text-sm font-medium text-bleu">
Simpl-Liste
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{/* User menu */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-sm"
>
<User size={18} />
<span className="hidden sm:inline max-w-[120px] truncate">
{userName}
</span>
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-full mt-1 z-50 w-48 bg-surface-light dark:bg-surface-dark border border-border-light dark:border-border-dark rounded-lg shadow-lg py-1">
<div className="px-3 py-2 text-xs text-foreground/50 truncate">
{userName}
</div>
<Link
href="/api/logto/sign-out"
className="flex items-center gap-2 px-3 py-2 text-sm text-rouge hover:bg-rouge/10 transition-colors"
onClick={() => setMenuOpen(false)}
>
<LogOut size={14} />
Se déconnecter
</Link>
</div>
</>
)}
</div>
</div>
</header>
);
}

View file

@ -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 = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border-light dark:border-border-dark">
<h1 className="text-lg font-bold text-bleu">Simpl-Liste</h1>
</div>
{/* Lists */}
<nav className="flex-1 overflow-y-auto p-2 space-y-1">
<p className="px-3 py-1 text-xs font-semibold uppercase text-foreground/50">
Listes
</p>
{lists.map((list) => {
const isActive = pathname === `/lists/${list.id}`;
return (
<Link
key={list.id}
href={`/lists/${list.id}`}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? "bg-bleu/10 text-bleu font-medium"
: "hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{list.isInbox ? (
<Inbox size={16} className="text-bleu shrink-0" />
) : (
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: list.color || "#4A90A4" }}
/>
)}
<span className="truncate">{list.name}</span>
</Link>
);
})}
{/* New list form */}
{showNewList ? (
<div className="px-3 py-1">
<input
autoFocus
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateList();
if (e.key === "Escape") {
setShowNewList(false);
setNewListName("");
}
}}
placeholder="Nom de la liste..."
className="w-full px-2 py-1 text-sm border border-border-light dark:border-border-dark rounded bg-transparent focus:outline-none focus:border-bleu"
/>
</div>
) : (
<button
onClick={() => setShowNewList(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground/60 hover:text-foreground transition-colors w-full"
>
<Plus size={16} />
Nouvelle liste
</button>
)}
{/* Tags section */}
<div className="mt-4">
<button
onClick={() => setTagsExpanded(!tagsExpanded)}
className="flex items-center gap-2 px-3 py-1 text-xs font-semibold uppercase text-foreground/50 w-full hover:text-foreground/70"
>
{tagsExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
Étiquettes
</button>
{tagsExpanded &&
tags.map((tag) => (
<div
key={tag.id}
className="flex items-center gap-2 px-3 py-1.5 text-sm"
>
<Tag size={14} style={{ color: tag.color }} />
<span>{tag.name}</span>
</div>
))}
</div>
</nav>
{/* Sign out */}
<div className="p-4 border-t border-border-light dark:border-border-dark">
<Link
href="/api/logto/sign-out"
className="flex items-center gap-2 text-sm text-foreground/60 hover:text-rouge transition-colors"
>
<List size={16} />
Se déconnecter
</Link>
</div>
</div>
);
return (
<>
{/* Mobile hamburger */}
<button
onClick={() => setMobileOpen(true)}
className="md:hidden fixed top-3 left-3 z-50 p-2 rounded-lg bg-surface-light dark:bg-surface-dark shadow-md"
>
<Menu size={20} />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={`md:hidden fixed inset-y-0 left-0 z-50 w-72 bg-surface-light dark:bg-surface-dark transform transition-transform ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-3 right-3 p-1"
>
<X size={20} />
</button>
{sidebarContent}
</aside>
{/* Desktop sidebar */}
<aside className="hidden md:flex md:w-64 md:shrink-0 bg-surface-light dark:bg-surface-dark border-r border-border-light dark:border-border-dark">
{sidebarContent}
</aside>
</>
);
}

View file

@ -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 (
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-2 w-full px-4 py-3 text-sm text-foreground/60 hover:text-foreground border border-dashed border-border-light dark:border-border-dark rounded-lg hover:border-bleu transition-colors"
>
<Plus size={16} />
Ajouter une tâche
</button>
);
}
return (
<form
onSubmit={handleSubmit}
className="border border-border-light dark:border-border-dark rounded-lg p-4 space-y-3 bg-surface-light dark:bg-surface-dark"
>
<div className="flex items-center gap-2">
<input
autoFocus
value={title}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => {
setExpanded(false);
onClose?.();
}}
className="p-1 text-foreground/40 hover:text-foreground"
>
<X size={16} />
</button>
)}
</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes..."
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
<div className="flex flex-wrap items-center gap-3">
<select
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{PRIORITY_LABELS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
/>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
<option value="">Pas de récurrence</option>
<option value="daily">Quotidienne</option>
<option value="weekly">Hebdomadaire</option>
<option value="monthly">Mensuelle</option>
<option value="yearly">Annuelle</option>
</select>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setExpanded(false);
setTitle("");
onClose?.();
}}
className="px-3 py-1.5 text-sm text-foreground/60 hover:text-foreground"
>
Annuler
</button>
<button
type="submit"
disabled={!title.trim() || submitting}
className="px-3 py-1.5 text-sm bg-bleu text-white rounded-lg hover:bg-bleu/90 disabled:opacity-50 transition-colors"
>
{submitting ? "..." : "Ajouter"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,303 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
ChevronDown,
ChevronRight,
Trash2,
Calendar,
Repeat,
Check,
} from "lucide-react";
import type { Task } from "@/lib/types";
import { TaskForm } from "./TaskForm";
const PRIORITY_COLORS: Record<number, string> = {
0: "",
1: "border-l-vert",
2: "border-l-sable",
3: "border-l-rouge",
};
const PRIORITY_LABELS: Record<number, string> = {
0: "Aucune",
1: "Basse",
2: "Moyenne",
3: "Haute",
};
function formatDate(dateStr: string | Date | null): string {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("fr-CA", {
month: "short",
day: "numeric",
});
}
function recurrenceLabel(r: string | null): string {
if (!r) return "";
const map: Record<string, string> = {
daily: "Quotidienne",
weekly: "Hebdomadaire",
monthly: "Mensuelle",
yearly: "Annuelle",
};
return map[r] || r;
}
interface TaskItemProps {
task: Task;
subtasks?: Task[];
depth?: number;
}
export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(task.title);
const [notes, setNotes] = useState(task.notes || "");
const [priority, setPriority] = useState(task.priority);
const [dueDate, setDueDate] = useState(
task.dueDate ? new Date(String(task.dueDate)).toISOString().split("T")[0] : ""
);
const [recurrence, setRecurrence] = useState(task.recurrence || "");
const [showSubtaskForm, setShowSubtaskForm] = useState(false);
const [saving, setSaving] = useState(false);
// Sync state when task prop changes
useEffect(() => {
setTitle(task.title);
setNotes(task.notes || "");
setPriority(task.priority);
setDueDate(
task.dueDate ? new Date(String(task.dueDate)).toISOString().split("T")[0] : ""
);
setRecurrence(task.recurrence || "");
}, [task]);
const toggleComplete = async () => {
await fetch(`/api/tasks/${task.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !task.completed }),
});
router.refresh();
};
const saveEdit = async () => {
if (!title.trim() || saving) return;
setSaving(true);
try {
await fetch(`/api/tasks/${task.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
notes: notes || null,
priority,
dueDate: dueDate || null,
recurrence: recurrence || null,
}),
});
setEditing(false);
router.refresh();
} finally {
setSaving(false);
}
};
const deleteTask = async () => {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
router.refresh();
};
const borderClass = PRIORITY_COLORS[task.priority] || "";
return (
<div style={{ marginLeft: depth * 24 }}>
<div
className={`border border-border-light dark:border-border-dark rounded-lg mb-1.5 ${borderClass} ${
borderClass ? "border-l-[3px]" : ""
} ${task.completed ? "opacity-60" : ""}`}
>
{/* Main row */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Expand toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
{/* Checkbox */}
<button
onClick={toggleComplete}
className={`w-5 h-5 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
task.completed
? "bg-bleu border-bleu text-white"
: "border-foreground/30 hover:border-bleu"
}`}
>
{task.completed && <Check size={12} />}
</button>
{/* Title */}
<span
className={`flex-1 text-sm cursor-pointer ${
task.completed ? "line-through text-foreground/50" : ""
}`}
onClick={() => setExpanded(!expanded)}
>
{task.title}
</span>
{/* Badges */}
{task.dueDate && (
<span className="flex items-center gap-1 text-xs text-foreground/50">
<Calendar size={12} />
{formatDate(task.dueDate)}
</span>
)}
{task.recurrence && (
<span className="text-xs text-foreground/50">
<Repeat size={12} />
</span>
)}
{subtasks.length > 0 && (
<span className="text-xs text-foreground/40">
{subtasks.filter((s) => s.completed).length}/{subtasks.length}
</span>
)}
</div>
{/* Expanded view */}
{expanded && (
<div className="px-3 pb-3 pt-1 border-t border-border-light dark:border-border-dark">
{editing ? (
<div className="space-y-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-transparent text-sm font-medium focus:outline-none border-b border-border-light dark:border-border-dark pb-1"
/>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes..."
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
<div className="flex flex-wrap gap-2 items-center">
<select
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value={0}>Aucune priorité</option>
<option value={1}>Basse</option>
<option value={2}>Moyenne</option>
<option value={3}>Haute</option>
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
/>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value="">Pas de récurrence</option>
<option value="daily">Quotidienne</option>
<option value="weekly">Hebdomadaire</option>
<option value="monthly">Mensuelle</option>
<option value="yearly">Annuelle</option>
</select>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1 text-sm text-foreground/60 hover:text-foreground"
>
Annuler
</button>
<button
onClick={saveEdit}
disabled={!title.trim() || saving}
className="px-3 py-1 text-sm bg-bleu text-white rounded hover:bg-bleu/90 disabled:opacity-50"
>
{saving ? "..." : "Enregistrer"}
</button>
</div>
</div>
) : (
<div className="space-y-2">
{task.notes && (
<p className="text-sm text-foreground/70">{task.notes}</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-foreground/50">
{task.priority > 0 && (
<span>Priorité : {PRIORITY_LABELS[task.priority]}</span>
)}
{task.dueDate && (
<span>Échéance : {formatDate(task.dueDate)}</span>
)}
{task.recurrence && (
<span>Récurrence : {recurrenceLabel(task.recurrence)}</span>
)}
</div>
<div className="flex gap-2 pt-1">
<button
onClick={() => setEditing(true)}
className="text-xs text-bleu hover:underline"
>
Modifier
</button>
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
+ Sous-tâche
</button>
<button
onClick={deleteTask}
className="text-xs text-rouge hover:underline flex items-center gap-1"
>
<Trash2 size={12} />
Supprimer
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Subtask form */}
{showSubtaskForm && expanded && (
<div style={{ marginLeft: 24 }} className="mb-1.5">
<TaskForm
listId={task.listId}
parentId={task.id}
onClose={() => setShowSubtaskForm(false)}
/>
</div>
)}
{/* Subtasks */}
{subtasks.map((sub) => (
<TaskItem key={sub.id} task={sub} depth={depth + 1} />
))}
</div>
);
}

View file

@ -0,0 +1,52 @@
"use client";
import type { Task } from "@/lib/types";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { FilterBar } from "./FilterBar";
import { ClipboardList } from "lucide-react";
import { Suspense } from "react";
interface TaskListProps {
tasks: Task[];
subtasksMap: Record<string, Task[]>;
listId: string;
listName: string;
}
export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) {
return (
<div className="max-w-2xl mx-auto w-full">
{/* Header */}
<div className="mb-6">
<h2 className="text-xl font-semibold mb-3">{listName}</h2>
<Suspense fallback={null}>
<FilterBar />
</Suspense>
</div>
{/* Add task */}
<div className="mb-4">
<TaskForm listId={listId} />
</div>
{/* Tasks */}
{tasks.length === 0 ? (
<div className="text-center py-12 text-foreground/40">
<ClipboardList size={48} className="mx-auto mb-3 opacity-50" />
<p>Aucune tâche</p>
</div>
) : (
<div className="space-y-0">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
subtasks={subtasksMap[task.id] || []}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
// Inline script to set dark class before first paint (avoids flash)
export function ThemeScript() {
const script = `
(function() {
try {
var theme = localStorage.getItem('sl-theme') || 'system';
var isDark = theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch(e) {}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}

View file

@ -0,0 +1,40 @@
"use client";
import { useState, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
type Theme = "light" | "dark" | "system";
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("sl-theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
localStorage.setItem("sl-theme", theme);
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", isDark);
}, [theme]);
const cycle = () => {
setTheme((t) => (t === "light" ? "dark" : t === "dark" ? "system" : "light"));
};
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
return (
<button
onClick={cycle}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title={`Thème : ${theme === "light" ? "clair" : theme === "dark" ? "sombre" : "système"}`}
>
<Icon size={20} />
</button>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function useSync() {
const router = useRouter();
useEffect(() => {
let ws: WebSocket | null = null;
let retryTimeout: ReturnType<typeof setTimeout>;
async function connect() {
try {
// Get a WS ticket from the API
const res = await fetch("/api/ws-ticket", { method: "POST" });
if (!res.ok) return;
const { ticket } = await res.json();
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${window.location.host}/ws?ticket=${ticket}`);
ws.onmessage = () => {
// Any sync message triggers a data refresh
router.refresh();
};
ws.onclose = () => {
// Retry after 10 seconds
retryTimeout = setTimeout(connect, 10000);
};
} catch {
retryTimeout = setTimeout(connect, 10000);
}
}
connect();
return () => {
ws?.close();
clearTimeout(retryTimeout);
};
}, [router]);
}

41
web/src/lib/types.ts Normal file
View file

@ -0,0 +1,41 @@
// Shared types for the web frontend (mirrors DB schema)
export interface List {
id: string;
userId: string;
name: string;
color: string | null;
icon: string | null;
position: number;
isInbox: boolean;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt: Date | string | null;
}
export interface Task {
id: string;
userId: string;
title: string;
notes: string | null;
completed: boolean;
completedAt: Date | string | null;
priority: number;
dueDate: Date | string | null;
listId: string;
parentId: string | null;
position: number;
recurrence: string | null;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt: Date | string | null;
}
export interface Tag {
id: string;
userId: string;
name: string;
color: string;
createdAt: Date | string;
deletedAt: Date | string | null;
}