Initial commit

This commit is contained in:
2026-06-04 06:24:56 +02:00
commit 282f4864c4
111 changed files with 8083 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
node_modules
dist
.git
.gitignore
Dockerfile
.dockerignore
*.log
.DS_Store
.vscode
.idea

15
meal-plan-frontend/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/projectSettingsUpdater.xml
/modules.xml
/contentModel.xml
/.idea.meal-plan-frontend.iml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

4
meal-plan-frontend/.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
meal-plan-frontend/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,32 @@
# ---------- Build stage ----------
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies first for better layer caching.
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build
# ---------- Serve stage ----------
FROM nginx:alpine AS final
# OpenSSL is used to generate a self-signed certificate so HTTPS works out of the box.
RUN apk add --no-cache openssl && \
mkdir -p /etc/nginx/certs && \
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout /etc/nginx/certs/server.key \
-out /etc/nginx/certs/server.crt \
-subj "/CN=dailymeals.local" && \
chmod 600 /etc/nginx/certs/server.key
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 443
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --no-check-certificate --tries=1 --spider https://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,20 @@
# meal-plan-frontend
This folder is part of the **DailyMeals** solution. Open `../DailyMeals.sln` in Rider, not this directory alone.
## npm without Homebrew
From the **DailyMeals** root:
```bash
./scripts/install-node.sh
cd meal-plan-frontend
../scripts/npm install
../scripts/npm run dev
```
Or add to your shell for this session:
```bash
export PATH="$(cd .. && pwd)/.toolchain/node-v22.22.0-darwin-arm64/bin:$PATH"
```

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>DailyMeals</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server_tokens off;
# ---- gzip compression ----
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
# Upstream ASP.NET Core API (service name from docker-compose).
upstream api_upstream {
server api:5000;
}
# ---- Redirect all HTTP to HTTPS ----
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# ---- HTTPS server ----
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
root /usr/share/nginx/html;
index index.html;
# ---- Security headers (mirrored at the edge) ----
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
# ---- API reverse proxy ----
location /api/ {
proxy_pass http://api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# ---- Static assets: long cache ----
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# ---- SPA fallback for React Router ----
location / {
try_files $uri $uri/ /index.html;
}
}
}

3296
meal-plan-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "meal-plan-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.59.16",
"axios": "^1.7.7",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lucide-react": "^0.456.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.1",
"react-router-dom": "^6.27.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#16774c" />
<path
d="M11 7v8a3 3 0 0 0 3 3v7a1 1 0 0 0 2 0v-7a3 3 0 0 0 3-3V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0Z"
fill="#fff"
/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { PrivateRoute } from "@/components/layout/PrivateRoute";
import { LoginPage } from "@/pages/LoginPage";
import { RegisterPage } from "@/pages/RegisterPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { BreakfastPage } from "@/pages/BreakfastPage";
import { SecondBreakfastPage } from "@/pages/SecondBreakfastPage";
import { LunchPage } from "@/pages/LunchPage";
import { DinnerPage } from "@/pages/DinnerPage";
import { AllMealsPage } from "@/pages/AllMealsPage";
import { RecipeDetailPage } from "@/pages/RecipeDetailPage";
import { NotFoundPage } from "@/pages/NotFoundPage";
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/breakfast" element={<BreakfastPage />} />
<Route path="/second-breakfast" element={<SecondBreakfastPage />} />
<Route path="/lunch" element={<LunchPage />} />
<Route path="/dinner" element={<DinnerPage />} />
<Route path="/all-meals" element={<AllMealsPage />} />
<Route path="/recipes/:id" element={<RecipeDetailPage />} />
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,21 @@
import { api } from "./axiosInstance";
import type { LoginRequest, RegisterRequest, TokenResponse, UserInfo } from "@/types/auth.types";
export async function register(payload: RegisterRequest): Promise<TokenResponse> {
const { data } = await api.post<TokenResponse>("/auth/register", payload);
return data;
}
export async function login(payload: LoginRequest): Promise<TokenResponse> {
const { data } = await api.post<TokenResponse>("/auth/login", payload);
return data;
}
export async function logout(refreshToken: string): Promise<void> {
await api.post("/auth/logout", { refreshToken });
}
export async function fetchCurrentUser(): Promise<UserInfo> {
const { data } = await api.get<UserInfo>("/auth/me");
return data;
}

View File

@@ -0,0 +1,127 @@
import axios, {
AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import {
clearAuth,
getAccessToken,
getRefreshToken,
setTokens,
} from "@/store/authStore";
import type { TokenResponse } from "@/types/auth.types";
// All calls go through "/api"; nginx (prod) and the Vite dev proxy forward it to the API.
export const api: AxiosInstance = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
// A bare client for the refresh call so it never triggers the response interceptor (no loops).
const refreshClient = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// --- Silent refresh handling -------------------------------------------------
// While a refresh is in flight, queue concurrent 401s and replay them once done.
let isRefreshing = false;
let pendingQueue: Array<(token: string | null) => void> = [];
function flushQueue(token: string | null): void {
pendingQueue.forEach((resolve) => resolve(token));
pendingQueue = [];
}
function redirectToLogin(): void {
clearAuth();
const path = window.location.pathname;
if (path !== "/login" && path !== "/register") {
window.location.assign("/login");
}
}
async function performRefresh(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const { data } = await refreshClient.post<TokenResponse>("/auth/refresh", {
refreshToken,
});
setTokens(data);
return data.accessToken;
} catch {
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
const status = error.response?.status;
const isAuthEndpoint =
original?.url?.includes("/auth/login") ||
original?.url?.includes("/auth/register") ||
original?.url?.includes("/auth/refresh");
if (status !== 401 || !original || original._retry || isAuthEndpoint) {
return Promise.reject(error);
}
original._retry = true;
if (isRefreshing) {
// Wait for the in-flight refresh, then retry (or fail) with its result.
return new Promise((resolve, reject) => {
pendingQueue.push((token) => {
if (!token) {
reject(error);
return;
}
original.headers.Authorization = `Bearer ${token}`;
resolve(api(original));
});
});
}
isRefreshing = true;
const newToken = await performRefresh();
isRefreshing = false;
flushQueue(newToken);
if (!newToken) {
redirectToLogin();
return Promise.reject(error);
}
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
},
);
/** Extracts a user-facing message from an API error response. */
export function extractApiError(error: unknown, fallback = "Something went wrong."): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as { error?: string; title?: string } | undefined;
return data?.error ?? data?.title ?? error.message ?? fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}

