Initial commit
This commit is contained in:
10
meal-plan-frontend/.dockerignore
Normal file
10
meal-plan-frontend/.dockerignore
Normal 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
15
meal-plan-frontend/.idea/.gitignore
generated
vendored
Normal 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
4
meal-plan-frontend/.idea/encodings.xml
generated
Normal 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>
|
||||
8
meal-plan-frontend/.idea/indexLayout.xml
generated
Normal file
8
meal-plan-frontend/.idea/indexLayout.xml
generated
Normal 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
6
meal-plan-frontend/.idea/vcs.xml
generated
Normal 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>
|
||||
32
meal-plan-frontend/Dockerfile
Normal file
32
meal-plan-frontend/Dockerfile
Normal 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;"]
|
||||
20
meal-plan-frontend/README.md
Normal file
20
meal-plan-frontend/README.md
Normal 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"
|
||||
```
|
||||
20
meal-plan-frontend/index.html
Normal file
20
meal-plan-frontend/index.html
Normal 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>
|
||||
87
meal-plan-frontend/nginx.conf
Normal file
87
meal-plan-frontend/nginx.conf
Normal 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
3296
meal-plan-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
meal-plan-frontend/package.json
Normal file
36
meal-plan-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
meal-plan-frontend/postcss.config.js
Normal file
6
meal-plan-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
7
meal-plan-frontend/public/favicon.svg
Normal file
7
meal-plan-frontend/public/favicon.svg
Normal 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 |
40
meal-plan-frontend/src/App.tsx
Normal file
40
meal-plan-frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
meal-plan-frontend/src/api/auth.api.ts
Normal file
21
meal-plan-frontend/src/api/auth.api.ts
Normal 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;
|
||||
}
|
||||
127
meal-plan-frontend/src/api/axiosInstance.ts
Normal file
127
meal-plan-frontend/src/api/axiosInstance.ts
Normal 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;
|
||||
}
|
||||
32
meal-plan-frontend/src/api/recipes.api.ts
Normal file
32
meal-plan-frontend/src/api/recipes.api.ts
Normal 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;
|
||||
}
|
||||
20
meal-plan-frontend/src/components/layout/AppLayout.tsx
Normal file
20
meal-plan-frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
meal-plan-frontend/src/components/layout/Navbar.tsx
Normal file
57
meal-plan-frontend/src/components/layout/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
meal-plan-frontend/src/components/layout/PrivateRoute.tsx
Normal file
19
meal-plan-frontend/src/components/layout/PrivateRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
70
meal-plan-frontend/src/components/layout/Sidebar.tsx
Normal file
70
meal-plan-frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
263
meal-plan-frontend/src/components/pdf/PdfGenerator.tsx
Normal file
263
meal-plan-frontend/src/components/pdf/PdfGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
meal-plan-frontend/src/components/recipes/RecipeCard.tsx
Normal file
45
meal-plan-frontend/src/components/recipes/RecipeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
meal-plan-frontend/src/components/recipes/RecipeDetail.tsx
Normal file
99
meal-plan-frontend/src/components/recipes/RecipeDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
meal-plan-frontend/src/components/recipes/RecipeFilters.tsx
Normal file
57
meal-plan-frontend/src/components/recipes/RecipeFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
meal-plan-frontend/src/components/ui/CategoryBadge.tsx
Normal file
22
meal-plan-frontend/src/components/ui/CategoryBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
meal-plan-frontend/src/components/ui/EmptyState.tsx
Normal file
23
meal-plan-frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
meal-plan-frontend/src/components/ui/ErrorState.tsx
Normal file
23
meal-plan-frontend/src/components/ui/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
meal-plan-frontend/src/components/ui/Skeleton.tsx
Normal file
35
meal-plan-frontend/src/components/ui/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
meal-plan-frontend/src/hooks/useAuth.ts
Normal file
66
meal-plan-frontend/src/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
meal-plan-frontend/src/hooks/useRecipes.ts
Normal file
33
meal-plan-frontend/src/hooks/useRecipes.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
57
meal-plan-frontend/src/index.css
Normal file
57
meal-plan-frontend/src/index.css
Normal 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;
|
||||
}
|
||||
29
meal-plan-frontend/src/main.tsx
Normal file
29
meal-plan-frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
121
meal-plan-frontend/src/pages/AllMealsPage.tsx
Normal file
121
meal-plan-frontend/src/pages/AllMealsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
meal-plan-frontend/src/pages/BreakfastPage.tsx
Normal file
6
meal-plan-frontend/src/pages/BreakfastPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CategoryPage } from "./CategoryPage";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
export function BreakfastPage() {
|
||||
return <CategoryPage category={MealCategory.Breakfast} />;
|
||||
}
|
||||
50
meal-plan-frontend/src/pages/CategoryPage.tsx
Normal file
50
meal-plan-frontend/src/pages/CategoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
meal-plan-frontend/src/pages/DashboardPage.tsx
Normal file
75
meal-plan-frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
meal-plan-frontend/src/pages/DinnerPage.tsx
Normal file
6
meal-plan-frontend/src/pages/DinnerPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CategoryPage } from "./CategoryPage";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
export function DinnerPage() {
|
||||
return <CategoryPage category={MealCategory.Dinner} />;
|
||||
}
|
||||
131
meal-plan-frontend/src/pages/LoginPage.tsx
Normal file
131
meal-plan-frontend/src/pages/LoginPage.tsx
Normal 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't have an account?{" "}
|
||||
<Link to="/register" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
meal-plan-frontend/src/pages/LunchPage.tsx
Normal file
6
meal-plan-frontend/src/pages/LunchPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CategoryPage } from "./CategoryPage";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
export function LunchPage() {
|
||||
return <CategoryPage category={MealCategory.Lunch} />;
|
||||
}
|
||||
16
meal-plan-frontend/src/pages/NotFoundPage.tsx
Normal file
16
meal-plan-frontend/src/pages/NotFoundPage.tsx
Normal 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 you’re looking for doesn’t exist.
|
||||
</p>
|
||||
<Link to="/" className="btn-primary mt-6">
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
meal-plan-frontend/src/pages/RecipeDetailPage.tsx
Normal file
45
meal-plan-frontend/src/pages/RecipeDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
meal-plan-frontend/src/pages/RegisterPage.tsx
Normal file
181
meal-plan-frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
meal-plan-frontend/src/pages/SecondBreakfastPage.tsx
Normal file
6
meal-plan-frontend/src/pages/SecondBreakfastPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CategoryPage } from "./CategoryPage";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
export function SecondBreakfastPage() {
|
||||
return <CategoryPage category={MealCategory.SecondBreakfast} />;
|
||||
}
|
||||
35
meal-plan-frontend/src/store/authStore.ts
Normal file
35
meal-plan-frontend/src/store/authStore.ts
Normal 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);
|
||||
39
meal-plan-frontend/src/store/createStore.ts
Normal file
39
meal-plan-frontend/src/store/createStore.ts
Normal 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 };
|
||||
}
|
||||
32
meal-plan-frontend/src/store/themeStore.ts
Normal file
32
meal-plan-frontend/src/store/themeStore.ts
Normal 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);
|
||||
}
|
||||
23
meal-plan-frontend/src/types/auth.types.ts
Normal file
23
meal-plan-frontend/src/types/auth.types.ts
Normal 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;
|
||||
}
|
||||
61
meal-plan-frontend/src/types/recipe.types.ts
Normal file
61
meal-plan-frontend/src/types/recipe.types.ts
Normal 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;
|
||||
}
|
||||
179
meal-plan-frontend/src/utils/pdfExport.ts
Normal file
179
meal-plan-frontend/src/utils/pdfExport.ts
Normal 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
1
meal-plan-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
meal-plan-frontend/tailwind.config.js
Normal file
27
meal-plan-frontend/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
29
meal-plan-frontend/tsconfig.json
Normal file
29
meal-plan-frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
meal-plan-frontend/vite.config.ts
Normal file
23
meal-plan-frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user