Enable Gitea actions and move recipe mockup to a separate repo
This commit is contained in:
@@ -24,6 +24,8 @@ services:
|
||||
- GITEA__service__ENABLE_PASSKEY_AUTHENTICATION=false
|
||||
- GITEA__openid__ENABLE_OPENID_SIGNIN=false
|
||||
- GITEA__openid__ENABLE_OPENID_SIGNUP=false
|
||||
|
||||
- GITEA__actions__ENABLED=true
|
||||
networks:
|
||||
- homelab_apps
|
||||
- gitea_db_net
|
||||
@@ -68,6 +70,22 @@ services:
|
||||
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
|
||||
timeout: 5s
|
||||
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: gitea_runner
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: https://${GITEA_DOMAIN}
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /run/secrets/gitea_runner_token
|
||||
GITEA_RUNNER_NAME: homelab-runner
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- gitea_runner_data:/data
|
||||
secrets:
|
||||
- gitea_runner_token
|
||||
|
||||
volumes:
|
||||
gitea_data:
|
||||
driver: local
|
||||
@@ -75,6 +93,8 @@ volumes:
|
||||
driver: local
|
||||
gitea_db_data:
|
||||
driver: local
|
||||
gitea_runner_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
homelab_apps:
|
||||
@@ -85,3 +105,5 @@ networks:
|
||||
secrets:
|
||||
gitea_db_password:
|
||||
environment: GITEA_DB_PASSWORD
|
||||
gitea_runner_token:
|
||||
environment: GITEA_RUNNER_TOKEN
|
||||
19
stacks/recipe-mockup/docker-compose.yaml
Normal file
19
stacks/recipe-mockup/docker-compose.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
recipe-mockup:
|
||||
image: git.ulfrx.dev/ulfr/recipe-mockup:latest
|
||||
container_name: recipe-mockup
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- homelab_apps
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=homelab_apps"
|
||||
- "traefik.http.routers.recipe.rule=Host(`${RECIPE_DOMAIN}`)"
|
||||
- "traefik.http.routers.recipe.entrypoints=websecure"
|
||||
- "traefik.http.routers.recipe.tls=true"
|
||||
- "traefik.http.routers.recipe.tls.certresolver=le"
|
||||
- "traefik.http.services.recipe.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
homelab_apps:
|
||||
external: true
|
||||
@@ -1,12 +0,0 @@
|
||||
# Use the lightweight Nginx image
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY manifest.webmanifest /usr/share/nginx/html/
|
||||
COPY sw.js /usr/share/nginx/html/
|
||||
COPY icons /usr/share/nginx/html/icons
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY css /usr/share/nginx/html/css
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,53 +0,0 @@
|
||||
/* Slider styling */
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #111827;
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* View Transitions */
|
||||
.view-transition {
|
||||
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
}
|
||||
.slide-in {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.slide-out {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ingredient Active States */
|
||||
.ingredient-active .check-box {
|
||||
background-color: #111827;
|
||||
border-color: #111827;
|
||||
}
|
||||
.ingredient-active .check-icon {
|
||||
display: block;
|
||||
}
|
||||
.ingredient-active .ingredient-text {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
services:
|
||||
recipe-mockup:
|
||||
build: .
|
||||
container_name: recipe-mockup
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- homelab_apps
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=homelab_apps"
|
||||
- "traefik.http.routers.recipe.rule=Host(`${RECIPE_DOMAIN}`)"
|
||||
- "traefik.http.routers.recipe.entrypoints=websecure"
|
||||
- "traefik.http.routers.recipe.tls=true"
|
||||
- "traefik.http.routers.recipe.tls.certresolver=le"
|
||||
- "traefik.http.services.recipe.loadbalancer.server.port=80"
|
||||
|
||||
# Authentik Protection
|
||||
# - "traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
|
||||
# - "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true"
|
||||
# - "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version"
|
||||
# - "traefik.http.routers.recipe.middlewares=authentik"
|
||||
|
||||
networks:
|
||||
homelab_apps:
|
||||
external: true
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 564 B |
Binary file not shown.
|
Before Width: | Height: | Size: 593 B |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,49 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#111827">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Recipe">
|
||||
<title>Recipe App - Modular</title>
|
||||
<link rel="manifest" href="./manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* Slider styling */
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; height: 20px; width: 20px; border-radius: 50%;
|
||||
background: #111827; cursor: pointer; margin-top: -8px;
|
||||
}
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%; height: 4px; cursor: pointer; background: #e5e7eb; border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Ingredient Active States */
|
||||
.ingredient-active .check-box { background-color: #111827; border-color: #111827; }
|
||||
.ingredient-active .check-icon { display: block; }
|
||||
.ingredient-active .ingredient-text { text-decoration: line-through; color: #9ca3af; }
|
||||
|
||||
/* Utilities */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="m-0 min-h-dvh bg-white font-sans text-gray-800">
|
||||
|
||||
<div id="app-container" class="relative flex h-dvh min-h-0 w-full flex-col overflow-hidden bg-white">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,130 +0,0 @@
|
||||
import { getRecipeListHTML } from './views/RecipeList.js';
|
||||
import { getFilterHTML, setupFilter } from './views/Filter.js';
|
||||
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
|
||||
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js';
|
||||
import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js';
|
||||
import { getShoppingHTML, refreshShopping, setupShopping } from './views/Shopping.js';
|
||||
|
||||
function getAppToastHTML() {
|
||||
return `
|
||||
<div id="app-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[60] opacity-0 translate-y-2 transition-all duration-300" role="status">
|
||||
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="app-toast-text"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getBottomNavHTML() {
|
||||
return `
|
||||
<nav id="app-bottom-nav" class="absolute bottom-0 left-0 right-0 w-full bg-white border-t border-gray-200 flex justify-between px-1 py-2.5 pb-6 z-20 gap-0" aria-label="Główna nawigacja">
|
||||
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab flex flex-col items-center gap-0.5 text-black flex-1 min-w-0 max-w-[5.5rem]">
|
||||
<i class="fas fa-book text-base" aria-hidden="true"></i>
|
||||
<span class="text-[9px] font-medium leading-tight text-center">Przepisy</span>
|
||||
</button>
|
||||
<button type="button" data-tab="planner" id="nav-planner" class="nav-tab flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]">
|
||||
<i class="far fa-calendar-alt text-base" aria-hidden="true"></i>
|
||||
<span class="text-[9px] font-medium leading-tight text-center">Planer</span>
|
||||
</button>
|
||||
<button type="button" data-tab="pantry" id="nav-pantry" class="nav-tab flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]">
|
||||
<i class="fas fa-warehouse text-base" aria-hidden="true"></i>
|
||||
<span class="text-[9px] font-medium leading-tight text-center">Spiżarnia</span>
|
||||
</button>
|
||||
<button type="button" data-tab="shopping" id="nav-shopping" class="nav-tab flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]">
|
||||
<i class="fas fa-cart-shopping text-base" aria-hidden="true"></i>
|
||||
<span class="text-[9px] font-medium leading-tight text-center">Zakupy</span>
|
||||
</button>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
|
||||
function setupTabs() {
|
||||
const main = document.getElementById('main-view');
|
||||
const planner = document.getElementById('planner-view');
|
||||
const pantry = document.getElementById('pantry-view');
|
||||
const shopping = document.getElementById('shopping-view');
|
||||
const nav = document.getElementById('app-bottom-nav');
|
||||
if (!main || !planner || !pantry || !shopping || !nav) return;
|
||||
|
||||
const activeTab = 'nav-tab flex flex-col items-center gap-0.5 text-black flex-1 min-w-0 max-w-[5.5rem]';
|
||||
const idleTab = 'nav-tab flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]';
|
||||
|
||||
const apply = (tab) => {
|
||||
main.classList.toggle('hidden', tab !== 'recipes');
|
||||
planner.classList.toggle('hidden', tab !== 'planner');
|
||||
pantry.classList.toggle('hidden', tab !== 'pantry');
|
||||
shopping.classList.toggle('hidden', tab !== 'shopping');
|
||||
|
||||
if (tab === 'pantry') refreshPantry();
|
||||
if (tab === 'shopping') refreshShopping();
|
||||
|
||||
nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => {
|
||||
const id = btn.getAttribute('data-tab');
|
||||
if (btn.hasAttribute('disabled')) return;
|
||||
if (id === 'recipes' || id === 'planner' || id === 'pantry' || id === 'shopping') {
|
||||
btn.className = id === tab ? activeTab : idleTab;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
nav.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.nav-tab[data-tab]');
|
||||
if (!btn || btn.hasAttribute('disabled')) return;
|
||||
const tab = btn.getAttribute('data-tab');
|
||||
if (tab === 'recipes' || tab === 'planner' || tab === 'pantry' || tab === 'shopping') apply(tab);
|
||||
});
|
||||
|
||||
apply('recipes');
|
||||
|
||||
window.refreshStockViews = () => {
|
||||
refreshPantry();
|
||||
refreshShopping();
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const appContainer = document.getElementById('app-container');
|
||||
|
||||
appContainer.innerHTML = `
|
||||
${getRecipeListHTML()}
|
||||
${getMealPlannerHTML()}
|
||||
${getPantryHTML()}
|
||||
${getShoppingHTML()}
|
||||
${getBottomNavHTML()}
|
||||
${getRecipeDetailHTML()}
|
||||
${getFilterHTML()}
|
||||
${getAppToastHTML()}
|
||||
`;
|
||||
|
||||
setupTabs();
|
||||
setupMealPlanner();
|
||||
setupPantry();
|
||||
setupShopping();
|
||||
setupFilter();
|
||||
setupRecipeDetail();
|
||||
});
|
||||
|
||||
// --- GLOBAL NAVIGATION METHODS ---
|
||||
window.openRecipeDetail = () => {
|
||||
const view = document.getElementById('recipe-detail-view');
|
||||
// Swap Tailwind classes to slide IN
|
||||
view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
|
||||
view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto');
|
||||
};
|
||||
|
||||
window.closeRecipeDetail = () => {
|
||||
const view = document.getElementById('recipe-detail-view');
|
||||
// Swap Tailwind classes to slide OUT
|
||||
view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto');
|
||||
view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none');
|
||||
};
|
||||
|
||||
window.openFilters = () => {
|
||||
const fv = document.getElementById('filter-view');
|
||||
fv.classList.remove('hidden');
|
||||
fv.classList.add('flex');
|
||||
};
|
||||
|
||||
window.closeFilters = () => {
|
||||
const fv = document.getElementById('filter-view');
|
||||
fv.classList.add('hidden');
|
||||
fv.classList.remove('flex');
|
||||
};
|
||||
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* Katalog składników i przepisów — odpowiednik tabel w DB (edycja poza aplikacją).
|
||||
* pantryUnit: jednostka magazynowa / sumowania na liście zakupów (g, ml, szt.).
|
||||
* purchasePack: minimalna „sztuka” ze sklepu w tej samej jednostce co pantryUnit (np. 200 g).
|
||||
* nutritionPer100g — wartości szacunkowe na 100 g (dla płynów: traktuj ml≈g przy wodzie).
|
||||
*/
|
||||
|
||||
export const CATEGORY_LABELS = {
|
||||
pieczywo: 'Pieczywo',
|
||||
nabial: 'Nabiał',
|
||||
mieso_ryby: 'Mięso i ryby',
|
||||
warzywa: 'Warzywa',
|
||||
owoce: 'Owoce',
|
||||
suche: 'Suche i kasze',
|
||||
przyprawy: 'Przyprawy i zioła',
|
||||
inne: 'Inne',
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{ kcal: number, protein: number, fat: number, carbs: number }} NutritionPer100
|
||||
* @typedef {{ amount: number, label?: string }} PurchasePack
|
||||
* @typedef {{ id: string, name: string, category: keyof typeof CATEGORY_LABELS, pantryUnit: 'g'|'ml'|'szt', purchasePack?: PurchasePack, nutritionPer100g?: NutritionPer100 }} IngredientDef
|
||||
*/
|
||||
|
||||
/** @type {Record<string, IngredientDef>} */
|
||||
export const INGREDIENTS = {
|
||||
maka_pszenna: {
|
||||
id: 'maka_pszenna',
|
||||
name: 'Mąka pszenna',
|
||||
category: 'suche',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 364, protein: 10, fat: 1, carbs: 76 },
|
||||
},
|
||||
mleko: {
|
||||
id: 'mleko',
|
||||
name: 'Mleko',
|
||||
category: 'nabial',
|
||||
pantryUnit: 'ml',
|
||||
purchasePack: { amount: 1000, label: 'butelka 1 l' },
|
||||
nutritionPer100g: { kcal: 42, protein: 3.4, fat: 1, carbs: 5 },
|
||||
},
|
||||
jajko: {
|
||||
id: 'jajko',
|
||||
name: 'Jajka',
|
||||
category: 'nabial',
|
||||
pantryUnit: 'szt',
|
||||
nutritionPer100g: { kcal: 143, protein: 13, fat: 9.5, carbs: 1.1 },
|
||||
},
|
||||
piers_kurczaka: {
|
||||
id: 'piers_kurczaka',
|
||||
name: 'Pierś z kurczaka',
|
||||
category: 'mieso_ryby',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 165, protein: 31, fat: 3.6, carbs: 0 },
|
||||
},
|
||||
mix_salat: {
|
||||
id: 'mix_salat',
|
||||
name: 'Mix sałat',
|
||||
category: 'warzywa',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 20, protein: 1.5, fat: 0.3, carbs: 3 },
|
||||
},
|
||||
pomidor: {
|
||||
id: 'pomidor',
|
||||
name: 'Pomidor',
|
||||
category: 'warzywa',
|
||||
pantryUnit: 'szt',
|
||||
nutritionPer100g: { kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9 },
|
||||
},
|
||||
makaron_suchy: {
|
||||
id: 'makaron_suchy',
|
||||
name: 'Makaron',
|
||||
category: 'suche',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 371, protein: 13, fat: 1.5, carbs: 74 },
|
||||
},
|
||||
pomidory_krojone: {
|
||||
id: 'pomidory_krojone',
|
||||
name: 'Pomidory krojone (puszka)',
|
||||
category: 'warzywa',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 20, protein: 1, fat: 0.2, carbs: 4 },
|
||||
},
|
||||
bazylia_swieza: {
|
||||
id: 'bazylia_swieza',
|
||||
name: 'Bazylia świeża',
|
||||
category: 'przyprawy',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 23, protein: 3.2, fat: 0.6, carbs: 2.7 },
|
||||
},
|
||||
jogurt_naturalny: {
|
||||
id: 'jogurt_naturalny',
|
||||
name: 'Jogurt naturalny',
|
||||
category: 'nabial',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 61, protein: 3.5, fat: 3.3, carbs: 4.7 },
|
||||
},
|
||||
mieszanka_jagod: {
|
||||
id: 'mieszanka_jagod',
|
||||
name: 'Mieszanka jagód',
|
||||
category: 'owoce',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 50, protein: 0.7, fat: 0.3, carbs: 12 },
|
||||
},
|
||||
miod: {
|
||||
id: 'miod',
|
||||
name: 'Miód',
|
||||
category: 'inne',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 304, protein: 0.3, fat: 0, carbs: 82 },
|
||||
},
|
||||
chleb_zakwas: {
|
||||
id: 'chleb_zakwas',
|
||||
name: 'Chleb na zakwasie',
|
||||
category: 'pieczywo',
|
||||
pantryUnit: 'szt',
|
||||
nutritionPer100g: { kcal: 250, protein: 9, fat: 1.5, carbs: 49 },
|
||||
},
|
||||
awokado: {
|
||||
id: 'awokado',
|
||||
name: 'Awokado',
|
||||
category: 'warzywa',
|
||||
pantryUnit: 'szt',
|
||||
nutritionPer100g: { kcal: 160, protein: 2, fat: 15, carbs: 9 },
|
||||
},
|
||||
cytryna: {
|
||||
id: 'cytryna',
|
||||
name: 'Cytryna',
|
||||
category: 'owoce',
|
||||
pantryUnit: 'szt',
|
||||
nutritionPer100g: { kcal: 29, protein: 1.1, fat: 0.3, carbs: 9 },
|
||||
},
|
||||
losos_filet: {
|
||||
id: 'losos_filet',
|
||||
name: 'Filet z łososia',
|
||||
category: 'mieso_ryby',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 208, protein: 20, fat: 13, carbs: 0 },
|
||||
},
|
||||
koper_swiezy: {
|
||||
id: 'koper_swiezy',
|
||||
name: 'Koper',
|
||||
category: 'przyprawy',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 43, protein: 3.5, fat: 1.1, carbs: 7 },
|
||||
},
|
||||
mieso_wol_mielone: {
|
||||
id: 'mieso_wol_mielone',
|
||||
name: 'Mięso mielone wołowe',
|
||||
category: 'mieso_ryby',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 250, protein: 26, fat: 15, carbs: 0 },
|
||||
},
|
||||
tortilla_kukurydziana: {
|
||||
id: 'tortilla_kukurydziana',
|
||||
name: 'Tortille kukurydziane',
|
||||
category: 'pieczywo',
|
||||
pantryUnit: 'szt',
|
||||
nutritionPer100g: { kcal: 218, protein: 5.7, fat: 2.9, carbs: 44 },
|
||||
},
|
||||
salsa_pomidorowa: {
|
||||
id: 'salsa_pomidorowa',
|
||||
name: 'Salsa pomidorowa',
|
||||
category: 'warzywa',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 36, protein: 1.5, fat: 0.2, carbs: 8 },
|
||||
},
|
||||
platki_owsiane: {
|
||||
id: 'platki_owsiane',
|
||||
name: 'Płatki owsiane',
|
||||
category: 'suche',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 389, protein: 17, fat: 7, carbs: 66 },
|
||||
},
|
||||
serek_wiejski: {
|
||||
id: 'serek_wiejski',
|
||||
name: 'Serek wiejski',
|
||||
category: 'nabial',
|
||||
pantryUnit: 'g',
|
||||
purchasePack: { amount: 200, label: 'opakowanie 200 g' },
|
||||
nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 3 },
|
||||
},
|
||||
orzechy_wloskie: {
|
||||
id: 'orzechy_wloskie',
|
||||
name: 'Orzechy włoskie',
|
||||
category: 'suche',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 654, protein: 15, fat: 65, carbs: 14 },
|
||||
},
|
||||
truskawki: {
|
||||
id: 'truskawki',
|
||||
name: 'Truskawki',
|
||||
category: 'owoce',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 32, protein: 0.7, fat: 0.3, carbs: 8 },
|
||||
},
|
||||
borowki_amerykanskie: {
|
||||
id: 'borowki_amerykanskie',
|
||||
name: 'Borówki amerykańskie',
|
||||
category: 'owoce',
|
||||
pantryUnit: 'g',
|
||||
nutritionPer100g: { kcal: 57, protein: 0.7, fat: 0.3, carbs: 14 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Porcja bazowa = 1; składniki przez ingredientId */
|
||||
export const RECIPES = {
|
||||
placki: {
|
||||
id: 'placki',
|
||||
title: 'Puszyste placki',
|
||||
minutes: 15,
|
||||
thumbLabel: 'Placki',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'maka_pszenna', amount: 200, unit: 'g' },
|
||||
{ ingredientId: 'mleko', amount: 250, unit: 'ml' },
|
||||
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
salatka: {
|
||||
id: 'salatka',
|
||||
title: 'Sałatka z kurczakiem',
|
||||
minutes: 20,
|
||||
thumbLabel: 'Sałatka',
|
||||
allowedSlots: ['obiad'],
|
||||
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
|
||||
{ ingredientId: 'mix_salat', amount: 100, unit: 'g' },
|
||||
{ ingredientId: 'pomidor', amount: 1, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
makaron: {
|
||||
id: 'makaron',
|
||||
title: 'Makaron z pomidorami i bazylią',
|
||||
minutes: 30,
|
||||
thumbLabel: 'Makaron',
|
||||
allowedSlots: ['obiad', 'kolacja'],
|
||||
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'makaron_suchy', amount: 120, unit: 'g' },
|
||||
{ ingredientId: 'pomidory_krojone', amount: 400, unit: 'g' },
|
||||
{ ingredientId: 'bazylia_swieza', amount: 10, unit: 'g' },
|
||||
],
|
||||
},
|
||||
koktajl: {
|
||||
id: 'koktajl',
|
||||
title: 'Koktajl owocowy',
|
||||
minutes: 5,
|
||||
thumbLabel: 'Koktajl',
|
||||
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'jogurt_naturalny', amount: 200, unit: 'g' },
|
||||
{ ingredientId: 'mieszanka_jagod', amount: 150, unit: 'g' },
|
||||
{ ingredientId: 'miod', amount: 15, unit: 'g' },
|
||||
],
|
||||
},
|
||||
tost_awokado: {
|
||||
id: 'tost_awokado',
|
||||
title: 'Tost z awokado',
|
||||
minutes: 10,
|
||||
thumbLabel: 'Tost',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'chleb_zakwas', amount: 2, unit: 'kromki' },
|
||||
{ ingredientId: 'awokado', amount: 1, unit: 'szt.' },
|
||||
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
losos: {
|
||||
id: 'losos',
|
||||
title: 'Grillowany łosoś',
|
||||
minutes: 25,
|
||||
thumbLabel: 'Łosoś',
|
||||
allowedSlots: ['kolacja', 'obiad'],
|
||||
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'losos_filet', amount: 180, unit: 'g' },
|
||||
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
||||
{ ingredientId: 'koper_swiezy', amount: 5, unit: 'g' },
|
||||
],
|
||||
},
|
||||
tacos: {
|
||||
id: 'tacos',
|
||||
title: 'Tacos z wołowiną',
|
||||
minutes: 20,
|
||||
thumbLabel: 'Tacos',
|
||||
allowedSlots: ['kolacja', 'obiad'],
|
||||
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'mieso_wol_mielone', amount: 200, unit: 'g' },
|
||||
{ ingredientId: 'tortilla_kukurydziana', amount: 4, unit: 'szt.' },
|
||||
{ ingredientId: 'salsa_pomidorowa', amount: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
owsianka: {
|
||||
id: 'owsianka',
|
||||
title: 'Miska owsianki',
|
||||
minutes: 10,
|
||||
thumbLabel: 'Owsianka',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'platki_owsiane', amount: 60, unit: 'g' },
|
||||
{ ingredientId: 'mleko', amount: 200, unit: 'ml' },
|
||||
{ ingredientId: 'miod', amount: 20, unit: 'g' },
|
||||
],
|
||||
},
|
||||
serek_owoc: {
|
||||
id: 'serek_owoc',
|
||||
title: 'Serek wiejski z orzechami i owocami',
|
||||
minutes: 5,
|
||||
thumbLabel: 'Serek',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
||||
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
||||
ingredients: [
|
||||
{ ingredientId: 'serek_wiejski', amount: 200, unit: 'g' },
|
||||
{ ingredientId: 'miod', amount: 10, unit: 'g' },
|
||||
{ ingredientId: 'orzechy_wloskie', amount: 50, unit: 'g' },
|
||||
{ ingredientId: 'truskawki', amount: 100, unit: 'g' },
|
||||
{ ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Krok +/- w spiżarni: całe opakowanie albo domyślny krok (10 g/ml lub 1 szt.).
|
||||
* @param {string} ingredientId
|
||||
* @returns {number}
|
||||
*/
|
||||
export function pantryQtyStep(ingredientId) {
|
||||
const d = INGREDIENTS[ingredientId];
|
||||
if (!d) return 10;
|
||||
if (d.purchasePack && Number.isFinite(d.purchasePack.amount) && d.purchasePack.amount > 0) {
|
||||
return d.purchasePack.amount;
|
||||
}
|
||||
return d.pantryUnit === 'szt' ? 1 : 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IngredientDef} def
|
||||
* @param {number} stockQty — w pantryUnit
|
||||
*/
|
||||
export function nutritionForStock(def, stockQty) {
|
||||
const n = def.nutritionPer100g;
|
||||
if (!n || !Number.isFinite(stockQty) || stockQty <= 0) return null;
|
||||
const f = stockQty / 100;
|
||||
return {
|
||||
kcal: Math.round(n.kcal * f),
|
||||
protein: Math.round(n.protein * f * 10) / 10,
|
||||
fat: Math.round(n.fat * f * 10) / 10,
|
||||
carbs: Math.round(n.carbs * f * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pełne opakowania + reszta (np. 450 g / 200 → 2 + 50 g).
|
||||
* @param {IngredientDef} def
|
||||
* @param {number} stockQty
|
||||
* @returns {{ fullPacks: number, remainder: number } | null}
|
||||
*/
|
||||
export function splitStockIntoPacks(def, stockQty) {
|
||||
const size = def.purchasePack?.amount;
|
||||
if (!size || !Number.isFinite(size) || size <= 0 || !Number.isFinite(stockQty)) return null;
|
||||
const fullPacks = Math.floor(stockQty / size);
|
||||
const remainder = Math.round((stockQty - fullPacks * size) * 10) / 10;
|
||||
return { fullPacks, remainder };
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const MEAL_SLOTS = [
|
||||
{ id: 'sniadanie', label: 'Śniadanie', icon: 'fa-sun' },
|
||||
{ id: 'drugie_sniadanie', label: 'Drugie śniadanie', icon: 'fa-coffee' },
|
||||
{ id: 'obiad', label: 'Obiad', icon: 'fa-utensils' },
|
||||
{ id: 'przekaska', label: 'Przekąska', icon: 'fa-apple-alt' },
|
||||
{ id: 'kolacja', label: 'Kolacja', icon: 'fa-moon' },
|
||||
];
|
||||
@@ -1,51 +0,0 @@
|
||||
export function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function sameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function addDays(d, n) {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
|
||||
export function startOfWeekMonday(d) {
|
||||
const date = startOfDay(d);
|
||||
const day = date.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
return addDays(date, diff);
|
||||
}
|
||||
|
||||
export function startOfMonth(d) {
|
||||
const x = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
export function addMonths(d, n) {
|
||||
const x = new Date(d);
|
||||
x.setMonth(x.getMonth() + n);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
export function addWeeks(d, n) {
|
||||
return addDays(d, n * 7);
|
||||
}
|
||||
|
||||
export function weekContains(weekStart, d) {
|
||||
const t = startOfDay(d).getTime();
|
||||
const ws = weekStart.getTime();
|
||||
const we = addDays(weekStart, 6).getTime();
|
||||
return t >= ws && t <= we;
|
||||
}
|
||||
|
||||
export function sameMonth(a, b) {
|
||||
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js';
|
||||
import { PANTRY_STORAGE_KEY, SHOPPING_STORAGE_KEY } from '../storageKeys.js';
|
||||
|
||||
export const KITCHEN_LIST_ID = 'kitchen';
|
||||
export const MISC_LIST_ID = 'misc';
|
||||
|
||||
function newId(prefix) {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/** @typedef {{ id: string, ingredientId: string, name: string, amount: number, unit: string, category: string, checked: boolean, sourceNote?: string }} KitchenShoppingItem */
|
||||
/** @typedef {{ id: string, text: string, note?: string, checked: boolean }} FreeformShoppingItem */
|
||||
/** @typedef {{ id: string, name: string, type: 'kitchen'|'freeform', items: (KitchenShoppingItem|FreeformShoppingItem)[] }} ShoppingListDef */
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* lists: ShoppingListDef[],
|
||||
* activeListId: string
|
||||
* }} ShoppingState
|
||||
*/
|
||||
|
||||
function defaultShoppingState() {
|
||||
return {
|
||||
lists: [
|
||||
{ id: KITCHEN_LIST_ID, name: 'Kuchnia', type: 'kitchen', items: [] },
|
||||
{ id: MISC_LIST_ID, name: 'Inne zakupy', type: 'freeform', items: [] },
|
||||
],
|
||||
activeListId: KITCHEN_LIST_ID,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {unknown} x */
|
||||
function normalizeKitchenItem(x) {
|
||||
if (!x || typeof x !== 'object') return null;
|
||||
const o = /** @type {Record<string, unknown>} */ (x);
|
||||
if (!o.ingredientId || !Number.isFinite(Number(o.amount))) return null;
|
||||
const id = String(o.id && String(o.id).length ? o.id : newId('s'));
|
||||
const ingId = String(o.ingredientId);
|
||||
return {
|
||||
id,
|
||||
ingredientId: ingId,
|
||||
name: String(o.name || INGREDIENTS[ingId]?.name || ingId),
|
||||
amount: Math.round(Number(o.amount) * 100) / 100,
|
||||
unit: String(o.unit || ''),
|
||||
category: String(o.category || INGREDIENTS[ingId]?.category || 'inne'),
|
||||
checked: Boolean(o.checked),
|
||||
sourceNote: o.sourceNote ? String(o.sourceNote) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {unknown} x */
|
||||
function normalizeFreeformItem(x) {
|
||||
if (!x || typeof x !== 'object') return null;
|
||||
const o = /** @type {Record<string, unknown>} */ (x);
|
||||
if (!o.text || !String(o.text).trim()) return null;
|
||||
return {
|
||||
id: String(o.id && String(o.id).length ? o.id : newId('f')),
|
||||
text: String(o.text).trim(),
|
||||
note: o.note ? String(o.note) : undefined,
|
||||
checked: Boolean(o.checked),
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {ShoppingState} */
|
||||
function normalizeShoppingState(raw) {
|
||||
const base = defaultShoppingState();
|
||||
if (!raw || typeof raw !== 'object') return base;
|
||||
const p = /** @type {Record<string, unknown>} */ (raw);
|
||||
|
||||
if (Array.isArray(p)) {
|
||||
const items = p.map(normalizeKitchenItem).filter(Boolean);
|
||||
const kitchen = base.lists.find((l) => l.id === KITCHEN_LIST_ID);
|
||||
if (kitchen && kitchen.type === 'kitchen') {
|
||||
kitchen.items = /** @type {KitchenShoppingItem[]} */ (items);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
if (!Array.isArray(p.lists)) return base;
|
||||
|
||||
/** @type {ShoppingListDef[]} */
|
||||
const lists = [];
|
||||
for (const L of p.lists) {
|
||||
if (!L || typeof L !== 'object') continue;
|
||||
const row = /** @type {Record<string, unknown>} */ (L);
|
||||
const id = String(row.id || '');
|
||||
const name = String(row.name || 'Lista');
|
||||
let type = row.type === 'freeform' ? 'freeform' : 'kitchen';
|
||||
if (id === KITCHEN_LIST_ID) type = 'kitchen';
|
||||
if (id === MISC_LIST_ID) type = 'freeform';
|
||||
if (!id) continue;
|
||||
if (type === 'kitchen') {
|
||||
const items = Array.isArray(row.items)
|
||||
? row.items.map(normalizeKitchenItem).filter(Boolean)
|
||||
: [];
|
||||
lists.push({ id, name, type: 'kitchen', items: /** @type {KitchenShoppingItem[]} */ (items) });
|
||||
} else {
|
||||
const items = Array.isArray(row.items)
|
||||
? row.items.map(normalizeFreeformItem).filter(Boolean)
|
||||
: [];
|
||||
lists.push({ id, name, type: 'freeform', items: /** @type {FreeformShoppingItem[]} */ (items) });
|
||||
}
|
||||
}
|
||||
|
||||
if (lists.length === 0) return base;
|
||||
|
||||
const hasKitchen = lists.some((l) => l.id === KITCHEN_LIST_ID);
|
||||
if (!hasKitchen) {
|
||||
lists.unshift(/** @type {ShoppingListDef} */ (base.lists[0]));
|
||||
}
|
||||
const hasMisc = lists.some((l) => l.id === MISC_LIST_ID);
|
||||
if (!hasMisc) {
|
||||
lists.push(/** @type {ShoppingListDef} */ (base.lists[1]));
|
||||
}
|
||||
|
||||
let activeListId = String(p.activeListId || KITCHEN_LIST_ID);
|
||||
if (!lists.some((l) => l.id === activeListId)) activeListId = lists[0].id;
|
||||
|
||||
return { lists, activeListId };
|
||||
}
|
||||
|
||||
export function loadShoppingState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SHOPPING_STORAGE_KEY);
|
||||
if (!raw) return defaultShoppingState();
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeShoppingState(parsed);
|
||||
} catch {
|
||||
return defaultShoppingState();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveShoppingState(state) {
|
||||
try {
|
||||
localStorage.setItem(SHOPPING_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getListSummaries() {
|
||||
const { lists, activeListId } = loadShoppingState();
|
||||
return {
|
||||
lists: lists.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
openCount: l.items.filter((i) => !i.checked).length,
|
||||
})),
|
||||
activeListId,
|
||||
};
|
||||
}
|
||||
|
||||
export function setActiveListId(listId) {
|
||||
const s = loadShoppingState();
|
||||
if (!s.lists.some((l) => l.id === listId)) return s;
|
||||
s.activeListId = listId;
|
||||
saveShoppingState(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function getListById(listId) {
|
||||
return loadShoppingState().lists.find((l) => l.id === listId) ?? null;
|
||||
}
|
||||
|
||||
export function getActiveList() {
|
||||
const s = loadShoppingState();
|
||||
return s.lists.find((l) => l.id === s.activeListId) ?? s.lists[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {string} new list id
|
||||
*/
|
||||
export function addFreeformList(name) {
|
||||
const s = loadShoppingState();
|
||||
const id = newId('list');
|
||||
s.lists.push({
|
||||
id,
|
||||
name: name.trim() || 'Nowa lista',
|
||||
type: 'freeform',
|
||||
items: [],
|
||||
});
|
||||
s.activeListId = id;
|
||||
saveShoppingState(s);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} listId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deleteList(listId) {
|
||||
if (listId === KITCHEN_LIST_ID) return false;
|
||||
const s = loadShoppingState();
|
||||
const idx = s.lists.findIndex((l) => l.id === listId);
|
||||
if (idx < 0) return false;
|
||||
s.lists.splice(idx, 1);
|
||||
if (s.activeListId === listId) {
|
||||
s.activeListId = s.lists[0]?.id || KITCHEN_LIST_ID;
|
||||
}
|
||||
saveShoppingState(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje linie tylko do listy kuchennej (składniki z katalogu).
|
||||
* @param {{ ingredientId: string, amount: number, unit: string, name?: string, category?: string, sourceNote?: string }[]} lines
|
||||
* @param {string} [listId]
|
||||
*/
|
||||
export function addOrMergeShoppingLines(lines, listId = KITCHEN_LIST_ID) {
|
||||
const s = loadShoppingState();
|
||||
const list = s.lists.find((l) => l.id === listId && l.type === 'kitchen');
|
||||
if (!list || list.type !== 'kitchen') return s;
|
||||
|
||||
const items = /** @type {KitchenShoppingItem[]} */ (list.items);
|
||||
const open = items.filter((x) => !x.checked);
|
||||
const closed = items.filter((x) => x.checked);
|
||||
|
||||
for (const L of lines) {
|
||||
const def = INGREDIENTS[L.ingredientId];
|
||||
const name = L.name || def?.name || L.ingredientId;
|
||||
const category = L.category || def?.category || 'inne';
|
||||
const idx = open.findIndex(
|
||||
(x) => x.ingredientId === L.ingredientId && x.unit === L.unit,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
open[idx].amount = Math.round((open[idx].amount + L.amount) * 100) / 100;
|
||||
if (L.sourceNote && !open[idx].sourceNote) open[idx].sourceNote = L.sourceNote;
|
||||
} else {
|
||||
open.push({
|
||||
id: newId('s'),
|
||||
ingredientId: L.ingredientId,
|
||||
name,
|
||||
amount: Math.round(L.amount * 100) / 100,
|
||||
unit: L.unit,
|
||||
category,
|
||||
checked: false,
|
||||
sourceNote: L.sourceNote,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
list.items = [...open, ...closed];
|
||||
saveShoppingState(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Na listę kuchenną ze spiżarni (amount w pantryUnit: g, ml lub szt.).
|
||||
* @param {string} [sourceNote] — nadpisuje domyślne „Ze spiżarni”
|
||||
*/
|
||||
export function addIngredientToKitchenList(ingredientId, amount = 1, sourceNote) {
|
||||
const def = INGREDIENTS[ingredientId];
|
||||
if (!def) return;
|
||||
const unit = displayUnit(def.pantryUnit);
|
||||
addOrMergeShoppingLines([{
|
||||
ingredientId,
|
||||
amount,
|
||||
unit,
|
||||
name: def.name,
|
||||
category: def.category,
|
||||
sourceNote: sourceNote ?? 'Ze spiżarni',
|
||||
}], KITCHEN_LIST_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} listId
|
||||
* @param {string} text
|
||||
* @param {string} [note]
|
||||
*/
|
||||
export function addFreeformLine(listId, text, note) {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
const s = loadShoppingState();
|
||||
const list = s.lists.find((l) => l.id === listId && l.type === 'freeform');
|
||||
if (!list) return;
|
||||
const items = /** @type {FreeformShoppingItem[]} */ (list.items);
|
||||
items.push({
|
||||
id: newId('f'),
|
||||
text: t,
|
||||
note: note?.trim() || undefined,
|
||||
checked: false,
|
||||
});
|
||||
saveShoppingState(s);
|
||||
}
|
||||
|
||||
export function toggleItemInList(listId, itemId) {
|
||||
const s = loadShoppingState();
|
||||
const list = s.lists.find((l) => l.id === listId);
|
||||
if (!list) return s;
|
||||
const it = list.items.find((i) => i.id === itemId);
|
||||
if (it) it.checked = !it.checked;
|
||||
saveShoppingState(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function removeItemFromList(listId, itemId) {
|
||||
const s = loadShoppingState();
|
||||
const list = s.lists.find((l) => l.id === listId);
|
||||
if (!list) return s;
|
||||
list.items = list.items.filter((i) => i.id !== itemId);
|
||||
saveShoppingState(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function clearCheckedInList(listId) {
|
||||
const s = loadShoppingState();
|
||||
const list = s.lists.find((l) => l.id === listId);
|
||||
if (!list) return s;
|
||||
list.items = list.items.filter((i) => !i.checked);
|
||||
saveShoppingState(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function computeShortfalls(needLines, pantry = loadPantry()) {
|
||||
const short = [];
|
||||
for (const L of needLines) {
|
||||
const def = INGREDIENTS[L.ingredientId];
|
||||
if (!def) continue;
|
||||
const u = normalizeUnitForPantry(L.unit, def.pantryUnit);
|
||||
if (u === null) {
|
||||
short.push({ ...L, pantryQty: 0, shortfall: L.amount, unitMismatch: true });
|
||||
continue;
|
||||
}
|
||||
const have = Number(pantry[L.ingredientId]) || 0;
|
||||
const miss = Math.max(0, Math.round((L.amount - have) * 100) / 100);
|
||||
if (miss > 0) {
|
||||
short.push({
|
||||
...L,
|
||||
unit: displayUnit(def.pantryUnit),
|
||||
pantryQty: have,
|
||||
shortfall: miss,
|
||||
unitMismatch: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return short;
|
||||
}
|
||||
|
||||
function displayUnit(pantryUnit) {
|
||||
if (pantryUnit === 'szt') return 'szt.';
|
||||
return pantryUnit;
|
||||
}
|
||||
|
||||
function normalizeUnitForPantry(recipeUnit, pantryUnit) {
|
||||
const r = String(recipeUnit).trim().toLowerCase().replace(/\.$/, '');
|
||||
if (pantryUnit === 'g' && (r === 'g' || r === 'gram')) return 'g';
|
||||
if (pantryUnit === 'ml' && (r === 'ml' || r === 'mililitr')) return 'ml';
|
||||
if (pantryUnit === 'szt' && (r === 'szt' || r === 'sztuka' || r === 'kromki' || r === 'kromka')) return 'szt';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function categoryLabel(cat) {
|
||||
return CATEGORY_LABELS[cat] || CATEGORY_LABELS.inne;
|
||||
}
|
||||
|
||||
/** @returns {Record<string, number>} ingredientId -> ilość w pantryUnit */
|
||||
export function loadPantry() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PANTRY_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const p = JSON.parse(raw);
|
||||
if (typeof p !== 'object' || p === null) return {};
|
||||
const out = {};
|
||||
Object.keys(p).forEach((k) => {
|
||||
const n = Number(p[k]);
|
||||
if (!Number.isFinite(n) || n < 0) return;
|
||||
out[k] = n;
|
||||
});
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePantry(pantry) {
|
||||
try {
|
||||
localStorage.setItem(PANTRY_STORAGE_KEY, JSON.stringify(pantry));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function setPantryQty(ingredientId, qty) {
|
||||
const pantry = loadPantry();
|
||||
if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId];
|
||||
else pantry[ingredientId] = Math.round(qty * 1000) / 1000;
|
||||
savePantry(pantry);
|
||||
return pantry;
|
||||
}
|
||||
|
||||
/** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */
|
||||
export function applyCheckedKitchenListToPantry() {
|
||||
const s = loadShoppingState();
|
||||
const kitchen = s.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen');
|
||||
if (!kitchen) return { pantry: loadPantry(), state: s };
|
||||
|
||||
const pantry = loadPantry();
|
||||
const items = /** @type {KitchenShoppingItem[]} */ (kitchen.items);
|
||||
|
||||
for (const it of items) {
|
||||
if (!it.checked) continue;
|
||||
const def = INGREDIENTS[it.ingredientId];
|
||||
if (!def) continue;
|
||||
if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue;
|
||||
const cur = Number(pantry[it.ingredientId]) || 0;
|
||||
pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000;
|
||||
}
|
||||
|
||||
kitchen.items = items.filter((i) => !i.checked);
|
||||
savePantry(pantry);
|
||||
saveShoppingState(s);
|
||||
return { pantry, state: s };
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { INGREDIENTS, RECIPES } from '../data/catalog.js';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import { addDays } from './dateUtils.js';
|
||||
import { getDayPlan } from './planStore.js';
|
||||
|
||||
export function dayHasAnyMeal(plans, d) {
|
||||
const p = getDayPlan(plans, d);
|
||||
return MEAL_SLOTS.some((s) => {
|
||||
const arr = p[s.id];
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function sumDayNutrition(dayPlan) {
|
||||
let kcal = 0;
|
||||
let protein = 0;
|
||||
let fat = 0;
|
||||
let carbs = 0;
|
||||
let mealCount = 0;
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
mealCount += 1;
|
||||
kcal += r.nutritionPerServing.kcal * s;
|
||||
protein += r.nutritionPerServing.protein * s;
|
||||
fat += r.nutritionPerServing.fat * s;
|
||||
carbs += r.nutritionPerServing.carbs * s;
|
||||
});
|
||||
});
|
||||
return {
|
||||
kcal: Math.round(kcal),
|
||||
protein: Math.round(protein * 10) / 10,
|
||||
fat: Math.round(fat * 10) / 10,
|
||||
carbs: Math.round(carbs * 10) / 10,
|
||||
mealCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLine(ing, scaledAmount) {
|
||||
const def = INGREDIENTS[ing.ingredientId];
|
||||
return {
|
||||
ingredientId: ing.ingredientId,
|
||||
name: def?.name ?? ing.ingredientId,
|
||||
category: def?.category ?? 'inne',
|
||||
amount: scaledAmount,
|
||||
unit: ing.unit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Płaska lista składników z jednego dnia (wszystkie pory). */
|
||||
export function flattenDayIngredientLines(dayPlan) {
|
||||
/** @type {ReturnType<typeof resolveLine>[]} */
|
||||
const out = [];
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = RECIPES[entry.recipeId];
|
||||
if (!r || !Array.isArray(r.ingredients)) return;
|
||||
const serv = Math.max(1, Number(entry.servings) || 1);
|
||||
r.ingredients.forEach((ing) => {
|
||||
const scaled = Math.round(ing.amount * serv * 10) / 10;
|
||||
out.push(resolveLine(ing, scaled));
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sumuje po (ingredientId + unit) — ta sama jednostka jak w przepisie. */
|
||||
export function mergeIngredientLines(lines) {
|
||||
const m = new Map();
|
||||
for (const L of lines) {
|
||||
const key = `${L.ingredientId}\t${L.unit}`;
|
||||
const cur = m.get(key);
|
||||
if (!cur) {
|
||||
m.set(key, { ...L });
|
||||
} else {
|
||||
cur.amount = Math.round((cur.amount + L.amount) * 10) / 10;
|
||||
}
|
||||
}
|
||||
return [...m.values()].sort((a, b) => {
|
||||
const c = a.category.localeCompare(b.category);
|
||||
return c !== 0 ? c : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapotrzebowanie składników od weekStart (włącznie) przez 7 dni.
|
||||
* @param {Record<string, unknown>} plans
|
||||
* @param {Date} weekStart
|
||||
*/
|
||||
export function aggregateWeekIngredientNeed(plans, weekStart) {
|
||||
const all = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = addDays(weekStart, i);
|
||||
const dayPlan = getDayPlan(plans, day);
|
||||
all.push(...flattenDayIngredientLines(dayPlan));
|
||||
}
|
||||
return mergeIngredientLines(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
||||
*/
|
||||
export function aggregateDayIngredientsBySlot(dayPlan) {
|
||||
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { ingredientId: string, name: string, amount: number, unit: string, category: string }[] }[] }[]} */
|
||||
const blocks = [];
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||
const recipes = [];
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
const items = r.ingredients.map((ing) => {
|
||||
const def = INGREDIENTS[ing.ingredientId];
|
||||
return {
|
||||
ingredientId: ing.ingredientId,
|
||||
name: def?.name ?? ing.ingredientId,
|
||||
category: def?.category ?? 'inne',
|
||||
amount: Math.round(ing.amount * s * 10) / 10,
|
||||
unit: ing.unit,
|
||||
};
|
||||
});
|
||||
recipes.push({ recipeTitle: r.title, items });
|
||||
});
|
||||
if (recipes.length > 0) {
|
||||
blocks.push({ mealLabel: slot.label, recipes });
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function countDayShortfalls(dayPlan, pantry) {
|
||||
const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan));
|
||||
let count = 0;
|
||||
for (const line of lines) {
|
||||
if ((Number(pantry[line.ingredientId]) || 0) < line.amount) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kumulatywna prognoza zużycia spiżarni: od startDate przez lookAheadDays dni.
|
||||
* Zwraca tablicę dni (tylko te z posiłkami), każdy z listą składników i informacją
|
||||
* ile jest w spiżarni (po odjęciu zużycia z poprzednich dni) i ile brakuje.
|
||||
*/
|
||||
export function computeFullForecast(plans, pantry, startDate, lookAheadDays = 8) {
|
||||
const running = { ...pantry };
|
||||
const days = [];
|
||||
|
||||
for (let i = 0; i < lookAheadDays; i++) {
|
||||
const day = addDays(startDate, i);
|
||||
const dayPlan = getDayPlan(plans, day);
|
||||
const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan));
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
const items = lines.map((line) => {
|
||||
const def = INGREDIENTS[line.ingredientId];
|
||||
const have = Math.round((Number(running[line.ingredientId]) || 0) * 10) / 10;
|
||||
const miss = Math.max(0, Math.round((line.amount - have) * 10) / 10);
|
||||
return {
|
||||
...line,
|
||||
pantryQty: have,
|
||||
shortfall: miss,
|
||||
enough: miss <= 0,
|
||||
pantryUnit: def
|
||||
? def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit
|
||||
: line.unit,
|
||||
};
|
||||
});
|
||||
|
||||
for (const line of lines) {
|
||||
const have = Number(running[line.ingredientId]) || 0;
|
||||
running[line.ingredientId] = Math.max(0, Math.round((have - line.amount) * 10) / 10);
|
||||
}
|
||||
|
||||
days.push({ date: day, dayIndex: i, items, hasShortfall: items.some((it) => !it.enough) });
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { RECIPES } from '../data/catalog.js';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import { PLANS_STORAGE_KEY } from '../storageKeys.js';
|
||||
import { startOfDay } from './dateUtils.js';
|
||||
|
||||
export function dateKey(d) {
|
||||
const x = startOfDay(d);
|
||||
const y = x.getFullYear();
|
||||
const m = String(x.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(x.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function newPlanEntryId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `e${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/** Jedna pora dnia = tablica wpisów { id, recipeId, servings } */
|
||||
export function normalizeSlotValue(v) {
|
||||
if (!v) return [];
|
||||
if (Array.isArray(v)) {
|
||||
return v
|
||||
.filter((x) => x && x.recipeId && RECIPES[x.recipeId])
|
||||
.map((x) => ({
|
||||
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
|
||||
recipeId: x.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
|
||||
}));
|
||||
}
|
||||
if (typeof v === 'object' && v.recipeId && RECIPES[v.recipeId]) {
|
||||
return [{
|
||||
id: newPlanEntryId(),
|
||||
recipeId: v.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeDayPlan(day) {
|
||||
if (!day || typeof day !== 'object') return {};
|
||||
const out = {};
|
||||
MEAL_SLOTS.forEach((s) => {
|
||||
const arr = normalizeSlotValue(day[s.id]);
|
||||
if (arr.length > 0) out[s.id] = arr;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeAllPlans(plans) {
|
||||
if (!plans || typeof plans !== 'object') return {};
|
||||
const out = {};
|
||||
Object.keys(plans).forEach((key) => {
|
||||
const d = normalizeDayPlan(plans[key]);
|
||||
if (Object.keys(d).length > 0) out[key] = d;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function loadPlans() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLANS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||
return normalizeAllPlans(parsed);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlans(plans) {
|
||||
try {
|
||||
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getDayPlan(plans, d) {
|
||||
const key = dateKey(d);
|
||||
const day = plans[key];
|
||||
return day && typeof day === 'object' ? day : {};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
|
||||
export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1';
|
||||
export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1';
|
||||
@@ -1,13 +0,0 @@
|
||||
export function showAppToast(message) {
|
||||
const wrap = document.getElementById('app-toast');
|
||||
const text = document.getElementById('app-toast-text');
|
||||
if (!wrap || !text) return;
|
||||
text.textContent = message;
|
||||
wrap.classList.remove('opacity-0', 'translate-y-2');
|
||||
wrap.classList.add('opacity-100', 'translate-y-0');
|
||||
clearTimeout(showAppToast._t);
|
||||
showAppToast._t = setTimeout(() => {
|
||||
wrap.classList.add('opacity-0', 'translate-y-2');
|
||||
wrap.classList.remove('opacity-100', 'translate-y-0');
|
||||
}, 2600);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export function getFilterHTML() {
|
||||
return `
|
||||
<div id="filter-view" class="absolute inset-0 bg-white z-50 hidden flex-col">
|
||||
<div class="p-4 border-b border-gray-200 flex items-center justify-between mt-4">
|
||||
<button onclick="closeFilters()" class="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-100 rounded-full transition-colors"><i class="fas fa-arrow-left text-lg"></i></button>
|
||||
<h2 class="text-lg font-semibold text-black">Filtry</h2>
|
||||
<button class="px-2 text-sm font-medium text-gray-500 hover:text-black transition-colors">Wyczyść</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-black mb-4">Pora posiłku</h3>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<button class="px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors">Śniadanie</button>
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Drugie śniadanie</button>
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Obiad</button>
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Podwieczorek</button>
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Kolacja</button>
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Przekąska</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-black mb-4">Dieta i tagi</h3>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Wegetariańska</button>
|
||||
<button class="px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors">Wegańska</button>
|
||||
<button class="px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors">Wysokobiałkowe</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-base font-semibold text-black">Maks. czas przygotowania</h3>
|
||||
<span id="time-display" class="text-sm font-medium text-gray-600">30 min</span>
|
||||
</div>
|
||||
<div class="px-1">
|
||||
<input type="range" id="prep-time-slider" min="5" max="120" step="5" value="30" class="w-full appearance-none bg-transparent">
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-3 font-medium">
|
||||
<span>5 min</span><span>30 min</span><span>1 godz.</span><span>2 godz.+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-gray-200 bg-white mt-auto">
|
||||
<button onclick="closeFilters()" class="w-full bg-gray-900 hover:bg-black text-white py-3.5 rounded-xl font-semibold shadow-sm transition-colors text-sm">Pokaż 12 wyników</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function setupFilter() {
|
||||
const timeSlider = document.getElementById('prep-time-slider');
|
||||
const timeDisplay = document.getElementById('time-display');
|
||||
|
||||
if(timeSlider) {
|
||||
timeSlider.addEventListener('input', (e) => {
|
||||
const val = e.target.value;
|
||||
timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,995 +0,0 @@
|
||||
import { INGREDIENTS, RECIPES } from '../data/catalog.js';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
sameDay,
|
||||
sameMonth,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeekMonday,
|
||||
weekContains,
|
||||
} from '../services/dateUtils.js';
|
||||
import {
|
||||
computeFullForecast,
|
||||
countDayShortfalls,
|
||||
dayHasAnyMeal,
|
||||
sumDayNutrition,
|
||||
} from '../services/planIngredients.js';
|
||||
import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js';
|
||||
import {
|
||||
dateKey,
|
||||
getDayPlan,
|
||||
loadPlans,
|
||||
newPlanEntryId,
|
||||
savePlans,
|
||||
} from '../services/planStore.js';
|
||||
|
||||
const MONTHS_SHORT = [
|
||||
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
|
||||
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
|
||||
];
|
||||
const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
|
||||
const WEEKDAYS_LONG = [
|
||||
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
||||
];
|
||||
|
||||
/** Odstęp od dołu planera = miejsce na dolną nawigację. Ten sam w `bottom`, `max-height` i w `translateY(calc(100% + …))` przy zamknięciu — inaczej zostaje widoczny uchwyt. */
|
||||
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
|
||||
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
||||
|
||||
function recipesForSlot(slotId) {
|
||||
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
|
||||
}
|
||||
|
||||
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
|
||||
const today = startOfDay(new Date());
|
||||
if (!sameDay(selected, today)) return false;
|
||||
if (mode === 'week') return weekContains(weekStart, today);
|
||||
return sameMonth(monthAnchor, today);
|
||||
}
|
||||
|
||||
function syncTodayButton(mode, weekStart, monthAnchor, selected) {
|
||||
const btn = document.getElementById('cal-go-today');
|
||||
if (!btn) return;
|
||||
const onToday = isCalendarOnToday(mode, weekStart, monthAnchor, selected);
|
||||
const active = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors';
|
||||
const dim = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-100 bg-gray-50 px-2 text-[10px] font-semibold text-gray-400 shadow-none cursor-default transition-colors';
|
||||
btn.className = onToday ? dim : active;
|
||||
btn.disabled = onToday;
|
||||
}
|
||||
|
||||
export function getMealPlannerHTML() {
|
||||
return `
|
||||
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||||
<div class="shrink-0 bg-white border-b border-gray-200 mt-3">
|
||||
<div class="px-3 pt-2 pb-1.5 flex items-center gap-1">
|
||||
<button type="button" id="cal-prev" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors" aria-label="Poprzedni okres">
|
||||
<i class="fas fa-chevron-left text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<p id="cal-period-label" class="flex-1 min-w-0 text-xs font-medium text-gray-900 text-center tabular-nums leading-none px-1 truncate"></p>
|
||||
<button type="button" id="cal-next" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors" aria-label="Następny okres">
|
||||
<i class="fas fa-chevron-right text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-3 pb-2 flex items-center justify-center">
|
||||
<button type="button" id="cal-go-today" title="Dziś" aria-label="Przejdź do dzisiejszego dnia"
|
||||
class="h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2.5 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors">
|
||||
<i class="fas fa-calendar-day text-[9px] opacity-70" aria-hidden="true"></i>
|
||||
Dziś
|
||||
</button>
|
||||
</div>
|
||||
<div id="calendar-swipe-zone" style="touch-action: pan-x">
|
||||
<div id="calendar-week-wrap" class="px-3 pb-1" style="overflow: hidden; max-height: 10rem; opacity: 1">
|
||||
<div class="grid grid-cols-7 gap-0.5 text-center text-[9px] font-medium text-gray-400 uppercase tracking-wide mb-0.5 leading-none">
|
||||
${WEEKDAYS_SHORT.map((d) => `<div>${d}</div>`).join('')}
|
||||
</div>
|
||||
<div id="calendar-week-grid" class="grid grid-cols-7 gap-0.5"></div>
|
||||
</div>
|
||||
<div id="calendar-month-wrap" class="px-3 pb-1" style="overflow: hidden; max-height: 0; opacity: 0">
|
||||
<div class="grid grid-cols-7 gap-0.5 text-center text-[9px] font-medium text-gray-400 uppercase tracking-wide mb-0.5 leading-none">
|
||||
${WEEKDAYS_SHORT.map((d) => `<div>${d}</div>`).join('')}
|
||||
</div>
|
||||
<div id="calendar-month-grid" class="grid grid-cols-7 gap-0.5"></div>
|
||||
</div>
|
||||
<div id="calendar-drag-handle" class="flex items-center justify-center pb-2 pt-0.5">
|
||||
<i id="calendar-handle-icon" class="fas fa-chevron-down text-[8px] text-gray-300" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4">
|
||||
<p id="planner-day-heading" class="text-[13px] font-semibold text-gray-900 tabular-nums mb-2"></p>
|
||||
<div id="planner-summary-card" class="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 to-white p-2.5 shadow-sm mb-3">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wide text-amber-900/70">Dziś — podsumowanie</p>
|
||||
<p id="planner-summary-kcal" class="text-xl font-bold text-gray-900 tabular-nums leading-tight mt-0.5">0 <span class="text-[13px] font-semibold text-gray-500">kcal</span></p>
|
||||
</div>
|
||||
<button type="button" id="planner-toggle-nutrition" class="shrink-0 flex items-center gap-1 text-[11px] font-semibold text-amber-900/80 hover:text-gray-900 py-1 px-2 rounded-lg hover:bg-amber-100/50 transition-colors" aria-expanded="false">
|
||||
Szczegóły
|
||||
<i class="fas fa-chevron-down text-[9px] transition-transform" id="planner-nutrition-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="planner-macro-row" class="flex gap-2 mb-0">
|
||||
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
|
||||
<p class="text-[9px] font-semibold text-gray-500 uppercase">B</p>
|
||||
<p id="planner-macro-p" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
|
||||
<p class="text-[9px] font-semibold text-gray-500 uppercase">T</p>
|
||||
<p id="planner-macro-f" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
|
||||
<p class="text-[9px] font-semibold text-gray-500 uppercase">W</p>
|
||||
<p id="planner-macro-c" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="planner-nutrition-details" class="hidden mt-3 pt-3 border-t border-amber-200/60">
|
||||
<ul class="space-y-0 divide-y divide-amber-100/80 text-sm">
|
||||
<li class="flex justify-between py-2 font-bold"><span class="text-gray-800">Kalorie</span><span id="planner-detail-kcal" class="text-gray-900 tabular-nums">0 kcal</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Białko</span><span id="planner-detail-p" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Tłuszcze</span><span id="planner-detail-f" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Węglowodany</span><span id="planner-detail-c" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
|
||||
</ul>
|
||||
<p id="planner-summary-hint" class="text-[11px] text-gray-500 mt-2">Suma z zaplanowanych posiłków (porcje × wartości z przepisu).</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="planner-open-ingredients" class="w-full mb-3 flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-gray-300 bg-white text-[13px] font-semibold text-gray-800 hover:border-gray-400 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-shopping-basket text-gray-500 text-xs" aria-hidden="true"></i>
|
||||
Składniki na ten dzień
|
||||
</button>
|
||||
<div id="planner-meal-slots" class="space-y-3 pb-2"></div>
|
||||
</div>
|
||||
|
||||
<div id="planner-picker-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
||||
<div id="planner-picker-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
|
||||
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
|
||||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
|
||||
<h2 id="planner-picker-title" class="text-[15px] font-bold text-gray-900 leading-tight pr-2">Wybierz przepis</h2>
|
||||
<p id="planner-picker-sub" class="text-[11px] text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2.5 pb-8 space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<div id="planner-ing-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
|
||||
<div id="planner-ing-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
|
||||
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
|
||||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
|
||||
<h2 id="planner-ing-title" class="text-[15px] font-bold text-gray-900 leading-tight pr-2">Składniki i spiżarnia</h2>
|
||||
<p id="planner-ing-sub" class="text-[11px] text-gray-500 mt-1">Porównanie potrzeb z zapasami.</p>
|
||||
</div>
|
||||
<div id="planner-ing-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2 pb-2"></div>
|
||||
<div id="planner-ing-footer" class="shrink-0 p-4 pt-2 pb-5 border-t border-gray-100 bg-white space-y-2">
|
||||
<button type="button" id="planner-ing-add-all" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2">
|
||||
<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i>
|
||||
Dodaj braki na dziś do listy
|
||||
</button>
|
||||
<button type="button" id="planner-ing-add-btn" class="hidden w-full border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 py-2.5 rounded-xl font-semibold text-[13px] flex items-center justify-center gap-2 transition-colors">
|
||||
<i class="fas fa-calendar-week text-gray-500 text-[11px]" aria-hidden="true"></i>
|
||||
Dodaj braki na cały tydzień
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[55] opacity-0 translate-y-2 transition-all duration-300" role="status">
|
||||
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWeekGrid(weekStart, selected, plans) {
|
||||
const grid = document.getElementById('calendar-week-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = addDays(weekStart, i);
|
||||
const isSel = selected && sameDay(day, selected);
|
||||
const isToday = sameDay(day, new Date());
|
||||
const hasMeals = dayHasAnyMeal(plans, day);
|
||||
cells.push(`
|
||||
<button type="button" data-planner-day="${day.getTime()}"
|
||||
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0 gap-0.5 py-1
|
||||
${isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100'}
|
||||
${isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
|
||||
<span>${day.getDate()}</span>
|
||||
${hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
grid.innerHTML = cells.join('');
|
||||
}
|
||||
|
||||
function renderMonthGrid(monthAnchor, selected, plans) {
|
||||
const grid = document.getElementById('calendar-month-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const first = startOfMonth(monthAnchor);
|
||||
const startGrid = startOfWeekMonday(first);
|
||||
const cells = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const day = addDays(startGrid, i);
|
||||
const inMonth = day.getMonth() === first.getMonth();
|
||||
const isSel = selected && sameDay(day, selected);
|
||||
const isToday = sameDay(day, new Date());
|
||||
const hasMeals = inMonth && dayHasAnyMeal(plans, day);
|
||||
cells.push(`
|
||||
<button type="button" data-planner-day="${day.getTime()}"
|
||||
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0 gap-0.5 py-1
|
||||
${!inMonth ? 'text-gray-300' : (isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100')}
|
||||
${inMonth && isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
|
||||
<span>${day.getDate()}</span>
|
||||
${inMonth && hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
grid.innerHTML = cells.join('');
|
||||
}
|
||||
|
||||
function updatePeriodLabel(mode, weekStart, monthAnchor) {
|
||||
const el = document.getElementById('cal-period-label');
|
||||
if (!el) return;
|
||||
|
||||
if (mode === 'week') {
|
||||
const end = addDays(weekStart, 6);
|
||||
const y = weekStart.getFullYear();
|
||||
if (weekStart.getMonth() === end.getMonth()) {
|
||||
el.textContent = `${weekStart.getDate()}–${end.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${y}`;
|
||||
} else {
|
||||
el.textContent = `${weekStart.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} – ${end.getDate()} ${MONTHS_SHORT[end.getMonth()]} ${y}`;
|
||||
}
|
||||
} else {
|
||||
const m = monthAnchor.getMonth();
|
||||
const y = monthAnchor.getFullYear();
|
||||
const monthLong = [
|
||||
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
|
||||
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
|
||||
][m];
|
||||
el.textContent = `${monthLong} ${y}`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncModeToggle(mode) {
|
||||
const weekWrap = document.getElementById('calendar-week-wrap');
|
||||
const monthWrap = document.getElementById('calendar-month-wrap');
|
||||
const handleIcon = document.getElementById('calendar-handle-icon');
|
||||
|
||||
if (weekWrap) {
|
||||
weekWrap.style.maxHeight = mode === 'week' ? '10rem' : '0';
|
||||
weekWrap.style.opacity = mode === 'week' ? '1' : '0';
|
||||
}
|
||||
if (monthWrap) {
|
||||
monthWrap.style.maxHeight = mode === 'month' ? '25rem' : '0';
|
||||
monthWrap.style.opacity = mode === 'month' ? '1' : '0';
|
||||
}
|
||||
if (handleIcon) {
|
||||
handleIcon.className = mode === 'week'
|
||||
? 'fas fa-chevron-down text-[8px] text-gray-300'
|
||||
: 'fas fa-chevron-up text-[8px] text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
function bindDayClicks(container, state, rerender) {
|
||||
container?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-planner-day]');
|
||||
if (!btn) return;
|
||||
const ts = Number(btn.getAttribute('data-planner-day'));
|
||||
state.selected = new Date(ts);
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
function bindCalendarSwipeGesture(state, rerender) {
|
||||
const zone = document.getElementById('calendar-swipe-zone');
|
||||
if (!zone) return;
|
||||
|
||||
let startY = 0;
|
||||
let ptrId = null;
|
||||
let moved = false;
|
||||
|
||||
zone.addEventListener('pointerdown', (e) => {
|
||||
if (ptrId !== null) return;
|
||||
startY = e.clientY;
|
||||
ptrId = e.pointerId;
|
||||
moved = false;
|
||||
});
|
||||
|
||||
zone.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerId !== ptrId) return;
|
||||
if (Math.abs(e.clientY - startY) > 10) moved = true;
|
||||
});
|
||||
|
||||
zone.addEventListener('pointerup', (e) => {
|
||||
if (e.pointerId !== ptrId) return;
|
||||
const dy = e.clientY - startY;
|
||||
ptrId = null;
|
||||
|
||||
if (!moved || Math.abs(dy) < 30) return;
|
||||
|
||||
let switched = false;
|
||||
if (state.mode === 'week' && dy > 30) {
|
||||
state.mode = 'month';
|
||||
state.monthAnchor = startOfMonth(state.selected);
|
||||
switched = true;
|
||||
} else if (state.mode === 'month' && dy < -30) {
|
||||
state.mode = 'week';
|
||||
state.weekStart = startOfWeekMonday(state.selected);
|
||||
switched = true;
|
||||
}
|
||||
|
||||
if (switched) {
|
||||
zone.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}, { capture: true, once: true });
|
||||
rerender();
|
||||
}
|
||||
});
|
||||
|
||||
zone.addEventListener('pointercancel', () => {
|
||||
ptrId = null;
|
||||
moved = false;
|
||||
});
|
||||
}
|
||||
|
||||
function showPlannerToast(message) {
|
||||
const wrap = document.getElementById('planner-toast');
|
||||
const text = document.getElementById('planner-toast-text');
|
||||
if (!wrap || !text) return;
|
||||
text.textContent = message;
|
||||
wrap.classList.remove('opacity-0', 'translate-y-2');
|
||||
wrap.classList.add('opacity-100', 'translate-y-0');
|
||||
clearTimeout(showPlannerToast._t);
|
||||
showPlannerToast._t = setTimeout(() => {
|
||||
wrap.classList.add('opacity-0', 'translate-y-2');
|
||||
wrap.classList.remove('opacity-100', 'translate-y-0');
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
function openSheet(backdrop, sheet) {
|
||||
if (!backdrop || !sheet) return;
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
backdrop.classList.remove('hidden');
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.remove('opacity-0');
|
||||
});
|
||||
}
|
||||
|
||||
function closeSheet(backdrop, sheet) {
|
||||
if (!backdrop || !sheet) return;
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM;
|
||||
backdrop.classList.add('opacity-0');
|
||||
setTimeout(() => backdrop.classList.add('hidden'), 300);
|
||||
}
|
||||
|
||||
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */
|
||||
function bindPlannerSheetDragClose(sheet, closeFn) {
|
||||
const zone = sheet.querySelector('[data-planner-sheet-drag-zone]');
|
||||
if (!zone || !sheet) return;
|
||||
|
||||
let startY = 0;
|
||||
let pulling = false;
|
||||
let ptrId = null;
|
||||
|
||||
const resetVisual = () => {
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
};
|
||||
|
||||
zone.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
pulling = true;
|
||||
ptrId = e.pointerId;
|
||||
startY = e.clientY;
|
||||
sheet.style.transition = 'none';
|
||||
zone.setPointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
zone.addEventListener('pointermove', (e) => {
|
||||
if (!pulling || e.pointerId !== ptrId) return;
|
||||
const dy = Math.max(0, e.clientY - startY);
|
||||
sheet.style.transform = `translateY(${dy}px)`;
|
||||
});
|
||||
|
||||
zone.addEventListener('pointerup', (e) => {
|
||||
if (!pulling || e.pointerId !== ptrId) return;
|
||||
const dy = e.clientY - startY;
|
||||
pulling = false;
|
||||
ptrId = null;
|
||||
try {
|
||||
zone.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (dy > 56) {
|
||||
closeFn();
|
||||
return;
|
||||
}
|
||||
resetVisual();
|
||||
});
|
||||
|
||||
zone.addEventListener('pointercancel', () => {
|
||||
pulling = false;
|
||||
ptrId = null;
|
||||
resetVisual();
|
||||
});
|
||||
}
|
||||
|
||||
function renderDayContent(state) {
|
||||
const sel = state.selected;
|
||||
const heading = document.getElementById('planner-day-heading');
|
||||
if (heading) {
|
||||
const wd = WEEKDAYS_LONG[sel.getDay()];
|
||||
heading.textContent = `${wd}, ${sel.getDate()} ${MONTHS_SHORT[sel.getMonth()]}`;
|
||||
}
|
||||
|
||||
const dayPlan = getDayPlan(state.plans, sel);
|
||||
const totals = sumDayNutrition(dayPlan);
|
||||
|
||||
const kcalEl = document.getElementById('planner-summary-kcal');
|
||||
if (kcalEl) {
|
||||
kcalEl.innerHTML = totals.mealCount === 0
|
||||
? `— <span class="text-sm font-semibold text-gray-500">kcal</span>`
|
||||
: `${totals.kcal} <span class="text-sm font-semibold text-gray-500">kcal</span>`;
|
||||
}
|
||||
const fmt = (n) => `${n} g`;
|
||||
document.getElementById('planner-macro-p').textContent = totals.mealCount ? fmt(totals.protein) : '—';
|
||||
document.getElementById('planner-macro-f').textContent = totals.mealCount ? fmt(totals.fat) : '—';
|
||||
document.getElementById('planner-macro-c').textContent = totals.mealCount ? fmt(totals.carbs) : '—';
|
||||
|
||||
document.getElementById('planner-detail-kcal').textContent = `${totals.kcal} kcal`;
|
||||
document.getElementById('planner-detail-p').textContent = fmt(totals.protein);
|
||||
document.getElementById('planner-detail-f').textContent = fmt(totals.fat);
|
||||
document.getElementById('planner-detail-c').textContent = fmt(totals.carbs);
|
||||
|
||||
const ingBtn = document.getElementById('planner-open-ingredients');
|
||||
if (ingBtn) {
|
||||
const noMeals = totals.mealCount === 0;
|
||||
ingBtn.disabled = noMeals;
|
||||
ingBtn.classList.toggle('opacity-50', noMeals);
|
||||
ingBtn.classList.toggle('cursor-not-allowed', noMeals);
|
||||
if (!noMeals) {
|
||||
const shortCount = countDayShortfalls(dayPlan, loadPantry());
|
||||
if (shortCount > 0) {
|
||||
ingBtn.innerHTML = `<i class="fas fa-shopping-basket text-xs" aria-hidden="true"></i>
|
||||
Składniki na ten dzień
|
||||
<span class="ml-auto bg-red-500 text-white text-[10px] font-bold rounded-full w-5 h-5 inline-flex items-center justify-center">${shortCount}</span>`;
|
||||
} else {
|
||||
ingBtn.innerHTML = `<i class="fas fa-check-circle text-emerald-500 text-xs" aria-hidden="true"></i>
|
||||
Składniki na ten dzień
|
||||
<span class="ml-auto text-[10px] font-semibold text-emerald-600">OK</span>`;
|
||||
}
|
||||
} else {
|
||||
ingBtn.innerHTML = `<i class="fas fa-shopping-basket text-gray-500 text-xs" aria-hidden="true"></i> Składniki na ten dzień`;
|
||||
}
|
||||
}
|
||||
|
||||
const slotsRoot = document.getElementById('planner-meal-slots');
|
||||
if (!slotsRoot) return;
|
||||
|
||||
slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => {
|
||||
const entries = Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : [];
|
||||
const countLabel = entries.length > 1
|
||||
? `<span class="text-[10px] font-semibold text-gray-400 tabular-nums shrink-0 ml-auto">${entries.length} dania</span>`
|
||||
: '';
|
||||
|
||||
const entryCards = entries.map((entry) => {
|
||||
const recipe = entry && entry.recipeId ? RECIPES[entry.recipeId] : null;
|
||||
if (!recipe) return '';
|
||||
const servings = Math.max(1, Number(entry.servings) || 1);
|
||||
const n = recipe.nutritionPerServing;
|
||||
const kcal = Math.round(n.kcal * servings);
|
||||
const eid = escapeHtml(entry.id);
|
||||
return `
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm" data-slot-id="${slot.id}" data-entry-id="${eid}">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="w-8 h-8 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
||||
<span class="text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[13px] font-bold text-gray-900 truncate">${escapeHtml(recipe.title)}</p>
|
||||
<p class="text-[11px] text-gray-500 mt-0.5 tabular-nums">
|
||||
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
|
||||
<span class="mx-1.5 text-gray-300">·</span>
|
||||
<i class="fas fa-fire text-gray-400 mr-0.5" aria-hidden="true"></i>${kcal} kcal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="planner-clear-meal w-6 h-6 shrink-0 rounded-full border border-gray-200 text-gray-400 hover:text-red-600 hover:border-red-200 hover:bg-red-50 transition-colors flex items-center justify-center" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Usuń ten przepis">
|
||||
<i class="fas fa-times text-[9px]" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 mt-1.5 pt-1.5 border-t border-gray-100">
|
||||
<span class="text-[11px] font-medium text-gray-500">Porcje</span>
|
||||
<div class="flex items-center gap-0.5 bg-gray-100 p-0.5 rounded-lg">
|
||||
<button type="button" class="planner-serv-minus w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Mniej porcji"><i class="fas fa-minus text-[9px]"></i></button>
|
||||
<span class="planner-serv-count font-bold text-gray-900 text-xs w-5 text-center tabular-nums">${servings}</span>
|
||||
<button type="button" class="planner-serv-plus w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Więcej porcji"><i class="fas fa-plus text-[9px]"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny przepis';
|
||||
const addClasses = entries.length === 0
|
||||
? 'planner-add-meal w-full py-2 rounded-lg border border-dashed border-gray-200 text-[13px] font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors'
|
||||
: 'planner-add-meal w-full py-1.5 rounded-lg border border-dashed border-gray-200 text-xs font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors';
|
||||
|
||||
return `
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden" data-slot-id="${slot.id}">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 bg-gray-50/90">
|
||||
<span class="w-7 h-7 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 shrink-0">
|
||||
<i class="fas ${slot.icon} text-[13px]" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="text-[13px] font-semibold text-gray-900 truncate min-w-0 flex-1">${slot.label}</span>
|
||||
${countLabel}
|
||||
</div>
|
||||
<div class="p-2.5 space-y-2">
|
||||
${entryCards}
|
||||
<button type="button" class="${addClasses}" data-slot-id="${slot.id}">
|
||||
<i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>
|
||||
${addLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderPickerList(slotId) {
|
||||
const slot = MEAL_SLOTS.find((s) => s.id === slotId);
|
||||
const list = document.getElementById('planner-picker-list');
|
||||
const title = document.getElementById('planner-picker-title');
|
||||
const sub = document.getElementById('planner-picker-sub');
|
||||
if (!list || !title || !sub) return;
|
||||
|
||||
title.textContent = 'Wybierz przepis';
|
||||
sub.textContent = slot ? `Dla: ${slot.label}. Przeciągnij nagłówek w dół lub dotknij tła, by zamknąć.` : '';
|
||||
|
||||
const recipes = recipesForSlot(slotId);
|
||||
if (recipes.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-500 text-center py-6">Brak dopasowanych przepisów.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = recipes.map((r) => `
|
||||
<button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}">
|
||||
<div class="w-11 h-11 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
||||
<span class="text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 py-0.5">
|
||||
<p class="text-[13px] font-bold text-gray-900 line-clamp-2">${escapeHtml(r.title)}</p>
|
||||
<p class="text-[11px] text-gray-500 mt-1 tabular-nums">
|
||||
<i class="fas fa-fire text-gray-400 mr-0.5" aria-hidden="true"></i>${r.nutritionPerServing.kcal} kcal
|
||||
<span class="mx-1 text-gray-300">·</span>
|
||||
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${r.minutes} min
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function plIngredientWord(n) {
|
||||
if (n === 1) return 'składnik';
|
||||
const m10 = n % 10;
|
||||
const m100 = n % 100;
|
||||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'składniki';
|
||||
return 'składników';
|
||||
}
|
||||
|
||||
function updateIngButtons(state) {
|
||||
const btn1 = document.getElementById('planner-ing-add-all');
|
||||
const btn2 = document.getElementById('planner-ing-add-btn');
|
||||
|
||||
const todayCount = (state._todayShortfalls || []).length;
|
||||
const allCount = (state._allForecastShortfalls || []).length;
|
||||
|
||||
if (btn1) {
|
||||
if (todayCount > 0) {
|
||||
btn1.classList.remove('hidden');
|
||||
btn1.disabled = false;
|
||||
btn1.innerHTML = `<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i> Dodaj braki na dziś do listy`;
|
||||
} else {
|
||||
btn1.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
if (btn2) {
|
||||
if (allCount > todayCount) {
|
||||
btn2.classList.remove('hidden');
|
||||
btn2.innerHTML = `<i class="fas fa-calendar-week text-gray-500 text-[11px]" aria-hidden="true"></i> Dodaj braki na cały tydzień`;
|
||||
} else {
|
||||
btn2.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderIngredientsSheet(state) {
|
||||
const body = document.getElementById('planner-ing-body');
|
||||
const titleEl = document.getElementById('planner-ing-title');
|
||||
const subEl = document.getElementById('planner-ing-sub');
|
||||
if (!body) return;
|
||||
|
||||
const pantry = loadPantry();
|
||||
const forecast = computeFullForecast(state.plans, pantry, state.selected);
|
||||
|
||||
const today = forecast.length > 0 && forecast[0].dayIndex === 0 ? forecast[0] : null;
|
||||
const upcoming = forecast.filter((d) => d.dayIndex > 0 && d.hasShortfall);
|
||||
|
||||
state._todayShortfalls = today ? today.items.filter((it) => !it.enough) : [];
|
||||
state._allForecastShortfalls = [];
|
||||
for (const d of forecast) {
|
||||
for (const it of d.items) {
|
||||
if (!it.enough) state._allForecastShortfalls.push(it);
|
||||
}
|
||||
}
|
||||
|
||||
if (titleEl) {
|
||||
const wd = WEEKDAYS_LONG[state.selected.getDay()];
|
||||
titleEl.textContent = `${wd}, ${state.selected.getDate()} ${MONTHS_SHORT[state.selected.getMonth()]} — składniki`;
|
||||
}
|
||||
if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.';
|
||||
|
||||
if (!today || today.items.length === 0) {
|
||||
body.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">Najpierw zaplanuj posiłki.</p>';
|
||||
updateIngButtons(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const shortItems = today.items.filter((it) => !it.enough);
|
||||
const okItems = today.items.filter((it) => it.enough);
|
||||
let html = '';
|
||||
|
||||
if (shortItems.length === 0) {
|
||||
html += `<div class="rounded-xl bg-emerald-50 border border-emerald-200/80 p-3 mb-4 flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center shrink-0">
|
||||
<i class="fas fa-check text-emerald-600 text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-semibold text-emerald-800">Wszystko masz w spiżarni</p>
|
||||
<p class="text-[11px] text-emerald-600/80">${today.items.length} ${plIngredientWord(today.items.length)} — zapasy wystarczą</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="rounded-xl bg-red-50 border border-red-200/80 p-3 mb-4 flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
|
||||
<i class="fas fa-exclamation text-red-500 text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[13px] font-semibold text-red-800">${shortItems.length} ${plIngredientWord(shortItems.length)} do kupienia</p>
|
||||
<p class="text-[11px] text-red-600/80">Brakuje składników na zaplanowane posiłki</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (shortItems.length > 0) {
|
||||
html += `<div class="mb-5">
|
||||
<p class="text-[10px] font-bold text-red-400 uppercase tracking-wider mb-2 px-0.5">
|
||||
<i class="fas fa-cart-shopping text-[9px] mr-1"></i>Do kupienia
|
||||
</p>
|
||||
<ul class="border border-red-100/80 rounded-xl overflow-hidden bg-white divide-y divide-red-50">
|
||||
${shortItems.map((ing) => `
|
||||
<li class="flex items-start gap-3 py-3 px-3">
|
||||
<div class="w-2 h-2 rounded-full bg-red-400 mt-1.5 shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] font-semibold text-gray-900">${escapeHtml(ing.name)}</p>
|
||||
<p class="text-[11px] text-gray-500 mt-0.5">
|
||||
potrzeba <span class="font-medium text-gray-700">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
|
||||
<span class="mx-1 text-gray-300">·</span>
|
||||
w spiżarni <span class="font-medium ${ing.pantryQty > 0 ? 'text-amber-600' : 'text-gray-400'}">${ing.pantryQty > 0 ? formatAmount(ing.pantryQty) + ' ' + escapeHtml(ing.pantryUnit) : 'brak'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0 pt-0.5">
|
||||
<p class="text-[13px] font-bold text-red-600 tabular-nums leading-tight">−${formatAmount(ing.shortfall)}</p>
|
||||
<p class="text-[9px] text-red-400 font-medium">${escapeHtml(ing.pantryUnit)}</p>
|
||||
</div>
|
||||
</li>`).join('')}
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (okItems.length > 0) {
|
||||
html += `<div class="mb-5">
|
||||
<p class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider mb-2 px-0.5">
|
||||
<i class="fas fa-check text-[9px] mr-1"></i>W spiżarni
|
||||
</p>
|
||||
<ul class="border border-gray-100 rounded-xl overflow-hidden bg-white divide-y divide-gray-50">
|
||||
${okItems.map((ing) => `
|
||||
<li class="flex items-start gap-3 py-2.5 px-3">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-400 mt-1.5 shrink-0"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] font-medium text-gray-700">${escapeHtml(ing.name)}</p>
|
||||
<p class="text-[11px] text-gray-400 mt-0.5">
|
||||
potrzeba <span class="font-medium">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
|
||||
<span class="mx-1 text-gray-300">·</span>
|
||||
masz <span class="font-medium text-emerald-600">${formatAmount(ing.pantryQty)} ${escapeHtml(ing.pantryUnit)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</li>`).join('')}
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
html += `<div class="mb-2">
|
||||
<p class="text-[10px] font-bold text-amber-500 uppercase tracking-wider mb-2 px-0.5">
|
||||
<i class="fas fa-calendar-alt text-[9px] mr-1"></i>Nadchodzące braki
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
${upcoming.map((day) => {
|
||||
const wd = WEEKDAYS_LONG[day.date.getDay()];
|
||||
const label = `${wd}, ${day.date.getDate()} ${MONTHS_SHORT[day.date.getMonth()]}`;
|
||||
const shorts = day.items.filter((it) => !it.enough);
|
||||
return `<div class="rounded-xl border border-amber-200/80 bg-amber-50/50 p-3">
|
||||
<p class="text-[12px] font-semibold text-amber-900">
|
||||
<i class="fas fa-calendar-day text-[10px] mr-1.5 text-amber-500"></i>${escapeHtml(label)}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1.5">
|
||||
${shorts.map((it) => `
|
||||
<li class="flex items-center justify-between text-[11px]">
|
||||
<span class="font-medium text-amber-900">${escapeHtml(it.name)}</span>
|
||||
<span class="font-semibold text-red-600 tabular-nums">−${formatAmount(it.shortfall)} ${escapeHtml(it.pantryUnit)}</span>
|
||||
</li>`).join('')}
|
||||
</ul>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
updateIngButtons(state);
|
||||
}
|
||||
|
||||
function formatAmount(n) {
|
||||
return Number.isInteger(n) ? String(n) : String(n);
|
||||
}
|
||||
|
||||
function seedDemoIfEmpty(plans) {
|
||||
const todayKey = dateKey(new Date());
|
||||
if (Object.keys(plans).length > 0) return plans;
|
||||
return {
|
||||
...plans,
|
||||
[todayKey]: {
|
||||
sniadanie: [{ id: newPlanEntryId(), recipeId: 'owsianka', servings: 1 }],
|
||||
obiad: [{ id: newPlanEntryId(), recipeId: 'salatka', servings: 1 }],
|
||||
kolacja: [{ id: newPlanEntryId(), recipeId: 'makaron', servings: 1 }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupMealPlanner() {
|
||||
let plans = loadPlans();
|
||||
plans = seedDemoIfEmpty(plans);
|
||||
savePlans(plans);
|
||||
|
||||
const state = {
|
||||
mode: 'week',
|
||||
weekStart: startOfWeekMonday(new Date()),
|
||||
monthAnchor: startOfDay(new Date()),
|
||||
selected: startOfDay(new Date()),
|
||||
plans,
|
||||
nutritionExpanded: false,
|
||||
pickerSlot: null,
|
||||
};
|
||||
|
||||
const weekGrid = document.getElementById('calendar-week-grid');
|
||||
const monthGrid = document.getElementById('calendar-month-grid');
|
||||
|
||||
const pickerBackdrop = document.getElementById('planner-picker-backdrop');
|
||||
const pickerSheet = document.getElementById('planner-picker-sheet');
|
||||
const ingBackdrop = document.getElementById('planner-ing-backdrop');
|
||||
const ingSheet = document.getElementById('planner-ing-sheet');
|
||||
|
||||
const rerender = () => {
|
||||
syncModeToggle(state.mode);
|
||||
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor);
|
||||
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
|
||||
renderWeekGrid(state.weekStart, state.selected, state.plans);
|
||||
renderMonthGrid(state.monthAnchor, state.selected, state.plans);
|
||||
renderDayContent(state);
|
||||
};
|
||||
|
||||
const persist = () => {
|
||||
savePlans(state.plans);
|
||||
rerender();
|
||||
};
|
||||
|
||||
bindDayClicks(weekGrid?.parentElement, state, rerender);
|
||||
bindDayClicks(monthGrid?.parentElement, state, rerender);
|
||||
|
||||
document.getElementById('cal-prev')?.addEventListener('click', () => {
|
||||
if (state.mode === 'week') {
|
||||
state.weekStart = addWeeks(state.weekStart, -1);
|
||||
if (!weekContains(state.weekStart, state.selected)) {
|
||||
state.selected = new Date(state.weekStart);
|
||||
}
|
||||
} else {
|
||||
state.monthAnchor = addMonths(state.monthAnchor, -1);
|
||||
if (!sameMonth(state.monthAnchor, state.selected)) {
|
||||
state.selected = startOfMonth(state.monthAnchor);
|
||||
}
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('cal-next')?.addEventListener('click', () => {
|
||||
if (state.mode === 'week') {
|
||||
state.weekStart = addWeeks(state.weekStart, 1);
|
||||
if (!weekContains(state.weekStart, state.selected)) {
|
||||
state.selected = new Date(state.weekStart);
|
||||
}
|
||||
} else {
|
||||
state.monthAnchor = addMonths(state.monthAnchor, 1);
|
||||
if (!sameMonth(state.monthAnchor, state.selected)) {
|
||||
state.selected = startOfMonth(state.monthAnchor);
|
||||
}
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('cal-go-today')?.addEventListener('click', () => {
|
||||
const today = startOfDay(new Date());
|
||||
state.selected = today;
|
||||
state.weekStart = startOfWeekMonday(today);
|
||||
state.monthAnchor = startOfMonth(today);
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('planner-toggle-nutrition')?.addEventListener('click', () => {
|
||||
state.nutritionExpanded = !state.nutritionExpanded;
|
||||
const details = document.getElementById('planner-nutrition-details');
|
||||
const chev = document.getElementById('planner-nutrition-chevron');
|
||||
const btn = document.getElementById('planner-toggle-nutrition');
|
||||
if (details) details.classList.toggle('hidden', !state.nutritionExpanded);
|
||||
if (chev) chev.classList.toggle('rotate-180', state.nutritionExpanded);
|
||||
if (btn) btn.setAttribute('aria-expanded', state.nutritionExpanded ? 'true' : 'false');
|
||||
});
|
||||
|
||||
document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => {
|
||||
const addBtn = e.target.closest('.planner-add-meal');
|
||||
if (addBtn) {
|
||||
const slotId = addBtn.getAttribute('data-slot-id');
|
||||
state.pickerSlot = slotId;
|
||||
renderPickerList(slotId);
|
||||
openSheet(pickerBackdrop, pickerSheet);
|
||||
return;
|
||||
}
|
||||
const clearBtn = e.target.closest('.planner-clear-meal');
|
||||
if (clearBtn) {
|
||||
const slotId = clearBtn.getAttribute('data-slot-id');
|
||||
const entryId = clearBtn.getAttribute('data-entry-id');
|
||||
const key = dateKey(state.selected);
|
||||
const arr = state.plans[key]?.[slotId];
|
||||
if (!Array.isArray(arr) || !entryId) return;
|
||||
const next = arr.filter((x) => x && x.id !== entryId);
|
||||
if (!state.plans[key]) state.plans[key] = {};
|
||||
if (next.length === 0) delete state.plans[key][slotId];
|
||||
else state.plans[key][slotId] = next;
|
||||
if (Object.keys(state.plans[key]).length === 0) delete state.plans[key];
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
const minus = e.target.closest('.planner-serv-minus');
|
||||
const plus = e.target.closest('.planner-serv-plus');
|
||||
const slotId = (minus || plus)?.getAttribute('data-slot-id');
|
||||
const entryId = (minus || plus)?.getAttribute('data-entry-id');
|
||||
if (!slotId || !entryId) return;
|
||||
const key = dateKey(state.selected);
|
||||
const arr = state.plans[key]?.[slotId];
|
||||
if (!Array.isArray(arr)) return;
|
||||
const entry = arr.find((x) => x && x.id === entryId);
|
||||
if (!entry) return;
|
||||
let s = Math.max(1, Number(entry.servings) || 1);
|
||||
if (minus) s = Math.max(1, s - 1);
|
||||
if (plus) s = Math.min(12, s + 1);
|
||||
entry.servings = s;
|
||||
persist();
|
||||
});
|
||||
|
||||
const closePicker = () => {
|
||||
state.pickerSlot = null;
|
||||
closeSheet(pickerBackdrop, pickerSheet);
|
||||
};
|
||||
|
||||
bindPlannerSheetDragClose(pickerSheet, closePicker);
|
||||
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
|
||||
|
||||
pickerBackdrop?.addEventListener('click', closePicker);
|
||||
|
||||
document.getElementById('planner-picker-list')?.addEventListener('click', (e) => {
|
||||
const pick = e.target.closest('.planner-pick-recipe');
|
||||
if (!pick || !state.pickerSlot) return;
|
||||
const recipeId = pick.getAttribute('data-recipe-id');
|
||||
if (!recipeId || !RECIPES[recipeId]) return;
|
||||
const key = dateKey(state.selected);
|
||||
if (!state.plans[key]) state.plans[key] = {};
|
||||
const slotId = state.pickerSlot;
|
||||
if (!state.plans[key][slotId]) state.plans[key][slotId] = [];
|
||||
state.plans[key][slotId].push({ id: newPlanEntryId(), recipeId, servings: 1 });
|
||||
closePicker();
|
||||
persist();
|
||||
});
|
||||
|
||||
document.getElementById('planner-open-ingredients')?.addEventListener('click', () => {
|
||||
if (sumDayNutrition(getDayPlan(state.plans, state.selected)).mealCount === 0) return;
|
||||
renderIngredientsSheet(state);
|
||||
openSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
ingBackdrop?.addEventListener('click', () => {
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
ingSheet?.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('.planner-ing-row');
|
||||
if (!row || !ingSheet.contains(row)) return;
|
||||
row.classList.toggle('ingredient-active');
|
||||
});
|
||||
|
||||
document.getElementById('planner-ing-add-all')?.addEventListener('click', () => {
|
||||
const items = state._todayShortfalls || [];
|
||||
if (items.length === 0) return;
|
||||
const lines = items.map((it) => ({
|
||||
ingredientId: it.ingredientId,
|
||||
amount: it.shortfall,
|
||||
unit: it.pantryUnit,
|
||||
category: it.category,
|
||||
sourceNote: 'Braki z planu dnia',
|
||||
}));
|
||||
addOrMergeShoppingLines(lines);
|
||||
showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`);
|
||||
window.refreshShopping?.();
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
document.getElementById('planner-ing-add-btn')?.addEventListener('click', () => {
|
||||
const items = state._allForecastShortfalls || [];
|
||||
if (items.length === 0) return;
|
||||
const map = new Map();
|
||||
for (const it of items) {
|
||||
const key = it.ingredientId;
|
||||
if (map.has(key)) {
|
||||
const cur = map.get(key);
|
||||
cur.amount = Math.round((cur.amount + it.shortfall) * 10) / 10;
|
||||
} else {
|
||||
map.set(key, {
|
||||
ingredientId: it.ingredientId,
|
||||
amount: it.shortfall,
|
||||
unit: it.pantryUnit,
|
||||
category: it.category,
|
||||
sourceNote: 'Braki z planu tygodnia',
|
||||
});
|
||||
}
|
||||
}
|
||||
const lines = [...map.values()];
|
||||
addOrMergeShoppingLines(lines);
|
||||
showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`);
|
||||
window.refreshShopping?.();
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
bindCalendarSwipeGesture(state, rerender);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const ww = document.getElementById('calendar-week-wrap');
|
||||
const mw = document.getElementById('calendar-month-wrap');
|
||||
const t = 'max-height 300ms ease, opacity 200ms ease';
|
||||
if (ww) ww.style.transition = t;
|
||||
if (mw) mw.style.transition = t;
|
||||
});
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
import {
|
||||
INGREDIENTS,
|
||||
CATEGORY_LABELS,
|
||||
pantryQtyStep,
|
||||
} from '../data/catalog.js';
|
||||
import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js';
|
||||
import { showAppToast } from '../ui/toast.js';
|
||||
|
||||
/* ── helpers ── */
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function unitLabel(u) {
|
||||
return u === 'szt' ? 'szt.' : u;
|
||||
}
|
||||
|
||||
function normalizeSearch(q) {
|
||||
return String(q).trim().toLowerCase();
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
pieczywo: 'fa-bread-slice',
|
||||
nabial: 'fa-cheese',
|
||||
mieso_ryby: 'fa-drumstick-bite',
|
||||
warzywa: 'fa-carrot',
|
||||
owoce: 'fa-apple-whole',
|
||||
suche: 'fa-wheat-awn',
|
||||
przyprawy: 'fa-leaf',
|
||||
inne: 'fa-jar',
|
||||
};
|
||||
|
||||
/* ── state ── */
|
||||
|
||||
let showOnlyStock = false;
|
||||
let editingId = null;
|
||||
/** @type {Set<string>} */
|
||||
const selectedCategories = new Set();
|
||||
|
||||
let editShopStep = 1;
|
||||
let editShopUsesPacks = false;
|
||||
|
||||
const BOTTOM = '5.25rem';
|
||||
const HIDDEN_Y = `translateY(calc(100% + ${BOTTOM}))`;
|
||||
|
||||
/* ══════════════════════ HTML SHELL ══════════════════════ */
|
||||
|
||||
export function getPantryHTML() {
|
||||
return `
|
||||
<div id="pantry-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||||
<div class="shrink-0 bg-white border-b border-gray-100 mt-3 px-4 pt-2 pb-2.5 space-y-2">
|
||||
<div class="flex items-center gap-2.5 bg-gray-100 rounded-2xl px-3.5 py-2.5 focus-within:ring-2 focus-within:ring-gray-900/10 transition-all">
|
||||
<i class="fas fa-search text-gray-400 text-xs"></i>
|
||||
<input type="search" id="pantry-search" autocomplete="off" placeholder="Szukaj produktu…"
|
||||
class="flex-1 bg-transparent outline-none text-sm text-gray-800 placeholder-gray-400" />
|
||||
</div>
|
||||
<div id="pantry-category-chips" class="flex gap-2 overflow-x-auto no-scrollbar -mx-1 px-1 pb-0.5"></div>
|
||||
<div class="flex items-center justify-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<span class="text-xs font-medium text-gray-500">Tylko na stanie</span>
|
||||
<button type="button" id="pantry-stock-toggle" role="switch" aria-checked="false"
|
||||
class="relative w-10 h-[22px] rounded-full bg-gray-200 transition-colors duration-200 shrink-0">
|
||||
<span class="absolute left-0.5 top-0.5 w-[18px] h-[18px] bg-white rounded-full shadow-sm transition-transform duration-200"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pantry-scroll" class="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div id="pantry-board" class="px-4 pt-3 pb-4 space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── product sheet ── -->
|
||||
<div id="pv2-edit-bg" class="absolute inset-0 z-[38] bg-black/40 hidden opacity-0 transition-opacity duration-200"></div>
|
||||
<div id="pv2-edit-sheet" class="absolute left-0 right-0 z-[40] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] px-5 pt-2 pb-4 flex flex-col gap-2.5 max-h-[75%] min-h-0 overflow-y-auto no-scrollbar"
|
||||
style="bottom:${BOTTOM};transform:${HIDDEN_Y};transition:transform 300ms cubic-bezier(.32,.72,0,1)">
|
||||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto shrink-0"></div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<h2 id="pv2-edit-name" class="text-[15px] font-bold text-gray-900 leading-snug"></h2>
|
||||
<p id="pv2-edit-meta" class="text-[11px] text-gray-500"></p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 w-[3.2rem] shrink-0">Zapas</span>
|
||||
<button type="button" id="pv2-edit-minus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
</button>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<input type="number" id="pv2-edit-qty" min="0" step="1" inputmode="decimal"
|
||||
class="w-14 text-center text-lg font-bold tabular-nums bg-transparent outline-none" value="0" />
|
||||
<span id="pv2-edit-unit" class="text-xs text-gray-400 font-medium"></span>
|
||||
</div>
|
||||
<button type="button" id="pv2-edit-plus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 shrink-0"></div>
|
||||
|
||||
<div class="shrink-0 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 w-[3.2rem] shrink-0">Lista</span>
|
||||
<button type="button" id="pv2-shop-minus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
</button>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<input type="number" id="pv2-shop-qty" min="1" step="1" inputmode="numeric"
|
||||
class="w-14 text-center text-lg font-bold tabular-nums bg-transparent outline-none" value="1" />
|
||||
<span id="pv2-shop-unit" class="text-xs text-gray-400 font-medium"></span>
|
||||
</div>
|
||||
<button type="button" id="pv2-shop-plus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
<button type="button" id="pv2-shop-add" class="ml-auto shrink-0 px-3.5 py-2 rounded-xl bg-gray-900 text-white text-[11px] font-semibold hover:bg-black transition-colors active:scale-95">
|
||||
<i class="fas fa-cart-plus text-[9px] mr-1"></i>Dodaj
|
||||
</button>
|
||||
</div>
|
||||
<p id="pv2-shop-hint" class="text-[10px] text-gray-400 pl-[3.5rem]"></p>
|
||||
</div>
|
||||
|
||||
<div id="pv2-edit-nutrition" class="shrink-0"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ══════════════════════ CATEGORY CHIPS (multi-select) ══════════════════════ */
|
||||
|
||||
function allCategoryKeys() {
|
||||
const s = new Set();
|
||||
Object.values(INGREDIENTS).forEach(d => s.add(d.category));
|
||||
return [...s].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
|
||||
}
|
||||
|
||||
function renderCategoryChips() {
|
||||
const wrap = document.getElementById('pantry-category-chips');
|
||||
if (!wrap) return;
|
||||
|
||||
const keys = allCategoryKeys();
|
||||
wrap.innerHTML = keys.map(k => {
|
||||
const active = selectedCategories.has(k);
|
||||
const icon = CATEGORY_ICONS[k] || 'fa-jar';
|
||||
const cls = active
|
||||
? 'shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-semibold bg-gray-900 text-white transition-colors'
|
||||
: 'shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors';
|
||||
return `<button type="button" data-cat="${esc(k)}" class="pv2-cat-chip ${cls}"><i class="fas ${icon} text-[10px]"></i>${esc(categoryLabel(k))}</button>`;
|
||||
}).join('');
|
||||
|
||||
wrap.querySelectorAll('.pv2-cat-chip').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const cat = btn.dataset.cat;
|
||||
if (selectedCategories.has(cat)) selectedCategories.delete(cat);
|
||||
else selectedCategories.add(cat);
|
||||
renderCategoryChips();
|
||||
renderBoard();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════ BOARD RENDERING ══════════════════════ */
|
||||
|
||||
function getFilteredIds(searchRaw) {
|
||||
const q = normalizeSearch(searchRaw);
|
||||
return Object.keys(INGREDIENTS).filter(id => {
|
||||
const d = INGREDIENTS[id];
|
||||
if (selectedCategories.size > 0 && !selectedCategories.has(d.category)) return false;
|
||||
if (!q) return true;
|
||||
return d.name.toLowerCase().includes(q) || (CATEGORY_LABELS[d.category] || '').toLowerCase().includes(q);
|
||||
}).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
|
||||
}
|
||||
|
||||
function chipHtml(id, pantry) {
|
||||
const def = INGREDIENTS[id];
|
||||
const qty = Number(pantry[id]) || 0;
|
||||
const u = unitLabel(def.pantryUnit);
|
||||
|
||||
if (qty > 0) {
|
||||
return `<button type="button" class="pv2-chip inline-flex flex-col items-start px-3.5 py-2.5 rounded-xl bg-emerald-50 border border-emerald-200/80 text-left hover:bg-emerald-100/80 transition-colors active:scale-[0.96]" data-id="${esc(id)}">
|
||||
<span class="text-[13px] font-semibold text-gray-900 leading-tight whitespace-nowrap">${esc(def.name)}</span>
|
||||
<span class="text-[11px] text-emerald-600 font-semibold tabular-nums leading-tight mt-0.5">${Math.round(qty)} ${esc(u)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
return `<button type="button" class="pv2-chip inline-flex items-center px-3.5 py-2.5 rounded-xl border border-dashed border-gray-200 text-left hover:border-gray-300 hover:bg-white transition-colors active:scale-[0.96] group" data-id="${esc(id)}">
|
||||
<span class="text-[13px] font-medium text-gray-400 group-hover:text-gray-600 whitespace-nowrap transition-colors">${esc(def.name)}</span>
|
||||
<i class="fas fa-plus text-[8px] text-gray-300 group-hover:text-gray-500 ml-1.5 transition-colors"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function groupByCategory(ids) {
|
||||
/** @type {Map<string, string[]>} */
|
||||
const groups = new Map();
|
||||
for (const id of ids) {
|
||||
const cat = INGREDIENTS[id].category;
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat).push(id);
|
||||
}
|
||||
return [...groups.keys()]
|
||||
.sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)))
|
||||
.map(cat => ({ cat, ids: groups.get(cat) }));
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const root = document.getElementById('pantry-board');
|
||||
if (!root) return;
|
||||
|
||||
const q = document.getElementById('pantry-search')?.value || '';
|
||||
const pantry = loadPantry();
|
||||
const allFiltered = getFilteredIds(q);
|
||||
const visible = showOnlyStock
|
||||
? allFiltered.filter(id => (Number(pantry[id]) || 0) > 0)
|
||||
: allFiltered;
|
||||
|
||||
if (visible.length === 0) {
|
||||
root.innerHTML = showOnlyStock
|
||||
? `<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-box-open text-2xl text-gray-300"></i>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-700">Nic na stanie</p>
|
||||
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Wyłącz filtr, aby zobaczyć cały katalog produktów</p>
|
||||
</div>`
|
||||
: `<p class="text-sm text-gray-500 text-center py-10">Brak wyników — zmień wyszukiwanie lub filtry.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupByCategory(visible);
|
||||
let html = '';
|
||||
for (const { cat, ids } of groups) {
|
||||
const icon = CATEGORY_ICONS[cat] || 'fa-jar';
|
||||
html += `
|
||||
<div class="mb-4 last:mb-0">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-0.5">
|
||||
<i class="fas ${icon} text-[10px] mr-1"></i>${esc(categoryLabel(cat))}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">${ids.map(id => chipHtml(id, pantry)).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
root.innerHTML = html;
|
||||
|
||||
root.querySelectorAll('.pv2-chip').forEach(btn => {
|
||||
btn.addEventListener('click', () => openEditSheet(btn.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════ STOCK TOGGLE ══════════════════════ */
|
||||
|
||||
function updateToggleVisuals() {
|
||||
const btn = document.getElementById('pantry-stock-toggle');
|
||||
if (!btn) return;
|
||||
const thumb = btn.querySelector('span');
|
||||
btn.setAttribute('aria-checked', String(showOnlyStock));
|
||||
if (showOnlyStock) {
|
||||
btn.classList.remove('bg-gray-200');
|
||||
btn.classList.add('bg-emerald-500');
|
||||
thumb?.classList.add('translate-x-[18px]');
|
||||
} else {
|
||||
btn.classList.add('bg-gray-200');
|
||||
btn.classList.remove('bg-emerald-500');
|
||||
thumb?.classList.remove('translate-x-[18px]');
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════ EDIT BOTTOM SHEET ══════════════════════ */
|
||||
|
||||
function openEditSheet(ingredientId) {
|
||||
const def = INGREDIENTS[ingredientId];
|
||||
if (!def) return;
|
||||
editingId = ingredientId;
|
||||
|
||||
const pantry = loadPantry();
|
||||
const qty = Number(pantry[ingredientId]) || 0;
|
||||
const u = unitLabel(def.pantryUnit);
|
||||
const step = pantryQtyStep(ingredientId);
|
||||
const pack = def.purchasePack;
|
||||
|
||||
const nameEl = document.getElementById('pv2-edit-name');
|
||||
if (nameEl) nameEl.textContent = def.name;
|
||||
|
||||
const metaEl = document.getElementById('pv2-edit-meta');
|
||||
if (metaEl) {
|
||||
let meta = categoryLabel(def.category);
|
||||
if (pack) meta += ` · ${pack.label || `${pack.amount} ${u}`}`;
|
||||
metaEl.textContent = meta;
|
||||
}
|
||||
|
||||
const qtyEl = document.getElementById('pv2-edit-qty');
|
||||
if (qtyEl) qtyEl.value = qty > 0 ? String(Math.round(qty)) : '0';
|
||||
|
||||
const unitEl = document.getElementById('pv2-edit-unit');
|
||||
if (unitEl) unitEl.textContent = u;
|
||||
|
||||
editShopUsesPacks = Boolean(pack && pack.amount > 0);
|
||||
editShopStep = editShopUsesPacks ? 1 : step;
|
||||
|
||||
const shopQtyEl = document.getElementById('pv2-shop-qty');
|
||||
if (shopQtyEl) shopQtyEl.value = String(editShopStep);
|
||||
|
||||
const shopUnitEl = document.getElementById('pv2-shop-unit');
|
||||
if (shopUnitEl) shopUnitEl.textContent = editShopUsesPacks ? 'opak.' : u;
|
||||
|
||||
const shopHintEl = document.getElementById('pv2-shop-hint');
|
||||
if (shopHintEl) {
|
||||
if (editShopUsesPacks) {
|
||||
const lab = pack.label || `${pack.amount} ${u}`;
|
||||
shopHintEl.textContent = `1 opak. = ${lab}`;
|
||||
} else {
|
||||
shopHintEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
renderNutritionInSheet(def);
|
||||
|
||||
const bg = document.getElementById('pv2-edit-bg');
|
||||
const sheet = document.getElementById('pv2-edit-sheet');
|
||||
if (!bg || !sheet) return;
|
||||
bg.classList.remove('hidden');
|
||||
sheet.classList.remove('hidden');
|
||||
requestAnimationFrame(() => {
|
||||
bg.classList.remove('opacity-0');
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
});
|
||||
}
|
||||
|
||||
function nutritionListRow(label, valueHtml) {
|
||||
return `<li class="flex items-baseline justify-between gap-3 py-0.5 border-b border-gray-100/80 last:border-0">
|
||||
<span class="text-gray-500 shrink-0">${esc(label)}</span>
|
||||
<span class="text-right font-semibold tabular-nums text-gray-800">${valueHtml}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function renderNutritionInSheet(def) {
|
||||
const wrap = document.getElementById('pv2-edit-nutrition');
|
||||
if (!wrap) return;
|
||||
const n = def.nutritionPer100g;
|
||||
if (!n) { wrap.innerHTML = ''; return; }
|
||||
|
||||
const refLabel = def.pantryUnit === 'ml' ? '100 ml produktu' : '100 g produktu';
|
||||
wrap.innerHTML = `
|
||||
<div class="text-[10px] leading-snug mt-0.5 pt-2 border-t border-gray-100 space-y-1">
|
||||
<p class="text-[9px] font-semibold uppercase tracking-wide text-gray-500 px-0.5">${esc(refLabel)}</p>
|
||||
<ul class="space-y-0 rounded-lg bg-white/70 px-2 py-1 ring-1 ring-gray-100/90">
|
||||
${nutritionListRow('Energia', `${n.kcal} kcal`)}
|
||||
${nutritionListRow('Białko', `${n.protein} g`)}
|
||||
${nutritionListRow('Tłuszcz', `${n.fat} g`)}
|
||||
${nutritionListRow('Węglowodany', `${n.carbs} g`)}
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closeEditSheet() {
|
||||
editingId = null;
|
||||
const bg = document.getElementById('pv2-edit-bg');
|
||||
const sheet = document.getElementById('pv2-edit-sheet');
|
||||
if (sheet) sheet.style.transform = HIDDEN_Y;
|
||||
if (bg) bg.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
bg?.classList.add('hidden');
|
||||
sheet?.classList.add('hidden');
|
||||
}, 300);
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function getEditQty() {
|
||||
const el = document.getElementById('pv2-edit-qty');
|
||||
return Math.max(0, parseFloat(String(el?.value).replace(',', '.')) || 0);
|
||||
}
|
||||
|
||||
function setEditQty(v) {
|
||||
const el = document.getElementById('pv2-edit-qty');
|
||||
if (el) el.value = String(Math.max(0, Math.round(v)));
|
||||
}
|
||||
|
||||
function applyEditQty(newQty) {
|
||||
if (!editingId) return;
|
||||
const v = Math.max(0, Math.round(Number(newQty) * 1000) / 1000 || 0);
|
||||
setPantryQty(editingId, v);
|
||||
setEditQty(v);
|
||||
}
|
||||
|
||||
function readShopQty() {
|
||||
const el = document.getElementById('pv2-shop-qty');
|
||||
return Math.max(1, Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0));
|
||||
}
|
||||
|
||||
function setShopQty(v) {
|
||||
const el = document.getElementById('pv2-shop-qty');
|
||||
if (el) el.value = String(Math.max(1, Math.round(Number(v))));
|
||||
}
|
||||
|
||||
function bindEditSheet() {
|
||||
document.getElementById('pv2-edit-bg')?.addEventListener('click', closeEditSheet);
|
||||
|
||||
document.getElementById('pv2-edit-minus')?.addEventListener('click', () => {
|
||||
if (!editingId) return;
|
||||
applyEditQty(Math.max(0, getEditQty() - pantryQtyStep(editingId)));
|
||||
});
|
||||
|
||||
document.getElementById('pv2-edit-plus')?.addEventListener('click', () => {
|
||||
if (!editingId) return;
|
||||
applyEditQty(getEditQty() + pantryQtyStep(editingId));
|
||||
});
|
||||
|
||||
document.getElementById('pv2-edit-qty')?.addEventListener('change', () => {
|
||||
applyEditQty(getEditQty());
|
||||
});
|
||||
|
||||
document.getElementById('pv2-shop-minus')?.addEventListener('click', () => {
|
||||
setShopQty(Math.max(1, readShopQty() - editShopStep));
|
||||
});
|
||||
|
||||
document.getElementById('pv2-shop-plus')?.addEventListener('click', () => {
|
||||
setShopQty(readShopQty() + editShopStep);
|
||||
});
|
||||
|
||||
document.getElementById('pv2-shop-qty')?.addEventListener('change', () => {
|
||||
setShopQty(readShopQty());
|
||||
});
|
||||
|
||||
document.getElementById('pv2-shop-add')?.addEventListener('click', () => {
|
||||
if (!editingId) return;
|
||||
const def = INGREDIENTS[editingId];
|
||||
if (!def) return;
|
||||
const count = readShopQty();
|
||||
const u = unitLabel(def.pantryUnit);
|
||||
|
||||
if (editShopUsesPacks && def.purchasePack) {
|
||||
const packAmt = def.purchasePack.amount;
|
||||
const total = count * packAmt;
|
||||
const note = `${count}× ${def.purchasePack.label || `${packAmt} ${u}`}`;
|
||||
addIngredientToKitchenList(editingId, total, note);
|
||||
showAppToast(`Dodano ${count} op. (${total} ${u}) na listę.`);
|
||||
} else {
|
||||
addIngredientToKitchenList(editingId, count);
|
||||
showAppToast(`Dodano ${count} ${u} na listę.`);
|
||||
}
|
||||
window.refreshShopping?.();
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════ PUBLIC API ══════════════════════ */
|
||||
|
||||
export function refreshPantry() {
|
||||
renderCategoryChips();
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
export function setupPantry() {
|
||||
renderCategoryChips();
|
||||
renderBoard();
|
||||
bindEditSheet();
|
||||
|
||||
document.getElementById('pantry-search')?.addEventListener('input', () => renderBoard());
|
||||
|
||||
document.getElementById('pantry-stock-toggle')?.addEventListener('click', () => {
|
||||
showOnlyStock = !showOnlyStock;
|
||||
updateToggleVisuals();
|
||||
renderBoard();
|
||||
});
|
||||
|
||||
window.refreshPantry = refreshPantry;
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
export function getRecipeDetailHTML() {
|
||||
return `
|
||||
<div id="recipe-detail-view" class="absolute inset-0 bg-white z-30 transition-all duration-300 ease-in-out translate-x-full opacity-0 pointer-events-none flex flex-col overflow-hidden">
|
||||
|
||||
<div class="absolute top-0 w-full p-3.5 flex justify-between z-40 mt-3">
|
||||
<button onclick="closeRecipeDetail()" class="w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center shadow-sm text-gray-800 hover:bg-white transition-colors">
|
||||
<i class="fas fa-arrow-left text-[13px]"></i>
|
||||
</button>
|
||||
<button class="w-9 h-9 bg-white/90 backdrop-blur rounded-full flex items-center justify-center shadow-sm text-gray-400 hover:text-red-500 transition-colors">
|
||||
<i class="far fa-heart text-[13px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-[220px] shrink-0 w-full bg-[#d4d4d4] flex items-center justify-center relative">
|
||||
<span class="text-white font-medium text-[15px]">Zdjęcie: Serek z owocami</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-t-3xl -mt-6 relative z-30 pt-6 flex flex-col flex-1 overflow-hidden">
|
||||
|
||||
<div class="mb-3 px-5 shrink-0">
|
||||
<div class="flex justify-between items-start mb-2.5">
|
||||
<h1 class="text-xl font-bold text-gray-900">Serek wiejski z orzechami i owocami</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">Śniadanie</span>
|
||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">Wegetariańskie</span>
|
||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-[11px] rounded-md font-medium">Słodkie</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-[13px] text-gray-600 font-medium">
|
||||
<div class="flex gap-3.5">
|
||||
<div class="flex items-center gap-1.5"><i class="fas fa-clock text-gray-400 text-xs"></i><span>5 min</span></div>
|
||||
<div class="flex items-center gap-1.5"><i class="fas fa-fire text-gray-400 text-xs"></i><span>642 kcal</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 bg-gray-100 p-0.5 rounded-lg">
|
||||
<button onclick="changeServings(-1)" class="w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black hover:bg-gray-50"><i class="fas fa-minus text-[9px]"></i></button>
|
||||
<div class="flex items-center gap-1 px-1.5">
|
||||
<span id="servings-count" class="font-bold text-gray-900 text-[13px] w-3 text-center tabular-nums">1</span>
|
||||
<span class="text-[11px] text-gray-500"><i class="fas fa-user-friends"></i></span>
|
||||
</div>
|
||||
<button onclick="changeServings(1)" class="w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black hover:bg-gray-50"><i class="fas fa-plus text-[9px]"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex border-b border-gray-200 mb-2 px-5 shrink-0">
|
||||
<button class="flex-1 pb-2.5 text-[13px] font-semibold text-gray-900 border-b-2 border-gray-900 tab-btn" onclick="switchTab('ingredients', this)">Składniki</button>
|
||||
<button class="flex-1 pb-2.5 text-[13px] font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-700 tab-btn" onclick="switchTab('steps', this)">Kroki</button>
|
||||
<button class="flex-1 pb-2.5 text-[13px] font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-700 tab-btn" onclick="switchTab('nutrition', this)">Wartości</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-5 pt-2 pb-10 no-scrollbar relative">
|
||||
|
||||
<div id="tab-ingredients" class="tab-content block animate-fade-in">
|
||||
<div class="flex justify-between items-end mb-3">
|
||||
<span class="text-[11px] text-gray-500 font-medium">Zaznacz, by dodać do listy zakupów</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-0 mb-5" id="ingredient-list">
|
||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors">Serek wiejski</span>
|
||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount tabular-nums" data-base-amount="200" data-unit="g">200 g</span>
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors">Miód</span>
|
||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount tabular-nums" data-base-amount="10" data-unit="g">10 g</span>
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors font-medium text-gray-900" id="ingredient-orzechy">Orzechy włoskie</span>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button onclick="event.stopPropagation(); openSwapModal('orzechy')" class="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors shadow-sm" title="Zamień">
|
||||
<i class="fas fa-exchange-alt text-[9px]"></i>
|
||||
</button>
|
||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount w-10 text-right tabular-nums" data-base-amount="50" data-unit="g">50 g</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors font-medium text-gray-900" id="ingredient-owoce1">Truskawki</span>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button onclick="event.stopPropagation(); openSwapModal('owoce1')" class="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors shadow-sm" title="Zamień">
|
||||
<i class="fas fa-exchange-alt text-[9px]"></i>
|
||||
</button>
|
||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount w-10 text-right tabular-nums" data-base-amount="100" data-unit="g">100 g</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-2.5 py-2.5 border-b border-gray-100 cursor-pointer hover:bg-gray-50 px-1 -mx-1 transition-colors" onclick="toggleIngredient(this)">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-[13px] flex-1 ingredient-text transition-colors font-medium text-gray-900" id="ingredient-owoce2">Borówki ameryk.</span>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button onclick="event.stopPropagation(); openSwapModal('owoce2')" class="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900 transition-colors shadow-sm" title="Zamień">
|
||||
<i class="fas fa-exchange-alt text-[9px]"></i>
|
||||
</button>
|
||||
<span class="font-medium text-gray-900 text-[13px] ingredient-amount w-10 text-right tabular-nums" data-base-amount="100" data-unit="g">100 g</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2 mb-5">
|
||||
<i class="fas fa-plus text-xs"></i> Dodaj do listy zakupów
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-steps" class="tab-content hidden animate-fade-in">
|
||||
<div class="space-y-5 pb-5">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">1</div>
|
||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Przełóż serek wiejski do miseczki.</p></div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">2</div>
|
||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Dodaj miód i delikatnie wymieszaj.</p></div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">3</div>
|
||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Orzechy posiekaj na mniejsze kawałki i posyp nimi serek z miodem.</p></div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-900 text-white flex items-center justify-center text-[11px] font-bold shrink-0 shadow-sm">4</div>
|
||||
<div class="pt-0.5"><p class="text-[13px] text-gray-600 leading-relaxed">Umyj owoce (ew. pokrój na połówki) i ułóż na wierzchu. Gotowe!</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nutrition" class="tab-content hidden animate-fade-in">
|
||||
<div class="bg-gray-50 rounded-xl p-4 border border-gray-100 mb-5">
|
||||
<ul class="space-y-0 divide-y divide-gray-200">
|
||||
<li class="flex justify-between py-2 font-bold"><span class="text-gray-900 text-[13px]">Kalorie</span><span class="text-gray-900 text-[13px] tabular-nums">642 kcal</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Białko</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">32 g</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Tłuszcze</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">43 g</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-800 text-[13px] font-medium">Węglowodany</span><span class="font-medium text-gray-900 text-[13px] tabular-nums">41 g</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="swap-backdrop" onclick="closeSwapModal()" class="absolute inset-0 bg-black/40 z-40 hidden opacity-0 transition-opacity duration-300"></div>
|
||||
|
||||
<div id="swap-modal" class="absolute inset-x-0 bottom-0 bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.1)] z-50 transform translate-y-full transition-transform duration-300 ease-in-out p-5 flex flex-col max-h-[60%]">
|
||||
<div class="flex justify-between items-center mb-4 shrink-0">
|
||||
<h3 class="text-[15px] font-bold text-gray-900">Zmień <span id="swap-title-target" class="text-blue-600">składnik</span></h3>
|
||||
<button onclick="closeSwapModal()" class="w-7 h-7 flex items-center justify-center bg-gray-100 rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-900 transition-colors">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="swap-options-container" class="space-y-2 overflow-y-auto no-scrollbar pb-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function setupRecipeDetail() {
|
||||
let currentServings = 1; // Domślnie 1 porcja dla tego przepisu
|
||||
const defaultServings = 1;
|
||||
let currentlySwapping = null;
|
||||
|
||||
// Dane do dynamicznego modala
|
||||
const swapOptions = {
|
||||
'orzechy': [
|
||||
{ name: 'Orzechy włoskie', hint: 'Bazowe', color: 'gray' },
|
||||
{ name: 'Migdały', hint: '+ Białko', color: 'blue' },
|
||||
{ name: 'Orzechy laskowe', hint: 'Klasyk', color: 'gray' },
|
||||
{ name: 'Orzechy nerkowca', hint: 'Słodsze', color: 'gray' },
|
||||
{ name: 'Orzechy pekan', hint: '+ Tłuszcz', color: 'green' }
|
||||
],
|
||||
'owoce1': [
|
||||
{ name: 'Truskawki', hint: 'Bazowe', color: 'gray' },
|
||||
{ name: 'Gruszka konferencja', hint: '+ Węgle', color: 'blue' },
|
||||
{ name: 'Banany', hint: '+ Kalorie', color: 'green' }
|
||||
],
|
||||
'owoce2': [
|
||||
{ name: 'Borówki ameryk.', hint: 'Bazowe', color: 'gray' },
|
||||
{ name: 'Jagody leśne', hint: 'Sezonowe', color: 'blue' },
|
||||
{ name: 'Maliny', hint: '- Kalorie', color: 'green' }
|
||||
]
|
||||
};
|
||||
|
||||
window.switchTab = (tabId, clickedBtn) => {
|
||||
document.querySelectorAll('.tab-content').forEach(el => {
|
||||
el.classList.remove('block');
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
|
||||
const targetTab = document.getElementById(`tab-${tabId}`);
|
||||
targetTab.classList.remove('hidden');
|
||||
targetTab.classList.add('block');
|
||||
targetTab.parentElement.scrollTop = 0;
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold');
|
||||
btn.classList.add('text-gray-500', 'border-transparent', 'font-medium');
|
||||
});
|
||||
|
||||
clickedBtn.classList.remove('text-gray-500', 'border-transparent', 'font-medium');
|
||||
clickedBtn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold');
|
||||
};
|
||||
|
||||
window.toggleIngredient = (element) => {
|
||||
element.classList.toggle('ingredient-active');
|
||||
};
|
||||
|
||||
window.changeServings = (delta) => {
|
||||
const newServings = currentServings + delta;
|
||||
if (newServings < 1) return;
|
||||
|
||||
currentServings = newServings;
|
||||
document.getElementById('servings-count').innerText = currentServings;
|
||||
|
||||
const ratio = currentServings / defaultServings;
|
||||
document.querySelectorAll('.ingredient-amount').forEach(el => {
|
||||
const baseAmount = parseFloat(el.getAttribute('data-base-amount'));
|
||||
const unit = el.getAttribute('data-unit');
|
||||
|
||||
if (!isNaN(baseAmount)) {
|
||||
let newAmount = baseAmount * ratio;
|
||||
newAmount = Number.isInteger(newAmount) ? newAmount : parseFloat(newAmount.toFixed(1));
|
||||
el.innerText = `${newAmount} ${unit}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.openSwapModal = (type) => {
|
||||
currentlySwapping = type;
|
||||
|
||||
let title = '';
|
||||
if(type === 'orzechy') title = 'Orzechy';
|
||||
if(type === 'owoce1') title = 'Owoce bazy';
|
||||
if(type === 'owoce2') title = 'Dodatki owocowe';
|
||||
|
||||
document.getElementById('swap-title-target').innerText = title;
|
||||
|
||||
// Wygeneruj opcje na podstawie słownika
|
||||
const container = document.getElementById('swap-options-container');
|
||||
container.innerHTML = swapOptions[type].map(opt => {
|
||||
let badgeClass = 'text-gray-600 bg-gray-200'; // Domyślny gray
|
||||
if (opt.color === 'blue') badgeClass = 'text-blue-600 bg-blue-100';
|
||||
if (opt.color === 'green') badgeClass = 'text-green-600 bg-green-100';
|
||||
|
||||
return `
|
||||
<button onclick="confirmSwap('${opt.name}')" class="w-full flex justify-between items-center p-3 border border-gray-200 rounded-xl hover:border-gray-900 hover:shadow-sm transition-all bg-gray-50 hover:bg-white text-left">
|
||||
<span class="font-medium text-[13px] text-gray-800">${opt.name}</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-md font-semibold ${badgeClass}">${opt.hint}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const backdrop = document.getElementById('swap-backdrop');
|
||||
backdrop.classList.remove('hidden');
|
||||
setTimeout(() => backdrop.classList.remove('opacity-0'), 10);
|
||||
|
||||
const modal = document.getElementById('swap-modal');
|
||||
modal.classList.remove('translate-y-full');
|
||||
modal.classList.add('translate-y-0');
|
||||
};
|
||||
|
||||
window.closeSwapModal = () => {
|
||||
const backdrop = document.getElementById('swap-backdrop');
|
||||
backdrop.classList.add('opacity-0');
|
||||
setTimeout(() => backdrop.classList.add('hidden'), 300);
|
||||
|
||||
const modal = document.getElementById('swap-modal');
|
||||
modal.classList.remove('translate-y-0');
|
||||
modal.classList.add('translate-y-full');
|
||||
};
|
||||
|
||||
window.confirmSwap = (newItemName) => {
|
||||
if (currentlySwapping === 'orzechy') {
|
||||
document.getElementById('ingredient-orzechy').innerText = newItemName;
|
||||
} else if (currentlySwapping === 'owoce1') {
|
||||
document.getElementById('ingredient-owoce1').innerText = newItemName;
|
||||
} else if (currentlySwapping === 'owoce2') {
|
||||
document.getElementById('ingredient-owoce2').innerText = newItemName;
|
||||
}
|
||||
closeSwapModal();
|
||||
};
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
export function getRecipeListHTML() {
|
||||
return `
|
||||
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-gray-50 z-10">
|
||||
<div class="p-4 border-b border-gray-200 mt-4 bg-white">
|
||||
<div class="flex items-center w-full border border-gray-300 rounded-lg bg-white focus-within:border-gray-400 transition-colors">
|
||||
<div class="pl-3 pr-2 text-gray-400"><i class="fas fa-search"></i></div>
|
||||
<input type="text" placeholder="Szukaj przepisów..." class="flex-1 py-2.5 bg-transparent outline-none text-gray-600 placeholder-gray-400 text-sm">
|
||||
<div class="w-px h-6 bg-gray-200"></div>
|
||||
<button onclick="openFilters()" class="px-4 text-gray-700 hover:text-black flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-24 bg-gray-50">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Placki</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Puszyste placki</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Klasyczne placki na śniadanie</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>15 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>320 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Śniadanie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Sałatka</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Sałatka z kurczakiem</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Zielone warzywa z grillowanym kurczakiem</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>20 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>250 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Obiad</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Makaron</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Makaron z pomidorami i bazylią</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Aromatyczny sos pomidorowy z czosnkiem</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>30 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>450 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Kolacja</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Koktajl</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Koktajl owocowy</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Mix jagód i jogurtu</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>5 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>180 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Przekąska</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Tost z awokado</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Tost z awokado</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Chleb na zakwasie z rozgniecionym awokado</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>10 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>220 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Śniadanie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Łosoś</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Grillowany łosoś</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Świeży łosoś z masłem cytrynowym</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>25 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>380 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Kolacja</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Tacos</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Tacos z wołowiną</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Pikantna mielona wołowina ze świeżą salsą</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>20 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>410 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Kolacja</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onclick="openRecipeDetail()" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-[#d4d4d4] relative flex items-center justify-center">
|
||||
<span class="text-white font-medium text-xs">Owsianka</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-black mb-1 line-clamp-1">Miska owsianki</h3>
|
||||
<p class="text-gray-500 text-xs mb-3 line-clamp-2">Ciepła owsianka z miodem i orzechami</p>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-gray-600 font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>10 min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>210 kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">Śniadanie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import {
|
||||
addFreeformLine,
|
||||
addFreeformList,
|
||||
categoryLabel,
|
||||
deleteList,
|
||||
getActiveList,
|
||||
getListSummaries,
|
||||
KITCHEN_LIST_ID,
|
||||
removeItemFromList,
|
||||
setActiveListId,
|
||||
toggleItemInList,
|
||||
} from '../services/pantryShopping.js';
|
||||
import { showAppToast } from '../ui/toast.js';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function getShoppingHTML() {
|
||||
return `
|
||||
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||||
<div class="shrink-0 bg-white border-b border-gray-200 mt-3 px-4 pt-2 pb-3 space-y-3">
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="sr-only" for="shopping-list-select">Aktywna lista</label>
|
||||
<select id="shopping-list-select" class="flex-1 min-w-0 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5 text-sm font-medium text-gray-900 outline-none focus:border-gray-400"></select>
|
||||
<button type="button" id="shopping-new-list" class="shrink-0 w-10 h-10 rounded-xl border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 flex items-center justify-center" title="Nowa lista dowolna" aria-label="Nowa lista dowolna">
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" id="shopping-delete-list" class="hidden w-full py-2 rounded-lg text-xs font-medium text-red-600 hover:bg-red-50 transition-colors">
|
||||
Usuń tę listę (nie dotyczy listy kuchennej)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="shopping-freeform-add" class="hidden shrink-0 px-4 pt-3 pb-2 space-y-2 border-b border-gray-100">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="shopping-freeform-input" class="flex-1 rounded-xl border border-gray-200 px-3 py-2.5 text-sm outline-none focus:border-gray-400" placeholder="Co kupić?" maxlength="200" />
|
||||
<button type="button" id="shopping-freeform-submit" class="shrink-0 px-4 py-2.5 rounded-xl bg-gray-900 text-white text-xs font-semibold hover:bg-black">Dodaj</button>
|
||||
</div>
|
||||
<input type="text" id="shopping-freeform-note" class="w-full rounded-lg border border-gray-100 px-3 py-2 text-xs text-gray-600 outline-none focus:border-gray-300" placeholder="Opcjonalna notatka (ilość, sklep…)" maxlength="120" />
|
||||
</div>
|
||||
|
||||
<div id="shopping-scroll" class="flex-1 overflow-y-auto px-4 py-3 pb-4 no-scrollbar">
|
||||
<div id="shopping-list-root" class="space-y-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function syncListSelect() {
|
||||
const sel = document.getElementById('shopping-list-select');
|
||||
if (!sel) return;
|
||||
const { lists, activeListId } = getListSummaries();
|
||||
sel.innerHTML = lists.map((l) => {
|
||||
const suffix = l.openCount ? ` (${l.openCount})` : '';
|
||||
const label = `${l.name}${suffix}`;
|
||||
return `<option value="${escapeHtml(l.id)}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
sel.value = activeListId;
|
||||
}
|
||||
|
||||
function syncChromeForList() {
|
||||
const list = getActiveList();
|
||||
const isKitchen = list.type === 'kitchen';
|
||||
const delBtn = document.getElementById('shopping-delete-list');
|
||||
const ffAdd = document.getElementById('shopping-freeform-add');
|
||||
|
||||
if (ffAdd) ffAdd.classList.toggle('hidden', isKitchen);
|
||||
|
||||
if (delBtn) {
|
||||
delBtn.classList.toggle('hidden', isKitchen);
|
||||
}
|
||||
}
|
||||
|
||||
function renderKitchenItems() {
|
||||
const root = document.getElementById('shopping-list-root');
|
||||
if (!root) return;
|
||||
|
||||
const list = getActiveList();
|
||||
if (list.type !== 'kitchen') return;
|
||||
|
||||
const items = list.items;
|
||||
if (items.length === 0) {
|
||||
root.innerHTML = '<p class="text-sm text-gray-500 text-center py-10">Brak pozycji.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = {};
|
||||
for (const it of items) {
|
||||
const c = it.category || 'inne';
|
||||
if (!groups[c]) groups[c] = [];
|
||||
groups[c].push(it);
|
||||
}
|
||||
|
||||
root.innerHTML = Object.keys(groups)
|
||||
.sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)))
|
||||
.map((cat) => `
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wide px-3 py-2 bg-gray-50 border-b border-gray-100">${escapeHtml(categoryLabel(cat))}</p>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
${groups[cat].map((it) => `
|
||||
<li class="flex items-start gap-3 px-3 py-3 ${it.checked ? 'opacity-60' : ''}">
|
||||
<button type="button" data-shop-toggle="${escapeHtml(it.id)}" class="mt-0.5 w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors ${it.checked ? 'bg-gray-900 border-gray-900 text-white' : 'border-gray-300 bg-white'}" aria-label="Kupione">
|
||||
${it.checked ? '<i class="fas fa-check text-[10px]"></i>' : ''}
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 ${it.checked ? 'line-through text-gray-500' : ''}">${escapeHtml(it.name)}</p>
|
||||
<p class="text-xs text-gray-600 tabular-nums mt-0.5">${escapeHtml(String(it.amount))} ${escapeHtml(it.unit)}</p>
|
||||
${it.sourceNote ? `<p class="text-[10px] text-gray-400 mt-1">${escapeHtml(it.sourceNote)}</p>` : ''}
|
||||
</div>
|
||||
<button type="button" data-shop-remove="${escapeHtml(it.id)}" class="shrink-0 w-8 h-8 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors" aria-label="Usuń">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
bindItemButtons(list.id);
|
||||
}
|
||||
|
||||
function renderFreeformItems() {
|
||||
const root = document.getElementById('shopping-list-root');
|
||||
if (!root) return;
|
||||
|
||||
const list = getActiveList();
|
||||
if (list.type !== 'freeform') return;
|
||||
|
||||
const items = list.items;
|
||||
if (items.length === 0) {
|
||||
root.innerHTML = '<p class="text-sm text-gray-500 text-center py-10">Dodaj dowolny tekst powyżej — bez powiązania z katalogiem składników.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||
<ul class="divide-y divide-gray-100">
|
||||
${items.map((it) => `
|
||||
<li class="flex items-start gap-3 px-3 py-3 ${it.checked ? 'opacity-60' : ''}">
|
||||
<button type="button" data-shop-toggle="${escapeHtml(it.id)}" class="mt-0.5 w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors ${it.checked ? 'bg-gray-900 border-gray-900 text-white' : 'border-gray-300 bg-white'}">
|
||||
${it.checked ? '<i class="fas fa-check text-[10px]"></i>' : ''}
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 ${it.checked ? 'line-through text-gray-500' : ''}">${escapeHtml(it.text)}</p>
|
||||
${it.note ? `<p class="text-xs text-gray-500 mt-1">${escapeHtml(it.note)}</p>` : ''}
|
||||
</div>
|
||||
<button type="button" data-shop-remove="${escapeHtml(it.id)}" class="shrink-0 w-8 h-8 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors" aria-label="Usuń">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</li>`).join('')}
|
||||
</ul>
|
||||
</div>`;
|
||||
|
||||
bindItemButtons(list.id);
|
||||
}
|
||||
|
||||
function bindItemButtons(listId) {
|
||||
const root = document.getElementById('shopping-list-root');
|
||||
if (!root) return;
|
||||
|
||||
root.querySelectorAll('[data-shop-toggle]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.getAttribute('data-shop-toggle');
|
||||
if (id) toggleItemInList(listId, id);
|
||||
refreshShopping();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('[data-shop-remove]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.getAttribute('data-shop-remove');
|
||||
if (id) removeItemFromList(listId, id);
|
||||
refreshShopping();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshShopping() {
|
||||
syncListSelect();
|
||||
syncChromeForList();
|
||||
const list = getActiveList();
|
||||
if (list.type === 'kitchen') renderKitchenItems();
|
||||
else renderFreeformItems();
|
||||
}
|
||||
|
||||
export function setupShopping() {
|
||||
const sel = document.getElementById('shopping-list-select');
|
||||
sel?.addEventListener('change', () => {
|
||||
const v = sel.value;
|
||||
if (v) setActiveListId(v);
|
||||
refreshShopping();
|
||||
});
|
||||
|
||||
document.getElementById('shopping-new-list')?.addEventListener('click', () => {
|
||||
const name = window.prompt('Nazwa nowej listy (dowolne zakupy):', 'Nowa lista');
|
||||
if (name === null) return;
|
||||
addFreeformList(name);
|
||||
showAppToast('Utworzono listę.');
|
||||
refreshShopping();
|
||||
});
|
||||
|
||||
document.getElementById('shopping-delete-list')?.addEventListener('click', () => {
|
||||
const list = getActiveList();
|
||||
if (list.id === KITCHEN_LIST_ID) return;
|
||||
if (!window.confirm(`Usunąć listę „${list.name}”?`)) return;
|
||||
deleteList(list.id);
|
||||
showAppToast('Lista usunięta.');
|
||||
refreshShopping();
|
||||
});
|
||||
|
||||
const submitFreeform = () => {
|
||||
const list = getActiveList();
|
||||
if (list.type !== 'freeform') return;
|
||||
const input = document.getElementById('shopping-freeform-input');
|
||||
const note = document.getElementById('shopping-freeform-note');
|
||||
const text = input?.value || '';
|
||||
addFreeformLine(list.id, text, note?.value || '');
|
||||
if (input) input.value = '';
|
||||
if (note) note.value = '';
|
||||
refreshShopping();
|
||||
};
|
||||
|
||||
document.getElementById('shopping-freeform-submit')?.addEventListener('click', submitFreeform);
|
||||
document.getElementById('shopping-freeform-input')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitFreeform();
|
||||
}
|
||||
});
|
||||
|
||||
refreshShopping();
|
||||
window.refreshShopping = refreshShopping;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "Recipe App",
|
||||
"short_name": "Recipe",
|
||||
"description": "Plan posiłków, spiżarnia i zakupy",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#f3f4f6",
|
||||
"theme_color": "#111827",
|
||||
"lang": "pl",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Do not store responses in browser or shared caches (Cloudflare, etc. respect unless overridden)
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/* Minimal service worker — wymagany m.in. przez Chrome, żeby pokazać „Zainstaluj aplikację”.
|
||||
Sieć zawsze na pierwszym miejscu (bez cache’u zasobów). */
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
Reference in New Issue
Block a user