feat: first shoot on translation

This commit is contained in:
Julien Froidefond
2025-02-27 11:31:39 +01:00
parent 3c46afb294
commit f39e4779cf
15 changed files with 635 additions and 143 deletions

View File

@@ -4,6 +4,8 @@ import "@/styles/globals.css";
import { cn } from "@/lib/utils";
import ClientLayout from "@/components/layout/ClientLayout";
import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { I18nProvider } from "@/components/providers/I18nProvider";
import "@/i18n/i18n"; // Import i18next configuration
const inter = Inter({ subsets: ["latin"] });
@@ -114,9 +116,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
/>
</head>
<body className={cn("min-h-screen bg-background font-sans antialiased", inter.className)}>
<PreferencesProvider>
<ClientLayout>{children}</ClientLayout>
</PreferencesProvider>
<I18nProvider>
<PreferencesProvider>
<ClientLayout>{children}</ClientLayout>
</PreferencesProvider>
</I18nProvider>
</body>
</html>
);

View File

@@ -3,6 +3,7 @@
import { LoginForm } from "@/components/auth/LoginForm";
import { RegisterForm } from "@/components/auth/RegisterForm";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslate } from "@/hooks/useTranslate";
interface LoginContentProps {
searchParams: {
@@ -12,6 +13,7 @@ interface LoginContentProps {
}
export function LoginContent({ searchParams }: LoginContentProps) {
const { t } = useTranslate();
const defaultTab = searchParams.tab || "login";
return (
@@ -41,25 +43,20 @@ export function LoginContent({ searchParams }: LoginContentProps) {
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
Profitez de vos BD, mangas et comics préférés avec une expérience de lecture moderne
et fluide.
</p>
<p className="text-lg">{t("login.description")}</p>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Bienvenue sur StripStream</h1>
<p className="text-sm text-muted-foreground">
Connectez-vous ou créez un compte pour commencer
</p>
<h1 className="text-2xl font-semibold tracking-tight">{t("login.title")}</h1>
<p className="text-sm text-muted-foreground">{t("login.subtitle")}</p>
</div>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Connexion</TabsTrigger>
<TabsTrigger value="register">Inscription</TabsTrigger>
<TabsTrigger value="login">{t("login.tabs.login")}</TabsTrigger>
<TabsTrigger value="register">{t("login.tabs.register")}</TabsTrigger>
</TabsList>
<TabsContent value="login">
<LoginForm from={searchParams.from} />

View File

@@ -0,0 +1,43 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Languages } from "lucide-react";
export default function LanguageSelector() {
const { t, i18n } = useTranslation("common");
const handleLanguageChange = (newLocale: string) => {
i18n.changeLanguage(newLocale);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9" aria-label={t("language.select")}>
<Languages className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleLanguageChange("fr")}
className={i18n.language === "fr" ? "bg-accent" : ""}
>
{t("language.fr")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleLanguageChange("en")}
className={i18n.language === "en" ? "bg-accent" : ""}
>
{t("language.en")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,4 +1,7 @@
"use client";
import { Cover } from "@/components/ui/cover";
import { useTranslate } from "@/hooks/useTranslate";
interface OptimizedHeroSeries {
id: string;
@@ -12,6 +15,8 @@ interface HeroSectionProps {
}
export function HeroSection({ series }: HeroSectionProps) {
const { t } = useTranslate();
// console.log("HeroSection - Séries reçues:", {
// count: series?.length || 0,
// firstSeries: series?.[0],
@@ -29,7 +34,7 @@ export function HeroSection({ series }: HeroSectionProps) {
<Cover
type="series"
id={series.id}
alt={`Couverture de ${series.metadata.title}`}
alt={t("home.hero.coverAlt", { title: series.metadata.title })}
quality={25}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 16.666vw"
/>
@@ -43,10 +48,10 @@ export function HeroSection({ series }: HeroSectionProps) {
{/* Contenu */}
<div className="relative h-full container flex flex-col items-center justify-center text-center space-y-2 sm:space-y-4">
<h1 className="text-3xl sm:text-4xl lg:text-6xl font-bold tracking-tight">
Bienvenue sur StripStream
{t("home.hero.title")}
</h1>
<p className="text-lg sm:text-xl text-muted-foreground max-w-[600px]">
Votre bibliothèque numérique pour lire vos BD, mangas et comics préférés.
{t("home.hero.subtitle")}
</p>
</div>
</div>

View File

@@ -1,9 +1,12 @@
"use client";
import { HeroSection } from "./HeroSection";
import { MediaRow } from "./MediaRow";
import { KomgaBook, KomgaSeries } from "@/types/komga";
import { RefreshButton } from "@/components/library/RefreshButton";
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
import { HomeData } from "@/lib/services/home.service";
import { useTranslate } from "@/hooks/useTranslate";
interface HomeContentProps {
data: HomeData;
@@ -11,6 +14,8 @@ interface HomeContentProps {
}
export function HomeContent({ data, refreshHome }: HomeContentProps) {
const { t } = useTranslate();
// Vérification des données pour le debug
// console.log("HomeContent - Données reçues:", {
// ongoingCount: data.ongoing?.length || 0,
@@ -51,7 +56,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
return (
<main className="container mx-auto px-4 py-8 space-y-12">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Accueil</h1>
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
<RefreshButton libraryId="home" refreshLibrary={refreshHome} />
</div>
{/* Hero Section - Afficher uniquement si nous avons des séries en cours */}
@@ -63,7 +68,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
<div className="space-y-12">
{data.ongoing && data.ongoing.length > 0 && (
<MediaRow
title="Continuer la série"
title={t("home.sections.continue_series")}
items={optimizeSeriesData(data.ongoing)}
icon={<LibraryBig className="w-6 h-6" />}
/>
@@ -71,7 +76,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
<MediaRow
title="Continuer la lecture"
title={t("home.sections.continue_reading")}
items={optimizeBookData(data.ongoingBooks)}
icon={<BookOpen className="w-6 h-6" />}
/>
@@ -79,7 +84,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
{data.onDeck && data.onDeck.length > 0 && (
<MediaRow
title="À suivre"
title={t("home.sections.up_next")}
items={optimizeBookData(data.onDeck)}
icon={<Clock className="w-6 h-6" />}
/>
@@ -87,7 +92,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
{data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow
title="Dernières séries"
title={t("home.sections.latest_series")}
items={optimizeSeriesData(data.latestSeries)}
icon={<Sparkles className="w-6 h-6" />}
/>
@@ -95,7 +100,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
{data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow
title="Ajouts récents"
title={t("home.sections.recently_added")}
items={optimizeBookData(data.recentlyRead)}
icon={<History className="w-6 h-6" />}
/>

View File

@@ -1,5 +1,6 @@
import { Menu, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import LanguageSelector from "@/components/LanguageSelector";
interface HeaderProps {
onToggleSidebar: () => void;
@@ -33,7 +34,8 @@ export function Header({ onToggleSidebar }: HeaderProps) {
</div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<nav className="flex items-center">
<nav className="flex items-center space-x-2">
<LanguageSelector />
<button
onClick={toggleTheme}
className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md"

View File

@@ -0,0 +1,8 @@
"use client";
import { PropsWithChildren, useEffect } from "react";
import "@/i18n/i18n";
export function I18nProvider({ children }: PropsWithChildren) {
return <>{children}</>;
}

View File

@@ -0,0 +1,187 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

16
src/hooks/useTranslate.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useTranslation } from "react-i18next";
export function useTranslate() {
const { t, i18n } = useTranslation("common");
const changeLanguage = (lang: string) => {
i18n.changeLanguage(lang);
};
return {
t,
i18n,
changeLanguage,
currentLanguage: i18n.language,
};
}

42
src/i18n/i18n.ts Normal file
View File

@@ -0,0 +1,42 @@
"use client";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
// Importation des traductions
import frCommon from "./messages/fr/common.json";
import enCommon from "./messages/en/common.json";
// Ne pas initialiser i18next plus d'une fois
if (!i18n.isInitialized) {
i18n
.use(LanguageDetector) // Détecte la langue du navigateur
.use(initReactI18next)
.init({
resources: {
fr: {
common: frCommon,
},
en: {
common: enCommon,
},
},
defaultNS: "common",
fallbackLng: "fr",
interpolation: {
escapeValue: false, // React gère déjà l'échappement
},
detection: {
order: ["cookie", "localStorage", "navigator"],
lookupCookie: "NEXT_LOCALE",
caches: ["cookie"],
cookieOptions: {
path: "/",
maxAge: 365 * 24 * 60 * 60, // 1 an
},
},
});
}
export default i18n;

View File

@@ -0,0 +1,31 @@
{
"language": {
"select": "Change language",
"fr": "French",
"en": "English"
},
"login": {
"title": "Welcome to StripStream",
"subtitle": "Sign in or create an account to get started",
"tabs": {
"login": "Sign in",
"register": "Sign up"
},
"description": "Enjoy your favorite comics, manga and graphic novels with a modern and smooth reading experience."
},
"home": {
"hero": {
"title": "Welcome to StripStream",
"subtitle": "Your digital library to read your favorite comics, manga and graphic novels.",
"coverAlt": "Cover of {title}"
},
"title": "Home",
"sections": {
"continue_series": "Continue series",
"continue_reading": "Continue reading",
"up_next": "Up next",
"latest_series": "Latest series",
"recently_added": "Recently added"
}
}
}

View File

@@ -0,0 +1,31 @@
{
"language": {
"select": "Changer la langue",
"fr": "Français",
"en": "Anglais"
},
"login": {
"title": "Bienvenue sur StripStream",
"subtitle": "Connectez-vous ou créez un compte pour commencer",
"tabs": {
"login": "Connexion",
"register": "Inscription"
},
"description": "Profitez de vos BD, mangas et comics préférés avec une expérience de lecture moderne et fluide."
},
"home": {
"hero": {
"title": "Bienvenue sur StripStream",
"subtitle": "Votre bibliothèque numérique pour lire vos BD, mangas et comics préférés.",
"coverAlt": "Couverture de {title}"
},
"title": "Accueil",
"sections": {
"continue_series": "Continuer la série",
"continue_reading": "Continuer la lecture",
"up_next": "À suivre",
"latest_series": "Dernières séries",
"recently_added": "Ajouts récents"
}
}
}

4
src/types/json.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.json" {
const value: any;
export default value;
}