img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/apps/web/components/ui/command.tsx b/apps/web/components/ui/command.tsx
new file mode 100644
index 0000000..37fb2d9
--- /dev/null
+++ b/apps/web/components/ui/command.tsx
@@ -0,0 +1,196 @@
+"use client"
+
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ InputGroup,
+ InputGroupAddon,
+} from "@/components/ui/input-group"
+import { SearchIcon, CheckIcon } from "lucide-react"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = false,
+ ...props
+}: Omit, "children"> & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/apps/web/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/apps/web/components/ui/dropdown-menu.tsx b/apps/web/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d5ebbd
--- /dev/null
+++ b/apps/web/components/ui/dropdown-menu.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/apps/web/components/ui/fake-progress-bar.tsx b/apps/web/components/ui/fake-progress-bar.tsx
new file mode 100644
index 0000000..33a6ed0
--- /dev/null
+++ b/apps/web/components/ui/fake-progress-bar.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+
+interface FakeProgressBarProps {
+ active: boolean;
+ durationMs?: number;
+ label?: string;
+ className?: string;
+}
+
+export function FakeProgressBar({ active, durationMs = 12000, label, className }: FakeProgressBarProps) {
+ const [progress, setProgress] = useState(0);
+ const [visible, setVisible] = useState(false);
+ const intervalRef = useRef | null>(null);
+ const timeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (active) {
+ setVisible(true);
+ setProgress(0);
+ const start = Date.now();
+ intervalRef.current = setInterval(() => {
+ const elapsed = Date.now() - start;
+ const t = Math.min(elapsed / durationMs, 1);
+ // exponential ease: approaches ~88% asymptotically
+ const p = 88 * (1 - Math.exp(-3.5 * t));
+ setProgress(p);
+ }, 40);
+ } else {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ if (visible) {
+ setProgress(100);
+ timeoutRef.current = setTimeout(() => {
+ setVisible(false);
+ setProgress(0);
+ }, 600);
+ }
+ }
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [active]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
= 100 ? "width 0.3s ease-out" : "width 0.04s linear",
+ }}
+ />
+
+ {label && (
+
{label}
+ )}
+
+ );
+}
diff --git a/apps/web/components/ui/input-group.tsx b/apps/web/components/ui/input-group.tsx
new file mode 100644
index 0000000..da8f1dd
--- /dev/null
+++ b/apps/web/components/ui/input-group.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+ "inline-end":
+ "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+ "block-start":
+ "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+ "block-end":
+ "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "flex items-center gap-2 text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset"
+ }) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/apps/web/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/apps/web/components/ui/label.tsx b/apps/web/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/apps/web/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/apps/web/components/ui/navigation-menu.tsx b/apps/web/components/ui/navigation-menu.tsx
new file mode 100644
index 0000000..5958637
--- /dev/null
+++ b/apps/web/components/ui/navigation-menu.tsx
@@ -0,0 +1,168 @@
+import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
+import { cva } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon } from "lucide-react"
+
+function NavigationMenu({
+ align = "start",
+ className,
+ children,
+ ...props
+}: NavigationMenuPrimitive.Root.Props &
+ Pick) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function NavigationMenuList({
+ className,
+ ...props
+}: React.ComponentPropsWithRef) {
+ return (
+
+ )
+}
+
+function NavigationMenuItem({
+ className,
+ ...props
+}: React.ComponentPropsWithRef) {
+ return (
+
+ )
+}
+
+const navigationMenuTriggerStyle = cva(
+ "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
+)
+
+function NavigationMenuTrigger({
+ className,
+ children,
+ ...props
+}: NavigationMenuPrimitive.Trigger.Props) {
+ return (
+
+ {children}{" "}
+
+
+ )
+}
+
+function NavigationMenuContent({
+ className,
+ ...props
+}: NavigationMenuPrimitive.Content.Props) {
+ return (
+
+ )
+}
+
+function NavigationMenuPositioner({
+ className,
+ side = "bottom",
+ sideOffset = 8,
+ align = "start",
+ alignOffset = 0,
+ ...props
+}: NavigationMenuPrimitive.Positioner.Props) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+function NavigationMenuLink({
+ className,
+ ...props
+}: NavigationMenuPrimitive.Link.Props) {
+ return (
+
+ )
+}
+
+function NavigationMenuIndicator({
+ className,
+ ...props
+}: React.ComponentPropsWithRef) {
+ return (
+
+
+
+ )
+}
+
+export {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuIndicator,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+ NavigationMenuPositioner,
+}
diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx
new file mode 100644
index 0000000..0b73c6b
--- /dev/null
+++ b/apps/web/components/ui/popover.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/apps/web/components/ui/progress.tsx b/apps/web/components/ui/progress.tsx
new file mode 100644
index 0000000..986f346
--- /dev/null
+++ b/apps/web/components/ui/progress.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ children,
+ value,
+ ...props
+}: ProgressPrimitive.Root.Props) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
+ return (
+
+ )
+}
+
+function ProgressIndicator({
+ className,
+ ...props
+}: ProgressPrimitive.Indicator.Props) {
+ return (
+
+ )
+}
+
+function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
+ return (
+
+ )
+}
+
+function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+export {
+ Progress,
+ ProgressTrack,
+ ProgressIndicator,
+ ProgressLabel,
+ ProgressValue,
+}
diff --git a/apps/web/components/ui/radio-group.tsx b/apps/web/components/ui/radio-group.tsx
new file mode 100644
index 0000000..3172cf3
--- /dev/null
+++ b/apps/web/components/ui/radio-group.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { Radio as RadioPrimitive } from "@base-ui/react/radio"
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
+
+import { cn } from "@/lib/utils"
+
+function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/apps/web/components/ui/scroll-area.tsx b/apps/web/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/apps/web/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx
new file mode 100644
index 0000000..e8021f5
--- /dev/null
+++ b/apps/web/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/apps/web/components/ui/separator.tsx b/apps/web/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/apps/web/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/apps/web/components/ui/sheet.tsx b/apps/web/components/ui/sheet.tsx
new file mode 100644
index 0000000..78c0a76
--- /dev/null
+++ b/apps/web/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/apps/web/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx
new file mode 100644
index 0000000..9280ee5
--- /dev/null
+++ b/apps/web/components/ui/sonner.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx
new file mode 100644
index 0000000..9b8b44b
--- /dev/null
+++ b/apps/web/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/apps/web/components/ui/tabs.tsx b/apps/web/components/ui/tabs.tsx
new file mode 100644
index 0000000..8ee8054
--- /dev/null
+++ b/apps/web/components/ui/tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/apps/web/components/ui/textarea.tsx b/apps/web/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/apps/web/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/apps/web/components/ui/tooltip.tsx b/apps/web/components/ui/tooltip.tsx
new file mode 100644
index 0000000..69e8a82
--- /dev/null
+++ b/apps/web/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs
new file mode 100644
index 0000000..05e726d
--- /dev/null
+++ b/apps/web/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
new file mode 100644
index 0000000..177219e
--- /dev/null
+++ b/apps/web/lib/api-auth.ts
@@ -0,0 +1,104 @@
+import crypto from "node:crypto";
+import { headers } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth/server";
+import { db, apiKeys, users, eq } from "@epicure/db";
+import { applyRateLimit } from "@/lib/rate-limit";
+
+export async function requireSession() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) {
+ return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
+ }
+ return { session, response: null };
+}
+
+export async function requireAdmin() {
+ const { session, response } = await requireSession();
+ if (response) return { session: null, response };
+ if (session!.user.role !== "admin") {
+ return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
+ }
+ return { session, response: null };
+}
+
+type SessionLike = {
+ user: {
+ id: string;
+ email: string;
+ name: string;
+ tier: string;
+ role?: string;
+ image?: string | null;
+ };
+};
+
+type RateLimitOpts = { limit: number; windowSeconds: number };
+
+export async function requireSessionOrApiKey(
+ req: NextRequest,
+ opts?: { rateLimit?: RateLimitOpts }
+): Promise<{ session: SessionLike; response: null } | { session: null; response: NextResponse }> {
+ // 1. Try Bearer API key
+ const authHeader = req.headers.get("authorization");
+ if (authHeader?.startsWith("Bearer ")) {
+ const rawKey = authHeader.slice(7).trim();
+ if (rawKey.startsWith("ek_")) {
+ const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
+
+ const [keyRow] = await db
+ .select({ id: apiKeys.id, userId: apiKeys.userId })
+ .from(apiKeys)
+ .where(eq(apiKeys.keyHash, keyHash))
+ .limit(1);
+
+ if (keyRow) {
+ // Update lastUsedAt asynchronously — don't block response
+ void db
+ .update(apiKeys)
+ .set({ lastUsedAt: new Date() })
+ .where(eq(apiKeys.id, keyRow.id));
+
+ const [user] = await db
+ .select({
+ id: users.id,
+ email: users.email,
+ name: users.name,
+ tier: users.tier,
+ role: users.role,
+ })
+ .from(users)
+ .where(eq(users.id, keyRow.userId))
+ .limit(1);
+
+ if (user) {
+ // Apply rate limit when requested (API key path only)
+ if (opts?.rateLimit) {
+ const { limit, windowSeconds } = opts.rateLimit;
+ const rateLimitResponse = await applyRateLimit(
+ `rl:api:${user.id}`,
+ limit,
+ windowSeconds
+ );
+ if (rateLimitResponse) {
+ return { session: null, response: rateLimitResponse };
+ }
+ }
+
+ return {
+ session: { user: { ...user, image: null } },
+ response: null,
+ };
+ }
+ }
+
+ return {
+ session: null,
+ response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
+ };
+ }
+ }
+
+ // 2. Fall back to session cookie
+ return requireSession();
+}
diff --git a/apps/web/lib/auth/client.ts b/apps/web/lib/auth/client.ts
new file mode 100644
index 0000000..54b494f
--- /dev/null
+++ b/apps/web/lib/auth/client.ts
@@ -0,0 +1,13 @@
+"use client";
+
+import { createAuthClient } from "better-auth/react";
+
+export const authClient = createAuthClient();
+
+export const {
+ signIn,
+ signUp,
+ signOut,
+ useSession,
+ getSession,
+} = authClient;
diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts
new file mode 100644
index 0000000..78e94b7
--- /dev/null
+++ b/apps/web/lib/auth/server.ts
@@ -0,0 +1,113 @@
+import { betterAuth } from "better-auth";
+import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
+import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
+
+export const auth = betterAuth({
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ schema: { user: users, session: sessions, account: accounts, verification: verifications },
+ }),
+
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ sendResetPassword: async ({ user, url }) => {
+ await sendEmail({
+ to: user.email,
+ subject: "Reset your Epicure password",
+ html: resetPasswordHtml(url),
+ });
+ },
+ },
+
+ emailVerification: {
+ sendOnSignUp: true,
+ autoSignInAfterVerification: true,
+ sendVerificationEmail: async ({ user, url }) => {
+ await sendEmail({
+ to: user.email,
+ subject: "Verify your Epicure email",
+ html: verifyEmailHtml(url),
+ });
+ },
+ },
+
+ socialProviders: {
+ google: {
+ clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
+ clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
+ },
+ },
+
+ session: {
+ cookieCache: {
+ enabled: true,
+ maxAge: 60 * 5,
+ },
+ },
+
+ databaseHooks: {
+ user: {
+ create: {
+ after: async (user) => {
+ // First registered user becomes admin
+ const result = await db.select({ total: count() }).from(users);
+ if ((result[0]?.total ?? 0) === 1) {
+ await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id));
+ }
+ // Welcome email (fire and forget)
+ sendEmail({
+ to: user.email,
+ subject: "Welcome to Epicure",
+ html: welcomeHtml(user.name),
+ }).catch(() => {});
+ },
+ },
+ },
+ },
+
+ user: {
+ additionalFields: {
+ role: {
+ type: "string",
+ defaultValue: "user",
+ input: false,
+ },
+ tier: {
+ type: "string",
+ defaultValue: "free",
+ input: false,
+ },
+ username: {
+ type: "string",
+ required: false,
+ },
+ bio: {
+ type: "string",
+ required: false,
+ },
+ unitPref: {
+ type: "string",
+ defaultValue: "metric",
+ },
+ locale: {
+ type: "string",
+ defaultValue: "en",
+ },
+ },
+ changeEmail: {
+ enabled: true,
+ sendChangeEmailVerification: async ({ newEmail, url }) => {
+ await sendEmail({
+ to: newEmail,
+ subject: "Verify your new Epicure email",
+ html: verifyEmailHtml(url),
+ });
+ },
+ },
+ },
+});
+
+export type Session = typeof auth.$Infer.Session;
+export type User = typeof auth.$Infer.Session.user;
diff --git a/apps/web/lib/encrypt.ts b/apps/web/lib/encrypt.ts
new file mode 100644
index 0000000..1d25f6f
--- /dev/null
+++ b/apps/web/lib/encrypt.ts
@@ -0,0 +1,30 @@
+import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
+
+const ALGORITHM = "aes-256-gcm";
+
+function getKey(): Buffer {
+ const secret = process.env["BETTER_AUTH_SECRET"] ?? "dev-secret-needs-32-bytes-padding!";
+ return createHash("sha256").update(secret).digest();
+}
+
+export function encrypt(plaintext: string): string {
+ const key = getKey();
+ const iv = randomBytes(12);
+ const cipher = createCipheriv(ALGORITHM, key, iv);
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
+ const authTag = (cipher as ReturnType & { getAuthTag(): Buffer }).getAuthTag();
+ return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
+}
+
+export function decrypt(ciphertext: string): string {
+ const parts = ciphertext.split(":");
+ if (parts.length !== 3) throw new Error("Invalid ciphertext format");
+ const [ivHex, authTagHex, encryptedHex] = parts as [string, string, string];
+ const key = getKey();
+ const iv = Buffer.from(ivHex, "hex");
+ const authTag = Buffer.from(authTagHex, "hex");
+ const encrypted = Buffer.from(encryptedHex, "hex");
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
+ (decipher as ReturnType & { setAuthTag(tag: Buffer): void }).setAuthTag(authTag);
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
+}
diff --git a/apps/web/lib/i18n/provider.tsx b/apps/web/lib/i18n/provider.tsx
new file mode 100644
index 0000000..09ff6e7
--- /dev/null
+++ b/apps/web/lib/i18n/provider.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { createContext, useContext, useEffect, useState } from "react";
+import { NextIntlClientProvider } from "next-intl";
+
+export const SUPPORTED_LOCALES = [
+ { code: "en", label: "English" },
+ { code: "fr", label: "Français" },
+] as const;
+
+export type Locale = (typeof SUPPORTED_LOCALES)[number]["code"];
+
+const STORAGE_KEY = "epicure-locale";
+
+const LocaleContext = createContext<{
+ locale: Locale;
+ setLocale: (locale: Locale) => void;
+}>({ locale: "en", setLocale: () => {} });
+
+export function useLocale() {
+ return useContext(LocaleContext);
+}
+
+export function I18nProvider({
+ children,
+ messages,
+ initialLocale,
+}: {
+ children: React.ReactNode;
+ messages: Record>;
+ initialLocale?: Locale;
+}) {
+ const resolved = initialLocale ?? ("en" as Locale);
+ const [locale, setLocaleState] = useState(resolved);
+ const [currentMessages, setCurrentMessages] = useState(messages[resolved] ?? messages["en"]!);
+
+ useEffect(() => {
+ // If no server-side locale, fall back to localStorage
+ if (!initialLocale) {
+ const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
+ if (stored && messages[stored]) {
+ setLocaleState(stored);
+ setCurrentMessages(messages[stored]!);
+ }
+ }
+ }, [messages, initialLocale]);
+
+ function setLocale(next: Locale) {
+ setLocaleState(next);
+ setCurrentMessages(messages[next]!);
+ localStorage.setItem(STORAGE_KEY, next);
+ fetch("/api/v1/users/me", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ locale: next }),
+ }).catch(() => {});
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts
new file mode 100644
index 0000000..720110a
--- /dev/null
+++ b/apps/web/lib/rate-limit.ts
@@ -0,0 +1,51 @@
+import { getRedis } from "./redis";
+import { NextResponse } from "next/server";
+
+export async function rateLimit(
+ key: string,
+ limit: number,
+ windowSeconds: number
+): Promise<{ ok: boolean; remaining: number; reset: number }> {
+ const redis = getRedis();
+ const now = Date.now();
+ const windowStart = now - windowSeconds * 1000;
+ const reset = Math.ceil((now + windowSeconds * 1000) / 1000);
+
+ const pipeline = redis.pipeline();
+ pipeline.zremrangebyscore(key, "-inf", windowStart);
+ pipeline.zadd(key, now, `${now}-${Math.random()}`);
+ pipeline.zcard(key);
+ pipeline.expire(key, windowSeconds);
+ const results = await pipeline.exec();
+
+ const count = (results?.[2]?.[1] as number) ?? 0;
+ const remaining = Math.max(0, limit - count);
+ const ok = count <= limit;
+
+ return { ok, remaining, reset };
+}
+
+export async function applyRateLimit(
+ key: string,
+ limit: number,
+ windowSeconds: number
+): Promise {
+ const { ok, remaining, reset } = await rateLimit(key, limit, windowSeconds);
+
+ if (!ok) {
+ return NextResponse.json(
+ { error: "Too many requests", retryAfter: reset },
+ {
+ status: 429,
+ headers: {
+ "X-RateLimit-Limit": String(limit),
+ "X-RateLimit-Remaining": "0",
+ "X-RateLimit-Reset": String(reset),
+ "Retry-After": String(reset - Math.floor(Date.now() / 1000)),
+ },
+ }
+ );
+ }
+
+ return null;
+}
diff --git a/apps/web/lib/site-settings.ts b/apps/web/lib/site-settings.ts
new file mode 100644
index 0000000..0c32217
--- /dev/null
+++ b/apps/web/lib/site-settings.ts
@@ -0,0 +1,90 @@
+import { db, siteSettings, eq } from "@epicure/db";
+import { encrypt, decrypt } from "@/lib/encrypt";
+
+export type SiteSettingKey =
+ | "OPENAI_API_KEY"
+ | "ANTHROPIC_API_KEY"
+ | "OPENROUTER_API_KEY"
+ | "OPENROUTER_DEFAULT_MODEL"
+ | "OLLAMA_BASE_URL"
+ | "NEXT_PUBLIC_VAPID_PUBLIC_KEY"
+ | "VAPID_PRIVATE_KEY";
+
+const SECRET_KEYS: SiteSettingKey[] = [
+ "OPENAI_API_KEY",
+ "ANTHROPIC_API_KEY",
+ "OPENROUTER_API_KEY",
+ "VAPID_PRIVATE_KEY",
+];
+
+export function isSecretKey(key: SiteSettingKey): boolean {
+ return SECRET_KEYS.includes(key);
+}
+
+export async function getSiteSetting(key: SiteSettingKey): Promise {
+ try {
+ const row = await db.query.siteSettings.findFirst({ where: eq(siteSettings.key, key) });
+ if (row?.value) {
+ return row.isSecret ? decrypt(row.value) : row.value;
+ }
+ } catch {
+ // fall through to env
+ }
+ return process.env[key] ?? null;
+}
+
+export async function getAllSiteSettings(): Promise> {
+ const rows = await db.query.siteSettings.findMany();
+ const dbMap = new Map(rows.map((r) => [r.key, r]));
+
+ const KNOWN_KEYS: SiteSettingKey[] = [
+ "OPENAI_API_KEY",
+ "ANTHROPIC_API_KEY",
+ "OPENROUTER_API_KEY",
+ "OPENROUTER_DEFAULT_MODEL",
+ "OLLAMA_BASE_URL",
+ "NEXT_PUBLIC_VAPID_PUBLIC_KEY",
+ "VAPID_PRIVATE_KEY",
+ ];
+
+ const result: Record = {};
+ for (const k of KNOWN_KEYS) {
+ const row = dbMap.get(k);
+ const secret = isSecretKey(k);
+ if (row) {
+ result[k] = {
+ value: secret ? "••••••••••••" : (row.value ?? null),
+ isSecret: secret,
+ fromDb: true,
+ };
+ } else {
+ const envVal = process.env[k];
+ result[k] = {
+ value: envVal ? (secret ? "••••••••••••" : envVal) : null,
+ isSecret: secret,
+ fromDb: false,
+ };
+ }
+ }
+ return result;
+}
+
+export async function setSiteSetting(
+ key: SiteSettingKey,
+ value: string | null,
+ updatedById: string
+): Promise {
+ if (value === null || value === "") {
+ await db.delete(siteSettings).where(eq(siteSettings.key, key));
+ return;
+ }
+ const secret = isSecretKey(key);
+ const stored = secret ? encrypt(value) : value;
+ await db
+ .insert(siteSettings)
+ .values({ key, value: stored, isSecret: secret, updatedById, updatedAt: new Date() })
+ .onConflictDoUpdate({
+ target: siteSettings.key,
+ set: { value: stored, updatedAt: new Date(), updatedById },
+ });
+}
diff --git a/apps/web/lib/tiers.ts b/apps/web/lib/tiers.ts
new file mode 100644
index 0000000..efb729d
--- /dev/null
+++ b/apps/web/lib/tiers.ts
@@ -0,0 +1,82 @@
+import { db } from "@epicure/db";
+import { tierDefinitions, userUsage } from "@epicure/db";
+import { eq, and, sql } from "@epicure/db";
+
+function currentMonth() {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
+}
+
+export type LimitKey = "recipe" | "aiCall" | "storage";
+
+export class TierLimitError extends Error {
+ constructor(public readonly limit: LimitKey, public readonly tier: string) {
+ super(`Tier limit reached: ${limit} (tier: ${tier})`);
+ this.name = "TierLimitError";
+ }
+}
+
+export async function checkTierLimit(
+ userId: string,
+ userTier: "free" | "pro",
+ key: LimitKey
+): Promise {
+ const [tierDef] = await db
+ .select()
+ .from(tierDefinitions)
+ .where(eq(tierDefinitions.tier, userTier));
+
+ if (!tierDef) return;
+
+ const month = currentMonth();
+ const [usage] = await db
+ .select()
+ .from(userUsage)
+ .where(and(eq(userUsage.userId, userId), eq(userUsage.month, month)));
+
+ const current = usage ?? {
+ aiCallsUsed: 0,
+ recipeCount: 0,
+ storageUsedMb: 0,
+ };
+
+ if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) {
+ throw new TierLimitError("recipe", userTier);
+ }
+ if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) {
+ throw new TierLimitError("aiCall", userTier);
+ }
+}
+
+export async function incrementUsage(
+ userId: string,
+ key: LimitKey,
+ amount = 1
+): Promise {
+ const month = currentMonth();
+ const id = `${userId}-${month}`;
+
+ const initialValues = {
+ id,
+ userId,
+ month,
+ aiCallsUsed: key === "aiCall" ? amount : 0,
+ recipeCount: key === "recipe" ? amount : 0,
+ storageUsedMb: key === "storage" ? amount : 0,
+ };
+
+ const incrementSet =
+ key === "aiCall"
+ ? { aiCallsUsed: sql`${userUsage.aiCallsUsed} + ${amount}` }
+ : key === "recipe"
+ ? { recipeCount: sql`${userUsage.recipeCount} + ${amount}` }
+ : { storageUsedMb: sql`${userUsage.storageUsedMb} + ${amount}` };
+
+ await db
+ .insert(userUsage)
+ .values(initialValues)
+ .onConflictDoUpdate({
+ target: [userUsage.userId, userUsage.month],
+ set: incrementSet,
+ });
+}
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/apps/web/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
new file mode 100644
index 0000000..af20b62
--- /dev/null
+++ b/apps/web/messages/en.json
@@ -0,0 +1,341 @@
+{
+ "nav": {
+ "recipes": "Recipes",
+ "explore": "Explore",
+ "search": "Search",
+ "feed": "Feed",
+ "collections": "Collections",
+ "mealPlan": "Meal Plan",
+ "pantry": "Pantry",
+ "shopping": "Shopping",
+ "settings": "Settings",
+ "admin": "Admin",
+ "signOut": "Sign out",
+ "apiKeys": "API keys",
+ "webhooks": "Webhooks",
+ "language": "Language"
+ },
+ "recipe": {
+ "ingredients": "Ingredients",
+ "instructions": "Instructions",
+ "photos": "Photos",
+ "servings": "{count} servings",
+ "prep": "{mins}m prep",
+ "cook": "{mins}m cook",
+ "total": "{mins}m total",
+ "edit": "Edit",
+ "variations": "Variations",
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "visibility": {
+ "private": "Private",
+ "unlisted": "Unlisted",
+ "public": "Public"
+ },
+ "difficulty": {
+ "easy": "Easy",
+ "medium": "Medium",
+ "hard": "Hard"
+ },
+ "dietary": {
+ "vegan": "Vegan",
+ "vegetarian": "Vegetarian",
+ "glutenFree": "Gluten-free",
+ "dairyFree": "Dairy-free",
+ "nutFree": "Nut-free",
+ "halal": "Halal",
+ "kosher": "Kosher"
+ }
+ },
+ "ai": {
+ "generate": {
+ "title": "Generate recipe with AI",
+ "description": "Describe what you want to cook and AI will create a complete recipe.",
+ "prompt": "What do you want to cook?",
+ "placeholder": "e.g. A creamy mushroom risotto with parmesan, serves 4, easy difficulty",
+ "language": "Language",
+ "button": "Generate",
+ "generating": "Generating…",
+ "success": "Recipe generated! Review and edit before publishing.",
+ "error": "Failed to generate recipe",
+ "saveError": "Failed to save generated recipe"
+ },
+ "variations": {
+ "title": "AI Recipe Variations",
+ "description": "Generate creative variations of this recipe — dietary adaptations, flavor twists, technique changes.",
+ "generate": "Generate 3 variations",
+ "generating": "Generating variations…",
+ "regenerate": "Regenerate",
+ "apply": "Apply",
+ "applying": "Applying…",
+ "ingredientChanges": "Ingredient changes",
+ "stepChanges": "Step changes",
+ "success": "Variation created — review and edit before publishing.",
+ "error": "Failed to generate variations",
+ "saveError": "Failed to save variation",
+ "wait": "This may take 20–30 seconds"
+ },
+ "translate": {
+ "title": "Translate Recipe",
+ "description": "Translate this recipe's title, description, ingredients, and steps into another language.",
+ "targetLanguage": "Target language",
+ "button": "Translate",
+ "translating": "Translating…",
+ "success": "Translation saved as new draft recipe.",
+ "error": "Failed to translate recipe",
+ "saveError": "Failed to save translated recipe",
+ "wait": "This may take 20–30 seconds"
+ }
+ },
+ "settings": {
+ "title": "Settings",
+ "profile": "Profile",
+ "language": "Language",
+ "units": "Units",
+ "metric": "Metric",
+ "imperial": "Imperial"
+ },
+ "common": {
+ "loading": "Loading…",
+ "error": "Something went wrong",
+ "save": "Save",
+ "saved": "Saved",
+ "saveFailed": "Failed to save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "confirm": "Confirm",
+ "back": "Back"
+ },
+ "auth": {
+ "signIn": "Sign in",
+ "signInTitle": "Sign in to your recipe library",
+ "signInLoading": "Signing in…",
+ "signUp": "Sign up",
+ "signUpTitle": "Create account",
+ "signUpSubtitle": "Start building your recipe library",
+ "signUpLoading": "Creating account…",
+ "continueWithGoogle": "Continue with Google",
+ "email": "Email",
+ "emailPlaceholder": "you@example.com",
+ "password": "Password",
+ "name": "Name",
+ "namePlaceholder": "Your name",
+ "forgotPassword": "Forgot password?",
+ "forgotPasswordTitle": "Forgot password",
+ "forgotPasswordSent": "Check your inbox",
+ "forgotPasswordDescription": "Enter your email and we'll send you a reset link.",
+ "forgotPasswordSentDescription": "If an account exists for that email, you'll receive a reset link shortly.",
+ "sendResetLink": "Send reset link",
+ "sendingLink": "Sending…",
+ "resetPasswordTitle": "Reset password",
+ "resetPasswordDescription": "Enter a new password for your account.",
+ "newPassword": "New password",
+ "confirmPassword": "Confirm password",
+ "updatePassword": "Update password",
+ "updatingPassword": "Updating…",
+ "invalidToken": "Invalid or missing reset token.",
+ "noAccount": "No account? Sign up",
+ "alreadyHaveAccount": "Already have an account? Sign in",
+ "backToSignIn": "Back to sign in",
+ "emailNotVerified": "Email not verified",
+ "checkInboxVerification": "Check your inbox for a verification email.",
+ "resendVerification": "Resend verification email",
+ "resendingSending": "Sending…",
+ "or": "or"
+ },
+ "recipes": {
+ "title": "Recipes",
+ "subtitle": "Your personal recipe library",
+ "new": "New recipe",
+ "newTitle": "New recipe",
+ "newSubtitle": "Build your recipe from scratch",
+ "importUrl": "Import URL",
+ "generate": "Generate",
+ "search": "Search recipes…",
+ "noRecipes": "No recipes yet",
+ "noMatch": "No recipes match",
+ "clearSearch": "Clear search",
+ "createFirst": "Create your first recipe",
+ "resultSingular": "result",
+ "resultPlural": "results",
+ "for": "for"
+ },
+ "recipeForm": {
+ "titleLabel": "Title *",
+ "titleRequired": "Title is required",
+ "titlePlaceholder": "My amazing recipe",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "What makes this recipe special…",
+ "servings": "Servings",
+ "prepMins": "Prep (min)",
+ "cookMins": "Cook (min)",
+ "difficulty": "Difficulty",
+ "visibility": "Visibility",
+ "dietaryTags": "Dietary tags",
+ "photos": "Photos",
+ "ingredients": "Ingredients",
+ "amount": "Amount",
+ "unit": "Unit",
+ "ingredient": "Ingredient",
+ "note": "Note",
+ "addIngredient": "Add ingredient",
+ "steps": "Steps",
+ "stepPlaceholder": "Step {n}…",
+ "timerSeconds": "Timer (s)",
+ "addStep": "Add step",
+ "saving": "Saving…",
+ "saveChanges": "Save changes",
+ "createRecipe": "Create recipe",
+ "updateSuccess": "Recipe updated",
+ "createSuccess": "Recipe created",
+ "saveError": "Failed to save recipe"
+ },
+ "canCook": {
+ "title": "What can I cook?",
+ "subtitle": "Recipes you can make with your pantry",
+ "emptyPantry": "Your pantry is empty",
+ "addPantryItems": "Add pantry items",
+ "readyToCook": "Ready to cook",
+ "almostThere": "Almost there (70–99%)",
+ "partialMatches": "Partial matches",
+ "noMatches": "No strong matches found",
+ "missing": "Missing",
+ "ingredientProgress": "{matched}/{total} ingredients"
+ },
+ "mealPlan": {
+ "title": "Meal Plan",
+ "shoppingLists": "Shopping lists",
+ "addTo": "Add to {day} – {meal}",
+ "searchRecipes": "Search recipes…",
+ "servings": "Servings",
+ "noRecipes": "No recipes found",
+ "days": {
+ "mon": "Mon",
+ "tue": "Tue",
+ "wed": "Wed",
+ "thu": "Thu",
+ "fri": "Fri",
+ "sat": "Sat",
+ "sun": "Sun"
+ },
+ "meals": {
+ "breakfast": "Breakfast",
+ "lunch": "Lunch",
+ "dinner": "Dinner",
+ "snack": "Snack"
+ },
+ "generateWithAi": "Generate with AI",
+ "aiModalDescription": "AI will generate a full week of meals and create draft recipes for you.",
+ "dietaryPrefs": "Dietary preferences",
+ "dietaryPrefsPlaceholder": "e.g. vegetarian, gluten-free, low-carb…",
+ "aiPantryNote": "Your pantry items will be considered when available.",
+ "generating": "Generating…",
+ "generate": "Generate",
+ "cancel": "Cancel",
+ "aiGenerated": "Meal plan generated!",
+ "includePantryItems": "Include pantry items",
+ "pantryMode": "Pantry mode — maximize use of what you have",
+ "pantryModeDescription": "Prefer meals that use multiple pantry ingredients and minimize extra shopping."
+ },
+ "pantry": {
+ "title": "Pantry",
+ "subtitle": "Track what you have on hand",
+ "canCook": "What can I cook?"
+ },
+ "feed": {
+ "title": "Feed",
+ "followEmpty": "Follow chefs to see their recipes here.",
+ "noNew": "No new recipes from people you follow.",
+ "following": "Following",
+ "trending": "Trending",
+ "trendingEmpty": "No trending recipes this week.",
+ "loading": "Loading…"
+ },
+ "shoppingLists": {
+ "title": "Shopping Lists",
+ "subtitle": "Plan your grocery runs",
+ "empty": "No shopping lists yet",
+ "listEmpty": "Empty",
+ "generated": "Generated",
+ "items": "{checked}/{total} items"
+ },
+ "collections": {
+ "title": "Collections",
+ "subtitle": "Curate your favorite recipes into collections",
+ "empty": "No collections yet",
+ "public": "Public",
+ "recipeCount": "{count} recipe",
+ "recipeCountPlural": "{count} recipes"
+ },
+ "cookingMode": {
+ "cooking": "Cooking",
+ "stepOf": "Step {current} of {total}",
+ "ingredients": "Ingredients",
+ "startTimer": "Start timer",
+ "timerDone": "Done",
+ "pause": "Pause",
+ "reset": "Reset",
+ "previous": "Previous",
+ "next": "Next",
+ "finish": "Done!",
+ "voiceNotSupported": "Voice not supported in this browser",
+ "voiceError": "Voice error — try again",
+ "enableVoice": "Enable voice control",
+ "stopVoice": "Stop voice",
+ "toggleIngredients": "Toggle ingredients",
+ "shortcutsTitle": "Shortcuts & commands",
+ "keyboardSection": "Keyboard",
+ "voiceSection": "Voice commands",
+ "enableMicFirst": "Enable voice via the mic button first.",
+ "stepRepeatPrefix": "Step {n}: ",
+ "shortcuts": {
+ "nextStep": "Next step",
+ "prevStep": "Previous step",
+ "startTimer": "Start timer",
+ "toggleHelp": "Toggle this panel",
+ "exitMode": "Exit cook mode"
+ },
+ "voiceLabels": {
+ "nextCmd": "next / continue",
+ "prevCmd": "previous / back",
+ "timerCmd": "timer / start",
+ "stopCmd": "stop / pause",
+ "resetCmd": "reset",
+ "repeatCmd": "repeat / again",
+ "nextLabel": "Next step",
+ "prevLabel": "Previous step",
+ "timerLabel": "Start timer",
+ "stopLabel": "Pause timer",
+ "resetLabel": "Reset timer",
+ "repeatLabel": "Re-read step"
+ }
+ },
+ "settingsForm": {
+ "profile": "Profile",
+ "displayName": "Display name",
+ "email": "Email",
+ "emailReadOnly": "Email cannot be changed here.",
+ "saving": "Saving…",
+ "title": "Settings",
+ "subtitle": "Manage your account and preferences",
+ "language": "Language",
+ "languageDescription": "Choose your preferred language for the app and AI-generated content.",
+ "changeEmail": "Change email",
+ "newEmail": "New email address",
+ "changeEmailDescription": "A verification link will be sent to your new address before it takes effect.",
+ "sendVerification": "Send verification",
+ "sendingVerification": "Sending…",
+ "emailChangeSent": "Verification email sent. Check your inbox.",
+ "emailChangeFailed": "Failed to send verification email.",
+ "changePassword": "Change password",
+ "currentPassword": "Current password",
+ "newPassword": "New password",
+ "confirmPassword": "Confirm new password",
+ "passwordMismatch": "Passwords don't match.",
+ "changingPassword": "Changing…",
+ "changePasswordButton": "Change password",
+ "passwordChanged": "Password changed successfully.",
+ "passwordChangeFailed": "Failed to change password."
+ }
+}
\ No newline at end of file
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
new file mode 100644
index 0000000..608aefa
--- /dev/null
+++ b/apps/web/messages/fr.json
@@ -0,0 +1,341 @@
+{
+ "nav": {
+ "recipes": "Recettes",
+ "explore": "Explorer",
+ "search": "Rechercher",
+ "feed": "Fil d'actualité",
+ "collections": "Collections",
+ "mealPlan": "Planning repas",
+ "pantry": "Garde-manger",
+ "shopping": "Courses",
+ "settings": "Paramètres",
+ "admin": "Admin",
+ "signOut": "Se déconnecter",
+ "apiKeys": "Clés API",
+ "webhooks": "Webhooks",
+ "language": "Langue"
+ },
+ "recipe": {
+ "ingredients": "Ingrédients",
+ "instructions": "Instructions",
+ "photos": "Photos",
+ "servings": "{count} portions",
+ "prep": "{mins}m prép.",
+ "cook": "{mins}m cuisson",
+ "total": "{mins}m total",
+ "edit": "Modifier",
+ "variations": "Variations",
+ "save": "Enregistrer",
+ "cancel": "Annuler",
+ "delete": "Supprimer",
+ "visibility": {
+ "private": "Privée",
+ "unlisted": "Non répertoriée",
+ "public": "Publique"
+ },
+ "difficulty": {
+ "easy": "Facile",
+ "medium": "Moyen",
+ "hard": "Difficile"
+ },
+ "dietary": {
+ "vegan": "Végétalien",
+ "vegetarian": "Végétarien",
+ "glutenFree": "Sans gluten",
+ "dairyFree": "Sans lactose",
+ "nutFree": "Sans noix",
+ "halal": "Halal",
+ "kosher": "Casher"
+ }
+ },
+ "ai": {
+ "generate": {
+ "title": "Générer une recette avec l'IA",
+ "description": "Décrivez ce que vous voulez cuisiner et l'IA créera une recette complète.",
+ "prompt": "Que voulez-vous cuisiner ?",
+ "placeholder": "ex. Un risotto crémeux aux champignons et parmesan, 4 portions, niveau facile",
+ "language": "Langue",
+ "button": "Générer",
+ "generating": "Génération en cours…",
+ "success": "Recette générée ! Vérifiez et modifiez avant de publier.",
+ "error": "Échec de la génération de la recette",
+ "saveError": "Échec de l'enregistrement de la recette générée"
+ },
+ "variations": {
+ "title": "Variations de recette par IA",
+ "description": "Générez des variations créatives de cette recette — adaptations diététiques, profils de saveurs, changements de techniques.",
+ "generate": "Générer 3 variations",
+ "generating": "Génération des variations…",
+ "regenerate": "Régénérer",
+ "apply": "Appliquer",
+ "applying": "Application…",
+ "ingredientChanges": "Changements d'ingrédients",
+ "stepChanges": "Changements d'étapes",
+ "success": "Variation créée — vérifiez et modifiez avant de publier.",
+ "error": "Échec de la génération des variations",
+ "saveError": "Échec de l'enregistrement de la variation",
+ "wait": "Cela peut prendre 20 à 30 secondes"
+ },
+ "translate": {
+ "title": "Traduire la recette",
+ "description": "Traduisez le titre, la description, les ingrédients et les étapes de cette recette dans une autre langue.",
+ "targetLanguage": "Langue cible",
+ "button": "Traduire",
+ "translating": "Traduction en cours…",
+ "success": "Traduction enregistrée comme nouveau brouillon.",
+ "error": "Échec de la traduction de la recette",
+ "saveError": "Échec de l'enregistrement de la recette traduite",
+ "wait": "Cela peut prendre 20 à 30 secondes"
+ }
+ },
+ "settings": {
+ "title": "Paramètres",
+ "profile": "Profil",
+ "language": "Langue",
+ "units": "Unités",
+ "metric": "Métrique",
+ "imperial": "Impérial"
+ },
+ "common": {
+ "loading": "Chargement…",
+ "error": "Une erreur s'est produite",
+ "save": "Enregistrer",
+ "saved": "Enregistré",
+ "saveFailed": "Échec de l'enregistrement",
+ "cancel": "Annuler",
+ "delete": "Supprimer",
+ "confirm": "Confirmer",
+ "back": "Retour"
+ },
+ "auth": {
+ "signIn": "Se connecter",
+ "signInTitle": "Connectez-vous à votre bibliothèque de recettes",
+ "signInLoading": "Connexion en cours…",
+ "signUp": "S'inscrire",
+ "signUpTitle": "Créer un compte",
+ "signUpSubtitle": "Commencez à construire votre bibliothèque de recettes",
+ "signUpLoading": "Création du compte…",
+ "continueWithGoogle": "Continuer avec Google",
+ "email": "E-mail",
+ "emailPlaceholder": "vous@exemple.fr",
+ "password": "Mot de passe",
+ "name": "Nom",
+ "namePlaceholder": "Votre nom",
+ "forgotPassword": "Mot de passe oublié ?",
+ "forgotPasswordTitle": "Mot de passe oublié",
+ "forgotPasswordSent": "Vérifiez vos e-mails",
+ "forgotPasswordDescription": "Saisissez votre e-mail et nous vous enverrons un lien de réinitialisation.",
+ "forgotPasswordSentDescription": "Si un compte existe pour cet e-mail, vous recevrez un lien sous peu.",
+ "sendResetLink": "Envoyer le lien",
+ "sendingLink": "Envoi en cours…",
+ "resetPasswordTitle": "Réinitialiser le mot de passe",
+ "resetPasswordDescription": "Saisissez un nouveau mot de passe pour votre compte.",
+ "newPassword": "Nouveau mot de passe",
+ "confirmPassword": "Confirmer le mot de passe",
+ "updatePassword": "Mettre à jour le mot de passe",
+ "updatingPassword": "Mise à jour…",
+ "invalidToken": "Jeton de réinitialisation invalide ou manquant.",
+ "noAccount": "Pas de compte ? S'inscrire",
+ "alreadyHaveAccount": "Déjà un compte ? Se connecter",
+ "backToSignIn": "Retour à la connexion",
+ "emailNotVerified": "E-mail non vérifié",
+ "checkInboxVerification": "Vérifiez votre boîte de réception pour l'e-mail de vérification.",
+ "resendVerification": "Renvoyer l'e-mail de vérification",
+ "resendingSending": "Envoi en cours…",
+ "or": "ou"
+ },
+ "recipes": {
+ "title": "Recettes",
+ "subtitle": "Votre bibliothèque personnelle de recettes",
+ "new": "Nouvelle recette",
+ "newTitle": "Nouvelle recette",
+ "newSubtitle": "Créez votre recette depuis zéro",
+ "importUrl": "Importer une URL",
+ "generate": "Générer",
+ "search": "Rechercher des recettes…",
+ "noRecipes": "Aucune recette pour l'instant",
+ "noMatch": "Aucune recette ne correspond",
+ "clearSearch": "Effacer la recherche",
+ "createFirst": "Créer votre première recette",
+ "resultSingular": "résultat",
+ "resultPlural": "résultats",
+ "for": "pour"
+ },
+ "recipeForm": {
+ "titleLabel": "Titre *",
+ "titleRequired": "Le titre est requis",
+ "titlePlaceholder": "Ma super recette",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "Ce qui rend cette recette spéciale…",
+ "servings": "Portions",
+ "prepMins": "Prép. (min)",
+ "cookMins": "Cuisson (min)",
+ "difficulty": "Difficulté",
+ "visibility": "Visibilité",
+ "dietaryTags": "Tags alimentaires",
+ "photos": "Photos",
+ "ingredients": "Ingrédients",
+ "amount": "Quantité",
+ "unit": "Unité",
+ "ingredient": "Ingrédient",
+ "note": "Note",
+ "addIngredient": "Ajouter un ingrédient",
+ "steps": "Étapes",
+ "stepPlaceholder": "Étape {n}…",
+ "timerSeconds": "Minuteur (s)",
+ "addStep": "Ajouter une étape",
+ "saving": "Enregistrement…",
+ "saveChanges": "Enregistrer les modifications",
+ "createRecipe": "Créer la recette",
+ "updateSuccess": "Recette mise à jour",
+ "createSuccess": "Recette créée",
+ "saveError": "Échec de l'enregistrement de la recette"
+ },
+ "canCook": {
+ "title": "Que puis-je cuisiner ?",
+ "subtitle": "Recettes réalisables avec votre garde-manger",
+ "emptyPantry": "Votre garde-manger est vide",
+ "addPantryItems": "Ajouter des articles",
+ "readyToCook": "Prêt à cuisiner",
+ "almostThere": "Presque là (70–99 %)",
+ "partialMatches": "Correspondances partielles",
+ "noMatches": "Aucune correspondance trouvée",
+ "missing": "Manquant",
+ "ingredientProgress": "{matched}/{total} ingrédients"
+ },
+ "mealPlan": {
+ "title": "Planning repas",
+ "shoppingLists": "Listes de courses",
+ "addTo": "Ajouter à {day} – {meal}",
+ "searchRecipes": "Rechercher des recettes…",
+ "servings": "Portions",
+ "noRecipes": "Aucune recette trouvée",
+ "days": {
+ "mon": "Lun",
+ "tue": "Mar",
+ "wed": "Mer",
+ "thu": "Jeu",
+ "fri": "Ven",
+ "sat": "Sam",
+ "sun": "Dim"
+ },
+ "meals": {
+ "breakfast": "Petit-déjeuner",
+ "lunch": "Déjeuner",
+ "dinner": "Dîner",
+ "snack": "Collation"
+ },
+ "generateWithAi": "Générer avec l'IA",
+ "aiModalDescription": "L'IA va générer une semaine complète de repas et créer des recettes brouillons.",
+ "dietaryPrefs": "Préférences alimentaires",
+ "dietaryPrefsPlaceholder": "ex. végétarien, sans gluten, low-carb…",
+ "aiPantryNote": "Les articles de votre garde-manger seront pris en compte.",
+ "generating": "Génération…",
+ "generate": "Générer",
+ "cancel": "Annuler",
+ "aiGenerated": "Plan de repas généré !",
+ "includePantryItems": "Inclure les articles du garde-manger",
+ "pantryMode": "Mode garde-manger — maximiser ce que vous avez",
+ "pantryModeDescription": "Préférer les repas utilisant plusieurs ingrédients du garde-manger et limiter les achats supplémentaires."
+ },
+ "pantry": {
+ "title": "Garde-manger",
+ "subtitle": "Suivez ce que vous avez en stock",
+ "canCook": "Que puis-je cuisiner ?"
+ },
+ "feed": {
+ "title": "Fil d'actualité",
+ "followEmpty": "Suivez des chefs pour voir leurs recettes ici.",
+ "noNew": "Aucune nouvelle recette de vos abonnements.",
+ "following": "Abonnements",
+ "trending": "Tendances",
+ "trendingEmpty": "Aucune recette tendance cette semaine.",
+ "loading": "Chargement…"
+ },
+ "shoppingLists": {
+ "title": "Listes de courses",
+ "subtitle": "Planifiez vos courses",
+ "empty": "Aucune liste de courses",
+ "listEmpty": "Vide",
+ "generated": "Générée",
+ "items": "{checked}/{total} articles"
+ },
+ "collections": {
+ "title": "Collections",
+ "subtitle": "Organisez vos recettes favorites en collections",
+ "empty": "Aucune collection pour l'instant",
+ "public": "Publique",
+ "recipeCount": "{count} recette",
+ "recipeCountPlural": "{count} recettes"
+ },
+ "cookingMode": {
+ "cooking": "En cuisine",
+ "stepOf": "Étape {current} sur {total}",
+ "ingredients": "Ingrédients",
+ "startTimer": "Démarrer le minuteur",
+ "timerDone": "Terminé",
+ "pause": "Pause",
+ "reset": "Réinitialiser",
+ "previous": "Précédent",
+ "next": "Suivant",
+ "finish": "Terminé !",
+ "voiceNotSupported": "Commandes vocales non supportées dans ce navigateur",
+ "voiceError": "Erreur vocale — réessayez",
+ "enableVoice": "Activer les commandes vocales",
+ "stopVoice": "Désactiver les commandes vocales",
+ "toggleIngredients": "Afficher les ingrédients",
+ "shortcutsTitle": "Raccourcis et commandes",
+ "keyboardSection": "Clavier",
+ "voiceSection": "Commandes vocales",
+ "enableMicFirst": "Activez le micro via le bouton micro d'abord.",
+ "stepRepeatPrefix": "Étape {n} : ",
+ "shortcuts": {
+ "nextStep": "Étape suivante",
+ "prevStep": "Étape précédente",
+ "startTimer": "Démarrer le minuteur",
+ "toggleHelp": "Afficher/masquer ce panneau",
+ "exitMode": "Quitter le mode cuisine"
+ },
+ "voiceLabels": {
+ "nextCmd": "suivant / continuer",
+ "prevCmd": "précédent / retour",
+ "timerCmd": "minuteur / démarrer",
+ "stopCmd": "arrêter / pause",
+ "resetCmd": "réinitialiser",
+ "repeatCmd": "répéter / encore",
+ "nextLabel": "Étape suivante",
+ "prevLabel": "Étape précédente",
+ "timerLabel": "Démarrer le minuteur",
+ "stopLabel": "Mettre en pause",
+ "resetLabel": "Réinitialiser le minuteur",
+ "repeatLabel": "Relire l'étape"
+ }
+ },
+ "settingsForm": {
+ "profile": "Profil",
+ "displayName": "Nom d'affichage",
+ "email": "E-mail",
+ "emailReadOnly": "L'e-mail ne peut pas être modifié ici.",
+ "saving": "Enregistrement…",
+ "title": "Paramètres",
+ "subtitle": "Gérez votre compte et vos préférences",
+ "language": "Langue",
+ "languageDescription": "Choisissez la langue de l'application et du contenu généré par l'IA.",
+ "changeEmail": "Changer l'e-mail",
+ "newEmail": "Nouvelle adresse e-mail",
+ "changeEmailDescription": "Un lien de vérification sera envoyé à votre nouvelle adresse avant que la modification soit prise en compte.",
+ "sendVerification": "Envoyer la vérification",
+ "sendingVerification": "Envoi…",
+ "emailChangeSent": "E-mail de vérification envoyé. Vérifiez votre boîte de réception.",
+ "emailChangeFailed": "Échec de l'envoi de l'e-mail de vérification.",
+ "changePassword": "Changer le mot de passe",
+ "currentPassword": "Mot de passe actuel",
+ "newPassword": "Nouveau mot de passe",
+ "confirmPassword": "Confirmer le nouveau mot de passe",
+ "passwordMismatch": "Les mots de passe ne correspondent pas.",
+ "changingPassword": "Modification…",
+ "changePasswordButton": "Changer le mot de passe",
+ "passwordChanged": "Mot de passe modifié avec succès.",
+ "passwordChangeFailed": "Échec de la modification du mot de passe."
+ }
+}
\ No newline at end of file
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/apps/web/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..3907c28
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@epicure/web",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "@ai-sdk/anthropic": "^3.0.86",
+ "@ai-sdk/openai": "^3.0.74",
+ "@asteasolutions/zod-to-openapi": "^8.5.0",
+ "@aws-sdk/client-s3": "^3.1075.0",
+ "@aws-sdk/s3-request-presigner": "^3.1075.0",
+ "@base-ui/react": "^1.6.0",
+ "@epicure/api-types": "workspace:*",
+ "@epicure/db": "workspace:*",
+ "@hookform/resolvers": "^5.4.0",
+ "ai": "^6.0.209",
+ "better-auth": "^1.6.20",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "drizzle-orm": "^0.44.7",
+ "ioredis": "^5.11.1",
+ "lucide-react": "^1.21.0",
+ "next": "16.2.9",
+ "next-intl": "^4.13.0",
+ "next-themes": "^0.4.6",
+ "nodemailer": "^9.0.1",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "react-hook-form": "^7.80.0",
+ "shadcn": "^4.11.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.6.0",
+ "tw-animate-css": "^1.4.0",
+ "web-push": "^3.6.7",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20.19.43",
+ "@types/nodemailer": "^8.0.1",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@types/web-push": "^3.6.4",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.9",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/apps/web/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts
new file mode 100644
index 0000000..7f6b05f
--- /dev/null
+++ b/apps/web/proxy.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSessionCookie } from "better-auth/cookies";
+
+const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks"];
+const ADMIN_PATHS = ["/admin"];
+
+export async function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
+ const isAdmin = ADMIN_PATHS.some((p) => pathname.startsWith(p));
+ const isApi = pathname.startsWith("/api/v1");
+
+ if (isPublic) return NextResponse.next();
+
+ const sessionCookie = getSessionCookie(request);
+
+ if (!sessionCookie) {
+ if (isApi) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ return NextResponse.redirect(new URL("/login", request.url));
+ }
+
+ if (isAdmin) {
+ // Role check happens inside admin pages — middleware only checks auth
+ // Full role verification requires DB lookup, done server-side in admin layout
+ }
+
+ return NextResponse.next();
+}
+
+export default proxy;
+
+export const config = {
+ matcher: [
+ "/((?!_next/static|_next/image|favicon.ico|public/).*)",
+ ],
+};
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..b8c2ad1
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/web/types/speech.d.ts b/apps/web/types/speech.d.ts
new file mode 100644
index 0000000..d2fcbce
--- /dev/null
+++ b/apps/web/types/speech.d.ts
@@ -0,0 +1,36 @@
+declare class SpeechRecognition extends EventTarget {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
+ onerror: ((event: Event) => void) | null;
+ onend: (() => void) | null;
+ start(): void;
+ stop(): void;
+}
+
+declare class SpeechRecognitionEvent extends Event {
+ readonly results: SpeechRecognitionResultList;
+}
+
+declare class SpeechRecognitionResultList {
+ readonly length: number;
+ item(index: number): SpeechRecognitionResult;
+ [index: number]: SpeechRecognitionResult;
+}
+
+declare class SpeechRecognitionResult {
+ readonly length: number;
+ item(index: number): SpeechRecognitionAlternative;
+ [index: number]: SpeechRecognitionAlternative;
+}
+
+declare class SpeechRecognitionAlternative {
+ readonly transcript: string;
+ readonly confidence: number;
+}
+
+interface Window {
+ SpeechRecognition: typeof SpeechRecognition;
+ webkitSpeechRecognition: typeof SpeechRecognition;
+}