* Extended functionalities
* Added different UIs
This commit is contained in:
86
meal-plan-frontend/package-lock.json
generated
86
meal-plan-frontend/package-lock.json
generated
@@ -12,11 +12,13 @@
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"axios": "^1.7.7",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^26.3.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-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -2113,6 +2115,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
@@ -2139,6 +2150,34 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2741,6 +2780,33 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3152,7 +3218,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3200,6 +3266,15 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3276,6 +3351,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"axios": "^1.7.7",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^26.3.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-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,14 @@ import { SecondBreakfastPage } from "@/pages/SecondBreakfastPage";
|
||||
import { LunchPage } from "@/pages/LunchPage";
|
||||
import { DinnerPage } from "@/pages/DinnerPage";
|
||||
import { AllMealsPage } from "@/pages/AllMealsPage";
|
||||
import { ManageMealsPage } from "@/pages/ManageMealsPage";
|
||||
import { RecipeDetailPage } from "@/pages/RecipeDetailPage";
|
||||
import { IngredientCatalogPage } from "@/pages/IngredientCatalogPage";
|
||||
import { DietTrackerPage } from "@/pages/DietTrackerPage";
|
||||
import { MealPlannerPage } from "@/pages/MealPlannerPage";
|
||||
import { ShoppingListPage } from "@/pages/ShoppingListPage";
|
||||
import { DietGeneratorPage } from "@/pages/DietGeneratorPage";
|
||||
import { RecipeGeneratorPage } from "@/pages/RecipeGeneratorPage";
|
||||
import { NotFoundPage } from "@/pages/NotFoundPage";
|
||||
|
||||
export default function App() {
|
||||
@@ -31,6 +38,13 @@ export default function App() {
|
||||
<Route path="/lunch" element={<LunchPage />} />
|
||||
<Route path="/dinner" element={<DinnerPage />} />
|
||||
<Route path="/all-meals" element={<AllMealsPage />} />
|
||||
<Route path="/manage-meals" element={<ManageMealsPage />} />
|
||||
<Route path="/ingredients" element={<IngredientCatalogPage />} />
|
||||
<Route path="/diet" element={<DietTrackerPage />} />
|
||||
<Route path="/meal-planner" element={<MealPlannerPage />} />
|
||||
<Route path="/shopping-list" element={<ShoppingListPage />} />
|
||||
<Route path="/diet-generator" element={<DietGeneratorPage />} />
|
||||
<Route path="/recipe-generator" element={<RecipeGeneratorPage />} />
|
||||
<Route path="/recipes/:id" element={<RecipeDetailPage />} />
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
|
||||
@@ -115,10 +115,17 @@ api.interceptors.response.use(
|
||||
);
|
||||
|
||||
/** Extracts a user-facing message from an API error response. */
|
||||
const GENERIC_API_TITLES = new Set([
|
||||
"An unexpected error occurred.",
|
||||
"Internal Server Error",
|
||||
]);
|
||||
|
||||
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 (data?.error) return data.error;
|
||||
if (data?.title && !GENERIC_API_TITLES.has(data.title)) return data.title;
|
||||
return fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
||||
82
meal-plan-frontend/src/api/diet-generator.api.ts
Normal file
82
meal-plan-frontend/src/api/diet-generator.api.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { api } from "./axiosInstance";
|
||||
import type {
|
||||
CommitDietPlanResult,
|
||||
DietGeneratorSession,
|
||||
DietUserProfile,
|
||||
MacroProposal,
|
||||
} from "@/types/generator.types";
|
||||
|
||||
export async function fetchDietProfile(): Promise<DietUserProfile | null> {
|
||||
const { data } = await api.get<DietUserProfile>("/diet-generator/profile");
|
||||
return data.heightCm ? data : null;
|
||||
}
|
||||
|
||||
export async function saveDietProfile(profile: DietUserProfile): Promise<DietUserProfile> {
|
||||
const { data } = await api.put<DietUserProfile>("/diet-generator/profile", profile);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createDietSession(input: {
|
||||
planStartDate?: string;
|
||||
planDayCount?: number;
|
||||
calorieToleranceKcal?: number;
|
||||
}): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.post<DietGeneratorSession>("/diet-generator/sessions", input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDietSession(id: number): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.get<DietGeneratorSession>(`/diet-generator/sessions/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function proposeDietMacros(sessionId: number): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.post<DietGeneratorSession>(`/diet-generator/sessions/${sessionId}/propose-macros`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function acceptDietMacros(
|
||||
sessionId: number,
|
||||
macros?: Partial<MacroProposal>,
|
||||
): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.post<DietGeneratorSession>(`/diet-generator/sessions/${sessionId}/accept-macros`, {
|
||||
calories: macros?.calories,
|
||||
proteinG: macros?.proteinG,
|
||||
fatG: macros?.fatG,
|
||||
carbsG: macros?.carbsG,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateDietPlan(sessionId: number): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.post<DietGeneratorSession>(`/diet-generator/sessions/${sessionId}/generate-plan`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function regenerateDietMeal(
|
||||
sessionId: number,
|
||||
planDate: string,
|
||||
mealCategory: number,
|
||||
): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.post<DietGeneratorSession>(`/diet-generator/sessions/${sessionId}/regenerate-meal`, {
|
||||
planDate,
|
||||
mealCategory,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function toggleDietMealLock(
|
||||
sessionId: number,
|
||||
mealId: number,
|
||||
isLocked: boolean,
|
||||
): Promise<DietGeneratorSession> {
|
||||
const { data } = await api.patch<DietGeneratorSession>(`/diet-generator/sessions/${sessionId}/meals/${mealId}`, {
|
||||
isLocked,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function commitDietPlan(sessionId: number): Promise<CommitDietPlanResult> {
|
||||
const { data } = await api.post<CommitDietPlanResult>(`/diet-generator/sessions/${sessionId}/commit`);
|
||||
return data;
|
||||
}
|
||||
138
meal-plan-frontend/src/api/diet.api.ts
Normal file
138
meal-plan-frontend/src/api/diet.api.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { api } from "@/api/axiosInstance";
|
||||
import type {
|
||||
CopyMealsResult,
|
||||
CreateDietReminderInput,
|
||||
DailySummary,
|
||||
DietReminder,
|
||||
DietSuggestion,
|
||||
DrinkCatalogItem,
|
||||
DueReminder,
|
||||
FavoriteCatalogItem,
|
||||
FavoriteRecipe,
|
||||
HistoryDay,
|
||||
LogDrinkInput,
|
||||
LogMealInput,
|
||||
MealConsumption,
|
||||
MealPreview,
|
||||
RecentMeal,
|
||||
UpdateDietReminderInput,
|
||||
UserDailyGoals,
|
||||
} from "@/types/diet.types";
|
||||
import { normalizeDailySummary } from "@/utils/diet-normalize";
|
||||
import { todayIso } from "@/utils/date.util";
|
||||
|
||||
export { todayIso };
|
||||
|
||||
export async function fetchDailySummary(date = todayIso()): Promise<DailySummary> {
|
||||
const { data } = await api.get<unknown>("/diet/summary", { params: { date } });
|
||||
return normalizeDailySummary(data);
|
||||
}
|
||||
|
||||
export async function fetchGoals(): Promise<UserDailyGoals> {
|
||||
const { data } = await api.get<UserDailyGoals>("/diet/goals");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function saveGoals(goals: UserDailyGoals): Promise<UserDailyGoals> {
|
||||
const { data } = await api.put<UserDailyGoals>("/diet/goals", goals);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logMeal(input: LogMealInput): Promise<MealConsumption> {
|
||||
const { data } = await api.post<MealConsumption>("/diet/meals", input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function previewMeal(input: LogMealInput, date = todayIso()): Promise<MealPreview> {
|
||||
const { data } = await api.post<MealPreview>("/diet/meals/preview", input, { params: { date } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchMealSuggestions(date = todayIso(), limit = 5): Promise<DietSuggestion[]> {
|
||||
const { data } = await api.get<DietSuggestion[]>("/diet/suggestions", { params: { date, limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMeal(id: number): Promise<void> {
|
||||
await api.delete(`/diet/meals/${id}`);
|
||||
}
|
||||
|
||||
export async function fetchDrinkCatalog(): Promise<DrinkCatalogItem[]> {
|
||||
const { data } = await api.get<DrinkCatalogItem[]>("/diet/drinks/catalog");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logDrink(input: LogDrinkInput): Promise<void> {
|
||||
await api.post("/diet/drinks", input);
|
||||
}
|
||||
|
||||
export async function deleteDrink(id: number): Promise<void> {
|
||||
await api.delete(`/diet/drinks/${id}`);
|
||||
}
|
||||
|
||||
export async function fetchReminders(): Promise<DietReminder[]> {
|
||||
const { data } = await api.get<DietReminder[]>("/diet/reminders");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createReminder(input: CreateDietReminderInput): Promise<DietReminder> {
|
||||
const { data } = await api.post<DietReminder>("/diet/reminders", input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateReminder(id: number, input: UpdateDietReminderInput): Promise<DietReminder> {
|
||||
const { data } = await api.put<DietReminder>(`/diet/reminders/${id}`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteReminder(id: number): Promise<void> {
|
||||
await api.delete(`/diet/reminders/${id}`);
|
||||
}
|
||||
|
||||
export async function fetchDueReminders(date?: string, time?: string): Promise<DueReminder[]> {
|
||||
const { data } = await api.get<DueReminder[]>("/diet/reminders/due", { params: { date, time } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchHistory(from: string, to: string): Promise<HistoryDay[]> {
|
||||
const { data } = await api.get<HistoryDay[]>("/diet/history", { params: { from, to } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRecentMeals(limit = 10): Promise<RecentMeal[]> {
|
||||
const { data } = await api.get<RecentMeal[]>("/diet/meals/recent", { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function copyYesterdayMeals(date?: string): Promise<CopyMealsResult> {
|
||||
const { data } = await api.post<CopyMealsResult>("/diet/meals/copy-yesterday", null, { params: { date } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchFavoriteRecipes(): Promise<FavoriteRecipe[]> {
|
||||
const { data } = await api.get<FavoriteRecipe[]>("/diet/favorites/recipes");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addFavoriteRecipe(recipeId: number): Promise<FavoriteRecipe> {
|
||||
const { data } = await api.post<FavoriteRecipe>(`/diet/favorites/recipes/${recipeId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeFavoriteRecipe(recipeId: number): Promise<void> {
|
||||
await api.delete(`/diet/favorites/recipes/${recipeId}`);
|
||||
}
|
||||
|
||||
export async function fetchFavoriteCatalogItems(): Promise<FavoriteCatalogItem[]> {
|
||||
const { data } = await api.get<FavoriteCatalogItem[]>("/diet/favorites/catalog");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addFavoriteCatalogItem(catalogItemId: number): Promise<FavoriteCatalogItem> {
|
||||
const { data } = await api.post<FavoriteCatalogItem>(`/diet/favorites/catalog/${catalogItemId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeFavoriteCatalogItem(catalogItemId: number): Promise<void> {
|
||||
await api.delete(`/diet/favorites/catalog/${catalogItemId}`);
|
||||
}
|
||||
43
meal-plan-frontend/src/api/ingredient-catalog.api.ts
Normal file
43
meal-plan-frontend/src/api/ingredient-catalog.api.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { api } from "@/api/axiosInstance";
|
||||
import type {
|
||||
IngredientCategory,
|
||||
IngredientNutritionItem,
|
||||
UpsertIngredientNutritionItemInput,
|
||||
} from "@/types/ingredient-catalog.types";
|
||||
|
||||
export async function fetchIngredientCategories(): Promise<IngredientCategory[]> {
|
||||
const { data } = await api.get<IngredientCategory[]>("/ingredient-catalog/categories");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchIngredientCatalogItems(params?: {
|
||||
category?: string;
|
||||
search?: string;
|
||||
}): Promise<IngredientNutritionItem[]> {
|
||||
const { data } = await api.get<IngredientNutritionItem[]>("/ingredient-catalog", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchIngredientCatalogItem(id: number): Promise<IngredientNutritionItem> {
|
||||
const { data } = await api.get<IngredientNutritionItem>(`/ingredient-catalog/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createIngredientCatalogItem(
|
||||
input: UpsertIngredientNutritionItemInput,
|
||||
): Promise<IngredientNutritionItem> {
|
||||
const { data } = await api.post<IngredientNutritionItem>("/ingredient-catalog", input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateIngredientCatalogItem(
|
||||
id: number,
|
||||
input: UpsertIngredientNutritionItemInput,
|
||||
): Promise<IngredientNutritionItem> {
|
||||
const { data } = await api.put<IngredientNutritionItem>(`/ingredient-catalog/${id}`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteIngredientCatalogItem(id: number): Promise<void> {
|
||||
await api.delete(`/ingredient-catalog/${id}`);
|
||||
}
|
||||
21
meal-plan-frontend/src/api/meal-plan.api.ts
Normal file
21
meal-plan-frontend/src/api/meal-plan.api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { api } from "@/api/axiosInstance";
|
||||
import type { MealPlanEntry, ShoppingListItem, UpsertMealPlanEntryInput } from "@/types/meal-plan.types";
|
||||
|
||||
export async function fetchMealPlanEntries(from: string, to: string): Promise<MealPlanEntry[]> {
|
||||
const { data } = await api.get<MealPlanEntry[]>("/meal-plan", { params: { from, to } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function upsertMealPlanEntry(input: UpsertMealPlanEntryInput): Promise<MealPlanEntry> {
|
||||
const { data } = await api.put<MealPlanEntry>("/meal-plan", input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMealPlanEntry(id: number): Promise<void> {
|
||||
await api.delete(`/meal-plan/${id}`);
|
||||
}
|
||||
|
||||
export async function fetchShoppingList(from: string, to: string): Promise<ShoppingListItem[]> {
|
||||
const { data } = await api.get<ShoppingListItem[]>("/meal-plan/shopping-list", { params: { from, to } });
|
||||
return data;
|
||||
}
|
||||
60
meal-plan-frontend/src/api/recipe-generator.api.ts
Normal file
60
meal-plan-frontend/src/api/recipe-generator.api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { api } from "./axiosInstance";
|
||||
import type {
|
||||
CommitRecipesResult,
|
||||
GeneratedRecipeDraft,
|
||||
RecipeGeneratorConstraints,
|
||||
RecipeGeneratorSession,
|
||||
} from "@/types/generator.types";
|
||||
|
||||
export async function createRecipeSession(constraints: RecipeGeneratorConstraints): Promise<RecipeGeneratorSession> {
|
||||
const { data } = await api.post<RecipeGeneratorSession>("/recipe-generator/sessions", constraints);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRecipeSession(id: number): Promise<RecipeGeneratorSession> {
|
||||
const { data } = await api.get<RecipeGeneratorSession>(`/recipe-generator/sessions/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateRecipeDrafts(sessionId: number, count = 3): Promise<RecipeGeneratorSession> {
|
||||
const { data } = await api.post<RecipeGeneratorSession>(`/recipe-generator/sessions/${sessionId}/generate`, {
|
||||
count,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function regenerateRecipePart(
|
||||
sessionId: number,
|
||||
draftId: string,
|
||||
mode: "ingredients" | "steps" | "full",
|
||||
instruction?: string,
|
||||
): Promise<RecipeGeneratorSession> {
|
||||
const { data } = await api.post<RecipeGeneratorSession>(`/recipe-generator/sessions/${sessionId}/regenerate`, {
|
||||
draftId,
|
||||
mode,
|
||||
instruction,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeRecipeDraft(sessionId: number, draftId: string): Promise<RecipeGeneratorSession> {
|
||||
const { data } = await api.delete<RecipeGeneratorSession>(
|
||||
`/recipe-generator/sessions/${sessionId}/drafts/${encodeURIComponent(draftId)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateRecipeDraft(
|
||||
sessionId: number,
|
||||
draft: GeneratedRecipeDraft,
|
||||
): Promise<RecipeGeneratorSession> {
|
||||
const { data } = await api.put<RecipeGeneratorSession>(`/recipe-generator/sessions/${sessionId}/draft`, draft);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function commitRecipes(sessionId: number, draftIds?: string[]): Promise<CommitRecipesResult> {
|
||||
const { data } = await api.post<CommitRecipesResult>(`/recipe-generator/sessions/${sessionId}/commit`, {
|
||||
draftIds: draftIds?.length ? draftIds : undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
57
meal-plan-frontend/src/api/recipe-management.api.ts
Normal file
57
meal-plan-frontend/src/api/recipe-management.api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { api } from "./axiosInstance";
|
||||
import type { RecipeDetail } from "@/types/recipe.types";
|
||||
import type { CreateRecipeInput, RecipeImportResult, RecipeMacroRecalcResult } from "@/types/recipe-management.types";
|
||||
|
||||
function toPayload(input: CreateRecipeInput) {
|
||||
return {
|
||||
name: input.name,
|
||||
mealCategory: input.mealCategory,
|
||||
calories: input.calories,
|
||||
protein: input.protein,
|
||||
fat: input.fat,
|
||||
carbs: input.carbs,
|
||||
prepTimeMinutes: input.prepTimeMinutes,
|
||||
ingredients: input.ingredients.map((i, index) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams,
|
||||
unit: i.unit.trim() || null,
|
||||
sortOrder: i.sortOrder || index,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: input.steps.map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
description: s.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRecipe(input: CreateRecipeInput): Promise<RecipeDetail> {
|
||||
const { data } = await api.post<RecipeDetail>("/recipes", toPayload(input));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateRecipe(id: number, input: CreateRecipeInput): Promise<RecipeDetail> {
|
||||
const { data } = await api.put<RecipeDetail>(`/recipes/${id}`, toPayload(input));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadImportTemplate(): Promise<Blob> {
|
||||
const { data } = await api.get<Blob>("/recipes/import/template", {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importRecipesFromExcel(file: File): Promise<RecipeImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await api.post<RecipeImportResult>("/recipes/import/excel", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function recalculateAllRecipeMacros(): Promise<RecipeMacroRecalcResult> {
|
||||
const { data } = await api.post<RecipeMacroRecalcResult>("/recipes/recalculate-macros");
|
||||
return data;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
export interface RecipeQuery {
|
||||
category?: MealCategory;
|
||||
search?: string;
|
||||
/** Filters to recipes that contain an ingredient whose name matches this term. */
|
||||
ingredient?: string;
|
||||
}
|
||||
|
||||
export async function fetchRecipes(query: RecipeQuery = {}): Promise<RecipeListItem[]> {
|
||||
@@ -16,6 +18,7 @@ export async function fetchRecipes(query: RecipeQuery = {}): Promise<RecipeListI
|
||||
params: {
|
||||
category: query.category,
|
||||
search: query.search || undefined,
|
||||
ingredient: query.ingredient || undefined,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
|
||||
41
meal-plan-frontend/src/components/diet/DateNavigator.tsx
Normal file
41
meal-plan-frontend/src/components/diet/DateNavigator.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { addDaysIso, formatDisplayDate, todayIso } from "@/utils/date.util";
|
||||
|
||||
interface DateNavigatorProps {
|
||||
date: string;
|
||||
onChange: (date: string) => void;
|
||||
}
|
||||
|
||||
export function DateNavigator({ date, onChange }: DateNavigatorProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isToday = date === todayIso();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary !px-2.5 !py-1.5"
|
||||
aria-label={t("common.previous")}
|
||||
onClick={() => onChange(addDaysIso(date, -1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !py-1.5 !text-sm" onClick={() => onChange(todayIso())}>
|
||||
{t("diet.goToday")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary !px-2.5 !py-1.5"
|
||||
aria-label={t("common.next")}
|
||||
onClick={() => onChange(addDaysIso(date, 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
{formatDisplayDate(date, i18n.language)}
|
||||
{!isToday ? ` (${t("diet.notToday")})` : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
meal-plan-frontend/src/components/diet/HistoryChart.tsx
Normal file
36
meal-plan-frontend/src/components/diet/HistoryChart.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HistoryDay } from "@/types/diet.types";
|
||||
|
||||
interface HistoryChartProps {
|
||||
days: HistoryDay[];
|
||||
}
|
||||
|
||||
export function HistoryChart({ days }: HistoryChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const max = Math.max(...days.map((d) => d.calories), 1);
|
||||
|
||||
if (days.every((d) => d.calories === 0 && d.mealCount === 0)) {
|
||||
return <p className="text-sm text-slate-400">{t("diet.noHistory")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-500">{t("diet.historyMax", { max })}</p>
|
||||
<div className="flex h-40 items-end gap-1">
|
||||
{days.map((d) => {
|
||||
const pct = max > 0 ? (d.calories / max) * 100 : 0;
|
||||
return (
|
||||
<div key={d.date} className="flex min-w-0 flex-1 flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-full rounded-t bg-brand-500 transition-all"
|
||||
style={{ height: `${Math.max(pct, d.calories > 0 ? 4 : 0)}%` }}
|
||||
title={`${d.date}: ${d.calories} kcal`}
|
||||
/>
|
||||
<span className="truncate text-[10px] text-slate-400">{d.date.slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useLayout } from "@/store/layoutStore";
|
||||
import { useReminderNotifications } from "@/hooks/useReminderNotifications";
|
||||
import { Navbar } from "./Navbar";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Sidebar, type SidebarVariant } from "./Sidebar";
|
||||
import { HorizontalNav } from "./HorizontalNav";
|
||||
|
||||
function sidebarVariant(layout: string): SidebarVariant {
|
||||
if (layout === "compact") return "compact";
|
||||
if (layout === "wide") return "wide";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function mainClass(layout: string): string {
|
||||
const base = "mx-auto w-full flex-1 px-4 py-6 sm:px-6 sm:py-8";
|
||||
if (layout === "topnav") return `${base} max-w-7xl`;
|
||||
return `${base} max-w-6xl`;
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { layout } = useLayout();
|
||||
useReminderNotifications();
|
||||
|
||||
if (layout === "topnav") {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar showMenuToggle={false} />
|
||||
<HorizontalNav />
|
||||
<main className={mainClass(layout)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen lg:flex">
|
||||
<Sidebar open={sidebarOpen} onNavigate={() => setSidebarOpen(false)} />
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
variant={sidebarVariant(layout)}
|
||||
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">
|
||||
<main className={mainClass(layout)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
51
meal-plan-frontend/src/components/layout/HorizontalNav.tsx
Normal file
51
meal-plan-frontend/src/components/layout/HorizontalNav.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NavIconName } from "@/theme/appearance.config";
|
||||
import { NavIcon } from "@/components/ui/NavIcon";
|
||||
|
||||
const NAV_ITEMS: Array<{ to: string; labelKey: string; icon: NavIconName }> = [
|
||||
{ to: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ to: "/breakfast", labelKey: "nav.breakfast", icon: "coffee" },
|
||||
{ to: "/second-breakfast", labelKey: "nav.secondBreakfast", icon: "croissant" },
|
||||
{ to: "/lunch", labelKey: "nav.lunch", icon: "soup" },
|
||||
{ to: "/dinner", labelKey: "nav.dinner", icon: "moon" },
|
||||
{ to: "/all-meals", labelKey: "nav.allMeals", icon: "list" },
|
||||
{ to: "/manage-meals", labelKey: "nav.manageMeals", icon: "book" },
|
||||
{ to: "/ingredients", labelKey: "nav.ingredientCatalog", icon: "leaf" },
|
||||
{ to: "/diet", labelKey: "nav.dietTracker", icon: "diet" },
|
||||
{ to: "/meal-planner", labelKey: "nav.mealPlanner", icon: "calendar" },
|
||||
{ to: "/diet-generator", labelKey: "nav.dietGenerator", icon: "diet" },
|
||||
{ to: "/recipe-generator", labelKey: "nav.recipeGenerator", icon: "book" },
|
||||
{ to: "/shopping-list", labelKey: "nav.shoppingList", icon: "cart" },
|
||||
];
|
||||
|
||||
export function HorizontalNav() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90"
|
||||
aria-label="Main"
|
||||
>
|
||||
<div className="flex gap-1 overflow-x-auto px-4 py-2 sm:px-6">
|
||||
{NAV_ITEMS.map(({ to, labelKey, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`inline-flex shrink-0 items-center gap-2 rounded-xl px-3.5 py-2 text-sm font-medium transition ${
|
||||
isActive
|
||||
? "bg-brand-600 text-white shadow-sm"
|
||||
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<NavIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||
{t(labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LogOut, Moon, Sun, UtensilsCrossed } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { themeStore, toggleTheme } from "@/store/themeStore";
|
||||
import { AppearanceSwitcher } from "@/components/ui/AppearanceSwitcher";
|
||||
import { LanguageSwitcher } from "@/components/ui/LanguageSwitcher";
|
||||
import { LayoutSwitcher } from "@/components/ui/LayoutSwitcher";
|
||||
|
||||
interface NavbarProps {
|
||||
onToggleSidebar?: () => void;
|
||||
showMenuToggle?: boolean;
|
||||
}
|
||||
|
||||
export function Navbar({ onToggleSidebar }: NavbarProps) {
|
||||
export function Navbar({ onToggleSidebar, showMenuToggle = true }: NavbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user, logout } = useAuth();
|
||||
const theme = themeStore.useStore((s) => s.theme);
|
||||
|
||||
@@ -15,40 +21,46 @@ export function Navbar({ onToggleSidebar }: NavbarProps) {
|
||||
<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>
|
||||
{showMenuToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="btn-secondary !px-2.5 !py-2 lg:hidden"
|
||||
aria-label={t("nav.toggleNav")}
|
||||
>
|
||||
<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>
|
||||
<span className="text-lg">{t("app.name")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<AppearanceSwitcher />
|
||||
<LayoutSwitcher />
|
||||
<LanguageSwitcher />
|
||||
|
||||
<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"}
|
||||
aria-label={theme === "dark" ? t("nav.lightMode") : t("nav.darkMode")}
|
||||
>
|
||||
{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-sm font-semibold leading-tight">{user?.userName ?? t("nav.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>
|
||||
<span className="hidden sm:inline">{t("nav.logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,17 @@ interface PrivateRouteProps {
|
||||
/** Guards authenticated routes; redirects unauthenticated users to /login. */
|
||||
export function PrivateRoute({ children }: PrivateRouteProps) {
|
||||
const accessToken = authStore.useStore((s) => s.accessToken);
|
||||
const authReady = authStore.useStore((s) => s.authReady);
|
||||
const location = useLocation();
|
||||
|
||||
if (!authReady) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-slate-500">
|
||||
…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Coffee,
|
||||
Croissant,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Moon,
|
||||
Soup,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NavIconName } from "@/theme/appearance.config";
|
||||
import { NavIcon } from "@/components/ui/NavIcon";
|
||||
|
||||
export type SidebarVariant = "default" | "compact" | "wide";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
labelKey: string;
|
||||
icon: NavIconName;
|
||||
}
|
||||
|
||||
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 },
|
||||
{ to: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ to: "/breakfast", labelKey: "nav.breakfast", icon: "coffee" },
|
||||
{ to: "/second-breakfast", labelKey: "nav.secondBreakfast", icon: "croissant" },
|
||||
{ to: "/lunch", labelKey: "nav.lunch", icon: "soup" },
|
||||
{ to: "/dinner", labelKey: "nav.dinner", icon: "moon" },
|
||||
{ to: "/all-meals", labelKey: "nav.allMeals", icon: "list" },
|
||||
{ to: "/manage-meals", labelKey: "nav.manageMeals", icon: "book" },
|
||||
{ to: "/ingredients", labelKey: "nav.ingredientCatalog", icon: "leaf" },
|
||||
{ to: "/diet", labelKey: "nav.dietTracker", icon: "diet" },
|
||||
{ to: "/meal-planner", labelKey: "nav.mealPlanner", icon: "calendar" },
|
||||
{ to: "/diet-generator", labelKey: "nav.dietGenerator", icon: "diet" },
|
||||
{ to: "/recipe-generator", labelKey: "nav.recipeGenerator", icon: "book" },
|
||||
{ to: "/shopping-list", labelKey: "nav.shoppingList", icon: "cart" },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
variant?: SidebarVariant;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onNavigate }: SidebarProps) {
|
||||
export function Sidebar({ open, variant = "default", onNavigate }: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const widthClass =
|
||||
variant === "compact" ? "w-16" : variant === "wide" ? "w-72" : "w-64";
|
||||
const asideBg =
|
||||
variant === "wide"
|
||||
? "bg-gradient-to-b from-white to-brand-50/40 dark:from-slate-900 dark:to-brand-950/20"
|
||||
: "bg-white dark:bg-slate-900";
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
@@ -40,27 +53,41 @@ export function Sidebar({ open, onNavigate }: SidebarProps) {
|
||||
/>
|
||||
)}
|
||||
<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 ${
|
||||
className={`fixed inset-y-0 left-0 z-40 transform border-r border-slate-200 px-3 py-6 transition-transform dark:border-slate-800 lg:static lg:translate-x-0 ${widthClass} ${asideBg} ${
|
||||
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 }) => (
|
||||
{variant === "wide" && (
|
||||
<div className="mb-6 hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-800 p-4 text-white lg:block">
|
||||
<p className="text-lg font-extrabold tracking-tight">{t("app.name")}</p>
|
||||
<p className="mt-1 text-xs text-brand-100">{t("layout.wideTagline")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={`mt-16 lg:mt-0 ${variant === "compact" ? "flex flex-col items-center gap-2" : "flex flex-col gap-1"}`}
|
||||
aria-label="Main"
|
||||
>
|
||||
{NAV_ITEMS.map(({ to, labelKey, 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 ${
|
||||
title={variant === "compact" ? t(labelKey) : undefined}
|
||||
className={({ isActive }) => {
|
||||
const base = `flex items-center rounded-xl 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"
|
||||
}`
|
||||
}
|
||||
}`;
|
||||
if (variant === "compact") return `${base} justify-center p-2.5`;
|
||||
if (variant === "wide") return `${base} gap-3 px-4 py-3`;
|
||||
return `${base} gap-3 px-3.5 py-2.5`;
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
<NavIcon name={icon} />
|
||||
{variant !== "compact" && t(labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
182
meal-plan-frontend/src/components/pdf/MealPlanDayPdfExport.tsx
Normal file
182
meal-plan-frontend/src/components/pdf/MealPlanDayPdfExport.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileDown, Loader2 } from "lucide-react";
|
||||
import { fetchRecipeById } from "@/api/recipes.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import {
|
||||
DayOverviewPdfPage,
|
||||
PDF_PAGE_STYLE,
|
||||
RecipePdfPage,
|
||||
ShoppingListPdfPage,
|
||||
buildShoppingList,
|
||||
} from "@/components/pdf/pdfPageComponents";
|
||||
import { generatePdf, scaleRecipeDetail } from "@/utils/pdfExport";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import type { MealPlanEntry } from "@/types/meal-plan.types";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { formatDisplayDate } from "@/utils/date.util";
|
||||
|
||||
const PLANNER_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
interface MealPlanDayPdfExportProps {
|
||||
date: string;
|
||||
entries: MealPlanEntry[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "loading" | "rendering";
|
||||
|
||||
interface ExportRecipe {
|
||||
recipe: ReturnType<typeof scaleRecipeDetail>;
|
||||
slotLabel: string;
|
||||
portions: number;
|
||||
}
|
||||
|
||||
export function MealPlanDayPdfExport({ date, entries, className }: MealPlanDayPdfExportProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [exportRecipes, setExportRecipes] = useState<ExportRecipe[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const renderAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const dayEntries = useMemo(
|
||||
() =>
|
||||
entries
|
||||
.filter((e) => e.planDate === date)
|
||||
.sort((a, b) => a.mealCategory - b.mealCategory || a.id - b.id),
|
||||
[entries, date],
|
||||
);
|
||||
|
||||
const overviewSlots = useMemo(
|
||||
() =>
|
||||
PLANNER_CATEGORIES.map((cat) => {
|
||||
const slotEntries = dayEntries.filter((e) => e.mealCategory === cat);
|
||||
const first = slotEntries[0];
|
||||
return {
|
||||
label: categoryLabels[cat],
|
||||
recipeName: first?.recipeName ?? null,
|
||||
portions: first?.portions ?? null,
|
||||
};
|
||||
}),
|
||||
[dayEntries, categoryLabels],
|
||||
);
|
||||
|
||||
const renderAreaId = `pdf-render-area-${date}`;
|
||||
|
||||
async function handleGenerate() {
|
||||
if (phase !== "idle") return;
|
||||
setError(null);
|
||||
setPhase("loading");
|
||||
try {
|
||||
if (dayEntries.length === 0) {
|
||||
setExportRecipes([]);
|
||||
setPhase("rendering");
|
||||
return;
|
||||
}
|
||||
|
||||
const details = await Promise.all(dayEntries.map((entry) => fetchRecipeById(entry.recipeId)));
|
||||
setExportRecipes(
|
||||
details.map((recipe, index) => {
|
||||
const entry = dayEntries[index];
|
||||
const slotLabel = categoryLabels[entry.mealCategory as MealCategory];
|
||||
return {
|
||||
slotLabel,
|
||||
portions: entry.portions,
|
||||
recipe: scaleRecipeDetail(recipe, entry.portions),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setPhase("rendering");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, t("errors.pdfLoadFailed")));
|
||||
setPhase("idle");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "rendering") return;
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
await document.fonts?.ready?.catch(() => undefined);
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const safeDate = date.replace(/[^\d-]/g, "");
|
||||
await generatePdf(`dailymeals-${safeDate}.pdf`, renderAreaId);
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, t("errors.pdfGenerateFailed")));
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setPhase("idle");
|
||||
setExportRecipes([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [phase, date, t]);
|
||||
|
||||
const scaledRecipes = exportRecipes.map((item) => item.recipe);
|
||||
const shoppingList = scaledRecipes.length > 0 ? buildShoppingList(scaledRecipes) : [];
|
||||
const dateLabel = formatDisplayDate(date, i18n.language);
|
||||
const generatedOn = new Date().toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={phase !== "idle"}
|
||||
className={className ?? "text-xs font-semibold text-brand-600 hover:underline disabled:opacity-50"}
|
||||
title={t("mealPlanner.exportDay")}
|
||||
>
|
||||
{phase !== "idle" ? <Loader2 className="inline h-3 w-3 animate-spin" /> : <FileDown className="inline h-3 w-3" />}
|
||||
<span className="sr-only">{t("mealPlanner.exportDay")}</span>
|
||||
PDF
|
||||
</button>
|
||||
|
||||
{error && <p className="mt-1 text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
{phase === "rendering" && (
|
||||
<div id={renderAreaId} ref={renderAreaRef}>
|
||||
<div className="pdf-page" style={PDF_PAGE_STYLE}>
|
||||
<DayOverviewPdfPage dateLabel={dateLabel} slots={overviewSlots} t={t} />
|
||||
</div>
|
||||
{exportRecipes.map(({ recipe, slotLabel, portions }) => (
|
||||
<div className="pdf-page" style={PDF_PAGE_STYLE} key={`${recipe.id}-${slotLabel}`}>
|
||||
<RecipePdfPage
|
||||
recipe={recipe}
|
||||
categoryLabels={categoryLabels}
|
||||
t={t}
|
||||
subtitle={t("pdf.plannedMealMeta", { slot: slotLabel, portions })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="pdf-page" style={PDF_PAGE_STYLE}>
|
||||
<ShoppingListPdfPage
|
||||
items={shoppingList}
|
||||
recipes={scaledRecipes}
|
||||
generatedOn={generatedOn}
|
||||
categoryLabels={categoryLabels}
|
||||
t={t}
|
||||
subtitle={t("pdf.dayShoppingSubtitle", { date: dateLabel, count: scaledRecipes.length })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileDown, Loader2 } from "lucide-react";
|
||||
import { fetchRecipeById } from "@/api/recipes.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import {
|
||||
PDF_PAGE_STYLE,
|
||||
RecipePdfPage,
|
||||
ShoppingListPdfPage,
|
||||
buildShoppingList,
|
||||
formatBreakdown,
|
||||
formatQuantity,
|
||||
generatePdf,
|
||||
} from "@/utils/pdfExport";
|
||||
import { MEAL_CATEGORY_LABELS, type RecipeDetail } from "@/types/recipe.types";
|
||||
} from "@/components/pdf/pdfPageComponents";
|
||||
import { generatePdf } from "@/utils/pdfExport";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import type { RecipeDetail } from "@/types/recipe.types";
|
||||
|
||||
interface PdfGeneratorProps {
|
||||
selectedIds: number[];
|
||||
@@ -17,17 +20,9 @@ interface PdfGeneratorProps {
|
||||
|
||||
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 { t, i18n } = useTranslation();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [recipes, setRecipes] = useState<RecipeDetail[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -42,7 +37,7 @@ export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
|
||||
setRecipes(details);
|
||||
setPhase("rendering");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, "Could not load recipes for export."));
|
||||
setError(extractApiError(err, t("errors.pdfLoadFailed")));
|
||||
setPhase("idle");
|
||||
}
|
||||
}
|
||||
@@ -52,14 +47,13 @@ export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
|
||||
|
||||
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."));
|
||||
setError(extractApiError(err, t("errors.pdfGenerateFailed")));
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setPhase("idle");
|
||||
@@ -71,10 +65,10 @@ export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [phase, recipes]);
|
||||
}, [phase, recipes, t]);
|
||||
|
||||
const shoppingList = recipes.length > 0 ? buildShoppingList(recipes) : [];
|
||||
const generatedOn = new Date().toLocaleDateString(undefined, {
|
||||
const generatedOn = new Date().toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -93,7 +87,11 @@ export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
|
||||
) : (
|
||||
<FileDown className="h-4 w-4" />
|
||||
)}
|
||||
{phase === "loading" ? "Preparing..." : phase === "rendering" ? "Generating..." : "Generate PDF"}
|
||||
{phase === "loading"
|
||||
? t("pdf.preparing")
|
||||
: phase === "rendering"
|
||||
? t("pdf.generating")
|
||||
: t("pdf.generate")}
|
||||
</button>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
@@ -101,15 +99,17 @@ export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
|
||||
{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 className="pdf-page" style={PDF_PAGE_STYLE} key={recipe.id}>
|
||||
<RecipePdfPage recipe={recipe} categoryLabels={categoryLabels} t={t} />
|
||||
</div>
|
||||
))}
|
||||
<div className="pdf-page" style={PAGE_STYLE}>
|
||||
<div className="pdf-page" style={PDF_PAGE_STYLE}>
|
||||
<ShoppingListPdfPage
|
||||
items={shoppingList}
|
||||
recipes={recipes}
|
||||
generatedOn={generatedOn}
|
||||
categoryLabels={categoryLabels}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,147 +117,3 @@ export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
231
meal-plan-frontend/src/components/pdf/pdfPageComponents.tsx
Normal file
231
meal-plan-frontend/src/components/pdf/pdfPageComponents.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { RecipeDetail } from "@/types/recipe.types";
|
||||
import {
|
||||
buildShoppingList,
|
||||
formatBreakdown,
|
||||
formatQuantity,
|
||||
type ShoppingItem,
|
||||
} from "@/utils/pdfExport";
|
||||
import { formatIngredientAmount } from "@/utils/ingredientQuantity.util";
|
||||
|
||||
export const PDF_PAGE_STYLE: CSSProperties = {
|
||||
width: "794px",
|
||||
minHeight: "1123px",
|
||||
boxSizing: "border-box",
|
||||
padding: "48px",
|
||||
fontFamily: "Inter, Arial, sans-serif",
|
||||
color: "#0f172a",
|
||||
background: "#ffffff",
|
||||
};
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function DayOverviewPdfPage({
|
||||
dateLabel,
|
||||
slots,
|
||||
t,
|
||||
}: {
|
||||
dateLabel: string;
|
||||
slots: Array<{ label: string; recipeName: string | null; portions?: number | null }>;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ borderBottom: "3px solid #16774c", paddingBottom: "16px", marginBottom: "24px" }}>
|
||||
<h1 style={{ fontSize: "28px", fontWeight: 800, margin: 0 }}>{t("pdf.dayPlanTitle", { date: dateLabel })}</h1>
|
||||
</div>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
|
||||
{slots.map((slot) => (
|
||||
<li
|
||||
key={slot.label}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 0",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, color: "#475569" }}>{slot.label}</span>
|
||||
<span>
|
||||
{slot.recipeName
|
||||
? `${slot.recipeName}${slot.portions != null ? ` (${slot.portions}×)` : ""}`
|
||||
: t("pdf.emptySlot")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecipePdfPage({
|
||||
recipe,
|
||||
categoryLabels,
|
||||
t,
|
||||
subtitle,
|
||||
}: {
|
||||
recipe: RecipeDetail;
|
||||
categoryLabels: Record<number, string>;
|
||||
t: TranslateFn;
|
||||
subtitle?: string | null;
|
||||
}) {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{categoryLabels[recipe.mealCategory]}
|
||||
</span>
|
||||
<h1 style={{ fontSize: "28px", fontWeight: 800, margin: "12px 0 0" }}>{recipe.name}</h1>
|
||||
{subtitle ? (
|
||||
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>{subtitle}</p>
|
||||
) : null}
|
||||
{recipe.prepTimeMinutes != null && (
|
||||
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>
|
||||
{t("pdf.prepTime", { count: recipe.prepTimeMinutes })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "12px", marginBottom: "28px" }}>
|
||||
{[
|
||||
{ label: t("recipes.calories"), value: recipe.calories, suffix: "kcal" },
|
||||
{ label: t("recipes.protein"), value: recipe.protein, suffix: "g" },
|
||||
{ label: t("recipes.fat"), value: recipe.fat, suffix: "g" },
|
||||
{ label: t("recipes.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" }}>{t("recipes.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" }}>
|
||||
{formatIngredientAmount(ing.amountGrams, ing.unit)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 style={{ fontSize: "18px", fontWeight: 700, margin: "0 0 12px" }}>{t("recipes.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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShoppingListPdfPage({
|
||||
items,
|
||||
recipes,
|
||||
generatedOn,
|
||||
categoryLabels,
|
||||
t,
|
||||
subtitle,
|
||||
}: {
|
||||
items: ShoppingItem[];
|
||||
recipes: RecipeDetail[];
|
||||
generatedOn: string;
|
||||
categoryLabels: Record<number, string>;
|
||||
t: TranslateFn;
|
||||
subtitle?: 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 }}>{t("pdf.shoppingList")}</h1>
|
||||
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>
|
||||
{subtitle ?? t("pdf.generatedOn", { date: generatedOn, count: recipes.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p style={{ color: "#94a3b8", fontSize: "14px" }}>{t("pdf.emptyShoppingList")}</p>
|
||||
) : (
|
||||
<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" }}>
|
||||
{t("pdf.recipesInExport")}
|
||||
</h3>
|
||||
{recipes.length === 0 ? (
|
||||
<p style={{ fontSize: "12px", color: "#94a3b8", margin: 0 }}>{t("pdf.emptySlot")}</p>
|
||||
) : (
|
||||
<ol style={{ margin: 0, paddingLeft: "20px", fontSize: "12px", lineHeight: 1.7, color: "#475569" }}>
|
||||
{recipes.map((r) => (
|
||||
<li key={r.id}>
|
||||
{r.name} — {categoryLabels[r.mealCategory]}
|
||||
{r.calories != null ? ` · ${r.calories} kcal` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { buildShoppingList };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RecipeDetail } from "@/components/recipes/RecipeDetail";
|
||||
import type { DietGeneratorDraftMeal } from "@/types/generator.types";
|
||||
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
|
||||
|
||||
interface DietMealPreviewModalProps {
|
||||
meal: DietGeneratorDraftMeal | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function toRecipeDetail(meal: DietGeneratorDraftMeal): RecipeDetailModel {
|
||||
return {
|
||||
id: meal.recipeId,
|
||||
name: meal.recipeName,
|
||||
mealCategory: meal.mealCategory,
|
||||
calories: meal.calories ?? null,
|
||||
protein: meal.proteinG ?? null,
|
||||
fat: meal.fatG ?? null,
|
||||
carbs: meal.carbsG ?? null,
|
||||
prepTimeMinutes: meal.prepTimeMinutes ?? null,
|
||||
ingredients: (meal.ingredients ?? []).map((ingredient) => ({
|
||||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
amountGrams: ingredient.amountGrams,
|
||||
unit: ingredient.unit,
|
||||
sortOrder: ingredient.sortOrder,
|
||||
catalogItemId: null,
|
||||
})),
|
||||
steps: (meal.steps ?? []).map((step) => ({
|
||||
id: step.id,
|
||||
stepNumber: step.stepNumber,
|
||||
description: step.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function DietMealPreviewModal({ meal, onClose }: DietMealPreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!meal) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [meal, onClose]);
|
||||
|
||||
if (!meal) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card max-h-[90vh] w-full max-w-3xl overflow-y-auto p-6"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("generators.diet.viewRecipe")}
|
||||
>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button type="button" className="btn-secondary !px-2 !py-2" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<RecipeDetail recipe={toRecipeDetail(meal)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
meal-plan-frontend/src/components/recipes/ExcelImportPanel.tsx
Normal file
124
meal-plan-frontend/src/components/recipes/ExcelImportPanel.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Download, FileSpreadsheet, Loader2, Upload } from "lucide-react";
|
||||
import { useRecipeManagement } from "@/hooks/useRecipeManagement";
|
||||
|
||||
export function ExcelImportPanel() {
|
||||
const { t } = useTranslation();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const {
|
||||
importExcel,
|
||||
importPending,
|
||||
importError,
|
||||
importResult,
|
||||
resetImport,
|
||||
downloadTemplate,
|
||||
} = useRecipeManagement();
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const blob = await downloadTemplate();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "DailyMeals-recipe-import-template.xlsx";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) return;
|
||||
resetImport();
|
||||
await importExcel(selectedFile);
|
||||
setSelectedFile(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="card p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300">
|
||||
<FileSpreadsheet className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-bold">{t("manage.importTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t("manage.importDescription")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDownloadTemplate()}
|
||||
disabled={downloading}
|
||||
className="btn-secondary mt-4"
|
||||
>
|
||||
{downloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{t("manage.downloadTemplate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<label className="block text-sm font-medium">{t("manage.uploadLabel")}</label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="block w-full text-sm text-slate-500 file:mr-4 file:rounded-lg file:border-0 file:bg-brand-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-brand-700 hover:file:bg-brand-100 dark:file:bg-brand-900/40 dark:file:text-brand-300"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
{importError && (
|
||||
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50 p-4 dark:border-brand-800 dark:bg-brand-950/30">
|
||||
<div className="flex items-center gap-2 font-semibold text-brand-800 dark:text-brand-200">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
{t("manage.importComplete")}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{t("manage.createdCount", { count: importResult.createdCount })} ·{" "}
|
||||
{t("manage.skippedCount", { count: importResult.skippedCount })}
|
||||
</p>
|
||||
{importResult.errors.length > 0 && (
|
||||
<ul className="mt-3 max-h-40 space-y-1 overflow-y-auto text-sm text-amber-700 dark:text-amber-300">
|
||||
{importResult.errors.map((msg) => (
|
||||
<li key={msg}>• {msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedFile || importPending}
|
||||
onClick={() => void handleImport()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{importPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{importPending ? t("manage.importing") : t("manage.importRecipes")}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
<p className="font-medium text-slate-700 dark:text-slate-200">{t("manage.excelFormatTitle")}</p>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1">
|
||||
<li>{t("manage.excelRecipesSheet")}</li>
|
||||
<li>{t("manage.excelIngredientsSheet")}</li>
|
||||
<li>{t("manage.excelStepsSheet")}</li>
|
||||
</ul>
|
||||
<p className="mt-2">{t("manage.duplicateSkipped")}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Clock, Flame } from "lucide-react";
|
||||
import type { RecipeListItem } from "@/types/recipe.types";
|
||||
import { CategoryBadge } from "@/components/ui/CategoryBadge";
|
||||
|
||||
interface RecipeCardProps {
|
||||
recipe: RecipeListItem;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelected?: (id: number) => void;
|
||||
showCategory?: boolean;
|
||||
}
|
||||
|
||||
export function RecipeCard({ recipe, selectable, selected, onToggleSelected }: RecipeCardProps) {
|
||||
export function RecipeCard({
|
||||
recipe,
|
||||
selectable,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
showCategory,
|
||||
}: RecipeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="card group relative flex flex-col p-5 hover:shadow-md">
|
||||
{selectable && (
|
||||
@@ -19,12 +30,17 @@ export function RecipeCard({ recipe, selectable, selected, onToggleSelected }: R
|
||||
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}`}
|
||||
aria-label={t("recipes.selectRecipe", { name: recipe.name })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<Link to={`/recipes/${recipe.id}`} className="flex flex-1 flex-col">
|
||||
{showCategory && (
|
||||
<div className="mb-2 pr-8">
|
||||
<CategoryBadge category={recipe.mealCategory} />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="pr-8 text-base font-semibold leading-snug group-hover:text-brand-600">
|
||||
{recipe.name}
|
||||
</h3>
|
||||
@@ -32,11 +48,11 @@ export function RecipeCard({ recipe, selectable, selected, onToggleSelected }: R
|
||||
<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` : "—"}
|
||||
{recipe.calories != null ? t("recipes.kcal", { count: recipe.calories }) : "—"}
|
||||
</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` : "—"}
|
||||
{recipe.prepTimeMinutes != null ? t("recipes.minutes", { count: recipe.prepTimeMinutes }) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CategoryBadge } from "@/components/ui/CategoryBadge";
|
||||
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util";
|
||||
|
||||
type IngredientSortColumn = "name" | "amount";
|
||||
|
||||
interface MacroProps {
|
||||
label: string;
|
||||
@@ -35,6 +40,24 @@ interface RecipeDetailProps {
|
||||
}
|
||||
|
||||
export function RecipeDetail({ recipe }: RecipeDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ingredientSort, setIngredientSort] = useState<{
|
||||
column: IngredientSortColumn;
|
||||
direction: SortDirection;
|
||||
}>({ column: "name", direction: "asc" });
|
||||
|
||||
const sortedIngredients = useMemo(
|
||||
() =>
|
||||
sortBy(recipe.ingredients, ingredientSort.direction, (ing) =>
|
||||
ingredientSort.column === "name" ? ing.name : ing.amountGrams,
|
||||
),
|
||||
[recipe.ingredients, ingredientSort],
|
||||
);
|
||||
|
||||
const toggleIngredientSort = (column: IngredientSortColumn) => {
|
||||
setIngredientSort((current) => nextSort(current, column));
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="space-y-8">
|
||||
<header className="space-y-3">
|
||||
@@ -43,41 +66,61 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {
|
||||
{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
|
||||
{t("recipes.prepTime", { count: recipe.prepTimeMinutes })}
|
||||
</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" />
|
||||
<Macro label={t("recipes.calories")} value={recipe.calories} suffix="kcal" accent="text-orange-500" />
|
||||
<Macro label={t("recipes.protein")} value={recipe.protein} suffix="g" accent="text-brand-600" />
|
||||
<Macro label={t("recipes.fat")} value={recipe.fat} suffix="g" accent="text-amber-500" />
|
||||
<Macro label={t("recipes.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>
|
||||
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
|
||||
{recipe.ingredients.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">No ingredients listed.</p>
|
||||
<p className="mt-3 text-sm text-slate-500">{t("recipes.noIngredients")}</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>
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||
<div className="grid grid-cols-[1fr_auto] border-b border-slate-200 bg-slate-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-800/50">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left hover:text-slate-700 dark:hover:text-slate-200"
|
||||
onClick={() => toggleIngredientSort("name")}
|
||||
>
|
||||
{t("ingredientCatalog.columns.name")}
|
||||
{sortIndicator(ingredientSort, "name")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-right hover:text-slate-700 dark:hover:text-slate-200"
|
||||
onClick={() => toggleIngredientSort("amount")}
|
||||
>
|
||||
{t("diet.grams")}
|
||||
{sortIndicator(ingredientSort, "amount")}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{sortedIngredients.map((ing) => (
|
||||
<li key={ing.id} className="grid grid-cols-[1fr_auto] items-center 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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-bold">Preparation</h2>
|
||||
<h2 className="text-lg font-bold">{t("recipes.preparation")}</h2>
|
||||
{recipe.steps.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">No steps listed.</p>
|
||||
<p className="mt-3 text-sm text-slate-500">{t("recipes.noSteps")}</p>
|
||||
) : (
|
||||
<ol className="mt-4 space-y-4">
|
||||
{recipe.steps.map((step) => (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
|
||||
import { Carrot, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
|
||||
export type CategoryFilter = MealCategory | "all";
|
||||
|
||||
@@ -8,50 +10,80 @@ interface RecipeFiltersProps {
|
||||
onChange: (value: CategoryFilter) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchByIngredient: boolean;
|
||||
onSearchByIngredientChange: (value: boolean) => 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,
|
||||
searchByIngredient,
|
||||
onSearchByIngredientChange,
|
||||
}: RecipeFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
|
||||
const pills: Array<{ value: CategoryFilter; label: string }> = [
|
||||
{ value: "all", label: t("common.all") },
|
||||
{ value: MealCategory.Breakfast, label: categoryLabels[MealCategory.Breakfast] },
|
||||
{ value: MealCategory.SecondBreakfast, label: categoryLabels[MealCategory.SecondBreakfast] },
|
||||
{ value: MealCategory.Lunch, label: categoryLabels[MealCategory.Lunch] },
|
||||
{ value: MealCategory.Dinner, label: categoryLabels[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 className="flex flex-col gap-4">
|
||||
<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">
|
||||
{searchByIngredient ? (
|
||||
<Carrot className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-brand-500" />
|
||||
) : (
|
||||
<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={searchByIngredient ? t("recipes.searchByIngredient") : t("recipes.searchRecipes")}
|
||||
className="input pl-9"
|
||||
aria-label={
|
||||
searchByIngredient ? t("recipes.searchByIngredientAria") : t("recipes.searchByNameAria")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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" />
|
||||
<label className="inline-flex w-fit cursor-pointer select-none items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search recipes..."
|
||||
className="input pl-9"
|
||||
aria-label="Search recipes by name"
|
||||
type="checkbox"
|
||||
checked={searchByIngredient}
|
||||
onChange={(e) => onSearchByIngredientChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
{t("recipes.searchByIngredientLabel")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
306
meal-plan-frontend/src/components/recipes/RecipeForm.tsx
Normal file
306
meal-plan-frontend/src/components/recipes/RecipeForm.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { fetchIngredientCatalogItems } from "@/api/ingredient-catalog.api";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import {
|
||||
countUnlinkedMacroIngredients,
|
||||
estimateMacrosFromCatalog,
|
||||
isUnlinkedMacroIngredient,
|
||||
} from "@/utils/recipe-macros.util";
|
||||
import { ingredientDisplayName } from "@/utils/ingredient-catalog.util";
|
||||
import type { CreateRecipeInput } from "@/types/recipe-management.types";
|
||||
|
||||
const MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
const EMPTY_DEFAULT: CreateRecipeInput = {
|
||||
name: "",
|
||||
mealCategory: MealCategory.Breakfast,
|
||||
calories: null,
|
||||
protein: null,
|
||||
fat: null,
|
||||
carbs: null,
|
||||
prepTimeMinutes: null,
|
||||
ingredients: [{ name: "", amountGrams: null, unit: "", sortOrder: 0, catalogItemId: null }],
|
||||
steps: [{ stepNumber: 1, description: "" }],
|
||||
};
|
||||
|
||||
interface RecipeFormProps {
|
||||
onSubmit: (data: CreateRecipeInput) => Promise<void>;
|
||||
pending?: boolean;
|
||||
error?: string | null;
|
||||
initialValues?: CreateRecipeInput;
|
||||
submitLabel?: string;
|
||||
resetOnSubmit?: boolean;
|
||||
}
|
||||
|
||||
export function RecipeForm({
|
||||
onSubmit,
|
||||
pending,
|
||||
error,
|
||||
initialValues,
|
||||
submitLabel,
|
||||
resetOnSubmit = true,
|
||||
}: RecipeFormProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: ["ingredient-catalog", "all"],
|
||||
queryFn: () => fetchIngredientCatalogItems(),
|
||||
});
|
||||
|
||||
const recipeSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
name: z.string().min(1, t("validation.recipeNameRequired")).max(255),
|
||||
mealCategory: z.nativeEnum(MealCategory),
|
||||
calories: z.coerce.number().int().min(0).nullable().optional(),
|
||||
protein: z.coerce.number().min(0).nullable().optional(),
|
||||
fat: z.coerce.number().min(0).nullable().optional(),
|
||||
carbs: z.coerce.number().min(0).nullable().optional(),
|
||||
prepTimeMinutes: z.coerce.number().int().min(0).nullable().optional(),
|
||||
ingredients: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1, t("validation.ingredientNameRequired")),
|
||||
amountGrams: z.coerce.number().min(0).nullable().optional(),
|
||||
unit: z.string().optional(),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
catalogItemId: z.coerce.number().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
stepNumber: z.coerce.number().int().min(1, t("validation.stepNumberMin")),
|
||||
description: z.string().min(1, t("validation.stepDescriptionRequired")),
|
||||
}),
|
||||
)
|
||||
.min(1, t("validation.stepsMin")),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
type RecipeFormValues = z.infer<typeof recipeSchema>;
|
||||
|
||||
const formDefaults = initialValues ?? EMPTY_DEFAULT;
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<RecipeFormValues>({
|
||||
resolver: zodResolver(recipeSchema),
|
||||
defaultValues: formDefaults,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: ingredientFields,
|
||||
append: appendIngredient,
|
||||
remove: removeIngredient,
|
||||
} = useFieldArray({ control, name: "ingredients" });
|
||||
|
||||
const {
|
||||
fields: stepFields,
|
||||
append: appendStep,
|
||||
remove: removeStep,
|
||||
} = useFieldArray({ control, name: "steps" });
|
||||
|
||||
const watchedIngredients = watch("ingredients");
|
||||
const catalogItems = catalogQuery.data ?? [];
|
||||
|
||||
const estimatedMacros = useMemo(
|
||||
() => estimateMacrosFromCatalog(watchedIngredients ?? [], catalogItems),
|
||||
[watchedIngredients, catalogItems],
|
||||
);
|
||||
|
||||
const unlinkedCount = useMemo(
|
||||
() => countUnlinkedMacroIngredients(watchedIngredients ?? []),
|
||||
[watchedIngredients],
|
||||
);
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
mealCategory: values.mealCategory,
|
||||
calories: values.calories ?? null,
|
||||
protein: values.protein ?? null,
|
||||
fat: values.fat ?? null,
|
||||
carbs: values.carbs ?? null,
|
||||
prepTimeMinutes: values.prepTimeMinutes ?? null,
|
||||
ingredients: values.ingredients.map((i, index) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams ?? null,
|
||||
unit: i.unit ?? "",
|
||||
sortOrder: i.sortOrder ?? index,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: values.steps,
|
||||
});
|
||||
if (resetOnSubmit) reset(EMPTY_DEFAULT);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-8" noValidate>
|
||||
{error && (
|
||||
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<h2 className="text-lg font-bold">{t("manage.basicInfo")}</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.recipeName")}</label>
|
||||
<input className="input" {...register("name")} placeholder={t("manage.recipeNamePlaceholder")} />
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("common.category")}</label>
|
||||
<div className="relative">
|
||||
<select className="select" {...register("mealCategory", { valueAsNumber: true })}>
|
||||
{MEAL_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{categoryLabels[cat]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.prepTimeMin")}</label>
|
||||
<input type="number" className="input" {...register("prepTimeMinutes")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.caloriesKcal")}</label>
|
||||
<input type="number" className="input" {...register("calories")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.proteinG")}</label>
|
||||
<input type="number" step="0.1" className="input" {...register("protein")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.fatG")}</label>
|
||||
<input type="number" step="0.1" className="input" {...register("fat")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.carbsG")}</label>
|
||||
<input type="number" step="0.1" className="input" {...register("carbs")} />
|
||||
</div>
|
||||
</div>
|
||||
{estimatedMacros ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("manage.estimatedFromCatalog")}: {estimatedMacros.calories} kcal · P{" "}
|
||||
{estimatedMacros.protein.toFixed(1)}g · F {estimatedMacros.fat.toFixed(1)}g · C{" "}
|
||||
{estimatedMacros.carbs.toFixed(1)}g
|
||||
</p>
|
||||
) : null}
|
||||
{unlinkedCount > 0 ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
{t("manage.unlinkedIngredientsWarning", { count: unlinkedCount })}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() =>
|
||||
appendIngredient({
|
||||
name: "",
|
||||
amountGrams: null,
|
||||
unit: "",
|
||||
sortOrder: ingredientFields.length,
|
||||
catalogItemId: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ingredientFields.map((field, index) => {
|
||||
const ing = watchedIngredients?.[index];
|
||||
const unlinked = ing ? isUnlinkedMacroIngredient(ing) : false;
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={`grid gap-2 sm:grid-cols-[1fr_100px_100px_1fr_auto] rounded-lg p-1 ${
|
||||
unlinked ? "ring-1 ring-amber-400/70 bg-amber-50/50 dark:bg-amber-950/20" : ""
|
||||
}`}
|
||||
>
|
||||
<input className="input" placeholder={t("manage.ingredientName")} {...register(`ingredients.${index}.name`)} />
|
||||
<input type="number" step="0.1" className="input" placeholder={t("manage.ingredientGrams")} {...register(`ingredients.${index}.amountGrams`)} />
|
||||
<input className="input" placeholder={t("manage.ingredientUnit")} {...register(`ingredients.${index}.unit`)} />
|
||||
<select className="select" {...register(`ingredients.${index}.catalogItemId`, { setValueAs: (v) => (v === "" ? null : Number(v)) })}>
|
||||
<option value="">{t("manage.catalogLink")}</option>
|
||||
{catalogItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{ingredientDisplayName(item, i18n.language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn-secondary !px-2.5" onClick={() => removeIngredient(index)} aria-label={t("manage.removeIngredient")}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">{t("manage.preparationSteps")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => appendStep({ stepNumber: stepFields.length + 1, description: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("manage.addStep")}
|
||||
</button>
|
||||
</div>
|
||||
{errors.steps?.message && <p className="text-sm text-red-600">{errors.steps.message}</p>}
|
||||
<div className="space-y-3">
|
||||
{stepFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<input type="number" className="input w-20 shrink-0" {...register(`steps.${index}.stepNumber`)} />
|
||||
<input className="input flex-1" placeholder={t("manage.stepPlaceholder")} {...register(`steps.${index}.description`)} />
|
||||
<button type="button" className="btn-secondary !px-2.5" onClick={() => removeStep(index)} aria-label={t("manage.removeStep")}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" disabled={pending} className="btn-primary">
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{pending ? t("manage.saving") : (submitLabel ?? t("manage.saveRecipe"))}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect } from "react";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RecipeDetail } from "@/components/recipes/RecipeDetail";
|
||||
import { useRecipe } from "@/hooks/useRecipes";
|
||||
|
||||
interface RecipePreviewModalProps {
|
||||
recipeId: number | null;
|
||||
portions?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RecipePreviewModal({ recipeId, portions, onClose }: RecipePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: recipe, isLoading, isError } = useRecipe(recipeId ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recipeId) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [recipeId, onClose]);
|
||||
|
||||
if (!recipeId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card max-h-[90vh] w-full max-w-3xl overflow-y-auto p-6"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("generators.diet.viewRecipe")}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
{portions != null && portions !== 1 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("generators.diet.plannedPortions", { count: portions })}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" className="btn-secondary !px-2 !py-2" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t("common.loading")}
|
||||
</div>
|
||||
) : isError || !recipe ? (
|
||||
<p className="text-sm text-red-600">{t("errors.loadRecipeFailed")}</p>
|
||||
) : (
|
||||
<RecipeDetail recipe={recipe} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
meal-plan-frontend/src/components/ui/AppearanceSwitcher.tsx
Normal file
41
meal-plan-frontend/src/components/ui/AppearanceSwitcher.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppearance } from "@/store/appearanceStore";
|
||||
import type { AppearanceId } from "@/theme/appearance.config";
|
||||
|
||||
export function AppearanceSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { proposal, setAppearance, proposals } = useAppearance();
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<label htmlFor="appearance-select" className="sr-only">
|
||||
{t("appearance.label")}
|
||||
</label>
|
||||
<span
|
||||
className="hidden h-3 w-3 shrink-0 rounded-full sm:inline-block"
|
||||
style={{ backgroundColor: proposal.swatch }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="appearance-select"
|
||||
value={proposal.id}
|
||||
onChange={(e) => setAppearance(e.target.value as AppearanceId)}
|
||||
className="select !w-auto max-w-[9.5rem] !py-2 !pr-9 !text-sm sm:max-w-none"
|
||||
aria-label={t("appearance.label")}
|
||||
>
|
||||
{proposals.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{t(p.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { MEAL_CATEGORY_I18N_KEYS } from "@/i18n/useMealCategoryLabel";
|
||||
|
||||
const STYLES: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
@@ -12,11 +14,14 @@ interface CategoryBadgeProps {
|
||||
}
|
||||
|
||||
export function CategoryBadge({ category }: CategoryBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const labelKey = MEAL_CATEGORY_I18N_KEYS[category];
|
||||
|
||||
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"}
|
||||
{labelKey ? t(labelKey) : t("categories.unknown")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string;
|
||||
@@ -6,6 +7,8 @@ interface ErrorStateProps {
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: ErrorStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -15,7 +18,7 @@ export function ErrorState({ message, onRetry }: ErrorStateProps) {
|
||||
<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
|
||||
{t("common.tryAgain")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
29
meal-plan-frontend/src/components/ui/LanguageSwitcher.tsx
Normal file
29
meal-plan-frontend/src/components/ui/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ className = "" }: LanguageSwitcherProps) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const value = i18n.language.startsWith("pl") ? "pl" : "en";
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => void i18n.changeLanguage(e.target.value)}
|
||||
className="select !w-auto !py-2 !pr-9 !text-sm"
|
||||
aria-label={t("language.label")}
|
||||
>
|
||||
<option value="en">EN</option>
|
||||
<option value="pl">PL</option>
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
meal-plan-frontend/src/components/ui/LayoutSwitcher.tsx
Normal file
40
meal-plan-frontend/src/components/ui/LayoutSwitcher.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLayout, type LayoutId } from "@/store/layoutStore";
|
||||
|
||||
const OPTIONS: Array<{ id: LayoutId; labelKey: string }> = [
|
||||
{ id: "sidebar", labelKey: "layout.sidebar" },
|
||||
{ id: "topnav", labelKey: "layout.topnav" },
|
||||
{ id: "compact", labelKey: "layout.compact" },
|
||||
{ id: "wide", labelKey: "layout.wide" },
|
||||
];
|
||||
|
||||
export function LayoutSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { layout, setLayout } = useLayout();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label htmlFor="layout-select" className="sr-only">
|
||||
{t("layout.label")}
|
||||
</label>
|
||||
<select
|
||||
id="layout-select"
|
||||
value={layout}
|
||||
onChange={(e) => setLayout(e.target.value as LayoutId)}
|
||||
className="select !w-auto !py-2 !pr-9 !text-sm"
|
||||
aria-label={t("layout.label")}
|
||||
>
|
||||
{OPTIONS.map(({ id, labelKey }) => (
|
||||
<option key={id} value={id}>
|
||||
{t(labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
meal-plan-frontend/src/components/ui/NavIcon.tsx
Normal file
50
meal-plan-frontend/src/components/ui/NavIcon.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BookPlus,
|
||||
CalendarDays,
|
||||
Coffee,
|
||||
Croissant,
|
||||
LayoutDashboard,
|
||||
Leaf,
|
||||
ListChecks,
|
||||
Moon,
|
||||
Salad,
|
||||
ShoppingCart,
|
||||
Soup,
|
||||
} from "lucide-react";
|
||||
import { NAV_ICON_EMOJI, type NavIconName } from "@/theme/appearance.config";
|
||||
import { useAppearance } from "@/store/appearanceStore";
|
||||
|
||||
const ICON_MAP: Record<NavIconName, LucideIcon> = {
|
||||
dashboard: LayoutDashboard,
|
||||
coffee: Coffee,
|
||||
croissant: Croissant,
|
||||
soup: Soup,
|
||||
moon: Moon,
|
||||
list: ListChecks,
|
||||
book: BookPlus,
|
||||
leaf: Leaf,
|
||||
diet: Salad,
|
||||
calendar: CalendarDays,
|
||||
cart: ShoppingCart,
|
||||
};
|
||||
|
||||
interface NavIconProps {
|
||||
name: NavIconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NavIcon({ name, className = "h-5 w-5 shrink-0" }: NavIconProps) {
|
||||
const { useEmoji, strokeWidth } = useAppearance();
|
||||
|
||||
if (useEmoji) {
|
||||
return (
|
||||
<span className="text-lg leading-none" aria-hidden="true">
|
||||
{NAV_ICON_EMOJI[name]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = ICON_MAP[name];
|
||||
return <Icon className={className} strokeWidth={strokeWidth} aria-hidden="true" />;
|
||||
}
|
||||
28
meal-plan-frontend/src/components/ui/SortableTh.tsx
Normal file
28
meal-plan-frontend/src/components/ui/SortableTh.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SortDirection } from "@/utils/sort.util";
|
||||
import { sortIndicator } from "@/utils/sort.util";
|
||||
|
||||
const thButtonClass =
|
||||
"inline-flex items-center gap-1 font-inherit uppercase tracking-wide hover:text-slate-700 dark:hover:text-slate-200";
|
||||
|
||||
export function SortableTh({
|
||||
column,
|
||||
label,
|
||||
sort,
|
||||
onSort,
|
||||
className = "px-4 py-3",
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
sort: { column: string; direction: SortDirection } | null;
|
||||
onSort: (column: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th className={className}>
|
||||
<button type="button" className={thButtonClass} onClick={() => onSort(column)}>
|
||||
{label}
|
||||
{sortIndicator(sort, column)}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as authApi from "@/api/auth.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import type { LoginRequest, RegisterRequest } from "@/types/auth.types";
|
||||
|
||||
export function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const user = authStore.useStore((s) => s.user);
|
||||
@@ -52,13 +54,13 @@ export function useAuth() {
|
||||
login: loginMutation.mutateAsync,
|
||||
loginPending: loginMutation.isPending,
|
||||
loginError: loginMutation.isError
|
||||
? extractApiError(loginMutation.error, "Unable to sign in.")
|
||||
? extractApiError(loginMutation.error, t("errors.signInFailed"))
|
||||
: null,
|
||||
resetLoginError: loginMutation.reset,
|
||||
register: registerMutation.mutateAsync,
|
||||
registerPending: registerMutation.isPending,
|
||||
registerError: registerMutation.isError
|
||||
? extractApiError(registerMutation.error, "Unable to create account.")
|
||||
? extractApiError(registerMutation.error, t("errors.registerFailed"))
|
||||
: null,
|
||||
resetRegisterError: registerMutation.reset,
|
||||
logout,
|
||||
|
||||
13
meal-plan-frontend/src/hooks/useDebouncedValue.ts
Normal file
13
meal-plan-frontend/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Returns a copy of `value` that only updates after `delayMs` of no changes. */
|
||||
export function useDebouncedValue<T>(value: T, delayMs = 350): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
62
meal-plan-frontend/src/hooks/useRecipeManagement.ts
Normal file
62
meal-plan-frontend/src/hooks/useRecipeManagement.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as managementApi from "@/api/recipe-management.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import type { CreateRecipeInput } from "@/types/recipe-management.types";
|
||||
|
||||
export function useRecipeManagement() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateRecipes = () => queryClient.invalidateQueries({ queryKey: ["recipes"] });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (input: CreateRecipeInput) => managementApi.createRecipe(input),
|
||||
onSuccess: invalidateRecipes,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: CreateRecipeInput }) =>
|
||||
managementApi.updateRecipe(id, input),
|
||||
onSuccess: invalidateRecipes,
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => managementApi.importRecipesFromExcel(file),
|
||||
onSuccess: invalidateRecipes,
|
||||
});
|
||||
|
||||
const recalculateMutation = useMutation({
|
||||
mutationFn: () => managementApi.recalculateAllRecipeMacros(),
|
||||
onSuccess: invalidateRecipes,
|
||||
});
|
||||
|
||||
return {
|
||||
createRecipe: createMutation.mutateAsync,
|
||||
createPending: createMutation.isPending,
|
||||
createError: createMutation.isError
|
||||
? extractApiError(createMutation.error, t("errors.saveRecipeFailed"))
|
||||
: null,
|
||||
resetCreateError: createMutation.reset,
|
||||
updateRecipe: updateMutation.mutateAsync,
|
||||
updatePending: updateMutation.isPending,
|
||||
updateError: updateMutation.isError
|
||||
? extractApiError(updateMutation.error, t("errors.saveRecipeFailed"))
|
||||
: null,
|
||||
resetUpdateError: updateMutation.reset,
|
||||
importExcel: importMutation.mutateAsync,
|
||||
importPending: importMutation.isPending,
|
||||
importError: importMutation.isError
|
||||
? extractApiError(importMutation.error, t("errors.importFailed"))
|
||||
: null,
|
||||
importResult: importMutation.data ?? null,
|
||||
resetImport: importMutation.reset,
|
||||
downloadTemplate: managementApi.downloadImportTemplate,
|
||||
recalculateMacros: recalculateMutation.mutateAsync,
|
||||
recalculatePending: recalculateMutation.isPending,
|
||||
recalculateError: recalculateMutation.isError
|
||||
? extractApiError(recalculateMutation.error, t("errors.recalculateMacrosFailed"))
|
||||
: null,
|
||||
resetRecalculateError: recalculateMutation.reset,
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,16 @@ 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,
|
||||
list: (category?: MealCategory, search?: string, ingredient?: string) =>
|
||||
[...keys.all, "list", category ?? "all", search ?? "", ingredient ?? ""] as const,
|
||||
detail: (id: number) => [...keys.all, "detail", id] as const,
|
||||
categories: () => [...keys.all, "categories"] as const,
|
||||
};
|
||||
|
||||
export function useRecipes(category?: MealCategory, search?: string) {
|
||||
export function useRecipes(category?: MealCategory, search?: string, ingredient?: string) {
|
||||
return useQuery({
|
||||
queryKey: keys.list(category, search),
|
||||
queryFn: () => recipesApi.fetchRecipes({ category, search }),
|
||||
queryKey: keys.list(category, search, ingredient),
|
||||
queryFn: () => recipesApi.fetchRecipes({ category, search, ingredient }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
57
meal-plan-frontend/src/hooks/useReminderNotifications.ts
Normal file
57
meal-plan-frontend/src/hooks/useReminderNotifications.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchDueReminders } from "@/api/diet.api";
|
||||
import { isAuthenticated } from "@/store/authStore";
|
||||
|
||||
const POLL_MS = 60_000;
|
||||
const notifiedKeys = new Set<string>();
|
||||
|
||||
function slotKey(id: number, date: string, hour: number, minute: number): string {
|
||||
return `${date}:${hour}:${minute}:${id}`;
|
||||
}
|
||||
|
||||
export function useReminderNotifications(): void {
|
||||
const { t } = useTranslation();
|
||||
const permissionRef = useRef<NotificationPermission | "unsupported">("default");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof Notification === "undefined") {
|
||||
permissionRef.current = "unsupported";
|
||||
return;
|
||||
}
|
||||
permissionRef.current = Notification.permission;
|
||||
if (Notification.permission === "default") {
|
||||
void Notification.requestPermission().then((p) => {
|
||||
permissionRef.current = p;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
const tick = async () => {
|
||||
if (permissionRef.current !== "granted") return;
|
||||
try {
|
||||
const due = await fetchDueReminders();
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
for (const r of due) {
|
||||
const key = slotKey(r.id, date, now.getHours(), now.getMinutes());
|
||||
if (notifiedKeys.has(key)) continue;
|
||||
notifiedKeys.add(key);
|
||||
new Notification(r.title, {
|
||||
body: r.message ?? t("diet.reminderNotificationBody"),
|
||||
tag: key,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [t]);
|
||||
}
|
||||
34
meal-plan-frontend/src/i18n/index.ts
Normal file
34
meal-plan-frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import en from "./locales/en/translation.json";
|
||||
import pl from "./locales/pl/translation.json";
|
||||
|
||||
const STORAGE_KEY = "dailymeals.lang";
|
||||
|
||||
function detectLanguage(): string {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "en" || stored === "pl") {
|
||||
return stored;
|
||||
}
|
||||
return navigator.language.toLowerCase().startsWith("pl") ? "pl" : "en";
|
||||
}
|
||||
|
||||
const initialLanguage = detectLanguage();
|
||||
document.documentElement.lang = initialLanguage;
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
pl: { translation: pl },
|
||||
},
|
||||
lng: initialLanguage,
|
||||
fallbackLng: "en",
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
localStorage.setItem(STORAGE_KEY, lng);
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
435
meal-plan-frontend/src/i18n/locales/en/translation.json
Normal file
435
meal-plan-frontend/src/i18n/locales/en/translation.json
Normal file
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "DailyMeals"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"en": "English",
|
||||
"pl": "Polish"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"sidebar": "Sidebar",
|
||||
"topnav": "Top navigation",
|
||||
"compact": "Compact sidebar",
|
||||
"wide": "Wide sidebar",
|
||||
"wideTagline": "Plan your meals with ease"
|
||||
},
|
||||
"appearance": {
|
||||
"label": "Appearance",
|
||||
"forest": "Forest Classic",
|
||||
"forestDesc": "Green palette, outline icons",
|
||||
"ocean": "Ocean Fresh",
|
||||
"oceanDesc": "Blue palette, outline icons",
|
||||
"sunset": "Sunset Kitchen",
|
||||
"sunsetDesc": "Warm amber palette, emoji icons",
|
||||
"berry": "Berry Modern",
|
||||
"berryDesc": "Violet palette, bold icons"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"breakfast": "Breakfast",
|
||||
"secondBreakfast": "Second Breakfast",
|
||||
"lunch": "Lunch",
|
||||
"dinner": "Dinner",
|
||||
"allMeals": "All Meals",
|
||||
"manageMeals": "Manage Meals",
|
||||
"dietTracker": "My Diet",
|
||||
"ingredientCatalog": "Ingredients",
|
||||
"mealPlanner": "Meal Planner",
|
||||
"dietGenerator": "Diet Generator",
|
||||
"recipeGenerator": "Recipe Generator",
|
||||
"shoppingList": "Shopping List",
|
||||
"logout": "Logout",
|
||||
"user": "User",
|
||||
"toggleNav": "Toggle navigation",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"lightMode": "Switch to light mode",
|
||||
"darkMode": "Switch to dark mode"
|
||||
},
|
||||
"categories": {
|
||||
"breakfast": "Breakfast",
|
||||
"secondBreakfast": "Second Breakfast",
|
||||
"lunch": "Lunch",
|
||||
"dinner": "Dinner",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"tryAgain": "Try again",
|
||||
"back": "Back",
|
||||
"add": "Add",
|
||||
"category": "Category",
|
||||
"all": "All",
|
||||
"search": "Search",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"optional": "optional",
|
||||
"recipeCount_one": "{{count}} recipe",
|
||||
"recipeCount_other": "{{count}} recipes",
|
||||
"selectedCount_one": "{{count}} selected",
|
||||
"selectedCount_other": "{{count}} selected"
|
||||
},
|
||||
"auth": {
|
||||
"signInSubtitle": "Sign in to plan your meals",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"signIn": "Sign in",
|
||||
"signingIn": "Signing in...",
|
||||
"noAccount": "Don't have an account?",
|
||||
"createOne": "Create one",
|
||||
"createAccountTitle": "Create account",
|
||||
"createAccountSubtitle": "Join DailyMeals to browse and plan meals",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "Piotr",
|
||||
"confirmPassword": "Confirm password",
|
||||
"passwordHint": "At least 8 characters with uppercase, lowercase, and a number.",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"hasAccount": "Already have an account?"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required.",
|
||||
"emailInvalid": "Enter a valid email address.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"displayNameTooLong": "Display name is too long.",
|
||||
"passwordMinLength": "Password must be at least 8 characters.",
|
||||
"passwordUppercase": "Include at least one uppercase letter.",
|
||||
"passwordLowercase": "Include at least one lowercase letter.",
|
||||
"passwordDigit": "Include at least one digit.",
|
||||
"confirmPasswordRequired": "Please confirm your password.",
|
||||
"passwordsMismatch": "Passwords do not match.",
|
||||
"recipeNameRequired": "Recipe name is required.",
|
||||
"ingredientNameRequired": "Ingredient name is required.",
|
||||
"stepNumberMin": "Step number must be at least 1.",
|
||||
"stepDescriptionRequired": "Step description is required.",
|
||||
"stepsMin": "Add at least one preparation step."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong.",
|
||||
"signInFailed": "Unable to sign in.",
|
||||
"registerFailed": "Unable to create account.",
|
||||
"loadCategoriesFailed": "Failed to load categories.",
|
||||
"loadRecipesFailed": "Failed to load recipes.",
|
||||
"loadRecipeFailed": "Failed to load recipe.",
|
||||
"loadDietFailed": "Failed to load diet data.",
|
||||
"loadFailed": "Failed to load data.",
|
||||
"loadIngredientCatalogFailed": "Failed to load ingredient catalog.",
|
||||
"saveIngredientFailed": "Failed to save ingredient.",
|
||||
"saveRecipeFailed": "Failed to save recipe.",
|
||||
"recalculateMacrosFailed": "Failed to recalculate recipe macros.",
|
||||
"importFailed": "Import failed.",
|
||||
"pdfLoadFailed": "Could not load recipes for export.",
|
||||
"pdfGenerateFailed": "PDF generation failed.",
|
||||
"pdfRenderAreaMissing": "PDF render area not found.",
|
||||
"pdfNothingToExport": "Nothing to export."
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome back",
|
||||
"welcomeNamed": "Welcome back, {{name}}",
|
||||
"subtitle": "Browse recipes by meal category or explore everything at once.",
|
||||
"viewRecipes": "View recipes"
|
||||
},
|
||||
"recipes": {
|
||||
"allMealsTitle": "All Meals",
|
||||
"allMealsSubtitle": "Select recipes and export them with a combined shopping list.",
|
||||
"selectAllVisible": "Select all visible",
|
||||
"deselectAll": "Deselect all",
|
||||
"searchRecipes": "Search recipes...",
|
||||
"searchByIngredient": "Search by ingredient...",
|
||||
"searchByIngredientLabel": "Search by ingredient",
|
||||
"searchByNameAria": "Search recipes by name",
|
||||
"searchByIngredientAria": "Search recipes by ingredient",
|
||||
"noMatchingTitle": "No matching recipes",
|
||||
"noMatchingIngredient": "No recipes contain that ingredient. Try a different ingredient name.",
|
||||
"noMatchingFilter": "Try adjusting your search or category filter.",
|
||||
"emptyCategoryTitle": "No {{category}} recipes yet",
|
||||
"emptyCategoryDescription": "There are currently no recipes in this category.",
|
||||
"selectRecipe": "Select {{name}}",
|
||||
"prepTime": "{{count}} min preparation",
|
||||
"calories": "Calories",
|
||||
"protein": "Protein",
|
||||
"fat": "Fat",
|
||||
"carbs": "Carbs",
|
||||
"ingredients": "Ingredients",
|
||||
"preparation": "Preparation",
|
||||
"noIngredients": "No ingredients listed.",
|
||||
"noSteps": "No steps listed.",
|
||||
"kcal": "{{count}} kcal",
|
||||
"minutes": "{{count}} min"
|
||||
},
|
||||
"manage": {
|
||||
"title": "Manage Meals",
|
||||
"subtitle": "Add recipes manually or import them from an Excel spreadsheet.",
|
||||
"tabManual": "Add manually",
|
||||
"tabEdit": "Edit recipes",
|
||||
"tabImport": "Import Excel",
|
||||
"savedSuccess": "\"{{name}}\" saved successfully.",
|
||||
"updatedSuccess": "\"{{name}}\" updated successfully.",
|
||||
"selectRecipeToEdit": "Select a recipe from the list to edit.",
|
||||
"updateRecipe": "Update recipe",
|
||||
"catalogLink": "Link to ingredient catalog",
|
||||
"estimatedFromCatalog": "Estimated from linked catalog items",
|
||||
"unlinkedIngredientsWarning": "{{count}} ingredient(s) have grams but no catalog link — macros may be incomplete.",
|
||||
"recalculateMacros": "Recalculate all macros",
|
||||
"recalculating": "Recalculating…",
|
||||
"recalculateSuccess": "Updated {{updated}} of {{processed}} recipes. {{unlinked}} ingredient row(s) still unlinked.",
|
||||
"basicInfo": "Basic info",
|
||||
"recipeName": "Recipe name",
|
||||
"recipeNamePlaceholder": "Grilled chicken salad",
|
||||
"prepTimeMin": "Prep time (min)",
|
||||
"caloriesKcal": "Calories (kcal)",
|
||||
"proteinG": "Protein (g)",
|
||||
"fatG": "Fat (g)",
|
||||
"carbsG": "Carbs (g)",
|
||||
"preparationSteps": "Preparation steps",
|
||||
"addStep": "Add step",
|
||||
"stepPlaceholder": "Describe this step...",
|
||||
"ingredientName": "Name",
|
||||
"ingredientGrams": "Grams",
|
||||
"ingredientUnit": "Unit",
|
||||
"removeIngredient": "Remove ingredient",
|
||||
"removeStep": "Remove step",
|
||||
"saveRecipe": "Save recipe",
|
||||
"saving": "Saving...",
|
||||
"importTitle": "Import from Excel",
|
||||
"importDescription": "Use the template with three sheets: Recipes, Ingredients, and Steps. Recipe names must match across sheets.",
|
||||
"downloadTemplate": "Download template",
|
||||
"uploadLabel": "Upload filled .xlsx file",
|
||||
"importComplete": "Import complete",
|
||||
"createdCount": "Created: {{count}}",
|
||||
"skippedCount": "Skipped: {{count}}",
|
||||
"importRecipes": "Import recipes",
|
||||
"importing": "Importing...",
|
||||
"excelFormatTitle": "Excel format",
|
||||
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 0–3), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
|
||||
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
|
||||
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
|
||||
"duplicateSkipped": "Duplicate recipe names are skipped."
|
||||
},
|
||||
"generators": {
|
||||
"diet": {
|
||||
"title": "Diet Generator",
|
||||
"subtitle": "Calculate targets from your profile, build a weekly plan from your recipe catalog, and save it to the meal planner.",
|
||||
"profileTitle": "Your profile",
|
||||
"heightCm": "Height (cm)",
|
||||
"weightKg": "Weight (kg)",
|
||||
"age": "Age",
|
||||
"sex": "Sex",
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"activity": "Activity level",
|
||||
"sedentary": "Sedentary",
|
||||
"light": "Light",
|
||||
"moderate": "Moderate",
|
||||
"active": "Active",
|
||||
"veryActive": "Very active",
|
||||
"goal": "Goal",
|
||||
"loseWeight": "Lose weight",
|
||||
"maintain": "Maintain",
|
||||
"gainMuscle": "Gain muscle",
|
||||
"recomp": "Recomposition",
|
||||
"allergies": "Allergies / notes",
|
||||
"continue": "Calculate targets",
|
||||
"macrosTitle": "Daily targets",
|
||||
"acceptAndGenerate": "Accept & generate 7-day plan",
|
||||
"planHint": "Each day should be within ±10 kcal of your target. Lock meals you like, regenerate others.",
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
"viewRecipe": "Details",
|
||||
"noAlternativeMeals": "No other recipes are available for this meal — all suitable options are already in your plan.",
|
||||
"commit": "Save plan to calendar",
|
||||
"done": "Plan saved to your meal planner and daily goals.",
|
||||
"newPlan": "Create another plan",
|
||||
"error": "Diet generator failed."
|
||||
},
|
||||
"recipe": {
|
||||
"title": "Recipe Generator",
|
||||
"subtitle": "Create several recipe drafts with AI, review them, and save the ones you like.",
|
||||
"constraintsTitle": "What should we cook?",
|
||||
"prompt": "Describe the meal",
|
||||
"promptPlaceholder": "Light breakfast with oatmeal and fruit…",
|
||||
"targetCalories": "Target calories (kcal)",
|
||||
"targetCaloriesOptional": "e.g. 500",
|
||||
"targetCaloriesHint": "Ingredient amounts will be scaled to match (±10 kcal).",
|
||||
"caloriesWithTarget": "{{actual}} kcal (target: {{target}})",
|
||||
"maxPrep": "Max prep time (min)",
|
||||
"exclusions": "Exclude ingredients",
|
||||
"generate": "Generate recipe",
|
||||
"recipeCount": "How many recipes to generate",
|
||||
"reviewHint": "Review {{count}} generated recipes. Uncheck or remove ones you don't want, then save the rest.",
|
||||
"selectAll": "Select all",
|
||||
"remove": "Remove",
|
||||
"saveSelected": "Save selected ({{count}})",
|
||||
"doneMultiple": "{{count}} recipes saved to your library.",
|
||||
"generateMore": "Generate more recipes",
|
||||
"regenIngredients": "Regenerate ingredients",
|
||||
"regenSteps": "Regenerate steps",
|
||||
"regenFull": "Regenerate all",
|
||||
"save": "Save to library",
|
||||
"done": "Recipe saved.",
|
||||
"viewRecipe": "View recipe",
|
||||
"error": "Recipe generator failed."
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"generate": "Generate PDF",
|
||||
"preparing": "Preparing...",
|
||||
"generating": "Generating...",
|
||||
"prepTime": "Prep time: {{count}} min",
|
||||
"shoppingList": "Shopping List",
|
||||
"generatedOn_one": "Generated {{date}} · {{count}} recipe",
|
||||
"generatedOn_other": "Generated {{date}} · {{count}} recipes",
|
||||
"recipesInExport": "Recipes in this export",
|
||||
"toTaste": "to taste",
|
||||
"dayPlanTitle": "Daily meal plan — {{date}}",
|
||||
"emptySlot": "Not planned",
|
||||
"emptyShoppingList": "No shopping items for this day.",
|
||||
"plannedMealMeta": "{{slot}} · {{portions}}× portions",
|
||||
"dayShoppingSubtitle": "List for {{date}} · {{count}} meals"
|
||||
},
|
||||
"diet": {
|
||||
"title": "My Diet",
|
||||
"subtitle": "Track what you eat and drink, set daily targets, and stay on top of reminders.",
|
||||
"tabToday": "Today",
|
||||
"tabHistory": "History",
|
||||
"tabGoals": "Goals",
|
||||
"tabReminders": "Reminders",
|
||||
"goToday": "Today",
|
||||
"notToday": "not today",
|
||||
"quickLog": "Quick log",
|
||||
"copyYesterday": "Copy yesterday's meals",
|
||||
"recentMeals": "Recent",
|
||||
"favorites": "Favorites",
|
||||
"days": "days",
|
||||
"historyCalories": "Calories over time",
|
||||
"historyMax": "Peak: {{max}} kcal",
|
||||
"noHistory": "No history for this period.",
|
||||
"mealsOnDate": "Meals on {{date}}",
|
||||
"drinksOnDate": "Drinks on {{date}}",
|
||||
"editReminder": "Edit reminder",
|
||||
"reminderNotificationBody": "Time for your diet reminder.",
|
||||
"calories": "Calories",
|
||||
"water": "Water",
|
||||
"snack": "Snack",
|
||||
"logDrink": "Log a drink",
|
||||
"logMeal": "Mark a meal as eaten",
|
||||
"searchRecipe": "From a recipe",
|
||||
"orCustomMeal": "Or enter a custom meal name",
|
||||
"markEaten": "Mark as eaten",
|
||||
"mealsToday": "Meals today",
|
||||
"drinksToday": "Drinks today",
|
||||
"nothingLogged": "Nothing logged yet.",
|
||||
"noMacros": "No nutrition info",
|
||||
"removeEntry": "Remove entry",
|
||||
"dailyGoals": "Daily goals",
|
||||
"goalsHint": "Set targets like Lifesum or MyFitnessPal — the Today tab shows what is left.",
|
||||
"calorieGoal": "Calorie goal (kcal)",
|
||||
"waterGoal": "Water goal (ml)",
|
||||
"saveGoals": "Save goals",
|
||||
"addReminder": "Add reminder",
|
||||
"reminderType": "Type",
|
||||
"reminderWater": "Drink water",
|
||||
"reminderMeal": "Meal time",
|
||||
"reminderCustom": "Custom",
|
||||
"reminderTitle": "Title",
|
||||
"reminderTitlePlaceholder": "Drink a glass of water",
|
||||
"reminderTime": "Time",
|
||||
"saveReminder": "Save reminder",
|
||||
"yourReminders": "Your reminders",
|
||||
"noReminders": "No reminders yet.",
|
||||
"remaining": "Left",
|
||||
"portions": "Portions",
|
||||
"fromCatalog": "From ingredient catalog",
|
||||
"noCatalogItem": "None",
|
||||
"grams": "Amount (g)",
|
||||
"mealPreview": "This meal adds",
|
||||
"remainingAfter": "After logging you will still have",
|
||||
"suggestionsTitle": "What to eat next",
|
||||
"suggestionsHint": "Based on what you still need today.",
|
||||
"logSuggestion": "Log",
|
||||
"suggestionReasons": {
|
||||
"highProtein": "High protein",
|
||||
"needCarbs": "Good carbs",
|
||||
"needFat": "Healthy fats",
|
||||
"needCalories": "Energy boost",
|
||||
"balanced": "Balanced choice"
|
||||
}
|
||||
},
|
||||
"mealPlanner": {
|
||||
"title": "Meal Planner",
|
||||
"subtitle": "Plan recipes for each day of the week.",
|
||||
"today": "This week",
|
||||
"addRecipe": "Add recipe",
|
||||
"dailyTarget": "Daily calorie target",
|
||||
"daySummary": "{{logged}} / {{target}} kcal planned",
|
||||
"remaining": "{{count}} kcal left",
|
||||
"slotHint": "Suggested ~{{count}} kcal for this meal",
|
||||
"suggestionsTitle": "Suggestions for this slot",
|
||||
"noSuggestions": "Set a daily calorie target to see suggestions.",
|
||||
"estimatedMeal": "This meal: ~{{count}} kcal",
|
||||
"selectedRecipe": "Selected: {{name}}",
|
||||
"clearSelection": "Clear selection",
|
||||
"exportDay": "Export day plan (PDF)",
|
||||
"includingSelection": "incl. current pick: {{count}} kcal"
|
||||
},
|
||||
"shoppingList": {
|
||||
"title": "Shopping List",
|
||||
"subtitle": "Aggregated ingredients from your meal plan.",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"load": "Load list",
|
||||
"clearChecks": "Clear checked items",
|
||||
"empty": "No items for this date range.",
|
||||
"checklistHint": "Checked items are saved locally in your browser."
|
||||
},
|
||||
"ingredientCatalog": {
|
||||
"title": "Ingredient catalog",
|
||||
"subtitle": "Nutrition values per 100 g, grouped by food category.",
|
||||
"allCategories": "All",
|
||||
"searchPlaceholder": "Search ingredients…",
|
||||
"noResultsTitle": "No ingredients found",
|
||||
"noResultsDescription": "Try another category or search term.",
|
||||
"per100gNote": "All values are per 100 g.",
|
||||
"addIngredient": "Add ingredient",
|
||||
"editIngredient": "Edit ingredient",
|
||||
"selectCategory": "Select category",
|
||||
"nameEn": "Name (English)",
|
||||
"namePlLabel": "Name (Polish)",
|
||||
"formInvalid": "Please fill in category and name.",
|
||||
"deleteConfirm": "Remove this ingredient from the catalog?",
|
||||
"columns": {
|
||||
"name": "Ingredient",
|
||||
"category": "Category",
|
||||
"calories": "kcal",
|
||||
"protein": "Protein (g)",
|
||||
"fat": "Fat (g)",
|
||||
"carbs": "Carbs (g)",
|
||||
"fiber": "Fiber (g)"
|
||||
},
|
||||
"categories": {
|
||||
"meat": "Meat",
|
||||
"poultry": "Poultry",
|
||||
"fish": "Fish",
|
||||
"seafood": "Seafood",
|
||||
"vegetables": "Vegetables",
|
||||
"fruits": "Fruits",
|
||||
"dairy": "Dairy",
|
||||
"eggs": "Eggs",
|
||||
"grains": "Grains",
|
||||
"legumes": "Legumes",
|
||||
"nuts": "Nuts & seeds",
|
||||
"oils": "Oils & fats"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"description": "The page you're looking for doesn't exist.",
|
||||
"backToDashboard": "Back to dashboard"
|
||||
}
|
||||
}
|
||||
441
meal-plan-frontend/src/i18n/locales/pl/translation.json
Normal file
441
meal-plan-frontend/src/i18n/locales/pl/translation.json
Normal file
@@ -0,0 +1,441 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "DailyMeals"
|
||||
},
|
||||
"language": {
|
||||
"label": "Język",
|
||||
"en": "Angielski",
|
||||
"pl": "Polski"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Układ",
|
||||
"sidebar": "Panel boczny",
|
||||
"topnav": "Nawigacja górna",
|
||||
"compact": "Wąski panel",
|
||||
"wide": "Szeroki panel",
|
||||
"wideTagline": "Planuj posiłki wygodnie"
|
||||
},
|
||||
"appearance": {
|
||||
"label": "Wygląd",
|
||||
"forest": "Leśny klasyczny",
|
||||
"forestDesc": "Zielona paleta, ikony konturowe",
|
||||
"ocean": "Oceaniczna świeżość",
|
||||
"oceanDesc": "Niebieska paleta, ikony konturowe",
|
||||
"sunset": "Zachód kuchni",
|
||||
"sunsetDesc": "Ciepła bursztynowa paleta, emoji",
|
||||
"berry": "Jagodowy modern",
|
||||
"berryDesc": "Fioletowa paleta, pogrubione ikony"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"breakfast": "Śniadanie",
|
||||
"secondBreakfast": "Drugie śniadanie",
|
||||
"lunch": "Obiad",
|
||||
"dinner": "Kolacja",
|
||||
"allMeals": "Wszystkie posiłki",
|
||||
"manageMeals": "Zarządzaj posiłkami",
|
||||
"dietTracker": "Moja dieta",
|
||||
"ingredientCatalog": "Składniki",
|
||||
"mealPlanner": "Plan posiłków",
|
||||
"dietGenerator": "Generator diety",
|
||||
"recipeGenerator": "Generator przepisów",
|
||||
"shoppingList": "Lista zakupów",
|
||||
"logout": "Wyloguj",
|
||||
"user": "Użytkownik",
|
||||
"toggleNav": "Przełącz nawigację",
|
||||
"toggleTheme": "Przełącz motyw",
|
||||
"lightMode": "Przełącz na jasny motyw",
|
||||
"darkMode": "Przełącz na ciemny motyw"
|
||||
},
|
||||
"categories": {
|
||||
"breakfast": "Śniadanie",
|
||||
"secondBreakfast": "Drugie śniadanie",
|
||||
"lunch": "Obiad",
|
||||
"dinner": "Kolacja",
|
||||
"unknown": "Nieznana"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Ładowanie...",
|
||||
"tryAgain": "Spróbuj ponownie",
|
||||
"back": "Wstecz",
|
||||
"add": "Dodaj",
|
||||
"category": "Kategoria",
|
||||
"all": "Wszystkie",
|
||||
"search": "Szukaj",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"previous": "Wstecz",
|
||||
"next": "Dalej",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń",
|
||||
"optional": "opcjonalnie",
|
||||
"recipeCount_one": "{{count}} przepis",
|
||||
"recipeCount_few": "{{count}} przepisy",
|
||||
"recipeCount_many": "{{count}} przepisów",
|
||||
"recipeCount_other": "{{count}} przepisów",
|
||||
"selectedCount_one": "Wybrano: {{count}}",
|
||||
"selectedCount_few": "Wybrano: {{count}}",
|
||||
"selectedCount_many": "Wybrano: {{count}}",
|
||||
"selectedCount_other": "Wybrano: {{count}}"
|
||||
},
|
||||
"auth": {
|
||||
"signInSubtitle": "Zaloguj się, aby planować posiłki",
|
||||
"email": "E-mail",
|
||||
"password": "Hasło",
|
||||
"emailPlaceholder": "ty@example.com",
|
||||
"signIn": "Zaloguj się",
|
||||
"signingIn": "Logowanie...",
|
||||
"noAccount": "Nie masz konta?",
|
||||
"createOne": "Utwórz je",
|
||||
"createAccountTitle": "Utwórz konto",
|
||||
"createAccountSubtitle": "Dołącz do DailyMeals, aby przeglądać i planować posiłki",
|
||||
"displayName": "Nazwa wyświetlana",
|
||||
"displayNamePlaceholder": "Piotr",
|
||||
"confirmPassword": "Potwierdź hasło",
|
||||
"passwordHint": "Co najmniej 8 znaków, w tym wielka litera, mała litera i cyfra.",
|
||||
"createAccount": "Utwórz konto",
|
||||
"creatingAccount": "Tworzenie konta...",
|
||||
"hasAccount": "Masz już konto?"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-mail jest wymagany.",
|
||||
"emailInvalid": "Podaj prawidłowy adres e-mail.",
|
||||
"passwordRequired": "Hasło jest wymagane.",
|
||||
"displayNameTooLong": "Nazwa wyświetlana jest zbyt długa.",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 8 znaków.",
|
||||
"passwordUppercase": "Dołącz co najmniej jedną wielką literę.",
|
||||
"passwordLowercase": "Dołącz co najmniej jedną małą literę.",
|
||||
"passwordDigit": "Dołącz co najmniej jedną cyfrę.",
|
||||
"confirmPasswordRequired": "Potwierdź hasło.",
|
||||
"passwordsMismatch": "Hasła nie są identyczne.",
|
||||
"recipeNameRequired": "Nazwa przepisu jest wymagana.",
|
||||
"ingredientNameRequired": "Nazwa składnika jest wymagana.",
|
||||
"stepNumberMin": "Numer kroku musi być co najmniej 1.",
|
||||
"stepDescriptionRequired": "Opis kroku jest wymagany.",
|
||||
"stepsMin": "Dodaj co najmniej jeden krok przygotowania."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Coś poszło nie tak.",
|
||||
"signInFailed": "Nie udało się zalogować.",
|
||||
"registerFailed": "Nie udało się utworzyć konta.",
|
||||
"loadCategoriesFailed": "Nie udało się załadować kategorii.",
|
||||
"loadRecipesFailed": "Nie udało się załadować przepisów.",
|
||||
"loadRecipeFailed": "Nie udało się załadować przepisu.",
|
||||
"loadDietFailed": "Nie udało się załadować danych diety.",
|
||||
"loadFailed": "Nie udało się załadować danych.",
|
||||
"loadIngredientCatalogFailed": "Nie udało się załadować katalogu składników.",
|
||||
"saveIngredientFailed": "Nie udało się zapisać składnika.",
|
||||
"saveRecipeFailed": "Nie udało się zapisać przepisu.",
|
||||
"recalculateMacrosFailed": "Nie udało się przeliczyć makroskładników przepisów.",
|
||||
"importFailed": "Import nie powiódł się.",
|
||||
"pdfLoadFailed": "Nie udało się załadować przepisów do eksportu.",
|
||||
"pdfGenerateFailed": "Generowanie PDF nie powiodło się.",
|
||||
"pdfRenderAreaMissing": "Nie znaleziono obszaru renderowania PDF.",
|
||||
"pdfNothingToExport": "Brak danych do eksportu."
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Witaj ponownie",
|
||||
"welcomeNamed": "Witaj ponownie, {{name}}",
|
||||
"subtitle": "Przeglądaj przepisy według kategorii posiłków lub odkrywaj wszystkie naraz.",
|
||||
"viewRecipes": "Zobacz przepisy"
|
||||
},
|
||||
"recipes": {
|
||||
"allMealsTitle": "Wszystkie posiłki",
|
||||
"allMealsSubtitle": "Wybierz przepisy i wyeksportuj je wraz ze wspólną listą zakupów.",
|
||||
"selectAllVisible": "Zaznacz widoczne",
|
||||
"deselectAll": "Odznacz wszystkie",
|
||||
"searchRecipes": "Szukaj przepisów...",
|
||||
"searchByIngredient": "Szukaj po składniku...",
|
||||
"searchByIngredientLabel": "Szukaj po składniku",
|
||||
"searchByNameAria": "Szukaj przepisów po nazwie",
|
||||
"searchByIngredientAria": "Szukaj przepisów po składniku",
|
||||
"noMatchingTitle": "Brak pasujących przepisów",
|
||||
"noMatchingIngredient": "Żaden przepis nie zawiera tego składnika. Spróbuj innej nazwy.",
|
||||
"noMatchingFilter": "Spróbuj zmienić wyszukiwanie lub filtr kategorii.",
|
||||
"emptyCategoryTitle": "Brak przepisów: {{category}}",
|
||||
"emptyCategoryDescription": "W tej kategorii nie ma jeszcze żadnych przepisów.",
|
||||
"selectRecipe": "Wybierz {{name}}",
|
||||
"prepTime": "{{count}} min przygotowania",
|
||||
"calories": "Kalorie",
|
||||
"protein": "Białko",
|
||||
"fat": "Tłuszcz",
|
||||
"carbs": "Węglowodany",
|
||||
"ingredients": "Składniki",
|
||||
"preparation": "Przygotowanie",
|
||||
"noIngredients": "Brak składników.",
|
||||
"noSteps": "Brak kroków.",
|
||||
"kcal": "{{count}} kcal",
|
||||
"minutes": "{{count}} min"
|
||||
},
|
||||
"manage": {
|
||||
"title": "Zarządzaj posiłkami",
|
||||
"subtitle": "Dodawaj przepisy ręcznie lub importuj je z arkusza Excel.",
|
||||
"tabManual": "Dodaj ręcznie",
|
||||
"tabEdit": "Edytuj przepisy",
|
||||
"tabImport": "Import Excel",
|
||||
"savedSuccess": "„{{name}}” zapisano pomyślnie.",
|
||||
"updatedSuccess": "„{{name}}” zaktualizowano pomyślnie.",
|
||||
"selectRecipeToEdit": "Wybierz przepis z listy, aby go edytować.",
|
||||
"updateRecipe": "Zaktualizuj przepis",
|
||||
"catalogLink": "Powiąż z katalogiem składników",
|
||||
"estimatedFromCatalog": "Szacunkowo z powiązanych składników",
|
||||
"unlinkedIngredientsWarning": "{{count}} składnik(ów) ma gramy, ale brak powiązania z katalogiem — makra mogą być niepełne.",
|
||||
"recalculateMacros": "Przelicz wszystkie makra",
|
||||
"recalculating": "Przeliczanie…",
|
||||
"recalculateSuccess": "Zaktualizowano {{updated}} z {{processed}} przepisów. {{unlinked}} wierszy składników nadal niepowiązanych.",
|
||||
"basicInfo": "Podstawowe informacje",
|
||||
"recipeName": "Nazwa przepisu",
|
||||
"recipeNamePlaceholder": "Sałatka z grillowanym kurczakiem",
|
||||
"prepTimeMin": "Czas przygotowania (min)",
|
||||
"caloriesKcal": "Kalorie (kcal)",
|
||||
"proteinG": "Białko (g)",
|
||||
"fatG": "Tłuszcz (g)",
|
||||
"carbsG": "Węglowodany (g)",
|
||||
"preparationSteps": "Kroki przygotowania",
|
||||
"addStep": "Dodaj krok",
|
||||
"stepPlaceholder": "Opisz ten krok...",
|
||||
"ingredientName": "Nazwa",
|
||||
"ingredientGrams": "Gramy",
|
||||
"ingredientUnit": "Jednostka",
|
||||
"removeIngredient": "Usuń składnik",
|
||||
"removeStep": "Usuń krok",
|
||||
"saveRecipe": "Zapisz przepis",
|
||||
"saving": "Zapisywanie...",
|
||||
"importTitle": "Import z Excela",
|
||||
"importDescription": "Użyj szablonu z trzema arkuszami: Recipes, Ingredients i Steps. Nazwy przepisów muszą się zgadzać między arkuszami.",
|
||||
"downloadTemplate": "Pobierz szablon",
|
||||
"uploadLabel": "Prześlij wypełniony plik .xlsx",
|
||||
"importComplete": "Import zakończony",
|
||||
"createdCount": "Utworzono: {{count}}",
|
||||
"skippedCount": "Pominięto: {{count}}",
|
||||
"importRecipes": "Importuj przepisy",
|
||||
"importing": "Importowanie...",
|
||||
"excelFormatTitle": "Format Excela",
|
||||
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 0–3), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
|
||||
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
|
||||
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
|
||||
"duplicateSkipped": "Duplikaty nazw przepisów są pomijane."
|
||||
},
|
||||
"generators": {
|
||||
"diet": {
|
||||
"title": "Generator diety",
|
||||
"subtitle": "Oblicz cele z profilu, zbuduj tygodniowy plan z katalogu przepisów i zapisz w planerze.",
|
||||
"profileTitle": "Twój profil",
|
||||
"heightCm": "Wzrost (cm)",
|
||||
"weightKg": "Waga (kg)",
|
||||
"age": "Wiek",
|
||||
"sex": "Płeć",
|
||||
"male": "Mężczyzna",
|
||||
"female": "Kobieta",
|
||||
"activity": "Poziom aktywności",
|
||||
"sedentary": "Siedzący",
|
||||
"light": "Lekka",
|
||||
"moderate": "Umiarkowana",
|
||||
"active": "Aktywna",
|
||||
"veryActive": "Bardzo aktywna",
|
||||
"goal": "Cel",
|
||||
"loseWeight": "Redukcja wagi",
|
||||
"maintain": "Utrzymanie",
|
||||
"gainMuscle": "Budowa mięśni",
|
||||
"recomp": "Rekompozycja",
|
||||
"allergies": "Alergie / uwagi",
|
||||
"continue": "Oblicz cele",
|
||||
"macrosTitle": "Dzienne cele",
|
||||
"acceptAndGenerate": "Akceptuj i generuj plan 7 dni",
|
||||
"planHint": "Każdy dzień powinien mieścić się w ±10 kcal od celu. Zablokuj lub wygeneruj ponownie posiłki.",
|
||||
"lock": "Zablokuj",
|
||||
"unlock": "Odblokuj",
|
||||
"viewRecipe": "Szczegóły",
|
||||
"noAlternativeMeals": "Brak innych przepisów na ten posiłek — wszystkie pasujące opcje są już w planie.",
|
||||
"commit": "Zapisz plan w kalendarzu",
|
||||
"done": "Plan zapisany w planerze i celach dziennych.",
|
||||
"newPlan": "Utwórz kolejny plan",
|
||||
"error": "Generator diety nie powiódł się."
|
||||
},
|
||||
"recipe": {
|
||||
"title": "Generator przepisów",
|
||||
"subtitle": "Wygeneruj kilka propozycji przepisów z AI, przejrzyj je i zapisz te, które Ci pasują.",
|
||||
"constraintsTitle": "Co ugotować?",
|
||||
"prompt": "Opisz posiłek",
|
||||
"promptPlaceholder": "Lekkie śniadanie z owsianką i owocami…",
|
||||
"targetCalories": "Docelowa kaloryczność (kcal)",
|
||||
"targetCaloriesOptional": "np. 500",
|
||||
"targetCaloriesHint": "Składniki zostaną dopasowane do tej wartości (±10 kcal).",
|
||||
"caloriesWithTarget": "{{actual}} kcal (cel: {{target}})",
|
||||
"maxPrep": "Maks. czas przygotowania (min)",
|
||||
"exclusions": "Wyklucz składniki",
|
||||
"generate": "Generuj przepis",
|
||||
"recipeCount": "Ile przepisów wygenerować",
|
||||
"reviewHint": "Przejrzyj {{count}} wygenerowanych przepisów. Odznacz lub usuń te, których nie chcesz, i zapisz resztę.",
|
||||
"selectAll": "Zaznacz wszystkie",
|
||||
"remove": "Usuń",
|
||||
"saveSelected": "Zapisz zaznaczone ({{count}})",
|
||||
"doneMultiple": "Zapisano {{count}} przepisów w bibliotece.",
|
||||
"generateMore": "Generuj kolejne przepisy",
|
||||
"regenIngredients": "Generuj składniki ponownie",
|
||||
"regenSteps": "Generuj kroki ponownie",
|
||||
"regenFull": "Generuj całość ponownie",
|
||||
"save": "Zapisz w bibliotece",
|
||||
"done": "Przepis zapisany.",
|
||||
"viewRecipe": "Zobacz przepis",
|
||||
"error": "Generator przepisów nie powiódł się."
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"generate": "Generuj PDF",
|
||||
"preparing": "Przygotowywanie...",
|
||||
"generating": "Generowanie...",
|
||||
"prepTime": "Czas przygotowania: {{count}} min",
|
||||
"shoppingList": "Lista zakupów",
|
||||
"generatedOn_one": "Wygenerowano {{date}} · {{count}} przepis",
|
||||
"generatedOn_few": "Wygenerowano {{date}} · {{count}} przepisy",
|
||||
"generatedOn_many": "Wygenerowano {{date}} · {{count}} przepisów",
|
||||
"generatedOn_other": "Wygenerowano {{date}} · {{count}} przepisów",
|
||||
"recipesInExport": "Przepisy w tym eksporcie",
|
||||
"toTaste": "do smaku",
|
||||
"dayPlanTitle": "Plan posiłków — {{date}}",
|
||||
"emptySlot": "Nie zaplanowano",
|
||||
"emptyShoppingList": "Brak składników do zakupu dla tego dnia.",
|
||||
"plannedMealMeta": "{{slot}} · {{portions}}× porcji",
|
||||
"dayShoppingSubtitle": "Lista na {{date}} · {{count}} posiłków"
|
||||
},
|
||||
"diet": {
|
||||
"title": "Moja dieta",
|
||||
"subtitle": "Śledź posiłki i napoje, ustaw cele dzienne i przypomnienia.",
|
||||
"tabToday": "Dziś",
|
||||
"tabHistory": "Historia",
|
||||
"tabGoals": "Cele",
|
||||
"tabReminders": "Przypomnienia",
|
||||
"goToday": "Dziś",
|
||||
"notToday": "nie dziś",
|
||||
"quickLog": "Szybkie logowanie",
|
||||
"copyYesterday": "Skopiuj wczorajsze posiłki",
|
||||
"recentMeals": "Ostatnie",
|
||||
"favorites": "Ulubione",
|
||||
"days": "dni",
|
||||
"historyCalories": "Kalorie w czasie",
|
||||
"historyMax": "Szczyt: {{max}} kcal",
|
||||
"noHistory": "Brak historii w tym okresie.",
|
||||
"mealsOnDate": "Posiłki {{date}}",
|
||||
"drinksOnDate": "Napoje {{date}}",
|
||||
"editReminder": "Edytuj przypomnienie",
|
||||
"reminderNotificationBody": "Czas na przypomnienie dietetyczne.",
|
||||
"calories": "Kalorie",
|
||||
"water": "Woda",
|
||||
"snack": "Przekąska",
|
||||
"logDrink": "Dodaj napój",
|
||||
"logMeal": "Oznacz zjedzony posiłek",
|
||||
"searchRecipe": "Z przepisu",
|
||||
"orCustomMeal": "Lub wpisz własną nazwę posiłku",
|
||||
"markEaten": "Oznacz jako zjedzone",
|
||||
"mealsToday": "Posiłki dziś",
|
||||
"drinksToday": "Napoje dziś",
|
||||
"nothingLogged": "Nic jeszcze nie zalogowano.",
|
||||
"noMacros": "Brak danych odżywczych",
|
||||
"removeEntry": "Usuń wpis",
|
||||
"dailyGoals": "Cele dzienne",
|
||||
"goalsHint": "Ustaw cele jak w Lifesum lub MyFitnessPal — zakładka Dziś pokaże, co zostało.",
|
||||
"calorieGoal": "Cel kalorii (kcal)",
|
||||
"waterGoal": "Cel wody (ml)",
|
||||
"saveGoals": "Zapisz cele",
|
||||
"addReminder": "Dodaj przypomnienie",
|
||||
"reminderType": "Typ",
|
||||
"reminderWater": "Pij wodę",
|
||||
"reminderMeal": "Pora posiłku",
|
||||
"reminderCustom": "Własne",
|
||||
"reminderTitle": "Tytuł",
|
||||
"reminderTitlePlaceholder": "Wypij szklankę wody",
|
||||
"reminderTime": "Godzina",
|
||||
"saveReminder": "Zapisz przypomnienie",
|
||||
"yourReminders": "Twoje przypomnienia",
|
||||
"noReminders": "Brak przypomnień.",
|
||||
"remaining": "Pozostało",
|
||||
"portions": "Porcje",
|
||||
"fromCatalog": "Z katalogu składników",
|
||||
"noCatalogItem": "Brak",
|
||||
"grams": "Ilość (g)",
|
||||
"mealPreview": "Ten posiłek dodaje",
|
||||
"remainingAfter": "Po zapisaniu zostanie Ci",
|
||||
"suggestionsTitle": "Co warto zjeść dalej",
|
||||
"suggestionsHint": "Na podstawie tego, czego jeszcze brakuje dziś.",
|
||||
"logSuggestion": "Zapisz",
|
||||
"suggestionReasons": {
|
||||
"highProtein": "Dużo białka",
|
||||
"needCarbs": "Dobre węglowodany",
|
||||
"needFat": "Zdrowe tłuszcze",
|
||||
"needCalories": "Energia",
|
||||
"balanced": "Zbilansowany wybór"
|
||||
}
|
||||
},
|
||||
"mealPlanner": {
|
||||
"title": "Plan posiłków",
|
||||
"subtitle": "Planuj przepisy na każdy dzień tygodnia.",
|
||||
"today": "Ten tydzień",
|
||||
"addRecipe": "Dodaj przepis",
|
||||
"dailyTarget": "Cel kalorii na dzień",
|
||||
"daySummary": "Zaplanowano {{logged}} / {{target}} kcal",
|
||||
"remaining": "Pozostało {{count}} kcal",
|
||||
"slotHint": "Sugerowane ~{{count}} kcal na ten posiłek",
|
||||
"suggestionsTitle": "Propozycje na ten posiłek",
|
||||
"noSuggestions": "Ustaw cel kalorii na dzień, aby zobaczyć propozycje.",
|
||||
"estimatedMeal": "Ten posiłek: ~{{count}} kcal",
|
||||
"selectedRecipe": "Wybrany: {{name}}",
|
||||
"clearSelection": "Wyczyść wybór",
|
||||
"exportDay": "Eksportuj plan dnia (PDF)",
|
||||
"includingSelection": "w tym bieżący wybór: {{count}} kcal"
|
||||
},
|
||||
"shoppingList": {
|
||||
"title": "Lista zakupów",
|
||||
"subtitle": "Składniki zebrane z planu posiłków.",
|
||||
"from": "Od",
|
||||
"to": "Do",
|
||||
"load": "Wczytaj listę",
|
||||
"clearChecks": "Wyczyść zaznaczenia",
|
||||
"empty": "Brak pozycji w tym zakresie dat.",
|
||||
"checklistHint": "Zaznaczenia są zapisywane lokalnie w przeglądarce."
|
||||
},
|
||||
"ingredientCatalog": {
|
||||
"title": "Katalog składników",
|
||||
"subtitle": "Wartości odżywcze na 100 g, pogrupowane według kategorii.",
|
||||
"allCategories": "Wszystkie",
|
||||
"searchPlaceholder": "Szukaj składników…",
|
||||
"noResultsTitle": "Nie znaleziono składników",
|
||||
"noResultsDescription": "Spróbuj innej kategorii lub frazy.",
|
||||
"per100gNote": "Wszystkie wartości dotyczą 100 g.",
|
||||
"addIngredient": "Dodaj składnik",
|
||||
"editIngredient": "Edytuj składnik",
|
||||
"selectCategory": "Wybierz kategorię",
|
||||
"nameEn": "Nazwa (angielski)",
|
||||
"namePlLabel": "Nazwa (polski)",
|
||||
"formInvalid": "Wypełnij kategorię i nazwę.",
|
||||
"deleteConfirm": "Usunąć ten składnik z katalogu?",
|
||||
"columns": {
|
||||
"name": "Składnik",
|
||||
"category": "Kategoria",
|
||||
"calories": "kcal",
|
||||
"protein": "Białko (g)",
|
||||
"fat": "Tłuszcz (g)",
|
||||
"carbs": "Węglowodany (g)",
|
||||
"fiber": "Błonnik (g)"
|
||||
},
|
||||
"categories": {
|
||||
"meat": "Mięso",
|
||||
"poultry": "Drób",
|
||||
"fish": "Ryby",
|
||||
"seafood": "Owoce morza",
|
||||
"vegetables": "Warzywa",
|
||||
"fruits": "Owoce",
|
||||
"dairy": "Nabiał",
|
||||
"eggs": "Jaja",
|
||||
"grains": "Zboża",
|
||||
"legumes": "Rośliny strączkowe",
|
||||
"nuts": "Orzechy i nasiona",
|
||||
"oils": "Oleje i tłuszcze"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Nie znaleziono strony",
|
||||
"description": "Strona, której szukasz, nie istnieje.",
|
||||
"backToDashboard": "Wróć do panelu"
|
||||
}
|
||||
}
|
||||
24
meal-plan-frontend/src/i18n/useMealCategoryLabel.ts
Normal file
24
meal-plan-frontend/src/i18n/useMealCategoryLabel.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
export const MEAL_CATEGORY_I18N_KEYS: Record<MealCategory, string> = {
|
||||
[MealCategory.Breakfast]: "categories.breakfast",
|
||||
[MealCategory.SecondBreakfast]: "categories.secondBreakfast",
|
||||
[MealCategory.Lunch]: "categories.lunch",
|
||||
[MealCategory.Dinner]: "categories.dinner",
|
||||
};
|
||||
|
||||
export function useMealCategoryLabel(category: MealCategory): string {
|
||||
const { t } = useTranslation();
|
||||
return t(MEAL_CATEGORY_I18N_KEYS[category]);
|
||||
}
|
||||
|
||||
export function useMealCategoryLabels(): Record<MealCategory, string> {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
[MealCategory.Breakfast]: t(MEAL_CATEGORY_I18N_KEYS[MealCategory.Breakfast]),
|
||||
[MealCategory.SecondBreakfast]: t(MEAL_CATEGORY_I18N_KEYS[MealCategory.SecondBreakfast]),
|
||||
[MealCategory.Lunch]: t(MEAL_CATEGORY_I18N_KEYS[MealCategory.Lunch]),
|
||||
[MealCategory.Dinner]: t(MEAL_CATEGORY_I18N_KEYS[MealCategory.Dinner]),
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,78 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root,
|
||||
[data-color-theme="forest"] {
|
||||
--brand-50: #eefaf3;
|
||||
--brand-100: #d6f1e0;
|
||||
--brand-200: #b0e3c6;
|
||||
--brand-300: #7dcda4;
|
||||
--brand-400: #47b07e;
|
||||
--brand-500: #22935f;
|
||||
--brand-600: #16774c;
|
||||
--brand-700: #125f3f;
|
||||
--brand-800: #114c34;
|
||||
--brand-900: #0e3e2c;
|
||||
--surface-bg: #f8fafc;
|
||||
--surface-bg-dark: #020617;
|
||||
}
|
||||
|
||||
[data-color-theme="ocean"] {
|
||||
--brand-50: #eff6ff;
|
||||
--brand-100: #dbeafe;
|
||||
--brand-200: #bfdbfe;
|
||||
--brand-300: #93c5fd;
|
||||
--brand-400: #60a5fa;
|
||||
--brand-500: #3b82f6;
|
||||
--brand-600: #2563eb;
|
||||
--brand-700: #1d4ed8;
|
||||
--brand-800: #1e40af;
|
||||
--brand-900: #1e3a8a;
|
||||
--surface-bg: #f0f9ff;
|
||||
--surface-bg-dark: #0c1222;
|
||||
}
|
||||
|
||||
[data-color-theme="sunset"] {
|
||||
--brand-50: #fffbeb;
|
||||
--brand-100: #fef3c7;
|
||||
--brand-200: #fde68a;
|
||||
--brand-300: #fcd34d;
|
||||
--brand-400: #fbbf24;
|
||||
--brand-500: #f59e0b;
|
||||
--brand-600: #d97706;
|
||||
--brand-700: #b45309;
|
||||
--brand-800: #92400e;
|
||||
--brand-900: #78350f;
|
||||
--surface-bg: #fffbeb;
|
||||
--surface-bg-dark: #1c1410;
|
||||
}
|
||||
|
||||
[data-color-theme="berry"] {
|
||||
--brand-50: #f5f3ff;
|
||||
--brand-100: #ede9fe;
|
||||
--brand-200: #ddd6fe;
|
||||
--brand-300: #c4b5fd;
|
||||
--brand-400: #a78bfa;
|
||||
--brand-500: #8b5cf6;
|
||||
--brand-600: #7c3aed;
|
||||
--brand-700: #6d28d9;
|
||||
--brand-800: #5b21b6;
|
||||
--brand-900: #4c1d95;
|
||||
--surface-bg: #faf5ff;
|
||||
--surface-bg-dark: #140f1f;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100;
|
||||
background-color: var(--surface-bg);
|
||||
@apply text-slate-900 dark:text-slate-100;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: var(--surface-bg-dark);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -38,18 +104,27 @@
|
||||
|
||||
.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;
|
||||
placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-300
|
||||
dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:ring-brand-700;
|
||||
}
|
||||
|
||||
.select {
|
||||
@apply input appearance-none bg-none pr-10;
|
||||
min-height: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
@apply absolute -m-px h-px w-px overflow-hidden whitespace-nowrap border-0 p-0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
width: 794px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
z-index: -1;
|
||||
|
||||
40
meal-plan-frontend/src/lib/initAuth.ts
Normal file
40
meal-plan-frontend/src/lib/initAuth.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from "axios";
|
||||
import { fetchCurrentUser } from "@/api/auth.api";
|
||||
import { setTokens, setUser, authStore } from "@/store/authStore";
|
||||
import type { TokenResponse } from "@/types/auth.types";
|
||||
|
||||
const REFRESH_KEY = "dm_refresh_token";
|
||||
|
||||
export function persistRefreshToken(token: string | null): void {
|
||||
if (token) {
|
||||
localStorage.setItem(REFRESH_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export async function restoreSession(): Promise<boolean> {
|
||||
const stored = loadStoredRefreshToken();
|
||||
if (!stored) return false;
|
||||
|
||||
authStore.setState({ refreshToken: stored });
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<TokenResponse>("/api/auth/refresh", {
|
||||
refreshToken: stored,
|
||||
});
|
||||
setTokens(data);
|
||||
persistRefreshToken(data.refreshToken);
|
||||
const user = await fetchCurrentUser();
|
||||
setUser(user);
|
||||
return true;
|
||||
} catch {
|
||||
persistRefreshToken(null);
|
||||
authStore.setState({ accessToken: null, refreshToken: null, user: null });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import { initTheme } from "@/store/themeStore";
|
||||
import { initAppearance } from "@/store/appearanceStore";
|
||||
import { restoreSession } from "@/lib/initAuth";
|
||||
import { setAuthReady } from "@/store/authStore";
|
||||
import "@/i18n";
|
||||
import "./index.css";
|
||||
|
||||
initTheme();
|
||||
initAppearance();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -18,6 +23,8 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
void restoreSession().finally(() => setAuthReady(true));
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckSquare, Square, UtensilsCrossed } from "lucide-react";
|
||||
import { useRecipes } from "@/hooks/useRecipes";
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import { RecipeCard } from "@/components/recipes/RecipeCard";
|
||||
import { RecipeFilters, type CategoryFilter } from "@/components/recipes/RecipeFilters";
|
||||
import { PdfGenerator } from "@/components/pdf/PdfGenerator";
|
||||
@@ -8,22 +10,63 @@ import { GridSkeleton } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util";
|
||||
|
||||
type RecipeSortColumn = "name" | "calories" | "prepTime";
|
||||
|
||||
const RECIPE_SORT_COLUMNS: { id: RecipeSortColumn; labelKey: string }[] = [
|
||||
{ id: "name", labelKey: "manage.recipeName" },
|
||||
{ id: "calories", labelKey: "recipes.calories" },
|
||||
{ id: "prepTime", labelKey: "manage.prepTimeMin" },
|
||||
];
|
||||
|
||||
export function AllMealsPage() {
|
||||
const { data: recipes, isLoading, isError, error, refetch } = useRecipes();
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<CategoryFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchByIngredient, setSearchByIngredient] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [recipeSort, setRecipeSort] = useState<{ column: RecipeSortColumn; direction: SortDirection }>({
|
||||
column: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const debouncedSearch = useDebouncedValue(search.trim(), 350);
|
||||
const ingredientTerm = searchByIngredient && debouncedSearch ? debouncedSearch : undefined;
|
||||
|
||||
const { data: recipes, isLoading, isError, error, refetch } = useRecipes(
|
||||
undefined,
|
||||
undefined,
|
||||
ingredientTerm,
|
||||
);
|
||||
|
||||
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);
|
||||
const matchesSearch = searchByIngredient || term === "" || r.name.toLowerCase().includes(term);
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [recipes, filter, search]);
|
||||
}, [recipes, filter, search, searchByIngredient]);
|
||||
|
||||
const sortedVisible = useMemo(() => {
|
||||
const { column, direction } = recipeSort;
|
||||
return sortBy(visible, direction, (recipe) => {
|
||||
switch (column) {
|
||||
case "name":
|
||||
return recipe.name;
|
||||
case "calories":
|
||||
return recipe.calories;
|
||||
case "prepTime":
|
||||
return recipe.prepTimeMinutes;
|
||||
}
|
||||
});
|
||||
}, [visible, recipeSort]);
|
||||
|
||||
const toggleRecipeSort = (column: RecipeSortColumn) => {
|
||||
setRecipeSort((current) => nextSort(current, column));
|
||||
};
|
||||
|
||||
const toggleSelected = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
@@ -53,17 +96,22 @@ export function AllMealsPage() {
|
||||
<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>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{t("recipes.allMealsTitle")}</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("recipes.allMealsSubtitle")}</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
|
||||
{t("common.selectedCount", { count: selected.size })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<RecipeFilters active={filter} onChange={setFilter} search={search} onSearchChange={setSearch} />
|
||||
<RecipeFilters
|
||||
active={filter}
|
||||
onChange={setFilter}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchByIngredient={searchByIngredient}
|
||||
onSearchByIngredientChange={setSearchByIngredient}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
@@ -73,7 +121,7 @@ export function AllMealsPage() {
|
||||
className="btn-secondary"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Select all visible
|
||||
{t("recipes.selectAllVisible")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -82,7 +130,7 @@ export function AllMealsPage() {
|
||||
className="btn-secondary"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Deselect all
|
||||
{t("recipes.deselectAll")}
|
||||
</button>
|
||||
<div className="ml-auto">
|
||||
<PdfGenerator selectedIds={selectedIds} />
|
||||
@@ -92,29 +140,50 @@ export function AllMealsPage() {
|
||||
{isLoading && <GridSkeleton count={9} />}
|
||||
|
||||
{isError && (
|
||||
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
|
||||
<ErrorState
|
||||
message={extractApiError(error, t("errors.loadRecipesFailed"))}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && visible.length === 0 && (
|
||||
<EmptyState
|
||||
icon={UtensilsCrossed}
|
||||
title="No matching recipes"
|
||||
description="Try adjusting your search or category filter."
|
||||
title={t("recipes.noMatchingTitle")}
|
||||
description={
|
||||
searchByIngredient ? t("recipes.noMatchingIngredient") : t("recipes.noMatchingFilter")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!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 className="flex flex-wrap gap-2 text-sm">
|
||||
{RECIPE_SORT_COLUMNS.map(({ id, labelKey }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => toggleRecipeSort(id)}
|
||||
className="rounded-lg border border-slate-200 px-3 py-1.5 font-medium hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t(labelKey)}
|
||||
{sortIndicator(recipeSort, id)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedVisible.map((recipe) => (
|
||||
<RecipeCard
|
||||
key={recipe.id}
|
||||
recipe={recipe}
|
||||
selectable
|
||||
showCategory
|
||||
selected={selected.has(recipe.id)}
|
||||
onToggleSelected={toggleSelected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,49 +1,103 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { UtensilsCrossed } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRecipes } from "@/hooks/useRecipes";
|
||||
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { useMealCategoryLabel } from "@/i18n/useMealCategoryLabel";
|
||||
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";
|
||||
import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util";
|
||||
|
||||
type RecipeSortColumn = "name" | "calories" | "prepTime";
|
||||
|
||||
const RECIPE_SORT_COLUMNS: { id: RecipeSortColumn; labelKey: string }[] = [
|
||||
{ id: "name", labelKey: "manage.recipeName" },
|
||||
{ id: "calories", labelKey: "recipes.calories" },
|
||||
{ id: "prepTime", labelKey: "manage.prepTimeMin" },
|
||||
];
|
||||
|
||||
interface CategoryPageProps {
|
||||
category: MealCategory;
|
||||
}
|
||||
|
||||
export function CategoryPage({ category }: CategoryPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = useMealCategoryLabel(category);
|
||||
const { data: recipes, isLoading, isError, error, refetch } = useRecipes(category);
|
||||
const label = MEAL_CATEGORY_LABELS[category];
|
||||
const [recipeSort, setRecipeSort] = useState<{ column: RecipeSortColumn; direction: SortDirection }>({
|
||||
column: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const sortedRecipes = useMemo(() => {
|
||||
if (!recipes) return [];
|
||||
const { column, direction } = recipeSort;
|
||||
return sortBy(recipes, direction, (recipe) => {
|
||||
switch (column) {
|
||||
case "name":
|
||||
return recipe.name;
|
||||
case "calories":
|
||||
return recipe.calories;
|
||||
case "prepTime":
|
||||
return recipe.prepTimeMinutes;
|
||||
}
|
||||
});
|
||||
}, [recipes, recipeSort]);
|
||||
|
||||
const toggleRecipeSort = (column: RecipeSortColumn) => {
|
||||
setRecipeSort((current) => nextSort(current, column));
|
||||
};
|
||||
|
||||
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..."}
|
||||
{recipes ? t("common.recipeCount", { count: recipes.length }) : t("common.loading")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isLoading && <GridSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
|
||||
<ErrorState
|
||||
message={extractApiError(error, t("errors.loadRecipesFailed"))}
|
||||
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."
|
||||
title={t("recipes.emptyCategoryTitle", { category: label })}
|
||||
description={t("recipes.emptyCategoryDescription")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!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 className="flex flex-wrap gap-2 text-sm">
|
||||
{RECIPE_SORT_COLUMNS.map(({ id, labelKey }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => toggleRecipeSort(id)}
|
||||
className="rounded-lg border border-slate-200 px-3 py-1.5 font-medium hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t(labelKey)}
|
||||
{sortIndicator(recipeSort, id)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedRecipes.map((recipe) => (
|
||||
<RecipeCard key={recipe.id} recipe={recipe} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { MEAL_CATEGORY_ROUTES, MealCategory } from "@/types/recipe.types";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
@@ -20,22 +18,25 @@ const CARD_META: Record<MealCategory, { icon: LucideIcon; gradient: string }> =
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
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}` : ""}
|
||||
{user?.userName ? t("dashboard.welcomeNamed", { name: user.userName }) : t("dashboard.welcome")}
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
Browse recipes by meal category or explore everything at once.
|
||||
</p>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("dashboard.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState message={extractApiError(error, "Failed to load categories.")} onRetry={() => void refetch()} />
|
||||
<ErrorState
|
||||
message={extractApiError(error, t("errors.loadCategoriesFailed"))}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{isLoading
|
||||
@@ -54,16 +55,18 @@ export function DashboardPage() {
|
||||
>
|
||||
<Icon className="h-9 w-9 text-white" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Category</p>
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
{t("common.category")}
|
||||
</p>
|
||||
<h2 className="mt-1 text-xl font-bold">
|
||||
{MEAL_CATEGORY_LABELS[cat.category as MealCategory]}
|
||||
{categoryLabels[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"}
|
||||
{t("common.recipeCount", { count: cat.count })}
|
||||
</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" />
|
||||
{t("dashboard.viewRecipes")} <ArrowRight className="h-4 w-4 transition-all" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
364
meal-plan-frontend/src/pages/DietGeneratorPage.tsx
Normal file
364
meal-plan-frontend/src/pages/DietGeneratorPage.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { CheckCircle2, Eye, Loader2, RefreshCw, Sparkles } from "lucide-react";
|
||||
import {
|
||||
acceptDietMacros,
|
||||
commitDietPlan,
|
||||
createDietSession,
|
||||
fetchDietSession,
|
||||
generateDietPlan,
|
||||
proposeDietMacros,
|
||||
regenerateDietMeal,
|
||||
saveDietProfile,
|
||||
toggleDietMealLock,
|
||||
fetchDietProfile,
|
||||
} from "@/api/diet-generator.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import { DietMealPreviewModal } from "@/components/recipes/DietMealPreviewModal";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import type { DietGeneratorDraftMeal, DietGeneratorSession, DietUserProfile } from "@/types/generator.types";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
type Step = "profile" | "macros" | "plan" | "done";
|
||||
|
||||
function formatDraftMealMacros(meal: DietGeneratorDraftMeal): string {
|
||||
const parts = [`${meal.calories ?? "?"} kcal`];
|
||||
if (meal.proteinG != null) parts.push(`P ${meal.proteinG}g`);
|
||||
if (meal.fatG != null) parts.push(`F ${meal.fatG}g`);
|
||||
if (meal.carbsG != null) parts.push(`C ${meal.carbsG}g`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function isNoAlternativeMealsError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("response" in error)) return false;
|
||||
const response = (error as { response?: { status?: number } }).response;
|
||||
return response?.status === 400;
|
||||
}
|
||||
|
||||
const defaultProfile: DietUserProfile = {
|
||||
heightCm: 175,
|
||||
weightKg: 75,
|
||||
age: 30,
|
||||
sex: "male",
|
||||
activityLevel: "moderate",
|
||||
goal: "maintain",
|
||||
mealsPerDayMask: 15,
|
||||
allergiesNotes: "",
|
||||
dislikedFoods: "",
|
||||
};
|
||||
|
||||
export function DietGeneratorPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
const [step, setStep] = useState<Step>("profile");
|
||||
const [profile, setProfile] = useState<DietUserProfile>(defaultProfile);
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [macros, setMacros] = useState({ calories: 2000, proteinG: 150, fatG: 65, carbsG: 200, rationale: "" });
|
||||
const [session, setSession] = useState<DietGeneratorSession | null>(null);
|
||||
const [commitResult, setCommitResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewMeal, setPreviewMeal] = useState<DietGeneratorDraftMeal | null>(null);
|
||||
|
||||
useQuery({
|
||||
queryKey: ["diet-generator-profile"],
|
||||
queryFn: async () => {
|
||||
const p = await fetchDietProfile();
|
||||
if (p) setProfile({ ...defaultProfile, ...p });
|
||||
return p;
|
||||
},
|
||||
});
|
||||
|
||||
const refreshSession = async (id: number) => {
|
||||
const data = await fetchDietSession(id);
|
||||
setSession(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await saveDietProfile(profile);
|
||||
const session = await createDietSession({ planDayCount: 7, calorieToleranceKcal: 10 });
|
||||
setSessionId(session.id);
|
||||
if (session.proposedMacros) {
|
||||
setMacros({
|
||||
calories: session.proposedMacros.calories,
|
||||
proteinG: session.proposedMacros.proteinG,
|
||||
fatG: session.proposedMacros.fatG,
|
||||
carbsG: session.proposedMacros.carbsG,
|
||||
rationale: session.proposedMacros.rationale ?? "",
|
||||
});
|
||||
}
|
||||
const refined = await proposeDietMacros(session.id);
|
||||
if (refined.proposedMacros) {
|
||||
setMacros({
|
||||
calories: refined.proposedMacros.calories,
|
||||
proteinG: refined.proposedMacros.proteinG,
|
||||
fatG: refined.proposedMacros.fatG,
|
||||
carbsG: refined.proposedMacros.carbsG,
|
||||
rationale: refined.proposedMacros.rationale ?? "",
|
||||
});
|
||||
}
|
||||
setStep("macros");
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t("generators.diet.error")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptAndGenerate = async () => {
|
||||
if (!sessionId) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await acceptDietMacros(sessionId, macros);
|
||||
const plan = await generateDietPlan(sessionId);
|
||||
setSession(plan);
|
||||
setStep("plan");
|
||||
queryClient.invalidateQueries({ queryKey: ["meal-plan"] });
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t("generators.diet.error")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const mealsByDate = useMemo(() => {
|
||||
const map = new Map<string, typeof session extends undefined ? never : NonNullable<typeof session>["draftMeals"]>();
|
||||
for (const meal of session?.draftMeals ?? []) {
|
||||
const list = map.get(meal.planDate) ?? [];
|
||||
list.push(meal);
|
||||
map.set(meal.planDate, list);
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [session?.draftMeals]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-extrabold sm:text-3xl">
|
||||
<Sparkles className="h-7 w-7 text-brand-600" />
|
||||
{t("generators.diet.title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-500">{t("generators.diet.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
{info ? (
|
||||
<div role="status" className="rounded-xl bg-sky-50 p-3 text-sm text-sky-800 dark:bg-sky-950/40 dark:text-sky-200">
|
||||
{info}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "profile" ? (
|
||||
<section className="card space-y-4 p-6">
|
||||
<h2 className="text-lg font-bold">{t("generators.diet.profileTitle")}</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm">
|
||||
{t("generators.diet.heightCm")}
|
||||
<input type="number" className="input mt-1 w-full" value={profile.heightCm}
|
||||
onChange={(e) => setProfile({ ...profile, heightCm: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.diet.weightKg")}
|
||||
<input type="number" className="input mt-1 w-full" value={profile.weightKg}
|
||||
onChange={(e) => setProfile({ ...profile, weightKg: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.diet.age")}
|
||||
<input type="number" className="input mt-1 w-full" value={profile.age}
|
||||
onChange={(e) => setProfile({ ...profile, age: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.diet.sex")}
|
||||
<select className="select mt-1 w-full" value={profile.sex}
|
||||
onChange={(e) => setProfile({ ...profile, sex: e.target.value as DietUserProfile["sex"] })}>
|
||||
<option value="male">{t("generators.diet.male")}</option>
|
||||
<option value="female">{t("generators.diet.female")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm sm:col-span-2">
|
||||
{t("generators.diet.activity")}
|
||||
<select className="select mt-1 w-full" value={profile.activityLevel}
|
||||
onChange={(e) => setProfile({ ...profile, activityLevel: e.target.value as DietUserProfile["activityLevel"] })}>
|
||||
<option value="sedentary">{t("generators.diet.sedentary")}</option>
|
||||
<option value="light">{t("generators.diet.light")}</option>
|
||||
<option value="moderate">{t("generators.diet.moderate")}</option>
|
||||
<option value="active">{t("generators.diet.active")}</option>
|
||||
<option value="very_active">{t("generators.diet.veryActive")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm sm:col-span-2">
|
||||
{t("generators.diet.goal")}
|
||||
<select className="select mt-1 w-full" value={profile.goal}
|
||||
onChange={(e) => setProfile({ ...profile, goal: e.target.value as DietUserProfile["goal"] })}>
|
||||
<option value="lose_weight">{t("generators.diet.loseWeight")}</option>
|
||||
<option value="maintain">{t("generators.diet.maintain")}</option>
|
||||
<option value="gain_muscle">{t("generators.diet.gainMuscle")}</option>
|
||||
<option value="recomp">{t("generators.diet.recomp")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm sm:col-span-2">
|
||||
{t("generators.diet.allergies")}
|
||||
<textarea className="input mt-1 w-full" rows={2} value={profile.allergiesNotes ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, allergiesNotes: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="btn-primary" onClick={() => void startSession()}>
|
||||
{t("generators.diet.continue")}
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step === "macros" ? (
|
||||
<section className="card space-y-4 p-6">
|
||||
<h2 className="text-lg font-bold">{t("generators.diet.macrosTitle")}</h2>
|
||||
{macros.rationale ? <p className="text-sm text-slate-500">{macros.rationale}</p> : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm">
|
||||
{t("manage.caloriesKcal")}
|
||||
<input type="number" className="input mt-1 w-full" value={macros.calories}
|
||||
onChange={(e) => setMacros({ ...macros, calories: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("manage.proteinG")}
|
||||
<input type="number" className="input mt-1 w-full" value={macros.proteinG}
|
||||
onChange={(e) => setMacros({ ...macros, proteinG: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("manage.fatG")}
|
||||
<input type="number" className="input mt-1 w-full" value={macros.fatG}
|
||||
onChange={(e) => setMacros({ ...macros, fatG: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("manage.carbsG")}
|
||||
<input type="number" className="input mt-1 w-full" value={macros.carbsG}
|
||||
onChange={(e) => setMacros({ ...macros, carbsG: Number(e.target.value) })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="btn-secondary" onClick={() => setStep("profile")}>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={() => void acceptAndGenerate()}>
|
||||
{t("generators.diet.acceptAndGenerate")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step === "plan" && session ? (
|
||||
<section className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<p className="text-sm text-slate-500">{t("generators.diet.planHint")}</p>
|
||||
</div>
|
||||
{mealsByDate.map(([date, meals]) => {
|
||||
const summary = session.daySummaries.find((d) => d.planDate === date);
|
||||
return (
|
||||
<div key={date} className="card p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-bold">{date}</h3>
|
||||
<span className={`text-sm ${summary?.withinTolerance ? "text-brand-700" : "text-amber-700"}`}>
|
||||
{summary?.totalCalories ?? 0} / {summary?.targetCalories ?? 0} kcal
|
||||
{summary?.withinTolerance ? " ✓" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{meals.map((meal) => (
|
||||
<li key={meal.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 p-3 dark:bg-slate-900/40">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
{categoryLabels[meal.mealCategory as MealCategory] ?? meal.mealCategory}
|
||||
</p>
|
||||
<p className="font-medium">{meal.recipeName}</p>
|
||||
<p className="text-xs text-slate-500">{formatDraftMealMacros(meal)}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary inline-flex items-center gap-1 !px-2 !py-1 text-xs"
|
||||
onClick={() => setPreviewMeal(meal)}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("generators.diet.viewRecipe")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !px-2 !py-1 text-xs"
|
||||
onClick={() => void toggleDietMealLock(sessionId!, meal.id, !meal.isLocked).then(() => refreshSession(sessionId!))}>
|
||||
{meal.isLocked ? t("generators.diet.unlock") : t("generators.diet.lock")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !px-2 !py-1 text-xs" disabled={meal.isLocked}
|
||||
onClick={() => void regenerateDietMeal(sessionId!, meal.planDate, meal.mealCategory)
|
||||
.then((s) => {
|
||||
setSession(s);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (isNoAlternativeMealsError(e)) {
|
||||
setInfo(t("generators.diet.noAlternativeMeals"));
|
||||
setError(null);
|
||||
} else {
|
||||
setError(extractApiError(e, t("generators.diet.error")));
|
||||
setInfo(null);
|
||||
}
|
||||
})}>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="btn-primary"
|
||||
onClick={() => void commitDietPlan(sessionId!).then((r) => {
|
||||
setCommitResult(`${r.planStartDate} → ${r.planEndDate}`);
|
||||
setStep("done");
|
||||
queryClient.invalidateQueries({ queryKey: ["meal-plan"] });
|
||||
}).catch((e) => setError(extractApiError(e, t("generators.diet.error"))))}>
|
||||
{t("generators.diet.commit")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step === "done" ? (
|
||||
<div className="card flex items-center gap-3 p-6 text-brand-800 dark:text-brand-200">
|
||||
<CheckCircle2 className="h-6 w-6 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">{t("generators.diet.done")}</p>
|
||||
{commitResult ? <p className="text-sm">{commitResult}</p> : null}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link to="/meal-planner" className="btn-primary">{t("nav.mealPlanner")}</Link>
|
||||
<button type="button" className="btn-secondary" onClick={() => navigate("/diet-generator")}>
|
||||
{t("generators.diet.newPlan")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && step !== "done" ? (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t("common.loading")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DietMealPreviewModal meal={previewMeal} onClose={() => setPreviewMeal(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1079
meal-plan-frontend/src/pages/DietTrackerPage.tsx
Normal file
1079
meal-plan-frontend/src/pages/DietTrackerPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
403
meal-plan-frontend/src/pages/IngredientCatalogPage.tsx
Normal file
403
meal-plan-frontend/src/pages/IngredientCatalogPage.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createIngredientCatalogItem,
|
||||
deleteIngredientCatalogItem,
|
||||
fetchIngredientCatalogItems,
|
||||
fetchIngredientCategories,
|
||||
updateIngredientCatalogItem,
|
||||
} from "@/api/ingredient-catalog.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SortableTh } from "@/components/ui/SortableTh";
|
||||
import type { UpsertIngredientNutritionItemInput, IngredientNutritionItem } from "@/types/ingredient-catalog.types";
|
||||
import { ingredientDisplayName } from "@/utils/ingredient-catalog.util";
|
||||
import { nextSort, sortBy, type SortDirection } from "@/utils/sort.util";
|
||||
|
||||
type IngredientSortColumn = "name" | "category" | "calories" | "protein" | "fat" | "carbs" | "fiber";
|
||||
|
||||
const EMPTY_FORM: UpsertIngredientNutritionItemInput = {
|
||||
categoryId: 0,
|
||||
name: "",
|
||||
namePl: "",
|
||||
caloriesPer100G: 0,
|
||||
proteinGPer100G: 0,
|
||||
fatGPer100G: 0,
|
||||
carbsGPer100G: 0,
|
||||
fiberGPer100G: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export function IngredientCatalogPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<UpsertIngredientNutritionItemInput>({ ...EMPTY_FORM });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<{ column: IngredientSortColumn; direction: SortDirection }>({
|
||||
column: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ["ingredient-categories"],
|
||||
queryFn: fetchIngredientCategories,
|
||||
});
|
||||
|
||||
const itemsQuery = useQuery({
|
||||
queryKey: ["ingredient-catalog", category, debouncedSearch],
|
||||
queryFn: () =>
|
||||
fetchIngredientCatalogItems({
|
||||
category: category || undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
namePl: form.namePl?.trim() || null,
|
||||
fiberGPer100G: form.fiberGPer100G ?? null,
|
||||
};
|
||||
if (editingId) {
|
||||
return updateIngredientCatalogItem(editingId, payload);
|
||||
}
|
||||
return createIngredientCatalogItem(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setFormError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["ingredient-categories"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["ingredient-catalog"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setFormError(extractApiError(err, t("errors.saveIngredientFailed")));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteIngredientCatalogItem,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["ingredient-categories"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["ingredient-catalog"] });
|
||||
},
|
||||
});
|
||||
|
||||
const categoryLabel = (code: string, fallback: string) =>
|
||||
t(`ingredientCatalog.categories.${code}`, { defaultValue: fallback });
|
||||
|
||||
const totalCount = useMemo(
|
||||
() => categoriesQuery.data?.reduce((n, c) => n + c.itemCount, 0) ?? 0,
|
||||
[categoriesQuery.data],
|
||||
);
|
||||
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
window.clearTimeout((window as unknown as { __ingSearch?: number }).__ingSearch);
|
||||
(window as unknown as { __ingSearch?: number }).__ingSearch = window.setTimeout(
|
||||
() => setDebouncedSearch(value.trim()),
|
||||
300,
|
||||
);
|
||||
};
|
||||
|
||||
const openCreateForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({ ...EMPTY_FORM, categoryId: categoriesQuery.data?.[0]?.id ?? 0 });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEditForm = (item: IngredientNutritionItem) => {
|
||||
setEditingId(item.id);
|
||||
setForm({
|
||||
categoryId: item.categoryId,
|
||||
name: item.name,
|
||||
namePl: item.namePl ?? "",
|
||||
caloriesPer100G: item.caloriesPer100G,
|
||||
proteinGPer100G: item.proteinGPer100G,
|
||||
fatGPer100G: item.fatGPer100G,
|
||||
carbsGPer100G: item.carbsGPer100G,
|
||||
fiberGPer100G: item.fiberGPer100G,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
});
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const saveForm = () => {
|
||||
if (!form.categoryId || !form.name.trim()) {
|
||||
setFormError(t("ingredientCatalog.formInvalid"));
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
const deleteItem = (id: number) => {
|
||||
if (!window.confirm(t("ingredientCatalog.deleteConfirm"))) return;
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
const loading = categoriesQuery.isLoading || itemsQuery.isLoading;
|
||||
const error = categoriesQuery.error ?? itemsQuery.error;
|
||||
const items = itemsQuery.data ?? [];
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
const { column, direction } = sort;
|
||||
return sortBy(items, direction, (item) => {
|
||||
switch (column) {
|
||||
case "name":
|
||||
return ingredientDisplayName(item, i18n.language);
|
||||
case "category":
|
||||
return item.categoryCode;
|
||||
case "calories":
|
||||
return item.caloriesPer100G;
|
||||
case "protein":
|
||||
return Number(item.proteinGPer100G);
|
||||
case "fat":
|
||||
return Number(item.fatGPer100G);
|
||||
case "carbs":
|
||||
return Number(item.carbsGPer100G);
|
||||
case "fiber":
|
||||
return item.fiberGPer100G != null ? Number(item.fiberGPer100G) : null;
|
||||
}
|
||||
});
|
||||
}, [items, sort, i18n.language]);
|
||||
|
||||
const toggleSort = (column: IngredientSortColumn) => {
|
||||
setSort((current) => nextSort(current, column));
|
||||
};
|
||||
|
||||
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">
|
||||
{t("ingredientCatalog.title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("ingredientCatalog.subtitle")}</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary" onClick={openCreateForm}>
|
||||
+ {t("ingredientCatalog.addIngredient")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{showForm ? (
|
||||
<section className="card space-y-4 p-5">
|
||||
<h2 className="text-lg font-bold">
|
||||
{editingId ? t("ingredientCatalog.editIngredient") : t("ingredientCatalog.addIngredient")}
|
||||
</h2>
|
||||
{formError ? <p className="text-sm text-red-600">{formError}</p> : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm sm:col-span-2">
|
||||
<span className="font-medium">{t("ingredientCatalog.columns.category")}</span>
|
||||
<select
|
||||
className="select mt-1 w-full"
|
||||
value={form.categoryId}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, categoryId: Number(e.target.value) }))}
|
||||
>
|
||||
<option value={0} disabled>
|
||||
{t("ingredientCatalog.selectCategory")}
|
||||
</option>
|
||||
{(categoriesQuery.data ?? []).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{categoryLabel(cat.code, cat.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("ingredientCatalog.nameEn")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("ingredientCatalog.namePlLabel")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={form.namePl ?? ""}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, namePl: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
{(
|
||||
[
|
||||
["caloriesPer100G", "ingredientCatalog.columns.calories", 1],
|
||||
["proteinGPer100G", "ingredientCatalog.columns.protein", 0.1],
|
||||
["fatGPer100G", "ingredientCatalog.columns.fat", 0.1],
|
||||
["carbsGPer100G", "ingredientCatalog.columns.carbs", 0.1],
|
||||
["fiberGPer100G", "ingredientCatalog.columns.fiber", 0.1],
|
||||
] as const
|
||||
).map(([key, labelKey, step]) => (
|
||||
<label key={key} className="block text-sm">
|
||||
<span className="font-medium">{t(labelKey)}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={step}
|
||||
className="input mt-1 w-full"
|
||||
value={form[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[key]: e.target.value === "" ? null : Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={saveForm}
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={closeForm}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategory("")}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||
category === ""
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{t("ingredientCatalog.allCategories")} ({totalCount})
|
||||
</button>
|
||||
{(categoriesQuery.data ?? []).map((cat) => (
|
||||
<button
|
||||
key={cat.code}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat.code)}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||
category === cat.code
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{categoryLabel(cat.code, cat.name)} ({cat.itemCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="block max-w-md text-sm">
|
||||
<span className="font-medium">{t("common.search")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("ingredientCatalog.searchPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{loading && (
|
||||
<div className="card p-8 text-center text-slate-500">{t("common.loading")}</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ErrorState
|
||||
message={extractApiError(error, t("errors.loadIngredientCatalogFailed"))}
|
||||
onRetry={() => {
|
||||
categoriesQuery.refetch();
|
||||
itemsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<EmptyState
|
||||
title={t("ingredientCatalog.noResultsTitle")}
|
||||
description={t("ingredientCatalog.noResultsDescription")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && items.length > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<tr>
|
||||
<SortableTh column="name" label={t("ingredientCatalog.columns.name")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<SortableTh column="category" label={t("ingredientCatalog.columns.category")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<SortableTh column="calories" label={t("ingredientCatalog.columns.calories")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<SortableTh column="protein" label={t("ingredientCatalog.columns.protein")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<SortableTh column="fat" label={t("ingredientCatalog.columns.fat")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<SortableTh column="carbs" label={t("ingredientCatalog.columns.carbs")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<SortableTh column="fiber" label={t("ingredientCatalog.columns.fiber")} sort={sort} onSort={(c) => toggleSort(c as IngredientSortColumn)} />
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{sortedItems.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td className="px-4 py-3 font-medium">
|
||||
{ingredientDisplayName(item, i18n.language)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{categoryLabel(item.categoryCode, item.categoryName)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{item.caloriesPer100G}</td>
|
||||
<td className="px-4 py-3">{Number(item.proteinGPer100G).toFixed(1)}</td>
|
||||
<td className="px-4 py-3">{Number(item.fatGPer100G).toFixed(1)}</td>
|
||||
<td className="px-4 py-3">{Number(item.carbsGPer100G).toFixed(1)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.fiberGPer100G != null ? Number(item.fiberGPer100G).toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 text-brand-600"
|
||||
onClick={() => openEditForm(item)}
|
||||
>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-red-500"
|
||||
onClick={() => deleteItem(item.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="border-t border-slate-200 px-4 py-3 text-xs text-slate-400 dark:border-slate-700">
|
||||
{t("ingredientCatalog.per100gNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } 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 { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import { LanguageSwitcher } from "@/components/ui/LanguageSwitcher";
|
||||
|
||||
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>;
|
||||
type LoginForm = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
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 loginSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.emailInvalid")),
|
||||
password: z.string().min(1, t("validation.passwordRequired")),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -50,24 +60,25 @@ export function LoginPage() {
|
||||
|
||||
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="absolute right-4 top-4 flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="btn-secondary !px-2.5 !py-2"
|
||||
aria-label={t("nav.toggleTheme")}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h1 className="mt-4 text-2xl font-extrabold tracking-tight">{t("app.name")}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t("auth.signInSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card space-y-5 p-6 sm:p-8" noValidate>
|
||||
@@ -83,14 +94,14 @@ export function LoginPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
|
||||
Email
|
||||
{t("auth.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email && <p className="mt-1.5 text-sm text-red-600">{errors.email.message}</p>}
|
||||
@@ -98,7 +109,7 @@ export function LoginPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium">
|
||||
Password
|
||||
{t("auth.password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -115,13 +126,13 @@ export function LoginPage() {
|
||||
|
||||
<button type="submit" disabled={loginPending} className="btn-primary w-full">
|
||||
{loginPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{loginPending ? "Signing in..." : "Sign in"}
|
||||
{loginPending ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Don't have an account?{" "}
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link to="/register" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Create one
|
||||
{t("auth.createOne")}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
209
meal-plan-frontend/src/pages/ManageMealsPage.tsx
Normal file
209
meal-plan-frontend/src/pages/ManageMealsPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, PenLine, RefreshCw, Upload } from "lucide-react";
|
||||
import { RecipeForm } from "@/components/recipes/RecipeForm";
|
||||
import { ExcelImportPanel } from "@/components/recipes/ExcelImportPanel";
|
||||
import { useRecipeManagement } from "@/hooks/useRecipeManagement";
|
||||
import { fetchRecipeById, fetchRecipes } from "@/api/recipes.api";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import type { CreateRecipeInput } from "@/types/recipe-management.types";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
|
||||
type Tab = "manual" | "import" | "edit";
|
||||
|
||||
export function ManageMealsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<Tab>("manual");
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const {
|
||||
createRecipe,
|
||||
createPending,
|
||||
createError,
|
||||
resetCreateError,
|
||||
updateRecipe,
|
||||
updatePending,
|
||||
updateError,
|
||||
resetUpdateError,
|
||||
recalculateMacros,
|
||||
recalculatePending,
|
||||
recalculateError,
|
||||
resetRecalculateError,
|
||||
} = useRecipeManagement();
|
||||
|
||||
const recipesQuery = useQuery({
|
||||
queryKey: ["recipes", "manage-edit"],
|
||||
queryFn: () => fetchRecipes({}),
|
||||
enabled: tab === "edit",
|
||||
});
|
||||
|
||||
const editRecipeQuery = useQuery({
|
||||
queryKey: ["recipes", "detail", editingId],
|
||||
queryFn: () => fetchRecipeById(editingId!),
|
||||
enabled: editingId != null,
|
||||
});
|
||||
|
||||
const handleCreate = async (input: CreateRecipeInput) => {
|
||||
resetCreateError();
|
||||
setSuccessMessage(null);
|
||||
const recipe = await createRecipe(input);
|
||||
setSuccessMessage(t("manage.savedSuccess", { name: recipe.name }));
|
||||
setTab("manual");
|
||||
};
|
||||
|
||||
const handleUpdate = async (input: CreateRecipeInput) => {
|
||||
if (!editingId) return;
|
||||
resetUpdateError();
|
||||
setSuccessMessage(null);
|
||||
const recipe = await updateRecipe({ id: editingId, input });
|
||||
setSuccessMessage(t("manage.updatedSuccess", { name: recipe.name }));
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleRecalculate = async () => {
|
||||
resetRecalculateError();
|
||||
setSuccessMessage(null);
|
||||
const result = await recalculateMacros();
|
||||
setSuccessMessage(
|
||||
t("manage.recalculateSuccess", {
|
||||
updated: result.recipesUpdated,
|
||||
processed: result.recipesProcessed,
|
||||
unlinked: result.unlinkedIngredientRows,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const editInitialValues: CreateRecipeInput | undefined = editRecipeQuery.data
|
||||
? {
|
||||
name: editRecipeQuery.data.name,
|
||||
mealCategory: editRecipeQuery.data.mealCategory as MealCategory,
|
||||
calories: editRecipeQuery.data.calories,
|
||||
protein: editRecipeQuery.data.protein,
|
||||
fat: editRecipeQuery.data.fat,
|
||||
carbs: editRecipeQuery.data.carbs,
|
||||
prepTimeMinutes: editRecipeQuery.data.prepTimeMinutes,
|
||||
ingredients: editRecipeQuery.data.ingredients.map((i) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams,
|
||||
unit: i.unit ?? "",
|
||||
sortOrder: i.sortOrder,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: editRecipeQuery.data.steps.map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
description: s.description,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{t("manage.title")}</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("manage.subtitle")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary shrink-0"
|
||||
disabled={recalculatePending}
|
||||
onClick={() => void handleRecalculate()}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${recalculatePending ? "animate-spin" : ""}`} />
|
||||
{recalculatePending ? t("manage.recalculating") : t("manage.recalculateMacros")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{recalculateError ? (
|
||||
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{recalculateError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2 border-b border-slate-200 dark:border-slate-800">
|
||||
{(
|
||||
[
|
||||
{ id: "manual" as const, label: t("manage.tabManual"), icon: PenLine },
|
||||
{ id: "edit" as const, label: t("manage.tabEdit"), icon: PenLine },
|
||||
{ id: "import" as const, label: t("manage.tabImport"), icon: Upload },
|
||||
] as const
|
||||
).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
if (id !== "edit") setEditingId(null);
|
||||
}}
|
||||
className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition ${
|
||||
tab === id
|
||||
? "border-brand-600 text-brand-600"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{successMessage && (tab === "manual" || tab === "edit") && (
|
||||
<div className="flex items-center gap-2 rounded-xl bg-brand-50 p-3 text-sm font-medium text-brand-800 dark:bg-brand-950/40 dark:text-brand-200">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "manual" ? (
|
||||
<RecipeForm onSubmit={handleCreate} pending={createPending} error={createError} />
|
||||
) : tab === "import" ? (
|
||||
<ExcelImportPanel />
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="card p-5">
|
||||
<h2 className="text-lg font-bold">{t("manage.tabEdit")}</h2>
|
||||
{recipesQuery.isLoading ? (
|
||||
<Skeleton className="mt-4 h-32" />
|
||||
) : (
|
||||
<ul className="mt-4 max-h-96 space-y-1 overflow-y-auto">
|
||||
{(recipesQuery.data ?? []).map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(r.id)}
|
||||
className={`w-full rounded-lg px-3 py-2 text-left text-sm ${
|
||||
editingId === r.id
|
||||
? "bg-brand-600 text-white"
|
||||
: "hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{editingId == null ? (
|
||||
<p className="text-sm text-slate-400">{t("manage.selectRecipeToEdit")}</p>
|
||||
) : editRecipeQuery.isLoading || !editInitialValues ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : (
|
||||
<RecipeForm
|
||||
key={editingId}
|
||||
initialValues={editInitialValues}
|
||||
onSubmit={handleUpdate}
|
||||
pending={updatePending}
|
||||
error={updateError}
|
||||
submitLabel={t("manage.updateRecipe")}
|
||||
resetOnSubmit={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
408
meal-plan-frontend/src/pages/MealPlannerPage.tsx
Normal file
408
meal-plan-frontend/src/pages/MealPlannerPage.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
deleteMealPlanEntry,
|
||||
fetchMealPlanEntries,
|
||||
upsertMealPlanEntry,
|
||||
} from "@/api/meal-plan.api";
|
||||
import { fetchRecipes } from "@/api/recipes.api";
|
||||
import { fetchGoals } from "@/api/diet.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { MealPlanDayPdfExport } from "@/components/pdf/MealPlanDayPdfExport";
|
||||
import type { MealPlanEntry } from "@/types/meal-plan.types";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import {
|
||||
computeDayMealPlanning,
|
||||
entryCaloriesTotal,
|
||||
estimateEntryCalories,
|
||||
rankRecipeSuggestions,
|
||||
} from "@/utils/mealPlannerSuggestions.util";
|
||||
import {
|
||||
addDaysIso,
|
||||
endOfWeekIso,
|
||||
formatDisplayDate,
|
||||
startOfWeekIso,
|
||||
todayIso,
|
||||
} from "@/utils/date.util";
|
||||
|
||||
const PLANNER_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
export function MealPlannerPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeekIso(todayIso()));
|
||||
const [adding, setAdding] = useState<{ date: string; category: number } | null>(null);
|
||||
const [recipeSearch, setRecipeSearch] = useState("");
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
|
||||
const [portions, setPortions] = useState(1);
|
||||
const [dailyTargets, setDailyTargets] = useState<Record<string, number>>({});
|
||||
|
||||
const weekEnd = endOfWeekIso(weekStart);
|
||||
const weekDays = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const iso = addDaysIso(weekStart, i);
|
||||
return { iso, label: formatDisplayDate(iso, i18n.language) };
|
||||
}),
|
||||
[weekStart, i18n.language],
|
||||
);
|
||||
|
||||
const entriesQuery = useQuery({
|
||||
queryKey: ["meal-plan", weekStart, weekEnd],
|
||||
queryFn: () => fetchMealPlanEntries(weekStart, weekEnd),
|
||||
});
|
||||
|
||||
const goalsQuery = useQuery({
|
||||
queryKey: ["diet-goals"],
|
||||
queryFn: fetchGoals,
|
||||
});
|
||||
|
||||
const isSearching = recipeSearch.trim().length >= 2;
|
||||
|
||||
const recipesQuery = useQuery({
|
||||
queryKey: ["recipes", "planner-search", recipeSearch],
|
||||
queryFn: () => fetchRecipes({ search: recipeSearch }),
|
||||
enabled: !!adding && isSearching,
|
||||
});
|
||||
|
||||
const suggestionsQuery = useQuery({
|
||||
queryKey: ["recipes", "planner-suggestions", adding?.category],
|
||||
queryFn: () => fetchRecipes({ category: adding!.category as MealCategory }),
|
||||
enabled: !!adding,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: upsertMealPlanEntry,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["meal-plan"] });
|
||||
setAdding(null);
|
||||
setRecipeSearch("");
|
||||
setSelectedRecipeId(null);
|
||||
setPortions(1);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteMealPlanEntry,
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["meal-plan"] }),
|
||||
});
|
||||
|
||||
const entries = entriesQuery.data ?? [];
|
||||
|
||||
const dailyTarget = adding
|
||||
? (dailyTargets[adding.date] ?? goalsQuery.data?.calorieGoal ?? 0)
|
||||
: 0;
|
||||
|
||||
const selectedRecipe = useMemo(() => {
|
||||
if (!selectedRecipeId) return null;
|
||||
return (
|
||||
recipesQuery.data?.find((r) => r.id === selectedRecipeId) ??
|
||||
suggestionsQuery.data?.find((r) => r.id === selectedRecipeId) ??
|
||||
null
|
||||
);
|
||||
}, [selectedRecipeId, recipesQuery.data, suggestionsQuery.data]);
|
||||
|
||||
const estimatedMealCalories = estimateEntryCalories(selectedRecipe, portions);
|
||||
|
||||
const dayPlanning = useMemo(() => {
|
||||
if (!adding) return null;
|
||||
const pending =
|
||||
estimatedMealCalories != null && estimatedMealCalories > 0
|
||||
? { category: adding.category, calories: estimatedMealCalories }
|
||||
: null;
|
||||
return computeDayMealPlanning(adding.date, adding.category, entries, dailyTarget, pending);
|
||||
}, [adding, entries, dailyTarget, estimatedMealCalories]);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!adding || !dayPlanning || !suggestionsQuery.data) return [];
|
||||
return rankRecipeSuggestions(suggestionsQuery.data, adding.category, dayPlanning.slotTarget);
|
||||
}, [adding, dayPlanning, suggestionsQuery.data]);
|
||||
|
||||
function selectFromSearch(recipeId: number) {
|
||||
setSelectedRecipeId(recipeId);
|
||||
setRecipeSearch("");
|
||||
}
|
||||
|
||||
function selectFromSuggestion(recipeId: number) {
|
||||
setSelectedRecipeId(recipeId);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedRecipeId(null);
|
||||
}
|
||||
|
||||
function entriesFor(date: string, category: number): MealPlanEntry[] {
|
||||
return entries.filter((e) => e.planDate === date && e.mealCategory === category);
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
if (!adding || !selectedRecipeId) return;
|
||||
await saveMutation.mutateAsync({
|
||||
planDate: adding.date,
|
||||
mealCategory: adding.category,
|
||||
recipeId: selectedRecipeId,
|
||||
portions,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{t("mealPlanner.title")}</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("mealPlanner.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn-secondary !py-1.5 !text-sm" onClick={() => setWeekStart(addDaysIso(weekStart, -7))}>
|
||||
←
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !py-1.5 !text-sm" onClick={() => setWeekStart(startOfWeekIso(todayIso()))}>
|
||||
{t("mealPlanner.today")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !py-1.5 !text-sm" onClick={() => setWeekStart(addDaysIso(weekStart, 7))}>
|
||||
→
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
{formatDisplayDate(weekStart, i18n.language)} – {formatDisplayDate(weekEnd, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entriesQuery.isLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : entriesQuery.isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(entriesQuery.error, t("errors.loadFailed"))}
|
||||
onRetry={() => void entriesQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left font-semibold text-slate-500">{t("common.category")}</th>
|
||||
{weekDays.map((day) => (
|
||||
<th
|
||||
key={day.iso}
|
||||
className={`p-2 text-left font-semibold ${day.iso === todayIso() ? "text-brand-600" : ""}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{day.label}</span>
|
||||
<MealPlanDayPdfExport date={day.iso} entries={entries} />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PLANNER_CATEGORIES.map((cat) => (
|
||||
<tr key={cat} className="border-t border-slate-200 dark:border-slate-800">
|
||||
<td className="p-2 font-medium">{categoryLabels[cat]}</td>
|
||||
{weekDays.map((day) => (
|
||||
<td key={day.iso} className="p-2 align-top">
|
||||
{entriesFor(day.iso, cat).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="mb-1 flex items-start justify-between gap-1 rounded-lg bg-slate-50 px-2 py-1 dark:bg-slate-800/50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{entry.recipeName}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{entry.portions}× · {entryCaloriesTotal(entry) || "?"} kcal
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
onClick={() => void deleteMutation.mutateAsync(entry.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-xs font-semibold text-brand-600 hover:underline"
|
||||
onClick={() => {
|
||||
setAdding({ date: day.iso, category: cat });
|
||||
setRecipeSearch("");
|
||||
setSelectedRecipeId(null);
|
||||
}}
|
||||
>
|
||||
+ {t("mealPlanner.addRecipe")}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding ? (
|
||||
<div className="card space-y-4 p-5">
|
||||
<h2 className="text-lg font-bold">{t("mealPlanner.addRecipe")}</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{formatDisplayDate(adding.date, i18n.language)} · {categoryLabels[adding.category as MealCategory]}
|
||||
</p>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("mealPlanner.dailyTarget")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={50}
|
||||
className="input mt-1 w-full"
|
||||
value={dailyTarget || ""}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setDailyTargets((prev) => ({
|
||||
...prev,
|
||||
[adding.date]: Number.isFinite(value) ? value : 0,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{dayPlanning && dailyTarget > 0 ? (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:bg-slate-800/50 dark:text-slate-300">
|
||||
<p>
|
||||
{t("mealPlanner.daySummary", {
|
||||
logged: dayPlanning.logged,
|
||||
target: dayPlanning.dailyTarget,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t("mealPlanner.remaining", { count: Math.max(0, dayPlanning.remaining) })}
|
||||
{dayPlanning.slotTarget > 0
|
||||
? ` · ${t("mealPlanner.slotHint", { count: dayPlanning.slotTarget })}`
|
||||
: null}
|
||||
{dayPlanning.pendingCalories > 0
|
||||
? ` · ${t("mealPlanner.includingSelection", { count: dayPlanning.pendingCalories })}`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{t("mealPlanner.noSuggestions")}</p>
|
||||
)}
|
||||
{suggestions.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">{t("mealPlanner.suggestionsTitle")}</p>
|
||||
<ul className="max-h-48 space-y-1 overflow-y-auto rounded-xl border border-brand-200 bg-brand-50/50 p-2 dark:border-brand-900 dark:bg-brand-950/30">
|
||||
{suggestions.map(({ recipe, diff }) => (
|
||||
<li key={recipe.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectFromSuggestion(recipe.id)}
|
||||
className={`w-full rounded-lg px-3 py-2 text-left text-sm ${
|
||||
selectedRecipeId === recipe.id
|
||||
? "ring-2 ring-brand-600 ring-inset"
|
||||
: "hover:bg-white dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{recipe.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{recipe.calories} kcal
|
||||
{diff > 0 ? ` · ±${diff}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : suggestionsQuery.isLoading ? (
|
||||
<Skeleton className="h-24" />
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("diet.searchRecipe")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={recipeSearch}
|
||||
onChange={(e) => setRecipeSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isSearching && !selectedRecipeId && recipesQuery.isLoading ? (
|
||||
<Skeleton className="h-16" />
|
||||
) : null}
|
||||
{isSearching && !selectedRecipeId && recipesQuery.data && recipesQuery.data.length > 0 ? (
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-slate-200 p-2 dark:border-slate-700">
|
||||
{recipesQuery.data.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectFromSearch(r.id)}
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
{r.name}
|
||||
{r.calories != null ? (
|
||||
<span className="ml-2 text-xs text-slate-500">{r.calories} kcal</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : isSearching && !selectedRecipeId && recipesQuery.isFetched && !recipesQuery.isLoading ? (
|
||||
<p className="text-xs text-slate-500">{t("recipes.noMatchingTitle")}</p>
|
||||
) : null}
|
||||
{selectedRecipe ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-xl border border-brand-300 bg-brand-50 px-3 py-2 text-sm dark:border-brand-800 dark:bg-brand-950/40">
|
||||
<span className="font-medium text-brand-900 dark:text-brand-100">
|
||||
{t("mealPlanner.selectedRecipe", { name: selectedRecipe.name })}
|
||||
{estimatedMealCalories != null
|
||||
? ` · ${estimatedMealCalories} kcal`
|
||||
: selectedRecipe.calories != null
|
||||
? ` · ${selectedRecipe.calories} kcal`
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
onClick={clearSelection}
|
||||
aria-label={t("mealPlanner.clearSelection")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("diet.portions")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.25}
|
||||
step={0.25}
|
||||
className="input mt-1 w-full"
|
||||
value={portions}
|
||||
onChange={(e) => setPortions(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
{estimatedMealCalories != null ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("mealPlanner.estimatedMeal", { count: estimatedMealCalories })}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={!selectedRecipeId || saveMutation.isPending}
|
||||
onClick={() => void saveEntry()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={() => setAdding(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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>
|
||||
<h1 className="mt-4 text-xl font-bold">{t("notFound.title")}</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("notFound.description")}</p>
|
||||
<Link to="/" className="btn-primary mt-6">
|
||||
Back to dashboard
|
||||
{t("notFound.backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRecipe } from "@/hooks/useRecipes";
|
||||
import { RecipeDetail } from "@/components/recipes/RecipeDetail";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
@@ -7,6 +8,7 @@ import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
|
||||
export function RecipeDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const recipeId = Number(id);
|
||||
@@ -16,7 +18,7 @@ export function RecipeDetailPage() {
|
||||
<div className="space-y-6">
|
||||
<button type="button" onClick={() => navigate(-1)} className="btn-secondary">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
{t("common.back")}
|
||||
</button>
|
||||
|
||||
{isLoading && (
|
||||
@@ -34,7 +36,7 @@ export function RecipeDetailPage() {
|
||||
|
||||
{isError && (
|
||||
<ErrorState
|
||||
message={extractApiError(error, "Failed to load recipe.")}
|
||||
message={extractApiError(error, t("errors.loadRecipeFailed"))}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
330
meal-plan-frontend/src/pages/RecipeGeneratorPage.tsx
Normal file
330
meal-plan-frontend/src/pages/RecipeGeneratorPage.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CheckCircle2, Loader2, RefreshCw, Sparkles, Trash2 } from "lucide-react";
|
||||
import {
|
||||
commitRecipes,
|
||||
createRecipeSession,
|
||||
generateRecipeDrafts,
|
||||
regenerateRecipePart,
|
||||
removeRecipeDraft,
|
||||
} from "@/api/recipe-generator.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import type { CommitRecipeResult, GeneratedRecipeDraft, RecipeGeneratorConstraints } from "@/types/generator.types";
|
||||
|
||||
type Step = "constraints" | "review" | "done";
|
||||
|
||||
export function RecipeGeneratorPage() {
|
||||
const { t } = useTranslation();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
const [step, setStep] = useState<Step>("constraints");
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [drafts, setDrafts] = useState<GeneratedRecipeDraft[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [savedRecipes, setSavedRecipes] = useState<CommitRecipeResult[]>([]);
|
||||
const [recipeCount, setRecipeCount] = useState(3);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [constraints, setConstraints] = useState<RecipeGeneratorConstraints>({
|
||||
prompt: "",
|
||||
mealCategory: MealCategory.Breakfast,
|
||||
targetCalories: null,
|
||||
calorieToleranceKcal: 10,
|
||||
maxPrepTimeMinutes: 30,
|
||||
cuisineStyle: "",
|
||||
dietStyle: "",
|
||||
exclusions: "",
|
||||
});
|
||||
|
||||
const calorieTolerance = constraints.calorieToleranceKcal ?? 10;
|
||||
|
||||
function isWithinCalorieTarget(calories?: number | null): boolean {
|
||||
if (!constraints.targetCalories || calories == null) return true;
|
||||
return Math.abs(calories - constraints.targetCalories) <= calorieTolerance;
|
||||
}
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => drafts.length > 0 && drafts.every((d) => selectedIds.has(d.draftId)),
|
||||
[drafts, selectedIds],
|
||||
);
|
||||
|
||||
function syncDrafts(next: GeneratedRecipeDraft[]) {
|
||||
setDrafts(next);
|
||||
setSelectedIds(new Set(next.map((d) => d.draftId)));
|
||||
}
|
||||
|
||||
const runGenerate = async () => {
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
const session = sessionId
|
||||
? await generateRecipeDrafts(sessionId, recipeCount)
|
||||
: await (async () => {
|
||||
const created = await createRecipeSession(constraints);
|
||||
setSessionId(created.id);
|
||||
return generateRecipeDrafts(created.id, recipeCount);
|
||||
})();
|
||||
|
||||
if (!sessionId) setSessionId(session.id);
|
||||
syncDrafts(session.drafts ?? []);
|
||||
setStep("review");
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t("generators.recipe.error")));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const regen = async (draftId: string, mode: "ingredients" | "steps" | "full") => {
|
||||
if (!sessionId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await regenerateRecipePart(sessionId, draftId, mode, constraints.prompt);
|
||||
syncDrafts(session.drafts ?? []);
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t("generators.recipe.error")));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDraft = async (draftId: string) => {
|
||||
if (!sessionId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await removeRecipeDraft(sessionId, draftId);
|
||||
const next = session.drafts ?? [];
|
||||
syncDrafts(next);
|
||||
if (next.length === 0) setStep("constraints");
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t("generators.recipe.error")));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSelected = async () => {
|
||||
if (!sessionId || selectedIds.size === 0) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commitRecipes(sessionId, [...selectedIds]);
|
||||
setSavedRecipes(result.saved);
|
||||
const remaining = drafts.filter((d) => !selectedIds.has(d.draftId));
|
||||
if (remaining.length === 0) {
|
||||
setStep("done");
|
||||
} else {
|
||||
syncDrafts(remaining);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t("generators.recipe.error")));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelected = (draftId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(draftId)) next.delete(draftId);
|
||||
else next.add(draftId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(allSelected ? new Set() : new Set(drafts.map((d) => d.draftId)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-extrabold sm:text-3xl">
|
||||
<Sparkles className="h-7 w-7 text-brand-600" />
|
||||
{t("generators.recipe.title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-500">{t("generators.recipe.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "constraints" ? (
|
||||
<section className="card space-y-4 p-6">
|
||||
<h2 className="text-lg font-bold">{t("generators.recipe.constraintsTitle")}</h2>
|
||||
<label className="block text-sm">
|
||||
{t("generators.recipe.prompt")}
|
||||
<textarea className="input mt-1 w-full" rows={3} value={constraints.prompt}
|
||||
placeholder={t("generators.recipe.promptPlaceholder")}
|
||||
onChange={(e) => setConstraints({ ...constraints, prompt: e.target.value })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.recipe.targetCalories")}
|
||||
<input type="number" min={50} max={3000} className="input mt-1 w-full"
|
||||
value={constraints.targetCalories ?? ""}
|
||||
placeholder={t("generators.recipe.targetCaloriesOptional")}
|
||||
onChange={(e) => setConstraints({
|
||||
...constraints,
|
||||
targetCalories: e.target.value ? Number(e.target.value) : null,
|
||||
})} />
|
||||
<span className="mt-1 block text-xs text-slate-500">{t("generators.recipe.targetCaloriesHint")}</span>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.recipe.recipeCount")}
|
||||
<input type="number" min={1} max={8} className="input mt-1 w-full" value={recipeCount}
|
||||
onChange={(e) => setRecipeCount(Math.min(8, Math.max(1, Number(e.target.value) || 1)))} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("common.category")}
|
||||
<select className="select mt-1 w-full" value={constraints.mealCategory}
|
||||
onChange={(e) => setConstraints({ ...constraints, mealCategory: Number(e.target.value) })}>
|
||||
{[MealCategory.Breakfast, MealCategory.SecondBreakfast, MealCategory.Lunch, MealCategory.Dinner].map((cat) => (
|
||||
<option key={cat} value={cat}>{categoryLabels[cat]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.recipe.maxPrep")}
|
||||
<input type="number" className="input mt-1 w-full" value={constraints.maxPrepTimeMinutes ?? ""}
|
||||
onChange={(e) => setConstraints({ ...constraints, maxPrepTimeMinutes: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("generators.recipe.exclusions")}
|
||||
<input className="input mt-1 w-full" value={constraints.exclusions ?? ""}
|
||||
onChange={(e) => setConstraints({ ...constraints, exclusions: e.target.value })} />
|
||||
</label>
|
||||
<button type="button" className="btn-primary" disabled={pending || !constraints.prompt.trim()}
|
||||
onClick={() => void runGenerate()}>
|
||||
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t("generators.recipe.generate")}
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step === "review" && drafts.length > 0 ? (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-slate-500">{t("generators.recipe.reviewHint", { count: drafts.length })}</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
||||
{t("generators.recipe.selectAll")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{drafts.map((draft) => (
|
||||
<div key={draft.draftId} className="card space-y-3 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={selectedIds.has(draft.draftId)}
|
||||
onChange={() => toggleSelected(draft.draftId)}
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{draft.name}</h2>
|
||||
{draft.calories != null ? (
|
||||
<p className={`text-sm ${constraints.targetCalories && !isWithinCalorieTarget(draft.calories) ? "text-amber-700" : "text-slate-500"}`}>
|
||||
{constraints.targetCalories
|
||||
? t("generators.recipe.caloriesWithTarget", {
|
||||
actual: draft.calories,
|
||||
target: constraints.targetCalories,
|
||||
})
|
||||
: `${draft.calories} kcal`}
|
||||
{" · "}
|
||||
P {draft.protein ?? 0}g · F {draft.fat ?? 0}g · C {draft.carbs ?? 0}g
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<button type="button" className="btn-secondary !px-2 !py-1 text-xs" disabled={pending}
|
||||
onClick={() => void removeDraft(draft.draftId)}>
|
||||
<Trash2 className="h-3 w-3" /> {t("generators.recipe.remove")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draft.unlinkedIngredientCount > 0 ? (
|
||||
<p className="text-sm text-amber-700">{t("manage.unlinkedIngredientsWarning", { count: draft.unlinkedIngredientCount })}</p>
|
||||
) : null}
|
||||
|
||||
<h3 className="font-semibold">{t("recipes.ingredients")}</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{draft.ingredients.map((ing, i) => (
|
||||
<li key={i} className={!ing.isLinked && ing.amountGrams ? "text-amber-700" : ""}>
|
||||
{ing.name} — {ing.amountGrams ?? "?"}g {ing.unit ?? ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="font-semibold">{t("manage.preparationSteps")}</h3>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm">
|
||||
{draft.steps.map((s) => (
|
||||
<li key={s.stepNumber}>{s.description}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="btn-secondary !px-2 !py-1 text-xs" disabled={pending}
|
||||
onClick={() => void regen(draft.draftId, "ingredients")}>
|
||||
<RefreshCw className="h-3 w-3" /> {t("generators.recipe.regenIngredients")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !px-2 !py-1 text-xs" disabled={pending}
|
||||
onClick={() => void regen(draft.draftId, "steps")}>
|
||||
{t("generators.recipe.regenSteps")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary !px-2 !py-1 text-xs" disabled={pending}
|
||||
onClick={() => void regen(draft.draftId, "full")}>
|
||||
{t("generators.recipe.regenFull")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="btn-secondary" onClick={() => setStep("constraints")}>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" disabled={pending || selectedIds.size === 0}
|
||||
onClick={() => void saveSelected()}>
|
||||
{t("generators.recipe.saveSelected", { count: selectedIds.size })}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step === "done" && savedRecipes.length > 0 ? (
|
||||
<div className="card space-y-3 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-6 w-6 text-brand-600" />
|
||||
<p className="font-semibold">{t("generators.recipe.doneMultiple", { count: savedRecipes.length })}</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{savedRecipes.map((r) => (
|
||||
<li key={r.recipeId}>
|
||||
<Link to={`/recipes/${r.recipeId}`} className="text-brand-600 hover:underline">
|
||||
{r.recipeName}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="btn-secondary" onClick={() => {
|
||||
setStep("constraints");
|
||||
setDrafts([]);
|
||||
setSelectedIds(new Set());
|
||||
setSavedRecipes([]);
|
||||
setSessionId(null);
|
||||
}}>
|
||||
{t("generators.recipe.generateMore")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,50 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } 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 { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import { LanguageSwitcher } from "@/components/ui/LanguageSwitcher";
|
||||
|
||||
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>;
|
||||
type RegisterForm = {
|
||||
email: string;
|
||||
userName?: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
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 registerSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.emailInvalid")),
|
||||
userName: z.string().max(256, t("validation.displayNameTooLong")).optional(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t("validation.passwordMinLength"))
|
||||
.regex(/[A-Z]/, t("validation.passwordUppercase"))
|
||||
.regex(/[a-z]/, t("validation.passwordLowercase"))
|
||||
.regex(/[0-9]/, t("validation.passwordDigit")),
|
||||
confirmPassword: z.string().min(1, t("validation.confirmPasswordRequired")),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t("validation.passwordsMismatch"),
|
||||
path: ["confirmPassword"],
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -63,24 +75,25 @@ export function RegisterPage() {
|
||||
|
||||
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="absolute right-4 top-4 flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="btn-secondary !px-2.5 !py-2"
|
||||
aria-label={t("nav.toggleTheme")}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h1 className="mt-4 text-2xl font-extrabold tracking-tight">{t("auth.createAccountTitle")}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t("auth.createAccountSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card space-y-5 p-6 sm:p-8" noValidate>
|
||||
@@ -96,14 +109,14 @@ export function RegisterPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
|
||||
Email
|
||||
{t("auth.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email && <p className="mt-1.5 text-sm text-red-600">{errors.email.message}</p>}
|
||||
@@ -111,14 +124,15 @@ export function RegisterPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="userName" className="mb-1.5 block text-sm font-medium">
|
||||
Display name <span className="text-slate-400">(optional)</span>
|
||||
{t("auth.displayName")}{" "}
|
||||
<span className="text-slate-400">({t("common.optional")})</span>
|
||||
</label>
|
||||
<input
|
||||
id="userName"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className="input"
|
||||
placeholder="Piotr"
|
||||
placeholder={t("auth.displayNamePlaceholder")}
|
||||
{...register("userName")}
|
||||
/>
|
||||
{errors.userName && (
|
||||
@@ -128,7 +142,7 @@ export function RegisterPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium">
|
||||
Password
|
||||
{t("auth.password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -141,14 +155,12 @@ export function RegisterPage() {
|
||||
{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>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{t("auth.passwordHint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="mb-1.5 block text-sm font-medium">
|
||||
Confirm password
|
||||
{t("auth.confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
@@ -165,13 +177,13 @@ export function RegisterPage() {
|
||||
|
||||
<button type="submit" disabled={registerPending} className="btn-primary w-full">
|
||||
{registerPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{registerPending ? "Creating account..." : "Create account"}
|
||||
{registerPending ? t("auth.creatingAccount") : t("auth.createAccount")}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Already have an account?{" "}
|
||||
{t("auth.hasAccount")}{" "}
|
||||
<Link to="/login" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Sign in
|
||||
{t("auth.signIn")}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
134
meal-plan-frontend/src/pages/ShoppingListPage.tsx
Normal file
134
meal-plan-frontend/src/pages/ShoppingListPage.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchShoppingList } from "@/api/meal-plan.api";
|
||||
import { extractApiError } from "@/api/axiosInstance";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { endOfWeekIso, startOfWeekIso, todayIso } from "@/utils/date.util";
|
||||
|
||||
const STORAGE_KEY = "dm_shopping_checks";
|
||||
|
||||
function loadChecks(): Record<string, boolean> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as Record<string, boolean>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function itemKey(name: string, unit?: string | null): string {
|
||||
return `${name.toLowerCase()}|${unit ?? ""}`;
|
||||
}
|
||||
|
||||
export function ShoppingListPage() {
|
||||
const { t } = useTranslation();
|
||||
const [from, setFrom] = useState(() => startOfWeekIso(todayIso()));
|
||||
const [to, setTo] = useState(() => endOfWeekIso(todayIso()));
|
||||
const [checks, setChecks] = useState<Record<string, boolean>>(loadChecks);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["shopping-list", from, to],
|
||||
queryFn: () => fetchShoppingList(from, to),
|
||||
enabled: loaded,
|
||||
});
|
||||
|
||||
const items = listQuery.data ?? [];
|
||||
|
||||
const sortedItems = useMemo(
|
||||
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[items],
|
||||
);
|
||||
|
||||
function toggleCheck(name: string, unit?: string | null) {
|
||||
const key = itemKey(name, unit);
|
||||
setChecks((prev) => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearChecks() {
|
||||
setChecks({});
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{t("shoppingList.title")}</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t("shoppingList.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
<div className="card flex flex-wrap items-end gap-4 p-5">
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("shoppingList.from")}</span>
|
||||
<input type="date" className="input mt-1" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">{t("shoppingList.to")}</span>
|
||||
<input type="date" className="input mt-1" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</label>
|
||||
<button type="button" className="btn-primary" onClick={() => setLoaded(true)}>
|
||||
{t("shoppingList.load")}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={clearChecks}>
|
||||
{t("shoppingList.clearChecks")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500">{t("shoppingList.checklistHint")}</p>
|
||||
|
||||
{loaded && listQuery.isLoading ? (
|
||||
<Skeleton className="h-48" />
|
||||
) : listQuery.isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(listQuery.error, t("errors.loadFailed"))}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
) : sortedItems.length === 0 && loaded ? (
|
||||
<p className="text-sm text-slate-400">{t("shoppingList.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{sortedItems.map((item) => {
|
||||
const key = itemKey(item.name, item.unit);
|
||||
const checked = !!checks[key];
|
||||
const amount =
|
||||
item.totalGrams != null
|
||||
? `${item.totalGrams.toFixed(0)}g`
|
||||
: item.totalAmount != null
|
||||
? `${item.totalAmount.toFixed(1)}${item.unit ? ` ${item.unit}` : "g"}`
|
||||
: "";
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className={`card flex items-start gap-3 p-4 ${checked ? "opacity-50" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-slate-300"
|
||||
checked={checked}
|
||||
onChange={() => toggleCheck(item.name, item.unit)}
|
||||
/>
|
||||
<div>
|
||||
<p className={`font-medium ${checked ? "line-through" : ""}`}>
|
||||
{item.name} {amount ? `· ${amount}` : ""}
|
||||
</p>
|
||||
{item.breakdown.length > 0 ? (
|
||||
<ul className="mt-1 text-xs text-slate-400">
|
||||
{item.breakdown.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
meal-plan-frontend/src/store/appearanceStore.ts
Normal file
47
meal-plan-frontend/src/store/appearanceStore.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createStore } from "./createStore";
|
||||
import { THEME_PROPOSALS, type AppearanceId, type ThemeProposal } from "@/theme/appearance.config";
|
||||
|
||||
const STORAGE_KEY = "dailymeals.appearance";
|
||||
|
||||
function loadProposal(): ThemeProposal {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as AppearanceId | null;
|
||||
const found = THEME_PROPOSALS.find((p) => p.id === stored);
|
||||
return found ?? THEME_PROPOSALS[0];
|
||||
}
|
||||
|
||||
function applyProposal(proposal: ThemeProposal): void {
|
||||
document.documentElement.dataset.colorTheme = proposal.colorTheme;
|
||||
document.documentElement.dataset.iconStyle = proposal.iconStyle;
|
||||
}
|
||||
|
||||
const initial = loadProposal();
|
||||
applyProposal(initial);
|
||||
|
||||
interface AppearanceState {
|
||||
proposal: ThemeProposal;
|
||||
}
|
||||
|
||||
export const appearanceStore = createStore<AppearanceState>({ proposal: initial });
|
||||
|
||||
export function setAppearance(id: AppearanceId): void {
|
||||
const next = THEME_PROPOSALS.find((p) => p.id === id) ?? THEME_PROPOSALS[0];
|
||||
appearanceStore.setState({ proposal: next });
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
applyProposal(next);
|
||||
}
|
||||
|
||||
export function useAppearance() {
|
||||
const proposal = appearanceStore.useStore((s) => s.proposal);
|
||||
return {
|
||||
proposal,
|
||||
setAppearance,
|
||||
proposals: THEME_PROPOSALS,
|
||||
iconStyle: proposal.iconStyle,
|
||||
strokeWidth: proposal.iconStyle === "bold" ? 2.5 : 2,
|
||||
useEmoji: proposal.iconStyle === "emoji",
|
||||
};
|
||||
}
|
||||
|
||||
export function initAppearance(): void {
|
||||
applyProposal(appearanceStore.getState().proposal);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { createStore } from "./createStore";
|
||||
import type { TokenResponse, UserInfo } from "@/types/auth.types";
|
||||
import { persistRefreshToken } from "@/lib/initAuth";
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: UserInfo | null;
|
||||
authReady: boolean;
|
||||
}
|
||||
|
||||
const storedRefresh = typeof localStorage !== "undefined" ? localStorage.getItem("dm_refresh_token") : null;
|
||||
|
||||
const initialState: AuthState = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
refreshToken: storedRefresh,
|
||||
user: null,
|
||||
authReady: !storedRefresh,
|
||||
};
|
||||
|
||||
export const authStore = createStore<AuthState>(initialState);
|
||||
@@ -20,16 +25,23 @@ export function setTokens(tokens: TokenResponse): void {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
persistRefreshToken(tokens.refreshToken);
|
||||
}
|
||||
|
||||
export function setUser(user: UserInfo | null): void {
|
||||
authStore.setState({ user });
|
||||
}
|
||||
|
||||
export function setAuthReady(ready: boolean): void {
|
||||
authStore.setState({ authReady: ready });
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
authStore.setState({ accessToken: null, refreshToken: null, user: null });
|
||||
persistRefreshToken(null);
|
||||
authStore.setState({ accessToken: null, refreshToken: null, user: null, authReady: true });
|
||||
}
|
||||
|
||||
export const getAccessToken = (): string | null => authStore.getState().accessToken;
|
||||
export const getRefreshToken = (): string | null => authStore.getState().refreshToken;
|
||||
export const isAuthenticated = (): boolean => Boolean(authStore.getState().accessToken);
|
||||
export const isAuthReady = (): boolean => authStore.getState().authReady;
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
|
||||
31
meal-plan-frontend/src/store/layoutStore.ts
Normal file
31
meal-plan-frontend/src/store/layoutStore.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createStore } from "./createStore";
|
||||
|
||||
const STORAGE_KEY = "dailymeals.layout";
|
||||
|
||||
export type LayoutId = "sidebar" | "topnav" | "compact" | "wide";
|
||||
|
||||
const VALID: LayoutId[] = ["sidebar", "topnav", "compact", "wide"];
|
||||
|
||||
function loadLayout(): LayoutId {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const id = VALID.includes(stored as LayoutId) ? (stored as LayoutId) : "sidebar";
|
||||
document.documentElement.dataset.layout = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
interface LayoutState {
|
||||
layout: LayoutId;
|
||||
}
|
||||
|
||||
export const layoutStore = createStore<LayoutState>({ layout: loadLayout() });
|
||||
|
||||
export function setLayout(id: LayoutId): void {
|
||||
layoutStore.setState({ layout: id });
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
document.documentElement.dataset.layout = id;
|
||||
}
|
||||
|
||||
export function useLayout() {
|
||||
const layout = layoutStore.useStore((s) => s.layout);
|
||||
return { layout, setLayout };
|
||||
}
|
||||
74
meal-plan-frontend/src/theme/appearance.config.ts
Normal file
74
meal-plan-frontend/src/theme/appearance.config.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type ColorThemeId = "forest" | "ocean" | "sunset" | "berry";
|
||||
export type IconStyleId = "outline" | "emoji" | "bold";
|
||||
export type AppearanceId = ColorThemeId;
|
||||
|
||||
export interface ThemeProposal {
|
||||
id: AppearanceId;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
colorTheme: ColorThemeId;
|
||||
iconStyle: IconStyleId;
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const THEME_PROPOSALS: ThemeProposal[] = [
|
||||
{
|
||||
id: "forest",
|
||||
labelKey: "appearance.forest",
|
||||
descriptionKey: "appearance.forestDesc",
|
||||
colorTheme: "forest",
|
||||
iconStyle: "outline",
|
||||
swatch: "#16774c",
|
||||
},
|
||||
{
|
||||
id: "ocean",
|
||||
labelKey: "appearance.ocean",
|
||||
descriptionKey: "appearance.oceanDesc",
|
||||
colorTheme: "ocean",
|
||||
iconStyle: "outline",
|
||||
swatch: "#2563eb",
|
||||
},
|
||||
{
|
||||
id: "sunset",
|
||||
labelKey: "appearance.sunset",
|
||||
descriptionKey: "appearance.sunsetDesc",
|
||||
colorTheme: "sunset",
|
||||
iconStyle: "emoji",
|
||||
swatch: "#d97706",
|
||||
},
|
||||
{
|
||||
id: "berry",
|
||||
labelKey: "appearance.berry",
|
||||
descriptionKey: "appearance.berryDesc",
|
||||
colorTheme: "berry",
|
||||
iconStyle: "bold",
|
||||
swatch: "#7c3aed",
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV_ICON_EMOJI: Record<string, string> = {
|
||||
dashboard: "📊",
|
||||
coffee: "☕",
|
||||
croissant: "🥐",
|
||||
soup: "🍲",
|
||||
moon: "🌙",
|
||||
list: "📋",
|
||||
book: "✏️",
|
||||
leaf: "🥬",
|
||||
diet: "🥗",
|
||||
calendar: "📅",
|
||||
cart: "🛒",
|
||||
};
|
||||
|
||||
export type NavIconName =
|
||||
| "dashboard"
|
||||
| "coffee"
|
||||
| "croissant"
|
||||
| "soup"
|
||||
| "moon"
|
||||
| "list"
|
||||
| "book"
|
||||
| "leaf"
|
||||
| "diet"
|
||||
| "calendar"
|
||||
| "cart";
|
||||
215
meal-plan-frontend/src/types/diet.types.ts
Normal file
215
meal-plan-frontend/src/types/diet.types.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
export interface UserDailyGoals {
|
||||
calorieGoal?: number | null;
|
||||
proteinGoalG?: number | null;
|
||||
fatGoalG?: number | null;
|
||||
carbsGoalG?: number | null;
|
||||
waterGoalMl?: number | null;
|
||||
}
|
||||
|
||||
export interface MacroRemaining {
|
||||
goal?: number | null;
|
||||
consumed: number;
|
||||
remaining?: number | null;
|
||||
progressPercent?: number | null;
|
||||
}
|
||||
|
||||
export interface DecimalMacroRemaining {
|
||||
goal?: number | null;
|
||||
consumed: number;
|
||||
remaining?: number | null;
|
||||
progressPercent?: number | null;
|
||||
}
|
||||
|
||||
export interface MealConsumption {
|
||||
id: number;
|
||||
recipeId?: number | null;
|
||||
mealCategory: number;
|
||||
logDate: string;
|
||||
consumedAt: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface DrinkCatalogItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
defaultVolumeMl: number;
|
||||
caloriesPer100Ml?: number | null;
|
||||
iconEmoji?: string | null;
|
||||
}
|
||||
|
||||
export interface DrinkConsumption {
|
||||
id: number;
|
||||
drinkCatalogId?: number | null;
|
||||
logDate: string;
|
||||
consumedAt: string;
|
||||
name: string;
|
||||
volumeMl: number;
|
||||
calories?: number | null;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
goals?: UserDailyGoals | null;
|
||||
calories: MacroRemaining;
|
||||
proteinG: DecimalMacroRemaining;
|
||||
fatG: DecimalMacroRemaining;
|
||||
carbsG: DecimalMacroRemaining;
|
||||
waterMl: MacroRemaining;
|
||||
meals: MealConsumption[];
|
||||
drinks: DrinkConsumption[];
|
||||
}
|
||||
|
||||
export interface LogMealInput {
|
||||
recipeId?: number;
|
||||
catalogItemId?: number;
|
||||
grams?: number;
|
||||
name?: string;
|
||||
mealCategory: number;
|
||||
logDate?: string;
|
||||
portions?: number;
|
||||
calories?: number;
|
||||
proteinG?: number;
|
||||
fatG?: number;
|
||||
carbsG?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LogDrinkInput {
|
||||
drinkCatalogId?: number;
|
||||
name?: string;
|
||||
volumeMl: number;
|
||||
logDate?: string;
|
||||
calories?: number;
|
||||
}
|
||||
|
||||
export interface DietReminder {
|
||||
id: number;
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
timeOfDay: string;
|
||||
daysOfWeekMask: number;
|
||||
mealCategory?: number | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDietReminderInput {
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string;
|
||||
timeOfDay: string;
|
||||
daysOfWeekMask?: number;
|
||||
mealCategory?: number;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateDietReminderInput = CreateDietReminderInput;
|
||||
|
||||
export interface DueReminder {
|
||||
id: number;
|
||||
reminderType: number;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
timeOfDay: string;
|
||||
mealCategory?: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryDay {
|
||||
date: string;
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
carbsG: number;
|
||||
waterMl: number;
|
||||
mealCount: number;
|
||||
}
|
||||
|
||||
export interface RecentMeal {
|
||||
recipeId?: number | null;
|
||||
catalogItemId?: number | null;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
portions: number;
|
||||
grams?: number | null;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
lastLoggedAt: string;
|
||||
}
|
||||
|
||||
export interface FavoriteRecipe {
|
||||
recipeId: number;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
calories?: number | null;
|
||||
}
|
||||
|
||||
export interface FavoriteCatalogItem {
|
||||
catalogItemId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
categoryCode: string;
|
||||
}
|
||||
|
||||
export interface CopyMealsResult {
|
||||
sourceDate: string;
|
||||
targetDate: string;
|
||||
copiedCount: number;
|
||||
}
|
||||
|
||||
export enum DietReminderType {
|
||||
Water = 0,
|
||||
Meal = 1,
|
||||
Snack = 2,
|
||||
Custom = 3,
|
||||
}
|
||||
|
||||
export const CONSUMPTION_CATEGORIES = [
|
||||
{ value: 0, labelKey: "categories.breakfast" },
|
||||
{ value: 1, labelKey: "categories.secondBreakfast" },
|
||||
{ value: 2, labelKey: "categories.lunch" },
|
||||
{ value: 3, labelKey: "categories.dinner" },
|
||||
{ value: 4, labelKey: "diet.snack" },
|
||||
] as const;
|
||||
|
||||
export const REMINDER_DAYS_MASK_ALL = 127;
|
||||
|
||||
export interface MealMacroSnapshot {
|
||||
name: string;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
}
|
||||
|
||||
export interface RemainingAfterMeal {
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
}
|
||||
|
||||
export interface MealPreview {
|
||||
meal: MealMacroSnapshot;
|
||||
remainingAfter: RemainingAfterMeal;
|
||||
}
|
||||
|
||||
export interface DietSuggestion {
|
||||
catalogItemId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
suggestedGrams: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
reasonKey: string;
|
||||
}
|
||||
137
meal-plan-frontend/src/types/generator.types.ts
Normal file
137
meal-plan-frontend/src/types/generator.types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export interface DietUserProfile {
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
age: number;
|
||||
sex: "male" | "female";
|
||||
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
|
||||
goal: "lose_weight" | "maintain" | "gain_muscle" | "recomp";
|
||||
allergiesNotes?: string | null;
|
||||
dislikedFoods?: string | null;
|
||||
mealsPerDayMask: number;
|
||||
}
|
||||
|
||||
export interface MacroProposal {
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
carbsG: number;
|
||||
rationale?: string | null;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMealIngredient {
|
||||
id: number;
|
||||
name: string;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
sortOrder: number;
|
||||
sourceIngredientId?: number | null;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMealStep {
|
||||
id: number;
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DietGeneratorDraftMeal {
|
||||
id: number;
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
proteinG?: number | null;
|
||||
fatG?: number | null;
|
||||
carbsG?: number | null;
|
||||
isLocked: boolean;
|
||||
prepTimeMinutes?: number | null;
|
||||
ingredients: DietGeneratorDraftMealIngredient[];
|
||||
steps: DietGeneratorDraftMealStep[];
|
||||
}
|
||||
|
||||
export interface DietGeneratorDaySummary {
|
||||
planDate: string;
|
||||
totalCalories: number;
|
||||
targetCalories: number;
|
||||
withinTolerance: boolean;
|
||||
}
|
||||
|
||||
export interface DietGeneratorSession {
|
||||
id: number;
|
||||
status: string;
|
||||
proposedMacros?: MacroProposal | null;
|
||||
planStartDate?: string | null;
|
||||
planDayCount: number;
|
||||
calorieToleranceKcal: number;
|
||||
draftMeals: DietGeneratorDraftMeal[];
|
||||
daySummaries: DietGeneratorDaySummary[];
|
||||
}
|
||||
|
||||
export interface CommitDietPlanResult {
|
||||
sessionId: number;
|
||||
planStartDate: string;
|
||||
planEndDate: string;
|
||||
mealsWritten: number;
|
||||
}
|
||||
|
||||
export interface RecipeGeneratorConstraints {
|
||||
prompt: string;
|
||||
mealCategory: number;
|
||||
targetCalories?: number | null;
|
||||
calorieToleranceKcal?: number;
|
||||
maxPrepTimeMinutes?: number | null;
|
||||
cuisineStyle?: string | null;
|
||||
dietStyle?: string | null;
|
||||
exclusions?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedIngredient {
|
||||
name: string;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
catalogItemId?: number | null;
|
||||
catalogName?: string | null;
|
||||
isLinked: boolean;
|
||||
}
|
||||
|
||||
export interface GeneratedStep {
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GeneratedRecipeDraft {
|
||||
draftId: string;
|
||||
name: string;
|
||||
mealCategory: number;
|
||||
prepTimeMinutes?: number | null;
|
||||
calories?: number | null;
|
||||
protein?: number | null;
|
||||
fat?: number | null;
|
||||
carbs?: number | null;
|
||||
ingredients: GeneratedIngredient[];
|
||||
steps: GeneratedStep[];
|
||||
unlinkedIngredientCount: number;
|
||||
}
|
||||
|
||||
export interface RecipeGeneratorSession {
|
||||
id: number;
|
||||
status: string;
|
||||
constraints: RecipeGeneratorConstraints;
|
||||
draft?: GeneratedRecipeDraft | null;
|
||||
drafts: GeneratedRecipeDraft[];
|
||||
savedRecipeId?: number | null;
|
||||
generationVersion: number;
|
||||
}
|
||||
|
||||
export interface CommitRecipeResult {
|
||||
sessionId: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
draftId: string;
|
||||
}
|
||||
|
||||
export interface CommitRecipesResult {
|
||||
sessionId: number;
|
||||
saved: CommitRecipeResult[];
|
||||
}
|
||||
33
meal-plan-frontend/src/types/ingredient-catalog.types.ts
Normal file
33
meal-plan-frontend/src/types/ingredient-catalog.types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface IngredientCategory {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface IngredientNutritionItem {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
caloriesPer100G: number;
|
||||
proteinGPer100G: number;
|
||||
fatGPer100G: number;
|
||||
carbsGPer100G: number;
|
||||
fiberGPer100G: number | null;
|
||||
}
|
||||
|
||||
export interface UpsertIngredientNutritionItemInput {
|
||||
categoryId: number;
|
||||
name: string;
|
||||
namePl?: string | null;
|
||||
caloriesPer100G: number;
|
||||
proteinGPer100G: number;
|
||||
fatGPer100G: number;
|
||||
carbsGPer100G: number;
|
||||
fiberGPer100G?: number | null;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
24
meal-plan-frontend/src/types/meal-plan.types.ts
Normal file
24
meal-plan-frontend/src/types/meal-plan.types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface MealPlanEntry {
|
||||
id: number;
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
portions: number;
|
||||
calories?: number | null;
|
||||
}
|
||||
|
||||
export interface UpsertMealPlanEntryInput {
|
||||
planDate: string;
|
||||
mealCategory: number;
|
||||
recipeId: number;
|
||||
portions: number;
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
totalGrams?: number | null;
|
||||
totalAmount?: number | null;
|
||||
breakdown: string[];
|
||||
}
|
||||
39
meal-plan-frontend/src/types/recipe-management.types.ts
Normal file
39
meal-plan-frontend/src/types/recipe-management.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { MealCategory } from "./recipe.types";
|
||||
|
||||
export interface CreateIngredientInput {
|
||||
name: string;
|
||||
amountGrams: number | null;
|
||||
unit: string;
|
||||
sortOrder: number;
|
||||
catalogItemId?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateStepInput {
|
||||
stepNumber: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateRecipeInput {
|
||||
name: string;
|
||||
mealCategory: MealCategory;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
carbs: number | null;
|
||||
prepTimeMinutes: number | null;
|
||||
ingredients: CreateIngredientInput[];
|
||||
steps: CreateStepInput[];
|
||||
}
|
||||
|
||||
export interface RecipeImportResult {
|
||||
createdCount: number;
|
||||
skippedCount: number;
|
||||
errors: string[];
|
||||
createdRecipeIds: number[];
|
||||
}
|
||||
|
||||
export interface RecipeMacroRecalcResult {
|
||||
recipesProcessed: number;
|
||||
recipesUpdated: number;
|
||||
unlinkedIngredientRows: number;
|
||||
}
|
||||
@@ -5,13 +5,6 @@ export enum MealCategory {
|
||||
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",
|
||||
@@ -33,6 +26,7 @@ export interface Ingredient {
|
||||
amountGrams: number | null;
|
||||
unit: string | null;
|
||||
sortOrder: number;
|
||||
catalogItemId?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeStep {
|
||||
|
||||
29
meal-plan-frontend/src/utils/date.util.ts
Normal file
29
meal-plan-frontend/src/utils/date.util.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function startOfWeekIso(iso: string): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function endOfWeekIso(iso: string): string {
|
||||
return addDaysIso(startOfWeekIso(iso), 6);
|
||||
}
|
||||
|
||||
export function formatDisplayDate(iso: string, locale: string): string {
|
||||
return new Date(`${iso}T12:00:00`).toLocaleDateString(locale, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
77
meal-plan-frontend/src/utils/diet-normalize.ts
Normal file
77
meal-plan-frontend/src/utils/diet-normalize.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { DailySummary, DecimalMacroRemaining, MacroRemaining } from '@/types/diet.types';
|
||||
|
||||
const EMPTY_INT_MACRO: MacroRemaining = {
|
||||
consumed: 0,
|
||||
goal: null,
|
||||
remaining: null,
|
||||
progressPercent: null,
|
||||
};
|
||||
|
||||
const EMPTY_DECIMAL_MACRO: DecimalMacroRemaining = {
|
||||
consumed: 0,
|
||||
goal: null,
|
||||
remaining: null,
|
||||
progressPercent: null,
|
||||
};
|
||||
|
||||
function pick<T>(raw: Record<string, unknown>, camel: string, pascal: string): T | undefined {
|
||||
return (raw[camel] ?? raw[pascal]) as T | undefined;
|
||||
}
|
||||
|
||||
function normalizeIntMacro(raw: unknown): MacroRemaining {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_INT_MACRO };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
|
||||
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
|
||||
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
|
||||
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
|
||||
| number
|
||||
| null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDecimalMacro(raw: unknown): DecimalMacroRemaining {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_DECIMAL_MACRO };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
|
||||
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
|
||||
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
|
||||
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
|
||||
| number
|
||||
| null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDailySummary(raw: unknown): DailySummary {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
date: '',
|
||||
goals: null,
|
||||
calories: { ...EMPTY_INT_MACRO },
|
||||
proteinG: { ...EMPTY_DECIMAL_MACRO },
|
||||
fatG: { ...EMPTY_DECIMAL_MACRO },
|
||||
carbsG: { ...EMPTY_DECIMAL_MACRO },
|
||||
waterMl: { ...EMPTY_INT_MACRO },
|
||||
meals: [],
|
||||
drinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const meals = pick<unknown[]>(obj, 'meals', 'Meals');
|
||||
const drinks = pick<unknown[]>(obj, 'drinks', 'Drinks');
|
||||
|
||||
return {
|
||||
date: String(pick<string>(obj, 'date', 'Date') ?? ''),
|
||||
goals: (pick(obj, 'goals', 'Goals') as DailySummary['goals']) ?? null,
|
||||
calories: normalizeIntMacro(pick(obj, 'calories', 'Calories')),
|
||||
proteinG: normalizeDecimalMacro(pick(obj, 'proteinG', 'ProteinG')),
|
||||
fatG: normalizeDecimalMacro(pick(obj, 'fatG', 'FatG')),
|
||||
carbsG: normalizeDecimalMacro(pick(obj, 'carbsG', 'CarbsG')),
|
||||
waterMl: normalizeIntMacro(pick(obj, 'waterMl', 'WaterMl')),
|
||||
meals: Array.isArray(meals) ? (meals as DailySummary['meals']) : [],
|
||||
drinks: Array.isArray(drinks) ? (drinks as DailySummary['drinks']) : [],
|
||||
};
|
||||
}
|
||||
11
meal-plan-frontend/src/utils/ingredient-catalog.util.ts
Normal file
11
meal-plan-frontend/src/utils/ingredient-catalog.util.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { IngredientNutritionItem } from '@/types/ingredient-catalog.types';
|
||||
|
||||
export function ingredientDisplayName(
|
||||
item: Pick<IngredientNutritionItem, 'name' | 'namePl'>,
|
||||
language: string,
|
||||
): string {
|
||||
if (language.startsWith('pl') && item.namePl) {
|
||||
return item.namePl;
|
||||
}
|
||||
return item.name;
|
||||
}
|
||||
116
meal-plan-frontend/src/utils/ingredientQuantity.util.ts
Normal file
116
meal-plan-frontend/src/utils/ingredientQuantity.util.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/** Mirrors MealPlan.Api IngredientScalingHelper — AmountGrams is grams, not piece count. */
|
||||
|
||||
const COUNT_AMOUNT_THRESHOLD = 10;
|
||||
|
||||
const NON_QUANTIFIED_UNITS = new Set([
|
||||
"to taste",
|
||||
"for serving",
|
||||
"optional",
|
||||
"do smaku",
|
||||
"do podania",
|
||||
"opcjonalnie",
|
||||
"garnish",
|
||||
"szczypta",
|
||||
]);
|
||||
|
||||
function normalizeUnit(unit: string): string {
|
||||
return unit.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
export function isNonQuantifiedUnit(unit: string | null | undefined): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
return NON_QUANTIFIED_UNITS.has(normalizeUnit(unit));
|
||||
}
|
||||
|
||||
function isCountUnit(unit: string | null | undefined): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
const u = normalizeUnit(unit);
|
||||
return (
|
||||
u === "szt" ||
|
||||
u === "sztuka" ||
|
||||
u === "sztuki" ||
|
||||
u === "ząbek" ||
|
||||
u === "zabek" ||
|
||||
u === "zabki" ||
|
||||
u === "clove" ||
|
||||
u === "cloves" ||
|
||||
u === "łyżka" ||
|
||||
u === "lyzka" ||
|
||||
u === "łyżeczka" ||
|
||||
u === "lyzeczka" ||
|
||||
u === "jajko" ||
|
||||
u === "jajka" ||
|
||||
u === "egg" ||
|
||||
u === "eggs"
|
||||
);
|
||||
}
|
||||
|
||||
function gramsPerCountUnit(unit: string): number {
|
||||
const u = normalizeUnit(unit);
|
||||
switch (u) {
|
||||
case "ząbek":
|
||||
case "zabek":
|
||||
case "zabki":
|
||||
case "clove":
|
||||
case "cloves":
|
||||
return 4;
|
||||
case "łyżka":
|
||||
case "lyzka":
|
||||
return 15;
|
||||
case "łyżeczka":
|
||||
case "lyzeczka":
|
||||
return 5;
|
||||
default:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
function roundAmount(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Converts recipe ingredient rows to gram weights for aggregation / display. */
|
||||
export function normalizeIngredientQuantity(
|
||||
amountGrams: number | null | undefined,
|
||||
unit: string | null | undefined,
|
||||
): { amountGrams: number | null; unit: string | null } {
|
||||
const unitRaw = unit?.trim() || null;
|
||||
|
||||
if (isNonQuantifiedUnit(unitRaw)) {
|
||||
return { amountGrams: amountGrams ?? null, unit: unitRaw };
|
||||
}
|
||||
|
||||
if (amountGrams == null || amountGrams <= 0) {
|
||||
return { amountGrams: null, unit: unitRaw };
|
||||
}
|
||||
|
||||
if (isCountUnit(unitRaw) && unitRaw) {
|
||||
if (amountGrams <= COUNT_AMOUNT_THRESHOLD) {
|
||||
return {
|
||||
amountGrams: roundAmount(amountGrams * gramsPerCountUnit(unitRaw)),
|
||||
unit: null,
|
||||
};
|
||||
}
|
||||
return { amountGrams: roundAmount(amountGrams), unit: null };
|
||||
}
|
||||
|
||||
return { amountGrams: roundAmount(amountGrams), unit: null };
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(1).replace(/\.0$/, "");
|
||||
}
|
||||
|
||||
/** Human-readable amount for recipe cards and PDF recipe pages. */
|
||||
export function formatIngredientAmount(amountGrams: number | null, unit: string | null): string {
|
||||
if (isNonQuantifiedUnit(unit)) {
|
||||
return unit?.trim() ?? "";
|
||||
}
|
||||
|
||||
const normalized = normalizeIngredientQuantity(amountGrams, unit);
|
||||
if (normalized.amountGrams == null || normalized.amountGrams <= 0) {
|
||||
return normalized.unit ?? "";
|
||||
}
|
||||
|
||||
return `${formatNumber(normalized.amountGrams)} g`;
|
||||
}
|
||||
103
meal-plan-frontend/src/utils/mealPlannerSuggestions.util.ts
Normal file
103
meal-plan-frontend/src/utils/mealPlannerSuggestions.util.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { MealPlanEntry } from "@/types/meal-plan.types";
|
||||
import { MealCategory, type RecipeListItem } from "@/types/recipe.types";
|
||||
|
||||
export const PLANNER_MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
export interface DayMealPlanning {
|
||||
dailyTarget: number;
|
||||
logged: number;
|
||||
remaining: number;
|
||||
slotTarget: number;
|
||||
openSlots: number;
|
||||
pendingCalories: number;
|
||||
}
|
||||
|
||||
export interface PendingMealSelection {
|
||||
category: number;
|
||||
calories: number;
|
||||
}
|
||||
|
||||
export function entryCaloriesTotal(entry: MealPlanEntry): number {
|
||||
if (entry.calories == null) return 0;
|
||||
return Math.round(entry.calories * entry.portions);
|
||||
}
|
||||
|
||||
export function computeDayMealPlanning(
|
||||
date: string,
|
||||
category: number,
|
||||
entries: MealPlanEntry[],
|
||||
dailyTarget: number,
|
||||
pending?: PendingMealSelection | null,
|
||||
): DayMealPlanning {
|
||||
const dayEntries = entries.filter((e) => e.planDate === date);
|
||||
const categoriesWithSaved = new Set(dayEntries.map((e) => e.mealCategory));
|
||||
const existingInSlot = dayEntries.find((e) => e.mealCategory === category);
|
||||
|
||||
let logged = dayEntries.reduce((sum, e) => sum + entryCaloriesTotal(e), 0);
|
||||
|
||||
const pendingCalories =
|
||||
pending && pending.category === category && pending.calories > 0 ? pending.calories : 0;
|
||||
|
||||
if (pendingCalories > 0) {
|
||||
if (existingInSlot) {
|
||||
logged = logged - entryCaloriesTotal(existingInSlot) + pendingCalories;
|
||||
} else {
|
||||
logged += pendingCalories;
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = dailyTarget - logged;
|
||||
|
||||
const emptyCategories = PLANNER_MEAL_CATEGORIES.filter((cat) => !categoriesWithSaved.has(cat));
|
||||
const currentSlotSaved = categoriesWithSaved.has(category as MealCategory);
|
||||
|
||||
let unfilled = [...emptyCategories];
|
||||
if (pendingCalories > 0 && !currentSlotSaved) {
|
||||
unfilled = unfilled.filter((cat) => cat !== category);
|
||||
}
|
||||
|
||||
let openSlots: number;
|
||||
if (currentSlotSaved) {
|
||||
openSlots = Math.max(1, unfilled.length + 1);
|
||||
} else if (pendingCalories > 0) {
|
||||
openSlots = Math.max(1, unfilled.length);
|
||||
} else if (unfilled.includes(category as MealCategory)) {
|
||||
openSlots = Math.max(1, unfilled.length);
|
||||
} else {
|
||||
openSlots = Math.max(1, unfilled.length + 1);
|
||||
}
|
||||
|
||||
const slotTarget = dailyTarget > 0 ? Math.round(Math.max(0, remaining) / openSlots) : 0;
|
||||
|
||||
return { dailyTarget, logged, remaining, slotTarget, openSlots, pendingCalories };
|
||||
}
|
||||
|
||||
export interface RankedRecipeSuggestion {
|
||||
recipe: RecipeListItem;
|
||||
diff: number;
|
||||
}
|
||||
|
||||
export function rankRecipeSuggestions(
|
||||
recipes: RecipeListItem[],
|
||||
category: number,
|
||||
slotTarget: number,
|
||||
limit = 6,
|
||||
): RankedRecipeSuggestion[] {
|
||||
if (slotTarget <= 0) return [];
|
||||
|
||||
return recipes
|
||||
.filter((r) => r.mealCategory === category && r.calories != null && r.calories > 0)
|
||||
.map((recipe) => ({ recipe, diff: Math.abs(recipe.calories! - slotTarget) }))
|
||||
.sort((a, b) => a.diff - b.diff || a.recipe.name.localeCompare(b.recipe.name))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function estimateEntryCalories(recipe: RecipeListItem | null, portions: number): number | null {
|
||||
if (!recipe?.calories) return null;
|
||||
return Math.round(recipe.calories * portions);
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import jsPDF from "jspdf";
|
||||
import html2canvas from "html2canvas";
|
||||
import i18n from "@/i18n";
|
||||
import type { RecipeDetail } from "@/types/recipe.types";
|
||||
import { isNonQuantifiedUnit, normalizeIngredientQuantity } from "@/utils/ingredientQuantity.util";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -15,26 +11,9 @@ export interface ShoppingItem {
|
||||
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.
|
||||
@@ -46,18 +25,12 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
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);
|
||||
const normalized = normalizeIngredientQuantity(ing.amountGrams, ing.unit);
|
||||
const unitRaw = normalized.unit;
|
||||
const nonQuantified =
|
||||
isNonQuantifiedUnit(unitRaw) || (unitRaw === null && normalized.amountGrams === null);
|
||||
|
||||
let key: string;
|
||||
if (nonQuantified) {
|
||||
key = `nq:${normName}`;
|
||||
} else if (unitRaw === null) {
|
||||
key = `g:${normName}`; // grams
|
||||
} else {
|
||||
key = `u:${normName}:${unitRaw.toLowerCase()}`;
|
||||
}
|
||||
const key = nonQuantified ? `nq:${normName}` : `g:${normName}`;
|
||||
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
@@ -65,7 +38,7 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
name: displayName,
|
||||
totalGrams: null,
|
||||
totalAmount: null,
|
||||
unit: unitRaw,
|
||||
unit: nonQuantified ? unitRaw : null,
|
||||
quantified: !nonQuantified,
|
||||
sources: [],
|
||||
contributions: [],
|
||||
@@ -78,17 +51,16 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
}
|
||||
|
||||
if (nonQuantified) {
|
||||
continue; // no summing, no quantity
|
||||
continue;
|
||||
}
|
||||
|
||||
const amount = normalized.amountGrams;
|
||||
if (amount == null || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
bucket.totalGrams = (bucket.totalGrams ?? 0) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +69,25 @@ export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
);
|
||||
}
|
||||
|
||||
function roundMacro(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Scales a recipe's macros and ingredient amounts for the planned portion count. */
|
||||
export function scaleRecipeDetail(recipe: RecipeDetail, portions: number): RecipeDetail {
|
||||
return {
|
||||
...recipe,
|
||||
calories: recipe.calories != null ? Math.round(recipe.calories * portions) : null,
|
||||
protein: recipe.protein != null ? roundMacro(recipe.protein * portions) : null,
|
||||
fat: recipe.fat != null ? roundMacro(recipe.fat * portions) : null,
|
||||
carbs: recipe.carbs != null ? roundMacro(recipe.carbs * portions) : null,
|
||||
ingredients: recipe.ingredients.map((ing) => ({
|
||||
...ing,
|
||||
amountGrams: ing.amountGrams != null ? roundMacro(ing.amountGrams * portions) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
@@ -104,13 +95,9 @@ function formatNumber(value: number): string {
|
||||
/** 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";
|
||||
return item.unit ? item.unit : i18n.t("pdf.toTaste");
|
||||
}
|
||||
if (item.unit === null) {
|
||||
return `${formatNumber(item.totalGrams ?? 0)} g`;
|
||||
}
|
||||
const amount = item.totalAmount;
|
||||
return amount === null ? item.unit : `${formatNumber(amount)} ${item.unit}`;
|
||||
return `${formatNumber(item.totalGrams ?? 0)} g`;
|
||||
}
|
||||
|
||||
/** Parenthetical breakdown shown when the ingredient comes from more than one recipe. */
|
||||
@@ -124,7 +111,7 @@ export function formatBreakdown(item: ShoppingItem): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unitLabel = item.unit === null ? "g" : item.unit;
|
||||
const unitLabel = "g";
|
||||
const allEqual = amounts.every((a) => a === amounts[0]);
|
||||
|
||||
if (allEqual && amounts.length > 1) {
|
||||
@@ -138,15 +125,18 @@ export function formatBreakdown(item: ShoppingItem): string | null {
|
||||
* 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");
|
||||
export async function generatePdf(
|
||||
fileName = "meal-plan.pdf",
|
||||
containerId = "pdf-render-area",
|
||||
): Promise<void> {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
throw new Error("PDF render area not found.");
|
||||
throw new Error(i18n.t("errors.pdfRenderAreaMissing"));
|
||||
}
|
||||
|
||||
const pages = Array.from(container.querySelectorAll<HTMLElement>(".pdf-page"));
|
||||
if (pages.length === 0) {
|
||||
throw new Error("Nothing to export.");
|
||||
throw new Error(i18n.t("errors.pdfNothingToExport"));
|
||||
}
|
||||
|
||||
const pdf = new jsPDF({ unit: "pt", format: "a4", orientation: "portrait" });
|
||||
|
||||
65
meal-plan-frontend/src/utils/recipe-macros.util.ts
Normal file
65
meal-plan-frontend/src/utils/recipe-macros.util.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { IngredientNutritionItem } from "@/types/ingredient-catalog.types";
|
||||
|
||||
export interface MacroTotals {
|
||||
calories: number;
|
||||
protein: number;
|
||||
fat: number;
|
||||
carbs: number;
|
||||
}
|
||||
|
||||
export interface MacroIngredientInput {
|
||||
catalogItemId?: number | null;
|
||||
amountGrams?: number | null;
|
||||
unit?: string | null;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function hasNonMacroUnit(unit?: string | null): boolean {
|
||||
if (!unit?.trim()) return false;
|
||||
const u = unit.trim().toLowerCase();
|
||||
return u === "do smaku" || u === "optional" || u === "opcjonalnie" || u === "szczypta";
|
||||
}
|
||||
|
||||
export function shouldIncludeIngredientInMacroSum(ing: MacroIngredientInput): boolean {
|
||||
return Boolean(
|
||||
ing.catalogItemId && ing.amountGrams != null && ing.amountGrams > 0 && !hasNonMacroUnit(ing.unit),
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnlinkedMacroIngredient(ing: MacroIngredientInput): boolean {
|
||||
if (!ing.name?.trim()) return false;
|
||||
if (hasNonMacroUnit(ing.unit)) return false;
|
||||
return Boolean(ing.amountGrams != null && ing.amountGrams > 0 && !ing.catalogItemId);
|
||||
}
|
||||
|
||||
export function countUnlinkedMacroIngredients(ingredients: MacroIngredientInput[]): number {
|
||||
return ingredients.filter(isUnlinkedMacroIngredient).length;
|
||||
}
|
||||
|
||||
export function estimateMacrosFromCatalog(
|
||||
ingredients: MacroIngredientInput[],
|
||||
catalogItems: Pick<
|
||||
IngredientNutritionItem,
|
||||
"id" | "caloriesPer100G" | "proteinGPer100G" | "fatGPer100G" | "carbsGPer100G"
|
||||
>[],
|
||||
): MacroTotals | null {
|
||||
let calories = 0;
|
||||
let protein = 0;
|
||||
let fat = 0;
|
||||
let carbs = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const ing of ingredients) {
|
||||
if (!shouldIncludeIngredientInMacroSum(ing)) continue;
|
||||
const cat = catalogItems.find((c) => c.id === ing.catalogItemId);
|
||||
if (!cat) continue;
|
||||
const scale = Number(ing.amountGrams) / 100;
|
||||
calories += Math.round(cat.caloriesPer100G * scale);
|
||||
protein += cat.proteinGPer100G * scale;
|
||||
fat += cat.fatGPer100G * scale;
|
||||
carbs += cat.carbsGPer100G * scale;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? { calories, protein, fat, carbs } : null;
|
||||
}
|
||||
53
meal-plan-frontend/src/utils/sort.util.ts
Normal file
53
meal-plan-frontend/src/utils/sort.util.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export function nextSort<T extends string>(
|
||||
current: { column: T; direction: SortDirection } | null,
|
||||
column: T,
|
||||
): { column: T; direction: SortDirection } {
|
||||
if (current?.column === column) {
|
||||
return { column, direction: current.direction === "asc" ? "desc" : "asc" };
|
||||
}
|
||||
return { column, direction: "asc" };
|
||||
}
|
||||
|
||||
export function sortIndicator(
|
||||
current: { column: string; direction: SortDirection } | null,
|
||||
column: string,
|
||||
): string {
|
||||
if (current?.column !== column) return "";
|
||||
return current.direction === "asc" ? " ↑" : " ↓";
|
||||
}
|
||||
|
||||
export function compareText(a: string, b: string, direction: SortDirection): number {
|
||||
const result = a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
|
||||
return direction === "asc" ? result : -result;
|
||||
}
|
||||
|
||||
export function compareNumber(
|
||||
a: number | null | undefined,
|
||||
b: number | null | undefined,
|
||||
direction: SortDirection,
|
||||
): number {
|
||||
const aMissing = a == null || Number.isNaN(a);
|
||||
const bMissing = b == null || Number.isNaN(b);
|
||||
if (aMissing && bMissing) return 0;
|
||||
if (aMissing) return 1;
|
||||
if (bMissing) return -1;
|
||||
const result = a - b;
|
||||
return direction === "asc" ? result : -result;
|
||||
}
|
||||
|
||||
export function sortBy<T>(
|
||||
items: readonly T[],
|
||||
direction: SortDirection,
|
||||
getValue: (item: T) => string | number | null | undefined,
|
||||
): T[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const a = getValue(left);
|
||||
const b = getValue(right);
|
||||
if (typeof a === "number" || typeof b === "number") {
|
||||
return compareNumber(typeof a === "number" ? a : null, typeof b === "number" ? b : null, direction);
|
||||
}
|
||||
return compareText(String(a ?? ""), String(b ?? ""), direction);
|
||||
});
|
||||
}
|
||||
@@ -6,16 +6,16 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eefaf3",
|
||||
100: "#d6f1e0",
|
||||
200: "#b0e3c6",
|
||||
300: "#7dcda4",
|
||||
400: "#47b07e",
|
||||
500: "#22935f",
|
||||
600: "#16774c",
|
||||
700: "#125f3f",
|
||||
800: "#114c34",
|
||||
900: "#0e3e2c",
|
||||
50: "var(--brand-50)",
|
||||
100: "var(--brand-100)",
|
||||
200: "var(--brand-200)",
|
||||
300: "var(--brand-300)",
|
||||
400: "var(--brand-400)",
|
||||
500: "var(--brand-500)",
|
||||
600: "var(--brand-600)",
|
||||
700: "var(--brand-700)",
|
||||
800: "var(--brand-800)",
|
||||
900: "var(--brand-900)",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user