feat: add backoffice authentication with login page

- Add login page with logo background, glassmorphism card
- Add session management via JWT (jose) with httpOnly cookie
- Add Next.js proxy middleware to protect all routes
- Add logout button in nav
- Restructure app into (app) route group to isolate login layout
- Add ADMIN_USERNAME, ADMIN_PASSWORD, SESSION_SECRET env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 08:48:01 +01:00
parent 32d13984a1
commit 232ecdda41
27 changed files with 527 additions and 271 deletions

View File

@@ -1,7 +1,7 @@
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
import { getServerTranslations } from "../../../lib/i18n/server";
import { BooksGrid } from "../../components/BookCard";
import { OffsetPagination } from "../../components/ui";
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { BooksGrid } from "@/app/components/BookCard";
import { OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";

View File

@@ -1,7 +1,7 @@
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
import { getServerTranslations } from "../../lib/i18n/server";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";

View File

@@ -1,15 +1,15 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
import { BookPreview } from "@/app/components/BookPreview";
import { ConvertButton } from "@/app/components/ConvertButton";
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
import { SafeHtml } from "../../components/SafeHtml";
import { getServerTranslations } from "../../../lib/i18n/server";
import { SafeHtml } from "@/app/components/SafeHtml";
import { getServerTranslations } from "@/lib/i18n/server";
import Image from "next/image";
import Link from "next/link";
const EditBookForm = nextDynamic(
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
);
import { notFound } from "next/navigation";

View File

@@ -1,10 +1,10 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
import Image from "next/image";
import { getServerTranslations } from "../../lib/i18n/server";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -2,13 +2,13 @@ export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar
} from "../../components/ui";
import { JobDetailLive } from "../../components/JobDetailLive";
import { getServerTranslations } from "../../../lib/i18n/server";
} from "@/app/components/ui";
import { JobDetailLive } from "@/app/components/JobDetailLive";
import { getServerTranslations } from "@/lib/i18n/server";
interface JobDetailPageProps {
params: Promise<{ id: string }>;

View File

@@ -1,9 +1,9 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
import { JobsList } from "../components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
import { getServerTranslations } from "../../lib/i18n/server";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "@/lib/api";
import { JobsList } from "@/app/components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -0,0 +1,102 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr";
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations();
return (
<>
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<LogoutButton />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</>
);
}
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -1,9 +1,9 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { OffsetPagination } from "../../../components/ui";
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { OffsetPagination } from "@/app/components/ui";
import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../lib/i18n/server";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -1,24 +1,24 @@
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
import { OffsetPagination } from "../../../../components/ui";
import { SafeHtml } from "../../../../components/SafeHtml";
import { OffsetPagination } from "@/app/components/ui";
import { SafeHtml } from "@/app/components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
const EditSeriesForm = nextDynamic(
() => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm)
() => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
);
const MetadataSearchModal = nextDynamic(
() => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal)
() => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
);
const ProwlarrSearchModal = nextDynamic(
() => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
);
import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../../lib/i18n/server";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -1,12 +1,12 @@
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
import { OffsetPagination } from "../../../components/ui";
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
import { SeriesFilters } from "../../../components/SeriesFilters";
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
import { OffsetPagination } from "@/app/components/ui";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { SeriesFilters } from "@/app/components/SeriesFilters";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { getServerTranslations } from "../../../../lib/i18n/server";
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -1,16 +1,16 @@
import { revalidatePath } from "next/cache";
import Image from "next/image";
import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
import type { TranslationKey } from "../../lib/i18n/fr";
import { getServerTranslations } from "../../lib/i18n/server";
import { LibraryActions } from "../components/LibraryActions";
import { LibraryForm } from "../components/LibraryForm";
import { ProviderIcon } from "../components/ProviderIcon";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
import type { TranslationKey } from "@/lib/i18n/fr";
import { getServerTranslations } from "@/lib/i18n/server";
import { LibraryActions } from "@/app/components/LibraryActions";
import { LibraryForm } from "@/app/components/LibraryForm";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge
} from "../components/ui";
} from "@/app/components/ui";
export const dynamic = "force-dynamic";

View File