View File

@@ -0,0 +1,32 @@
import { api } from "./axiosInstance";
import type {
CategoryCount,
MealCategory,
RecipeDetail,
RecipeListItem,
} from "@/types/recipe.types";
export interface RecipeQuery {
category?: MealCategory;
search?: string;
}
export async function fetchRecipes(query: RecipeQuery = {}): Promise<RecipeListItem[]> {
const { data } = await api.get<RecipeListItem[]>("/recipes", {
params: {
category: query.category,
search: query.search || undefined,
},
});
return data;
}
export async function fetchRecipeById(id: number): Promise<RecipeDetail> {
const { data } = await api.get<RecipeDetail>(`/recipes/${id}`);
return data;
}
export async function fetchCategories(): Promise<CategoryCount[]> {
const { data } = await api.get<CategoryCount[]>("/recipes/categories");
return data;
}

View File

@@ -0,0 +1,20 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Navbar } from "./Navbar";
import { Sidebar } from "./Sidebar";
export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen lg:flex">
<Sidebar open={sidebarOpen} onNavigate={() => setSidebarOpen(false)} />
<div className="flex min-h-screen flex-1 flex-col">
<Navbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Link } from "react-router-dom";
import { LogOut, Moon, Sun, UtensilsCrossed } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { themeStore, toggleTheme } from "@/store/themeStore";
interface NavbarProps {
onToggleSidebar?: () => void;
}
export function Navbar({ onToggleSidebar }: NavbarProps) {
const { user, logout } = useAuth();
const theme = themeStore.useStore((s) => s.theme);
return (
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80">
<div className="flex h-16 items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="btn-secondary !px-2.5 !py-2 lg:hidden"
aria-label="Toggle navigation"
>
<span className="block h-0.5 w-5 bg-current shadow-[0_6px_0_currentColor,0_-6px_0_currentColor]" />
</button>
<Link to="/" className="flex items-center gap-2 font-extrabold tracking-tight">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-brand-600 text-white">
<UtensilsCrossed className="h-5 w-5" />
</span>
<span className="text-lg">DailyMeals</span>
</Link>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<button
type="button"
onClick={toggleTheme}
className="btn-secondary !px-2.5 !py-2"
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
<div className="hidden text-right sm:block">
<p className="text-sm font-semibold leading-tight">{user?.userName ?? "User"}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{user?.email}</p>
</div>
<button type="button" onClick={() => void logout()} className="btn-secondary">
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { authStore } from "@/store/authStore";
interface PrivateRouteProps {
children: ReactNode;
}
/** Guards authenticated routes; redirects unauthenticated users to /login. */
export function PrivateRoute({ children }: PrivateRouteProps) {
const accessToken = authStore.useStore((s) => s.accessToken);
const location = useLocation();
if (!accessToken) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,70 @@
import { NavLink } from "react-router-dom";
import {
Coffee,
Croissant,
LayoutDashboard,
ListChecks,
Moon,
Soup,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
}
const NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/breakfast", label: "Breakfast", icon: Coffee },
{ to: "/second-breakfast", label: "Second Breakfast", icon: Croissant },
{ to: "/lunch", label: "Lunch", icon: Soup },
{ to: "/dinner", label: "Dinner", icon: Moon },
{ to: "/all-meals", label: "All Meals", icon: ListChecks },
];
interface SidebarProps {
open: boolean;
onNavigate?: () => void;
}
export function Sidebar({ open, onNavigate }: SidebarProps) {
return (
<>
{open && (
<div
className="fixed inset-0 z-30 bg-slate-900/40 lg:hidden"
onClick={onNavigate}
aria-hidden="true"
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-40 w-64 transform border-r border-slate-200 bg-white px-3 py-6 transition-transform dark:border-slate-800 dark:bg-slate-900 lg:static lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<nav className="mt-16 flex flex-col gap-1 lg:mt-0">
{NAV_ITEMS.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
onClick={onNavigate}
className={({ isActive }) =>
`flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition ${
isActive
? "bg-brand-600 text-white"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}`
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
</aside>
</>
);
}

View File

@@ -0,0 +1,263 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { FileDown, Loader2 } from "lucide-react";
import { fetchRecipeById } from "@/api/recipes.api";
import { extractApiError } from "@/api/axiosInstance";
import {
buildShoppingList,
formatBreakdown,
formatQuantity,
generatePdf,
} from "@/utils/pdfExport";
import { MEAL_CATEGORY_LABELS, type RecipeDetail } from "@/types/recipe.types";
interface PdfGeneratorProps {
selectedIds: number[];
disabled?: boolean;
}
type Phase = "idle" | "loading" | "rendering";
const PAGE_STYLE: CSSProperties = {
width: "794px",
minHeight: "1123px",
boxSizing: "border-box",
padding: "48px",
fontFamily: "Inter, Arial, sans-serif",
color: "#0f172a",
background: "#ffffff",
};
export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
const [phase, setPhase] = useState<Phase>("idle");
const [recipes, setRecipes] = useState<RecipeDetail[]>([]);
const [error, setError] = useState<string | null>(null);
const renderAreaRef = useRef<HTMLDivElement | null>(null);
async function handleGenerate() {
if (selectedIds.length === 0 || phase !== "idle") return;
setError(null);
setPhase("loading");
try {
const details = await Promise.all(selectedIds.map((id) => fetchRecipeById(id)));
setRecipes(details);
setPhase("rendering");
} catch (err) {
setError(extractApiError(err, "Could not load recipes for export."));
setPhase("idle");
}
}
useEffect(() => {
if (phase !== "rendering" || recipes.length === 0) return;
let cancelled = false;
const run = async () => {
// Allow fonts/layout to settle before rasterizing.
await document.fonts?.ready?.catch(() => undefined);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
if (cancelled) return;
try {
await generatePdf("dailymeals-plan.pdf");
} catch (err) {
setError(extractApiError(err, "PDF generation failed."));
} finally {
if (!cancelled) {
setPhase("idle");
setRecipes([]);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [phase, recipes]);
const shoppingList = recipes.length > 0 ? buildShoppingList(recipes) : [];
const generatedOn = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={disabled || selectedIds.length === 0 || phase !== "idle"}
className="btn-primary"
>
{phase !== "idle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileDown className="h-4 w-4" />
)}
{phase === "loading" ? "Preparing..." : phase === "rendering" ? "Generating..." : "Generate PDF"}
</button>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
{phase === "rendering" && (
<div id="pdf-render-area" ref={renderAreaRef}>
{recipes.map((recipe) => (
<div className="pdf-page" style={PAGE_STYLE} key={recipe.id}>
<RecipePdfPage recipe={recipe} />
</div>
))}
<div className="pdf-page" style={PAGE_STYLE}>
<ShoppingListPdfPage
items={shoppingList}
recipes={recipes}
generatedOn={generatedOn}
/>
</div>
</div>
)}
</>
);
}
function RecipePdfPage({ recipe }: { recipe: RecipeDetail }) {
return (
<div>
<div style={{ borderBottom: "3px solid #16774c", paddingBottom: "16px", marginBottom: "24px" }}>
<span
style={{
display: "inline-block",
background: "#d6f1e0",
color: "#125f3f",
borderRadius: "999px",
padding: "4px 12px",
fontSize: "12px",
fontWeight: 700,
}}
>
{MEAL_CATEGORY_LABELS[recipe.mealCategory]}
</span>
<h1 style={{ fontSize: "28px", fontWeight: 800, margin: "12px 0 0" }}>{recipe.name}</h1>
{recipe.prepTimeMinutes != null && (
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>
Prep time: {recipe.prepTimeMinutes} min
</p>
)}
</div>
<div style={{ display: "flex", gap: "12px", marginBottom: "28px" }}>
{[
{ label: "Calories", value: recipe.calories, suffix: "kcal" },
{ label: "Protein", value: recipe.protein, suffix: "g" },
{ label: "Fat", value: recipe.fat, suffix: "g" },
{ label: "Carbs", value: recipe.carbs, suffix: "g" },
].map((m) => (
<div
key={m.label}
style={{
flex: 1,
border: "1px solid #e2e8f0",
borderRadius: "12px",
padding: "12px",
textAlign: "center",
}}
>
<div style={{ fontSize: "20px", fontWeight: 800 }}>
{m.value != null ? `${m.value} ${m.suffix}` : "—"}
</div>
<div style={{ fontSize: "11px", textTransform: "uppercase", color: "#64748b", marginTop: "2px" }}>
{m.label}
</div>
</div>
))}
</div>
<h2 style={{ fontSize: "18px", fontWeight: 700, margin: "0 0 12px" }}>Ingredients</h2>
<ul style={{ margin: "0 0 28px", padding: 0, listStyle: "none" }}>
{recipe.ingredients.map((ing) => (
<li
key={ing.id}
style={{
display: "flex",
justifyContent: "space-between",
padding: "7px 0",
borderBottom: "1px solid #f1f5f9",
fontSize: "14px",
}}
>
<span>{ing.name}</span>
<span style={{ color: "#64748b" }}>
{ing.amountGrams != null
? `${ing.amountGrams} ${ing.unit ?? "g"}`
: ing.unit ?? ""}
</span>
</li>
))}
</ul>
<h2 style={{ fontSize: "18px", fontWeight: 700, margin: "0 0 12px" }}>Preparation</h2>
<ol style={{ margin: 0, paddingLeft: "20px", fontSize: "14px", lineHeight: 1.6 }}>
{recipe.steps.map((step) => (
<li key={step.id} style={{ marginBottom: "8px" }}>
{step.description}
</li>
))}
</ol>
</div>
);
}
function ShoppingListPdfPage({
items,
recipes,
generatedOn,
}: {
items: ReturnType<typeof buildShoppingList>;
recipes: RecipeDetail[];
generatedOn: string;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "1027px" }}>
<div style={{ borderBottom: "3px solid #16774c", paddingBottom: "16px", marginBottom: "24px" }}>
<h1 style={{ fontSize: "28px", fontWeight: 800, margin: 0 }}>Shopping List</h1>
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>
Generated {generatedOn} · {recipes.length} recipe{recipes.length === 1 ? "" : "s"}
</p>
</div>
<ul style={{ flex: 1, margin: 0, padding: 0, listStyle: "none" }}>
{items.map((item) => {
const breakdown = formatBreakdown(item);
return (
<li
key={`${item.name}-${item.unit ?? "g"}-${item.quantified}`}
style={{
display: "flex",
alignItems: "baseline",
padding: "8px 0",
borderBottom: "1px solid #f1f5f9",
fontSize: "14px",
}}
>
<span style={{ flex: "0 0 240px", fontWeight: 600 }}>{item.name}</span>
<span style={{ flex: "0 0 110px", color: "#0f172a" }}> {formatQuantity(item)}</span>
{breakdown && <span style={{ color: "#94a3b8", fontSize: "12px" }}>({breakdown})</span>}
</li>
);
})}
</ul>
<div style={{ marginTop: "32px", borderTop: "1px solid #e2e8f0", paddingTop: "16px" }}>
<h3 style={{ fontSize: "13px", fontWeight: 700, textTransform: "uppercase", color: "#64748b", margin: "0 0 10px" }}>
Recipes in this export
</h3>
<ol style={{ margin: 0, paddingLeft: "20px", fontSize: "12px", lineHeight: 1.7, color: "#475569" }}>
{recipes.map((r) => (
<li key={r.id}>
{r.name} {MEAL_CATEGORY_LABELS[r.mealCategory]}
{r.calories != null ? ` · ${r.calories} kcal` : ""}
</li>
))}
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import { Clock, Flame } from "lucide-react";
import type { RecipeListItem } from "@/types/recipe.types";
interface RecipeCardProps {
recipe: RecipeListItem;
selectable?: boolean;
selected?: boolean;
onToggleSelected?: (id: number) => void;
}
export function RecipeCard({ recipe, selectable, selected, onToggleSelected }: RecipeCardProps) {
return (
<div className="card group relative flex flex-col p-5 hover:shadow-md">
{selectable && (
<label className="absolute right-4 top-4 z-10 flex cursor-pointer items-center">
<input
type="checkbox"
checked={Boolean(selected)}
onChange={() => onToggleSelected?.(recipe.id)}
className="h-5 w-5 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
aria-label={`Select ${recipe.name}`}
/>
</label>
)}
<Link to={`/recipes/${recipe.id}`} className="flex flex-1 flex-col">
<h3 className="pr-8 text-base font-semibold leading-snug group-hover:text-brand-600">
{recipe.name}
</h3>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-5 text-sm text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-2.5 py-1 dark:bg-slate-800">
<Flame className="h-4 w-4 text-orange-500" />
{recipe.calories != null ? `${recipe.calories} kcal` : "—"}
</span>
<span className="inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-2.5 py-1 dark:bg-slate-800">
<Clock className="h-4 w-4 text-sky-500" />
{recipe.prepTimeMinutes != null ? `${recipe.prepTimeMinutes} min` : "—"}
</span>
</div>
</Link>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { Clock } from "lucide-react";
import { CategoryBadge } from "@/components/ui/CategoryBadge";
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
interface MacroProps {
label: string;
value: number | null;
suffix: string;
accent: string;
}
function Macro({ label, value, suffix, accent }: MacroProps) {
return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
<p className={`text-2xl font-extrabold ${accent}`}>
{value != null ? value : "—"}
{value != null && <span className="text-sm font-semibold"> {suffix}</span>}
</p>
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</p>
</div>
);
}
function formatAmount(amount: number | null, unit: string | null): string {
if (amount == null && !unit) return "";
if (amount == null) return unit ?? "";
const display = Number.isInteger(amount) ? amount.toString() : amount.toString();
return unit ? `${display} ${unit}` : `${display} g`;
}
interface RecipeDetailProps {
recipe: RecipeDetailModel;
}
export function RecipeDetail({ recipe }: RecipeDetailProps) {
return (
<article className="space-y-8">
<header className="space-y-3">
<CategoryBadge category={recipe.mealCategory} />
<h1 className="text-3xl font-extrabold tracking-tight">{recipe.name}</h1>
{recipe.prepTimeMinutes != null && (
<p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
<Clock className="h-4 w-4" />
{recipe.prepTimeMinutes} min preparation
</p>
)}
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Macro label="Calories" value={recipe.calories} suffix="kcal" accent="text-orange-500" />
<Macro label="Protein" value={recipe.protein} suffix="g" accent="text-brand-600" />
<Macro label="Fat" value={recipe.fat} suffix="g" accent="text-amber-500" />
<Macro label="Carbs" value={recipe.carbs} suffix="g" accent="text-sky-500" />
</section>
<div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
<section>
<h2 className="text-lg font-bold">Ingredients</h2>
{recipe.ingredients.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">No ingredients listed.</p>
) : (
<ul className="mt-4 divide-y divide-slate-200 rounded-2xl border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
{recipe.ingredients.map((ing) => (
<li key={ing.id} className="flex items-center justify-between px-4 py-3 text-sm">
<span>{ing.name}</span>
<span className="font-medium text-slate-500 dark:text-slate-400">
{formatAmount(ing.amountGrams, ing.unit)}
</span>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-lg font-bold">Preparation</h2>
{recipe.steps.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">No steps listed.</p>
) : (
<ol className="mt-4 space-y-4">
{recipe.steps.map((step) => (
<li key={step.id} className="flex gap-4">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
{step.stepNumber}
</span>
<p className="pt-1 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{step.description}
</p>
</li>
))}
</ol>
)}
</section>
</div>
</article>
);
}

View File

@@ -0,0 +1,57 @@
import { Search } from "lucide-react";
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
export type CategoryFilter = MealCategory | "all";
interface RecipeFiltersProps {
active: CategoryFilter;
onChange: (value: CategoryFilter) => void;
search: string;
onSearchChange: (value: string) => void;
}
const PILLS: Array<{ value: CategoryFilter; label: string }> = [
{ value: "all", label: "All" },
{ value: MealCategory.Breakfast, label: MEAL_CATEGORY_LABELS[MealCategory.Breakfast] },
{ value: MealCategory.SecondBreakfast, label: MEAL_CATEGORY_LABELS[MealCategory.SecondBreakfast] },
{ value: MealCategory.Lunch, label: MEAL_CATEGORY_LABELS[MealCategory.Lunch] },
{ value: MealCategory.Dinner, label: MEAL_CATEGORY_LABELS[MealCategory.Dinner] },
];
export function RecipeFilters({ active, onChange, search, onSearchChange }: RecipeFiltersProps) {
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
{PILLS.map((pill) => {
const isActive = pill.value === active;
return (
<button
key={String(pill.value)}
type="button"
onClick={() => onChange(pill.value)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
isActive
? "bg-brand-600 text-white"
: "border border-slate-300 bg-white text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
}`}
>
{pill.label}
</button>
);
})}
</div>
<div className="relative md:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search recipes..."
className="input pl-9"
aria-label="Search recipes by name"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
const STYLES: Record<MealCategory, string> = {
[MealCategory.Breakfast]: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
[MealCategory.SecondBreakfast]: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
[MealCategory.Lunch]: "bg-brand-100 text-brand-800 dark:bg-brand-900/40 dark:text-brand-300",
[MealCategory.Dinner]: "bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300",
};
interface CategoryBadgeProps {
category: MealCategory;
}
export function CategoryBadge({ category }: CategoryBadgeProps) {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${STYLES[category] ?? ""}`}
>
{MEAL_CATEGORY_LABELS[category] ?? "Unknown"}
</span>
);
}

View File

@@ -0,0 +1,23 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Inbox } from "lucide-react";
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon: Icon = Inbox, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 px-6 py-16 text-center dark:border-slate-700">
<div className="rounded-full bg-slate-100 p-4 dark:bg-slate-800">
<Icon className="h-8 w-8 text-slate-400" />
</div>
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
{description && <p className="mt-1 max-w-sm text-sm text-slate-500 dark:text-slate-400">{description}</p>}
{action && <div className="mt-6">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { AlertTriangle } from "lucide-react";
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div
role="alert"
className="flex flex-col items-center justify-center rounded-2xl border border-red-200 bg-red-50 px-6 py-12 text-center dark:border-red-900/50 dark:bg-red-950/30"
>
<AlertTriangle className="h-8 w-8 text-red-500" />
<p className="mt-3 text-sm font-medium text-red-700 dark:text-red-300">{message}</p>
{onRetry && (
<button type="button" onClick={onRetry} className="btn-secondary mt-5">
Try again
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800 ${className}`}
aria-hidden="true"
/>
);
}
export function RecipeCardSkeleton() {
return (
<div className="card p-5">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="mt-4 h-4 w-1/2" />
<div className="mt-6 flex gap-3">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
);
}
export function GridSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, i) => (
<RecipeCardSkeleton key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as authApi from "@/api/auth.api";
import { extractApiError } from "@/api/axiosInstance";
import {
authStore,
clearAuth,
getRefreshToken,
setTokens,
setUser,
} from "@/store/authStore";
import type { LoginRequest, RegisterRequest } from "@/types/auth.types";
export function useAuth() {
const queryClient = useQueryClient();
const user = authStore.useStore((s) => s.user);
const accessToken = authStore.useStore((s) => s.accessToken);
const isAuthenticated = Boolean(accessToken);
const establishSession = async (tokens: Awaited<ReturnType<typeof authApi.login>>) => {
setTokens(tokens);
const me = await authApi.fetchCurrentUser();
setUser(me);
return me;
};
const loginMutation = useMutation({
mutationFn: async (payload: LoginRequest) => establishSession(await authApi.login(payload)),
});
const registerMutation = useMutation({
mutationFn: async (payload: RegisterRequest) =>
establishSession(await authApi.register(payload)),
});
const logout = useCallback(async () => {
const refreshToken = getRefreshToken();
try {
if (refreshToken) {
await authApi.logout(refreshToken);
}
} finally {
clearAuth();
queryClient.clear();
}
}, [queryClient]);
return {
user,
isAuthenticated,
login: loginMutation.mutateAsync,
loginPending: loginMutation.isPending,
loginError: loginMutation.isError
? extractApiError(loginMutation.error, "Unable to sign in.")
: null,
resetLoginError: loginMutation.reset,
register: registerMutation.mutateAsync,
registerPending: registerMutation.isPending,
registerError: registerMutation.isError
? extractApiError(registerMutation.error, "Unable to create account.")
: null,
resetRegisterError: registerMutation.reset,
logout,
};
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import * as recipesApi from "@/api/recipes.api";
import type { MealCategory } from "@/types/recipe.types";
const keys = {
all: ["recipes"] as const,
list: (category?: MealCategory, search?: string) =>
[...keys.all, "list", category ?? "all", search ?? ""] as const,
detail: (id: number) => [...keys.all, "detail", id] as const,
categories: () => [...keys.all, "categories"] as const,
};
export function useRecipes(category?: MealCategory, search?: string) {
return useQuery({
queryKey: keys.list(category, search),
queryFn: () => recipesApi.fetchRecipes({ category, search }),
});
}
export function useRecipe(id: number) {
return useQuery({
queryKey: keys.detail(id),
queryFn: () => recipesApi.fetchRecipeById(id),
enabled: Number.isFinite(id) && id > 0,
});
}
export function useCategories() {
return useQuery({
queryKey: keys.categories(),
queryFn: recipesApi.fetchCategories,
});
}

View File

@@ -0,0 +1,57 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply antialiased;
}
body {
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100;
}
:focus-visible {
@apply outline-none ring-2 ring-brand-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-950;
}
}
@layer components {
.card {
@apply rounded-2xl border border-slate-200 bg-white shadow-sm transition
dark:border-slate-800 dark:bg-slate-900;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold
transition focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800;
}
.btn-secondary {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100
dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700;
}
.input {
@apply w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900
placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/40
dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500;
}
}
/* Off-screen render surface for PDF generation. Kept renderable (not display:none)
so html2canvas can measure and rasterize it. */
#pdf-render-area {
position: fixed;
left: -10000px;
top: 0;
width: 794px; /* ~A4 width at 96dpi */
background: #ffffff;
color: #0f172a;
z-index: -1;
pointer-events: none;
}

View File

@@ -0,0 +1,29 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import { initTheme } from "@/store/themeStore";
import "./index.css";
initTheme();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from "react";
import { CheckSquare, Square, UtensilsCrossed } from "lucide-react";
import { useRecipes } from "@/hooks/useRecipes";
import { RecipeCard } from "@/components/recipes/RecipeCard";
import { RecipeFilters, type CategoryFilter } from "@/components/recipes/RecipeFilters";
import { PdfGenerator } from "@/components/pdf/PdfGenerator";
import { GridSkeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
export function AllMealsPage() {
const { data: recipes, isLoading, isError, error, refetch } = useRecipes();
const [filter, setFilter] = useState<CategoryFilter>("all");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const visible = useMemo(() => {
if (!recipes) return [];
const term = search.trim().toLowerCase();
return recipes.filter((r) => {
const matchesCategory = filter === "all" || r.mealCategory === filter;
const matchesSearch = term === "" || r.name.toLowerCase().includes(term);
return matchesCategory && matchesSearch;
});
}, [recipes, filter, search]);
const toggleSelected = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const selectAllVisible = () => {
setSelected((prev) => {
const next = new Set(prev);
visible.forEach((r) => next.add(r.id));
return next;
});
};
const deselectAll = () => setSelected(new Set());
const selectedIds = useMemo(() => Array.from(selected), [selected]);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">All Meals</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Select recipes and export them with a combined shopping list.
</p>
</div>
<span className="inline-flex items-center gap-2 rounded-full bg-brand-100 px-3.5 py-1.5 text-sm font-semibold text-brand-800 dark:bg-brand-900/40 dark:text-brand-300">
{selected.size} selected
</span>
</header>
<RecipeFilters active={filter} onChange={setFilter} search={search} onSearchChange={setSearch} />
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={selectAllVisible}
disabled={visible.length === 0}
className="btn-secondary"
>
<CheckSquare className="h-4 w-4" />
Select all visible
</button>
<button
type="button"
onClick={deselectAll}
disabled={selected.size === 0}
className="btn-secondary"
>
<Square className="h-4 w-4" />
Deselect all
</button>
<div className="ml-auto">
<PdfGenerator selectedIds={selectedIds} />
</div>
</div>
{isLoading && <GridSkeleton count={9} />}
{isError && (
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
)}
{!isLoading && !isError && visible.length === 0 && (
<EmptyState
icon={UtensilsCrossed}
title="No matching recipes"
description="Try adjusting your search or category filter."
/>
)}
{!isLoading && !isError && visible.length > 0 && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
selectable
selected={selected.has(recipe.id)}
onToggleSelected={toggleSelected}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function BreakfastPage() {
return <CategoryPage category={MealCategory.Breakfast} />;
}

View File

@@ -0,0 +1,50 @@
import { UtensilsCrossed } from "lucide-react";
import { useRecipes } from "@/hooks/useRecipes";
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
import { RecipeCard } from "@/components/recipes/RecipeCard";
import { GridSkeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
interface CategoryPageProps {
category: MealCategory;
}
export function CategoryPage({ category }: CategoryPageProps) {
const { data: recipes, isLoading, isError, error, refetch } = useRecipes(category);
const label = MEAL_CATEGORY_LABELS[category];
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{label}</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
{recipes ? `${recipes.length} recipe${recipes.length === 1 ? "" : "s"}` : "Loading..."}
</p>
</header>
{isLoading && <GridSkeleton />}
{isError && (
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
)}
{!isLoading && !isError && recipes && recipes.length === 0 && (
<EmptyState
icon={UtensilsCrossed}
title={`No ${label.toLowerCase()} recipes yet`}
description="There are currently no recipes in this category."
/>
)}
{!isLoading && !isError && recipes && recipes.length > 0 && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Link } from "react-router-dom";
import { ArrowRight, Coffee, Croissant, Moon, Soup } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCategories } from "@/hooks/useRecipes";
import { useAuth } from "@/hooks/useAuth";
import {
MEAL_CATEGORY_LABELS,
MEAL_CATEGORY_ROUTES,
MealCategory,
} from "@/types/recipe.types";
import { Skeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
const CARD_META: Record<MealCategory, { icon: LucideIcon; gradient: string }> = {
[MealCategory.Breakfast]: { icon: Coffee, gradient: "from-amber-400 to-orange-500" },
[MealCategory.SecondBreakfast]: { icon: Croissant, gradient: "from-sky-400 to-blue-500" },
[MealCategory.Lunch]: { icon: Soup, gradient: "from-brand-400 to-brand-600" },
[MealCategory.Dinner]: { icon: Moon, gradient: "from-violet-400 to-purple-600" },
};
export function DashboardPage() {
const { user } = useAuth();
const { data: categories, isLoading, isError, error, refetch } = useCategories();
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">
Welcome back{user?.userName ? `, ${user.userName}` : ""}
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Browse recipes by meal category or explore everything at once.
</p>
</header>
{isError ? (
<ErrorState message={extractApiError(error, "Failed to load categories.")} onRetry={() => void refetch()} />
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{isLoading
? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-40" />)
: categories?.map((cat) => {
const meta = CARD_META[cat.category as MealCategory];
const Icon = meta?.icon ?? Coffee;
return (
<Link
key={cat.category}
to={MEAL_CATEGORY_ROUTES[cat.category as MealCategory]}
className="card group relative overflow-hidden p-6 hover:shadow-lg"
>
<div
className={`absolute -right-6 -top-6 grid h-24 w-24 place-items-center rounded-full bg-gradient-to-br ${meta?.gradient} opacity-90`}
>
<Icon className="h-9 w-9 text-white" />
</div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Category</p>
<h2 className="mt-1 text-xl font-bold">
{MEAL_CATEGORY_LABELS[cat.category as MealCategory]}
</h2>
<p className="mt-6 text-3xl font-extrabold text-brand-600">{cat.count}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
recipe{cat.count === 1 ? "" : "s"}
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-brand-600 group-hover:gap-2">
View recipes <ArrowRight className="h-4 w-4 transition-all" />
</span>
</Link>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function DinnerPage() {
return <CategoryPage category={MealCategory.Dinner} />;
}

View File

@@ -0,0 +1,131 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { AlertCircle, Loader2, Moon, Sun, UtensilsCrossed } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { authStore } from "@/store/authStore";
import { themeStore, toggleTheme } from "@/store/themeStore";
const loginSchema = z.object({
email: z.string().min(1, "Email is required.").email("Enter a valid email address."),
password: z.string().min(1, "Password is required."),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login, loginPending, loginError, resetLoginError } = useAuth();
const accessToken = authStore.useStore((s) => s.accessToken);
const theme = themeStore.useStore((s) => s.theme);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
useEffect(() => () => resetLoginError(), [resetLoginError]);
const redirectTo = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? "/";
if (accessToken) {
return <Navigate to={redirectTo} replace />;
}
const onSubmit = async (data: LoginForm) => {
try {
await login(data);
navigate(redirectTo, { replace: true });
} catch {
// Error surfaced via loginError below.
}
};
return (
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4 dark:from-slate-950 dark:to-slate-900">
<button
type="button"
onClick={toggleTheme}
className="btn-secondary absolute right-4 top-4 !px-2.5 !py-2"
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
<div className="w-full max-w-md">
<div className="mb-8 flex flex-col items-center text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-brand-600 text-white shadow-lg">
<UtensilsCrossed className="h-7 w-7" />
</span>
<h1 className="mt-4 text-2xl font-extrabold tracking-tight">DailyMeals</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Sign in to plan your meals
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="card space-y-5 p-6 sm:p-8" noValidate>
{loginError && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{loginError}</span>
</div>
)}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
{...register("email")}
/>
{errors.email && <p className="mt-1.5 text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
className="input"
placeholder="••••••••"
{...register("password")}
/>
{errors.password && (
<p className="mt-1.5 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<button type="submit" disabled={loginPending} className="btn-primary w-full">
{loginPending && <Loader2 className="h-4 w-4 animate-spin" />}
{loginPending ? "Signing in..." : "Sign in"}
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Don&apos;t have an account?{" "}
<Link to="/register" className="font-semibold text-brand-600 hover:text-brand-700">
Create one
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function LunchPage() {
return <CategoryPage category={MealCategory.Lunch} />;
}

View File

@@ -0,0 +1,16 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<p className="text-6xl font-black text-brand-600">404</p>
<h1 className="mt-4 text-xl font-bold">Page not found</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
The page youre looking for doesnt exist.
</p>
<Link to="/" className="btn-primary mt-6">
Back to dashboard
</Link>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { ArrowLeft } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useRecipe } from "@/hooks/useRecipes";
import { RecipeDetail } from "@/components/recipes/RecipeDetail";
import { Skeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
export function RecipeDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const recipeId = Number(id);
const { data: recipe, isLoading, isError, error, refetch } = useRecipe(recipeId);
return (
<div className="space-y-6">
<button type="button" onClick={() => navigate(-1)} className="btn-secondary">
<ArrowLeft className="h-4 w-4" />
Back
</button>
{isLoading && (
<div className="space-y-6">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-9 w-2/3" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
<Skeleton className="h-64" />
</div>
)}
{isError && (
<ErrorState
message={extractApiError(error, "Failed to load recipe.")}
onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && recipe && <RecipeDetail recipe={recipe} />}
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { AlertCircle, Loader2, Moon, Sun, UtensilsCrossed } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { authStore } from "@/store/authStore";
import { themeStore, toggleTheme } from "@/store/themeStore";
const registerSchema = z
.object({
email: z.string().min(1, "Email is required.").email("Enter a valid email address."),
userName: z.string().max(256, "Display name is too long.").optional(),
password: z
.string()
.min(8, "Password must be at least 8 characters.")
.regex(/[A-Z]/, "Include at least one uppercase letter.")
.regex(/[a-z]/, "Include at least one lowercase letter.")
.regex(/[0-9]/, "Include at least one digit."),
confirmPassword: z.string().min(1, "Please confirm your password."),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
type RegisterForm = z.infer<typeof registerSchema>;
export function RegisterPage() {
const navigate = useNavigate();
const { register: registerUser, registerPending, registerError, resetRegisterError } = useAuth();
const accessToken = authStore.useStore((s) => s.accessToken);
const theme = themeStore.useStore((s) => s.theme);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
defaultValues: { email: "", userName: "", password: "", confirmPassword: "" },
});
useEffect(() => () => resetRegisterError(), [resetRegisterError]);
if (accessToken) {
return <Navigate to="/" replace />;
}
const onSubmit = async (data: RegisterForm) => {
try {
await registerUser({
email: data.email,
password: data.password,
userName: data.userName?.trim() || undefined,
});
navigate("/", { replace: true });
} catch {
// Error surfaced via registerError below.
}
};
return (
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4 dark:from-slate-950 dark:to-slate-900">
<button
type="button"
onClick={toggleTheme}
className="btn-secondary absolute right-4 top-4 !px-2.5 !py-2"
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
<div className="w-full max-w-md">
<div className="mb-8 flex flex-col items-center text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-brand-600 text-white shadow-lg">
<UtensilsCrossed className="h-7 w-7" />
</span>
<h1 className="mt-4 text-2xl font-extrabold tracking-tight">Create account</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Join DailyMeals to browse and plan meals
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="card space-y-5 p-6 sm:p-8" noValidate>
{registerError && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{registerError}</span>
</div>
)}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
{...register("email")}
/>
{errors.email && <p className="mt-1.5 text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="userName" className="mb-1.5 block text-sm font-medium">
Display name <span className="text-slate-400">(optional)</span>
</label>
<input
id="userName"
type="text"
autoComplete="name"
className="input"
placeholder="Piotr"
{...register("userName")}
/>
{errors.userName && (
<p className="mt-1.5 text-sm text-red-600">{errors.userName.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
className="input"
placeholder="••••••••"
{...register("password")}
/>
{errors.password && (
<p className="mt-1.5 text-sm text-red-600">{errors.password.message}</p>
)}
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
At least 8 characters with uppercase, lowercase, and a number.
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="mb-1.5 block text-sm font-medium">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
className="input"
placeholder="••••••••"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="mt-1.5 text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" disabled={registerPending} className="btn-primary w-full">
{registerPending && <Loader2 className="h-4 w-4 animate-spin" />}
{registerPending ? "Creating account..." : "Create account"}
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Already have an account?{" "}
<Link to="/login" className="font-semibold text-brand-600 hover:text-brand-700">
Sign in
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function SecondBreakfastPage() {
return <CategoryPage category={MealCategory.SecondBreakfast} />;
}

View File

@@ -0,0 +1,35 @@
import { createStore } from "./createStore";
import type { TokenResponse, UserInfo } from "@/types/auth.types";
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: UserInfo | null;
}
const initialState: AuthState = {
accessToken: null,
refreshToken: null,
user: null,
};
export const authStore = createStore<AuthState>(initialState);
export function setTokens(tokens: TokenResponse): void {
authStore.setState({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
}
export function setUser(user: UserInfo | null): void {
authStore.setState({ user });
}
export function clearAuth(): void {
authStore.setState({ accessToken: null, refreshToken: null, user: null });
}
export const getAccessToken = (): string | null => authStore.getState().accessToken;
export const getRefreshToken = (): string | null => authStore.getState().refreshToken;
export const isAuthenticated = (): boolean => Boolean(authStore.getState().accessToken);

View File

@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
/**
* Minimal dependency-free observable store built on useSyncExternalStore.
* Keeps app state in memory only (no localStorage / sessionStorage), as required.
*/
export interface Store<T> {
getState: () => T;
setState: (partial: Partial<T> | ((prev: T) => Partial<T>)) => void;
subscribe: (listener: () => void) => () => void;
useStore: <S>(selector: (state: T) => S) => S;
}
export function createStore<T>(initial: T): Store<T> {
let state = initial;
const listeners = new Set<() => void>();
const getState = () => state;
const setState: Store<T>["setState"] = (partial) => {
const next = typeof partial === "function" ? partial(state) : partial;
state = { ...state, ...next };
listeners.forEach((l) => l());
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const useStore = <S,>(selector: (s: T) => S): S =>
useSyncExternalStore(
subscribe,
() => selector(state),
() => selector(state),
);
return { getState, setState, subscribe, useStore };
}

View File

@@ -0,0 +1,32 @@
import { createStore } from "./createStore";
export type Theme = "light" | "dark";
function detectInitialTheme(): Theme {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
}
interface ThemeState {
theme: Theme;
}
// Preference is held in memory only (no localStorage), defaulting to the OS setting.
export const themeStore = createStore<ThemeState>({ theme: detectInitialTheme() });
function applyTheme(theme: Theme): void {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
}
export function initTheme(): void {
applyTheme(themeStore.getState().theme);
}
export function toggleTheme(): void {
const next: Theme = themeStore.getState().theme === "dark" ? "light" : "dark";
themeStore.setState({ theme: next });
applyTheme(next);
}

View File

@@ -0,0 +1,23 @@
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
userName?: string;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresInSeconds: number;
tokenType: string;
}
export interface UserInfo {
id: string;
userName: string | null;
email: string | null;
}

View File

@@ -0,0 +1,61 @@
export enum MealCategory {
Breakfast = 0,
SecondBreakfast = 1,
Lunch = 2,
Dinner = 3,
}
export const MEAL_CATEGORY_LABELS: Record<MealCategory, string> = {
[MealCategory.Breakfast]: "Breakfast",
[MealCategory.SecondBreakfast]: "Second Breakfast",
[MealCategory.Lunch]: "Lunch",
[MealCategory.Dinner]: "Dinner",
};
export const MEAL_CATEGORY_ROUTES: Record<MealCategory, string> = {
[MealCategory.Breakfast]: "/breakfast",
[MealCategory.SecondBreakfast]: "/second-breakfast",
[MealCategory.Lunch]: "/lunch",
[MealCategory.Dinner]: "/dinner",
};
export interface RecipeListItem {
id: number;
name: string;
mealCategory: MealCategory;
calories: number | null;
prepTimeMinutes: number | null;
}
export interface Ingredient {
id: number;
name: string;
amountGrams: number | null;
unit: string | null;
sortOrder: number;
}
export interface RecipeStep {
id: number;
stepNumber: number;
description: string;
}
export interface RecipeDetail {
id: number;
name: string;
mealCategory: MealCategory;
calories: number | null;
protein: number | null;
fat: number | null;
carbs: number | null;
prepTimeMinutes: number | null;
ingredients: Ingredient[];
steps: RecipeStep[];
}
export interface CategoryCount {
category: MealCategory;
name: string;
count: number;
}

View File

@@ -0,0 +1,179 @@
import jsPDF from "jspdf";
import html2canvas from "html2canvas";
import type { RecipeDetail } from "@/types/recipe.types";
/**
* A single aggregated shopping-list row.
* - `unit === null` means the quantity is expressed in grams (`totalGrams`).
* - quantified non-gram units (pcs, tbsp, ...) use `totalAmount` + `unit`.
* - non-quantified items ("to taste", "for serving", "optional") have `quantified === false`.
*/
export interface ShoppingItem {
name: string;
totalGrams: number | null;
totalAmount: number | null;
unit: string | null;
quantified: boolean;
sources: string[];
/** Per-source contributing amounts, used to render the parenthetical breakdown. */
contributions: Array<{ recipe: string; amount: number | null }>;
}
// Unit labels that represent "no concrete quantity" — listed once, never summed.
const NON_QUANTIFIED_UNITS = new Set([
"to taste",
"for serving",
"optional",
"do smaku",
"do podania",
"opcjonalnie",
"garnish",
]);
function isNonQuantifiedUnit(unit: string | null): boolean {
if (!unit) return false;
return NON_QUANTIFIED_UNITS.has(unit.trim().toLowerCase());
}
/**
* Aggregates ingredients across the selected recipes.
* Grouping key normalizes name (trim + lowercase); display keeps original capitalization.
*/
export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
const buckets = new Map<string, ShoppingItem>();
for (const recipe of recipes) {
for (const ing of recipe.ingredients) {
const displayName = ing.name.trim();
const normName = displayName.toLowerCase();
const unitRaw = ing.unit?.trim() || null;
const nonQuantified = isNonQuantifiedUnit(unitRaw) ||
(unitRaw === null && ing.amountGrams === null);
let key: string;
if (nonQuantified) {
key = `nq:${normName}`;
} else if (unitRaw === null) {
key = `g:${normName}`; // grams
} else {
key = `u:${normName}:${unitRaw.toLowerCase()}`;
}
let bucket = buckets.get(key);
if (!bucket) {
bucket = {
name: displayName,
totalGrams: null,
totalAmount: null,
unit: unitRaw,
quantified: !nonQuantified,
sources: [],
contributions: [],
};
buckets.set(key, bucket);
}
if (!bucket.sources.includes(recipe.name)) {
bucket.sources.push(recipe.name);
}
if (nonQuantified) {
continue; // no summing, no quantity
}
const amount = ing.amountGrams ?? null;
bucket.contributions.push({ recipe: recipe.name, amount });
if (unitRaw === null) {
bucket.totalGrams = (bucket.totalGrams ?? 0) + (amount ?? 0);
} else {
bucket.totalAmount = (bucket.totalAmount ?? 0) + (amount ?? 0);
}
}
}
return Array.from(buckets.values()).sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, "");
}
/** Right-hand main quantity, e.g. "540 g", "7 pcs", "to taste". */
export function formatQuantity(item: ShoppingItem): string {
if (!item.quantified) {
return item.unit ? item.unit : "to taste";
}
if (item.unit === null) {
return `${formatNumber(item.totalGrams ?? 0)} g`;
}
const amount = item.totalAmount;
return amount === null ? item.unit : `${formatNumber(amount)} ${item.unit}`;
}
/** Parenthetical breakdown shown when the ingredient comes from more than one recipe. */
export function formatBreakdown(item: ShoppingItem): string | null {
if (!item.quantified || item.sources.length <= 1) {
return null;
}
const amounts = item.contributions.map((c) => c.amount).filter((a): a is number => a !== null);
if (amounts.length === 0) {
return null;
}
const unitLabel = item.unit === null ? "g" : item.unit;
const allEqual = amounts.every((a) => a === amounts[0]);
if (allEqual && amounts.length > 1) {
return `${amounts.length} × ${formatNumber(amounts[0])} ${unitLabel}`;
}
return amounts.map((a) => `${formatNumber(a)} ${unitLabel}`).join(" + ");
}
/**
* Renders every `.pdf-page` element inside #pdf-render-area into an A4 portrait PDF,
* one source element per page, then triggers a download.
*/
export async function generatePdf(fileName = "meal-plan.pdf"): Promise<void> {
const container = document.getElementById("pdf-render-area");
if (!container) {
throw new Error("PDF render area not found.");
}
const pages = Array.from(container.querySelectorAll<HTMLElement>(".pdf-page"));
if (pages.length === 0) {
throw new Error("Nothing to export.");
}
const pdf = new jsPDF({ unit: "pt", format: "a4", orientation: "portrait" });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
for (let i = 0; i < pages.length; i += 1) {
const canvas = await html2canvas(pages[i], {
scale: 2,
backgroundColor: "#ffffff",
useCORS: true,
logging: false,
});
const imgData = canvas.toDataURL("image/jpeg", 0.92);
const imgWidth = pageWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
if (i > 0) {
pdf.addPage();
}
// If a single rendered page is taller than A4, scale it down to fit one page.
const finalHeight = Math.min(imgHeight, pageHeight);
const finalWidth = finalHeight === imgHeight ? imgWidth : (canvas.width * finalHeight) / canvas.height;
pdf.addImage(imgData, "JPEG", (pageWidth - finalWidth) / 2, 0, finalWidth, finalHeight);
}
pdf.save(fileName);
}

1
meal-plan-frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
brand: {
50: "#eefaf3",
100: "#d6f1e0",
200: "#b0e3c6",
300: "#7dcda4",
400: "#47b07e",
500: "#22935f",
600: "#16774c",
700: "#125f3f",
800: "#114c34",
900: "#0e3e2c",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
// During local dev, proxy /api to the backend so the axios baseURL ("/api") works
// the same way it does behind nginx in production.
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:5000",
changeOrigin: true,
},
},
},
});