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 ( +