@@ -1,12 +1,12 @@
import React from "react";
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts";
import { PeriodToggle } from "./components/PeriodToggle";
import { fetchStats, StatsResponse, getBookCoverUrl } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
import { PeriodToggle } from "@/app/components/PeriodToggle";
import Image from "next/image";
import Link from "next/link";
import { getServerTranslations } from "../lib/i18n/server";
import type { TranslateFunction } from "../lib/i18n/dictionaries";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
export const dynamic = "force-dynamic";
@@ -88,7 +88,19 @@ export default async function DashboardPage({
);
}
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats;
const {
overview,
reading_status,
currently_reading = [],
recently_read = [],
reading_over_time = [],
by_format,
by_library,
top_series,
additions_over_time,
jobs_over_time = [],
metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
} = stats;
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [

View File

@@ -1,11 +1,11 @@
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
import { getServerTranslations } from "../../lib/i18n/server";
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
import { LiveSearchForm } from "../components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui";
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
import { ProviderIcon } from "../components/ProviderIcon";
import { ProviderIcon } from "@/app/components/ProviderIcon";
export const dynamic = "force-dynamic";

View File

@@ -1,11 +1,11 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
import type { Locale } from "../../lib/i18n/types";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
import { ProviderIcon } from "@/app/components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "@/lib/api";
import { useTranslation } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/types";
interface SettingsPageProps {
initialSettings: Settings;

View File

@@ -1,4 +1,4 @@
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
import { getSettings, getCacheStats, getThumbnailStats } from "@/lib/api";
import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic";

View File

@@ -1,8 +1,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { getServerTranslations } from "../../lib/i18n/server";
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "@/lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const expectedUsername = process.env.ADMIN_USERNAME || "admin";
const expectedPassword = process.env.ADMIN_PASSWORD;
if (!expectedPassword) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
if (body.username !== expectedUsername || body.password !== expectedPassword) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/",
});
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/session";
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete(SESSION_COOKIE);
return response;
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button
onClick={handleLogout}
title="Se déconnecter"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
);
}

View File

@@ -1,130 +1,27 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import "./globals.css";
import { ThemeProvider } from "./theme-provider";
import { ThemeToggle } from "./theme-toggle";
import { JobsIndicator } from "./components/JobsIndicator";
import { NavIcon, Icon } from "./components/ui";
import { MobileNav } from "./components/MobileNav";
import { LocaleProvider } from "../lib/i18n/context";
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
import type { TranslationKey } from "../lib/i18n/fr";
import { LocaleProvider } from "@/lib/i18n/context";
import { getServerLocale } from "@/lib/i18n/server";
export const metadata: Metadata = {
title: "StripStream Backoffice",
description: "Administration backoffice pour StripStream Librarian"
};
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getServerLocale();
const { t } = await getServerTranslations();
return (
<html lang={locale} suppressHydrationWarning>
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
<ThemeProvider>
<LocaleProvider initialLocale={locale}>
{/* Header avec effet glassmorphism */}
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Brand */}
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image
src="/logo.png"
alt="StripStream"
width={36}
height={36}
className="rounded-lg"
/>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">
StripStream
</span>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
{/* Navigation Links */}
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</LocaleProvider>
</ThemeProvider>
</body>
</html>
);
}
// Navigation Link Component
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useState, Suspense } from "react";
function LoginForm() {
const searchParams = useSearchParams();
const from = searchParams.get("from") || "/";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
window.location.href = from;
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || "Identifiants invalides");
}
} catch {
setError("Erreur réseau");
} finally {
setLoading(false);
}
}
return (
<div className="relative min-h-screen flex flex-col items-center justify-center px-4 py-16 overflow-hidden">
{/* Background logo */}
<Image
src="/logo.png"
alt=""
fill
className="object-cover opacity-20"
priority
aria-hidden
/>
{/* Hero */}
<div className="relative flex flex-col items-center mb-10">
<h1 className="text-4xl font-bold tracking-tight text-foreground">
StripStream{" "}
<span className="text-primary font-light">: Librarian</span>
</h1>
<p className="text-sm text-muted-foreground mt-1.5 tracking-wide uppercase font-medium">
Administration
</p>
</div>
{/* Form card */}
<div
className="relative w-full max-w-sm rounded-2xl border border-white/20 backdrop-blur-sm p-8"
style={{ boxShadow: "0 24px 48px -12px rgb(0 0 0 / 0.18)" }}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
Identifiant
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
disabled={loading}
placeholder="admin"
className="
flex w-full h-11 px-4
rounded-xl border border-input bg-background/60
text-sm text-foreground
placeholder:text-muted-foreground/40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
disabled:opacity-50
transition-all duration-200
"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
disabled={loading}
placeholder="••••••••"
className="
flex w-full h-11 px-4
rounded-xl border border-input bg-background/60
text-sm text-foreground
placeholder:text-muted-foreground/40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
disabled:opacity-50
transition-all duration-200
"
/>
</div>
{error && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-sm text-destructive">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="
w-full h-11 mt-2
inline-flex items-center justify-center gap-2
rounded-xl font-medium text-sm
bg-primary text-primary-foreground
hover:bg-primary/90
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
active:scale-[0.98]
"
style={{ boxShadow: "0 4px 16px -4px hsl(198 78% 37% / 0.5)" }}
>
{loading ? (
<>
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Connexion
</>
) : "Se connecter"}
</button>
</form>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}