From 5b40968c9d89c9e7a8e3ff567119c18fae3dde7d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 08:09:31 +0200 Subject: [PATCH] feat(web): Next.js 15 app shell, auth, i18n, base UI App Router setup with next-intl (en/fr), Better Auth wiring, shadcn/ui components, Tailwind, AES-256-GCM encrypt util, Redis rate limiter, tier limit checker, site_settings helper for runtime .env overrides. --- apps/web/app/globals.css | 159 +++++++++ apps/web/app/layout.tsx | 44 +++ apps/web/app/manifest.ts | 17 + apps/web/components.json | 25 ++ apps/web/components/layout/nav.tsx | 123 +++++++ apps/web/components/providers.tsx | 29 ++ apps/web/components/ui/alert-dialog.tsx | 187 ++++++++++ apps/web/components/ui/avatar.tsx | 109 ++++++ apps/web/components/ui/badge.tsx | 52 +++ apps/web/components/ui/button.tsx | 58 ++++ apps/web/components/ui/card.tsx | 103 ++++++ apps/web/components/ui/command.tsx | 196 +++++++++++ apps/web/components/ui/dialog.tsx | 160 +++++++++ apps/web/components/ui/dropdown-menu.tsx | 268 +++++++++++++++ apps/web/components/ui/fake-progress-bar.tsx | 66 ++++ apps/web/components/ui/input-group.tsx | 158 +++++++++ apps/web/components/ui/input.tsx | 20 ++ apps/web/components/ui/label.tsx | 20 ++ apps/web/components/ui/navigation-menu.tsx | 168 +++++++++ apps/web/components/ui/popover.tsx | 90 +++++ apps/web/components/ui/progress.tsx | 83 +++++ apps/web/components/ui/radio-group.tsx | 38 +++ apps/web/components/ui/scroll-area.tsx | 55 +++ apps/web/components/ui/select.tsx | 201 +++++++++++ apps/web/components/ui/separator.tsx | 25 ++ apps/web/components/ui/sheet.tsx | 138 ++++++++ apps/web/components/ui/skeleton.tsx | 13 + apps/web/components/ui/sonner.tsx | 49 +++ apps/web/components/ui/switch.tsx | 32 ++ apps/web/components/ui/tabs.tsx | 82 +++++ apps/web/components/ui/textarea.tsx | 18 + apps/web/components/ui/tooltip.tsx | 66 ++++ apps/web/eslint.config.mjs | 18 + apps/web/lib/api-auth.ts | 104 ++++++ apps/web/lib/auth/client.ts | 13 + apps/web/lib/auth/server.ts | 113 ++++++ apps/web/lib/encrypt.ts | 30 ++ apps/web/lib/i18n/provider.tsx | 66 ++++ apps/web/lib/rate-limit.ts | 51 +++ apps/web/lib/site-settings.ts | 90 +++++ apps/web/lib/tiers.ts | 82 +++++ apps/web/lib/utils.ts | 6 + apps/web/messages/en.json | 341 +++++++++++++++++++ apps/web/messages/fr.json | 341 +++++++++++++++++++ apps/web/next.config.ts | 7 + apps/web/package.json | 55 +++ apps/web/postcss.config.mjs | 7 + apps/web/proxy.ts | 39 +++ apps/web/tsconfig.json | 23 ++ apps/web/types/speech.d.ts | 36 ++ 50 files changed, 4274 insertions(+) create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/manifest.ts create mode 100644 apps/web/components.json create mode 100644 apps/web/components/layout/nav.tsx create mode 100644 apps/web/components/providers.tsx create mode 100644 apps/web/components/ui/alert-dialog.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/command.tsx create mode 100644 apps/web/components/ui/dialog.tsx create mode 100644 apps/web/components/ui/dropdown-menu.tsx create mode 100644 apps/web/components/ui/fake-progress-bar.tsx create mode 100644 apps/web/components/ui/input-group.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/navigation-menu.tsx create mode 100644 apps/web/components/ui/popover.tsx create mode 100644 apps/web/components/ui/progress.tsx create mode 100644 apps/web/components/ui/radio-group.tsx create mode 100644 apps/web/components/ui/scroll-area.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/sheet.tsx create mode 100644 apps/web/components/ui/skeleton.tsx create mode 100644 apps/web/components/ui/sonner.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/components/ui/tooltip.tsx create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/lib/api-auth.ts create mode 100644 apps/web/lib/auth/client.ts create mode 100644 apps/web/lib/auth/server.ts create mode 100644 apps/web/lib/encrypt.ts create mode 100644 apps/web/lib/i18n/provider.tsx create mode 100644 apps/web/lib/rate-limit.ts create mode 100644 apps/web/lib/site-settings.ts create mode 100644 apps/web/lib/tiers.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/messages/en.json create mode 100644 apps/web/messages/fr.json create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/proxy.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/types/speech.d.ts diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..652c905 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,159 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-lora); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-lora); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +@media print { + header, nav, [data-print-hide], .print-hide { + display: none !important; + } + body { + background: white !important; + color: black !important; + font-size: 12pt; + } + .container { + max-width: 100% !important; + padding: 0 !important; + } + a { + text-decoration: none; + color: inherit; + } + img { + max-width: 100%; + page-break-inside: avoid; + } + h1, h2, h3 { + page-break-after: avoid; + } + ol, ul, li { + page-break-inside: avoid; + } +} \ No newline at end of file diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..ff86d0f --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { Lora, Geist_Mono } from "next/font/google"; +import { headers } from "next/headers"; +import { Providers } from "@/components/providers"; +import { auth } from "@/lib/auth/server"; +import type { Locale } from "@/lib/i18n/provider"; +import { SwRegister } from "@/components/pwa/sw-register"; +import "./globals.css"; + +const lora = Lora({ + variable: "--font-lora", + subsets: ["latin"], + display: "swap", +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: { default: "Epicure", template: "%s | Epicure" }, + description: "Your personal AI-powered recipe book.", +}; + +export default async function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const session = await auth.api.getSession({ headers: await headers() }).catch(() => null); + const initialLocale = ((session?.user as { locale?: string })?.locale ?? "en") as Locale; + + return ( + + + {children} + + + + ); +} diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts new file mode 100644 index 0000000..eba0664 --- /dev/null +++ b/apps/web/app/manifest.ts @@ -0,0 +1,17 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Epicure", + short_name: "Epicure", + description: "Your AI-powered recipe book", + start_url: "/recipes", + display: "standalone", + background_color: "#ffffff", + theme_color: "#18181b", + icons: [ + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + }; +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx new file mode 100644 index 0000000..7795bbc --- /dev/null +++ b/apps/web/components/layout/nav.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Languages, Search, Compass } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { authClient } from "@/lib/auth/client"; +import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider"; +import { useTranslations } from "next-intl"; + +const NAV_ITEMS = [ + { href: "/recipes", key: "recipes", icon: BookOpen }, + { href: "/explore", key: "explore", icon: Compass }, + { href: "/search", key: "search", icon: Search }, + { href: "/feed", key: "feed", icon: Rss }, + { href: "/collections", key: "collections", icon: FolderOpen }, + { href: "/meal-plan", key: "mealPlan", icon: Calendar }, + { href: "/pantry", key: "pantry", icon: Package }, + { href: "/shopping-lists", key: "shopping", icon: ShoppingCart }, +] as const; + +export function Nav() { + const pathname = usePathname(); + const { data: session } = authClient.useSession(); + const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; + const { locale, setLocale } = useLocale(); + const t = useTranslations("nav"); + return ( +
+
+ + + Epicure + + +
+ + + + + + + + + + + + {t("settings")} + + + {t("apiKeys")} + + + {t("webhooks")} + + +
+

+ + {t("language")} +

+
+ {SUPPORTED_LOCALES.map((l) => ( + + ))} +
+
+ {isAdmin && ( + <> + + + + + {t("admin")} + + + + )} + + authClient.signOut()}> + {t("signOut")} + +
+
+
+
+
+ ); +} diff --git a/apps/web/components/providers.tsx b/apps/web/components/providers.tsx new file mode 100644 index 0000000..454fca8 --- /dev/null +++ b/apps/web/components/providers.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ThemeProvider } from "next-themes"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { I18nProvider, type Locale } from "@/lib/i18n/provider"; +import en from "@/messages/en.json"; +import fr from "@/messages/fr.json"; + +const messages = { en, fr } as Record>; + +export function Providers({ + children, + initialLocale, +}: { + children: React.ReactNode; + initialLocale?: Locale; +}) { + return ( + + + + {children} + + + + + ); +} diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0ee2c5f --- /dev/null +++ b/apps/web/components/ui/alert-dialog.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +