Merge pull request 'feat: implement web frontend with full task management UI (#39)' (#46) from issue-39-frontend-web into master
This commit is contained in:
commit
b7a090df71
19 changed files with 1230 additions and 78 deletions
10
web/package-lock.json
generated
10
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
44
web/src/app/(app)/layout.tsx
Normal file
44
web/src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
web/src/app/(app)/lists/[id]/page.tsx
Normal file
108
web/src/app/(app)/lists/[id]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
web/src/app/(app)/page.tsx
Normal file
36
web/src/app/(app)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
8
web/src/components/AppShell.tsx
Normal file
8
web/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useSync } from "./useSync";
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
useSync();
|
||||
return <>{children}</>;
|
||||
}
|
||||
83
web/src/components/FilterBar.tsx
Normal file
83
web/src/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
web/src/components/Header.tsx
Normal file
64
web/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
web/src/components/Sidebar.tsx
Normal file
188
web/src/components/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
164
web/src/components/TaskForm.tsx
Normal file
164
web/src/components/TaskForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
web/src/components/TaskItem.tsx
Normal file
303
web/src/components/TaskItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/components/TaskList.tsx
Normal file
52
web/src/components/TaskList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
web/src/components/ThemeScript.tsx
Normal file
14
web/src/components/ThemeScript.tsx
Normal 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 }} />;
|
||||
}
|
||||
40
web/src/components/ThemeToggle.tsx
Normal file
40
web/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
web/src/components/useSync.ts
Normal file
44
web/src/components/useSync.ts
Normal 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
41
web/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue