+
+
+ {/* 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 (
+
+ );
+}
diff --git a/web/src/components/TaskItem.tsx b/web/src/components/TaskItem.tsx
new file mode 100644
index 0000000..ede3aad
--- /dev/null
+++ b/web/src/components/TaskItem.tsx
@@ -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
= {
+ 0: "",
+ 1: "border-l-vert",
+ 2: "border-l-sable",
+ 3: "border-l-rouge",
+};
+
+const PRIORITY_LABELS: Record = {
+ 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 = {
+ 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 (
+
+
+ {/* Main row */}
+
+ {/* Expand toggle */}
+
+
+ {/* Checkbox */}
+
+
+ {/* Title */}
+ setExpanded(!expanded)}
+ >
+ {task.title}
+
+
+ {/* Badges */}
+ {task.dueDate && (
+
+
+ {formatDate(task.dueDate)}
+
+ )}
+ {task.recurrence && (
+
+
+
+ )}
+ {subtasks.length > 0 && (
+
+ {subtasks.filter((s) => s.completed).length}/{subtasks.length}
+
+ )}
+
+
+ {/* Expanded view */}
+ {expanded && (
+
+ {editing ? (
+
+ ) : (
+
+ {task.notes && (
+
{task.notes}
+ )}
+
+ {task.priority > 0 && (
+ Priorité : {PRIORITY_LABELS[task.priority]}
+ )}
+ {task.dueDate && (
+ Échéance : {formatDate(task.dueDate)}
+ )}
+ {task.recurrence && (
+ Récurrence : {recurrenceLabel(task.recurrence)}
+ )}
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+ {/* Subtask form */}
+ {showSubtaskForm && expanded && (
+
+ setShowSubtaskForm(false)}
+ />
+
+ )}
+
+ {/* Subtasks */}
+ {subtasks.map((sub) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/TaskList.tsx b/web/src/components/TaskList.tsx
new file mode 100644
index 0000000..0aa6099
--- /dev/null
+++ b/web/src/components/TaskList.tsx
@@ -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;
+ listId: string;
+ listName: string;
+}
+
+export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) {
+ return (
+
+ {/* Header */}
+
+
{listName}
+
+
+
+
+
+ {/* Add task */}
+
+
+
+
+ {/* Tasks */}
+ {tasks.length === 0 ? (
+
+ ) : (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/ThemeScript.tsx b/web/src/components/ThemeScript.tsx
new file mode 100644
index 0000000..14e7637
--- /dev/null
+++ b/web/src/components/ThemeScript.tsx
@@ -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 ;
+}
diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..995abea
--- /dev/null
+++ b/web/src/components/ThemeToggle.tsx
@@ -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("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 (
+
+ );
+}
diff --git a/web/src/components/useSync.ts b/web/src/components/useSync.ts
new file mode 100644
index 0000000..6f69c5c
--- /dev/null
+++ b/web/src/components/useSync.ts
@@ -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;
+
+ 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]);
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
new file mode 100644
index 0000000..c44b140
--- /dev/null
+++ b/web/src/lib/types.ts
@@ -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;
+}