* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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 />} />

View File

@@ -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;

View 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;
}

View 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}`);
}

View 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}`);
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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>

View File

@@ -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 }} />;
}

View File

@@ -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>

View 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>
)}
</>
);
}

View File

@@ -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>
);
}

View 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 };

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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" />;
}

View 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>
);
}

View File

@@ -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,

View 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;
}

View 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,
};
}

View File

@@ -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 }),
});
}

View 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]);
}

View 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;

View 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 / 03), 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"
}
}

View 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 / 03), 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"
}
}

View 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]),
};
}

View File

@@ -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;

View 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;
}
}

View File

@@ -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}>

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -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&apos;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>

View 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>
);
}

View 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>
);
}

View File

@@ -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 youre looking for doesnt 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>
);

View File

@@ -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()}
/>
)}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View 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);
}

View File

@@ -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;

View File

@@ -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;

View 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 };
}

View 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";

View 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;
}

View 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[];
}

View 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;
}

View 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[];
}

View 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;
}

View File

@@ -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 {

View 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",
});
}

View 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']) : [],
};
}

View 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;
}

View 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`;
}

View 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);
}

View File

@@ -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" });

View 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;
}

View 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);
});
}

View File

@@ -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: {