Redesign recipe list

This commit is contained in:
2026-04-06 11:20:25 +02:00
parent 0aa8d12c1e
commit 2b0601956f
7 changed files with 604 additions and 129 deletions

24
.idea/workspace.xml generated
View File

@@ -4,8 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="ae0e4ce8-372f-4cfd-a04f-600640f32223" name="Changes" comment="Redesign meal planner"> <list default="true" id="ae0e4ce8-372f-4cfd-a04f-600640f32223" name="Changes" comment="Restore gitea action">
<change afterPath="$PROJECT_DIR$/.gitea/workflows/build-and-deploy.yaml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/js/app.js" beforeDir="false" afterPath="$PROJECT_DIR$/js/app.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/js/data/catalog.js" beforeDir="false" afterPath="$PROJECT_DIR$/js/data/catalog.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/js/views/Filter.js" beforeDir="false" afterPath="$PROJECT_DIR$/js/views/Filter.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/js/views/RecipeList.js" beforeDir="false" afterPath="$PROJECT_DIR$/js/views/RecipeList.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/manifest.webmanifest" beforeDir="false" afterPath="$PROJECT_DIR$/manifest.webmanifest" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -84,7 +89,7 @@
<option name="number" value="Default" /> <option name="number" value="Default" />
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1775222853874</updated> <updated>1775222853874</updated>
<workItem from="1775222854878" duration="6283000" /> <workItem from="1775222854878" duration="7564000" />
</task> </task>
<task id="LOCAL-00001" summary="Rework calendar"> <task id="LOCAL-00001" summary="Rework calendar">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -118,7 +123,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1775337998927</updated> <updated>1775337998927</updated>
</task> </task>
<option name="localTasksCounter" value="5" /> <task id="LOCAL-00005" summary="Restore gitea action">
<option name="closed" value="true" />
<created>1775338197344</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1775338197344</updated>
</task>
<option name="localTasksCounter" value="6" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -172,6 +185,7 @@
<MESSAGE value="Redesign meal plan editor" /> <MESSAGE value="Redesign meal plan editor" />
<MESSAGE value="Redesign recipe details" /> <MESSAGE value="Redesign recipe details" />
<MESSAGE value="Redesign meal planner" /> <MESSAGE value="Redesign meal planner" />
<option name="LAST_COMMIT_MESSAGE" value="Redesign meal planner" /> <MESSAGE value="Restore gitea action" />
<option name="LAST_COMMIT_MESSAGE" value="Restore gitea action" />
</component> </component>
</project> </project>

View File

@@ -8,7 +8,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Recipe"> <meta name="apple-mobile-web-app-title" content="Recipe">
<title>Recipe App - Modular</title> <title>Recipe App - Modular</title>
<link rel="manifest" href="./manifest.webmanifest"> <link rel="manifest" href="./manifest.webmanifest?v=20260406-9">
<link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png">
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -228,6 +228,11 @@
#pantry-view { #pantry-view {
background: rgb(var(--app-bg-rgb)) !important; background: rgb(var(--app-bg-rgb)) !important;
} }
#main-view,
#main-view > div:last-child,
#recipe-grid {
background: #2d2e2b !important;
}
#planner-view, #planner-view,
#planner-view > div:first-child, #planner-view > div:first-child,
#planner-scroll, #planner-scroll,
@@ -248,15 +253,15 @@
/* Cards and sheets */ /* Cards and sheets */
#recipe-grid > div { #recipe-grid > div {
background: linear-gradient(180deg, rgba(var(--surface-rgb), 0.94), rgba(var(--surface-soft-rgb), 0.97)) !important; background: #393937 !important;
border-color: rgba(var(--line-rgb), 0.14) !important; border: none !important;
border-radius: 1.75rem !important; border-radius: 1.75rem !important;
box-shadow: var(--panel-shadow) !important; box-shadow: none !important;
transition: transform 180ms ease, box-shadow 180ms ease !important; transition: transform 180ms ease, box-shadow 180ms ease !important;
} }
#recipe-grid > div:hover { #recipe-grid > div:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--panel-shadow-strong) !important; box-shadow: none !important;
} }
#recipe-grid > div img { #recipe-grid > div img {
transition: transform 240ms ease; transition: transform 240ms ease;
@@ -272,6 +277,48 @@
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(var(--app-bg-rgb), 0.5) 92%); background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(var(--app-bg-rgb), 0.5) 92%);
pointer-events: none; pointer-events: none;
} }
#recipe-search-shell {
min-height: 2.75rem;
border-radius: 1.7rem;
background: #3a3b38 !important;
border: 1px solid rgba(79, 81, 76, 0.95) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
0 0 0 1px rgba(255, 255, 255, 0.015) !important;
}
#recipe-search-shell:focus-within {
background: #3a3b38 !important;
border: 1px solid rgba(92, 94, 88, 0.98) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 0 0 1px rgba(255, 255, 255, 0.02) !important;
}
#recipe-search-input {
appearance: none;
-webkit-appearance: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
color: #dfd9cf !important;
caret-color: #dfd9cf;
font-size: 15px;
font-weight: 400;
letter-spacing: -0.02em;
}
#recipe-search-input::placeholder {
color: #beb8ae !important;
opacity: 1;
}
#recipe-filter-btn {
border-radius: 999px;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
#recipe-filter-btn:hover {
background: rgba(255, 255, 255, 0.03) !important;
}
#planner-picker-sheet, #planner-picker-sheet,
#planner-ing-sheet, #planner-ing-sheet,
#pv2-edit-sheet, #pv2-edit-sheet,
@@ -331,10 +378,61 @@
</div> </div>
<script> <script>
const APP_ASSET_VERSION = '20260406-9';
const APP_VERSION_STORAGE_KEY = 'recipe-app-asset-version';
const APP_VERSION_QUERY_KEY = 'appv';
window.__APP_ASSET_VERSION__ = APP_ASSET_VERSION;
window.__APP_BOOTSTRAP__ = (async () => {
try {
const previousVersion = localStorage.getItem(APP_VERSION_STORAGE_KEY);
if (previousVersion === APP_ASSET_VERSION) return;
localStorage.setItem(APP_VERSION_STORAGE_KEY, APP_ASSET_VERSION);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {}); const registrations = await navigator.serviceWorker.getRegistrations().catch(() => []);
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
} }
if ('caches' in window) {
const cacheKeys = await caches.keys().catch(() => []);
await Promise.all(cacheKeys.map((key) => caches.delete(key).catch(() => false)));
}
const nextUrl = new URL(window.location.href);
if (nextUrl.searchParams.get(APP_VERSION_QUERY_KEY) !== APP_ASSET_VERSION) {
nextUrl.searchParams.set(APP_VERSION_QUERY_KEY, APP_ASSET_VERSION);
window.location.replace(nextUrl.toString());
await new Promise(() => {});
}
} catch (_) {
/* Ignore bootstrap cache reset errors and continue loading the app. */
}
})();
</script>
<script type="module">
const appVersion = window.__APP_ASSET_VERSION__ || '20260406-9';
try {
await window.__APP_BOOTSTRAP__;
} catch (_) {
/* Ignore bootstrap cache reset errors and continue loading the app. */
}
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register(`./sw.js?v=${encodeURIComponent(appVersion)}`, {
scope: './',
updateViaCache: 'none',
});
registration.update().catch(() => {});
} catch (_) {
/* Ignore service worker registration failures. */
}
}
await import(`./js/app.js?v=${encodeURIComponent(appVersion)}`);
</script> </script>
<script type="module" src="js/app.js?v=18"></script>
</body> </body>
</html> </html>

View File

@@ -1,9 +1,43 @@
import { getRecipeListHTML, setupRecipeList } from './views/RecipeList.js?v=2'; const APP_ASSET_VERSION = window.__APP_ASSET_VERSION__
import { getFilterHTML, setupFilter } from './views/Filter.js?v=2'; || new URL(import.meta.url).searchParams.get('v')
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetailV2.js?v=2'; || 'dev';
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js?v=20';
import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js?v=2'; let getRecipeListHTML;
import { getMealPlanEditorHTML, setupMealPlanEditor } from './ui/mealPlanEditor.js?v=7'; let setupRecipeList;
let getFilterHTML;
let setupFilter;
let getRecipeDetailHTML;
let setupRecipeDetail;
let getMealPlannerHTML;
let setupMealPlanner;
let getPantryHTML;
let refreshPantry;
let setupPantry;
let getMealPlanEditorHTML;
let setupMealPlanEditor;
const moduleLoadPromise = Promise.all([
import(`./views/RecipeList.js?v=${APP_ASSET_VERSION}`),
import(`./views/Filter.js?v=${APP_ASSET_VERSION}`),
import(`./views/RecipeDetailV2.js?v=${APP_ASSET_VERSION}`),
import(`./views/MealPlanner.js?v=${APP_ASSET_VERSION}`),
import(`./views/Pantry.js?v=${APP_ASSET_VERSION}`),
import(`./ui/mealPlanEditor.js?v=${APP_ASSET_VERSION}`),
]).then(([
recipeListModule,
filterModule,
recipeDetailModule,
mealPlannerModule,
pantryModule,
mealPlanEditorModule,
]) => {
({ getRecipeListHTML, setupRecipeList } = recipeListModule);
({ getFilterHTML, setupFilter } = filterModule);
({ getRecipeDetailHTML, setupRecipeDetail } = recipeDetailModule);
({ getMealPlannerHTML, setupMealPlanner } = mealPlannerModule);
({ getPantryHTML, refreshPantry, setupPantry } = pantryModule);
({ getMealPlanEditorHTML, setupMealPlanEditor } = mealPlanEditorModule);
});
function getAppToastHTML() { function getAppToastHTML() {
return ` return `
@@ -96,8 +130,16 @@ function setupTabs() {
}; };
} }
document.addEventListener('DOMContentLoaded', () => { let initAppPromise = null;
async function initApp() {
if (initAppPromise) return initAppPromise;
initAppPromise = (async () => {
await moduleLoadPromise;
const appContainer = document.getElementById('app-container'); const appContainer = document.getElementById('app-container');
if (!appContainer) return;
appContainer.innerHTML = ` appContainer.innerHTML = `
${getRecipeListHTML()} ${getRecipeListHTML()}
@@ -118,4 +160,22 @@ document.addEventListener('DOMContentLoaded', () => {
setupFilter(); setupFilter();
setupMealPlanEditor(); setupMealPlanEditor();
setupRecipeDetail(); setupRecipeDetail();
})().catch((error) => {
initAppPromise = null;
throw error;
}); });
return initAppPromise;
}
function bootApp() {
initApp().catch((error) => {
console.error('Failed to initialize app', error);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootApp, { once: true });
} else {
bootApp();
}

View File

@@ -295,12 +295,17 @@ export const INGREDIENTS = {
}, },
}; };
/**
* @typedef {{ ingredientId: string, amount: number, unit: string, alternatives?: string[] }} RecipeIngredientDef
* @typedef {{ id: string, title: string, minutes: number, thumbLabel: string, image?: string, allowedSlots: string[], tags?: string[], nutritionPerServing: NutritionPer100, ingredients: RecipeIngredientDef[], steps: string[] }} RecipeDef
*/
/** Porcja bazowa = 1; składniki przez ingredientId */ /** Porcja bazowa = 1; składniki przez ingredientId */
/** @type {Record<string, RecipeDef>} */
export const RECIPES = { export const RECIPES = {
kanapka_parmenska: { kanapka_parmenska: {
id: 'kanapka_parmenska', id: 'kanapka_parmenska',
title: 'Kanapka z szynką parmeńską i mozzarellą', title: 'Kanapka z szynką parmeńską i mozzarellą',
description: 'Bułka grahamka z szynką parmeńską, mozzarellą i pomidorkami — włoskie smaki na szybko.',
minutes: 5, minutes: 5,
thumbLabel: 'Parmeńska', thumbLabel: 'Parmeńska',
image: 'images/recipes/kanapka_parmenska.jpg', image: 'images/recipes/kanapka_parmenska.jpg',
@@ -322,7 +327,6 @@ export const RECIPES = {
makaron_ricotta: { makaron_ricotta: {
id: 'makaron_ricotta', id: 'makaron_ricotta',
title: 'Makaron z ricottą i pomidorami', title: 'Makaron z ricottą i pomidorami',
description: 'Makaron z sosem z pieczonych pomidorków koktajlowych, ricottą i słonecznikiem.',
minutes: 20, minutes: 20,
thumbLabel: 'Ricotta', thumbLabel: 'Ricotta',
image: 'images/recipes/makaron_ricotta.jpg', image: 'images/recipes/makaron_ricotta.jpg',
@@ -350,7 +354,6 @@ export const RECIPES = {
jajecznica: { jajecznica: {
id: 'jajecznica', id: 'jajecznica',
title: 'Jajecznica z pieczywem', title: 'Jajecznica z pieczywem',
description: 'Klasyczna jajecznica z 4 jajek z bułką grahamką i szczypiorkiem.',
minutes: 5, minutes: 5,
thumbLabel: 'Jajecznica', thumbLabel: 'Jajecznica',
image: 'images/recipes/jajecznica.png', image: 'images/recipes/jajecznica.png',
@@ -373,7 +376,6 @@ export const RECIPES = {
kanapka_hummus: { kanapka_hummus: {
id: 'kanapka_hummus', id: 'kanapka_hummus',
title: 'Kanapka z hummusem, wędliną i warzywami', title: 'Kanapka z hummusem, wędliną i warzywami',
description: 'Bułka grahamka z hummusem, szynką z kurczaka i świeżymi warzywami.',
minutes: 5, minutes: 5,
thumbLabel: 'Hummus', thumbLabel: 'Hummus',
image: 'images/recipes/kanapka_hummus.png', image: 'images/recipes/kanapka_hummus.png',
@@ -399,7 +401,6 @@ export const RECIPES = {
kanapka_losos: { kanapka_losos: {
id: 'kanapka_losos', id: 'kanapka_losos',
title: 'Kanapka z wędzonym łososiem', title: 'Kanapka z wędzonym łososiem',
description: 'Bułka grahamka z łososiem wędzonym, pastą chrzanowo-serową i kiełkami.',
minutes: 5, minutes: 5,
thumbLabel: 'Łosoś', thumbLabel: 'Łosoś',
image: 'images/recipes/kanapka_losos.jpg', image: 'images/recipes/kanapka_losos.jpg',
@@ -425,7 +426,6 @@ export const RECIPES = {
serek_owoc: { serek_owoc: {
id: 'serek_owoc', id: 'serek_owoc',
title: 'Serek wiejski z orzechami i owocami', title: 'Serek wiejski z orzechami i owocami',
description: 'Lekki, pożywny posiłek: serek z orzechami, truskawkami i borówkami.',
minutes: 5, minutes: 5,
thumbLabel: 'Serek', thumbLabel: 'Serek',
image: 'images/recipes/serek_owoc.jpg', image: 'images/recipes/serek_owoc.jpg',

View File

@@ -2,6 +2,24 @@ import { RECIPES } from '../data/catalog.js?v=2';
import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { applyFilters, getFilterState, getFilteredCount, refreshRecipeList } from './RecipeList.js'; import { applyFilters, getFilterState, getFilteredCount, refreshRecipeList } from './RecipeList.js';
const FILTER_SHEET_TRANSITION = 'transform 300ms cubic-bezier(0.32,0.72,0,1)';
const FILTER_SURFACE = '#2d2e2b';
const FILTER_SURFACE_SOFT = '#2f2f2d';
const FILTER_BORDER = '#444442';
const FILTER_BORDER_ACTIVE = '#787876';
const FILTER_CHIP_ACTIVE = '#23221e';
const FILTER_TEXT_PRIMARY = '#ddd6ca';
const FILTER_TEXT_SECONDARY = '#d7d2c8';
const FILTER_TEXT_MUTED = '#9b978f';
const FILTER_TEXT_DIM = '#7d7a74';
const FILTER_TEXT_ACTIVE = '#f2efe8';
const FILTER_TRACK = '#444442';
const FILTER_TRACK_FILL = '#9b978f';
const PREP_TIME_MIN = 5;
const PREP_TIME_MAX = 120;
const PREP_TIME_STEP = 5;
const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
function escapeHtml(s) { function escapeHtml(s) {
return String(s) return String(s)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -20,38 +38,104 @@ function collectAllTags() {
export function getFilterHTML() { export function getFilterHTML() {
return ` return `
<div id="filter-view" class="absolute inset-0 bg-white z-50 hidden flex-col"> <style id="filter-view-styles">
<div class="p-4 border-b border-gray-200 flex items-center justify-between mt-4"> #filter-view.filter-open {
<button id="filter-close-btn" 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> pointer-events: auto;
<h2 class="text-lg font-semibold text-black">Filtry</h2> }
<button id="filter-clear-btn" class="px-2 text-sm font-medium text-gray-500 hover:text-black transition-colors">Wyczyść</button>
#filter-view .prep-time-range-track,
#filter-view .prep-time-range-fill {
border-radius: 9999px;
height: 0.375rem;
}
#filter-view .prep-time-range-handle {
width: 1rem;
height: 1rem;
border-radius: 9999px;
border: 2px solid ${FILTER_SURFACE};
background: ${FILTER_TEXT_ACTIVE};
box-shadow: 0 0 0 1px ${FILTER_BORDER_ACTIVE};
touch-action: none;
outline: none;
}
</style>
<div id="filter-view" class="absolute inset-0 z-[55] hidden items-end opacity-0 transition-opacity duration-200" style="pointer-events:none; background:transparent !important; background-image:none !important;" aria-hidden="true">
<div id="filter-sheet" class="relative w-full flex flex-col overflow-hidden rounded-t-3xl" style="background:${FILTER_SURFACE} !important; background-image:none !important; max-height:calc(100dvh - 4.5rem); transform:translateY(100%); transition:${FILTER_SHEET_TRANSITION}; box-shadow:0 -26px 46px rgba(0,0,0,0.28), 0 -6px 16px rgba(0,0,0,0.22);">
<div class="pointer-events-none absolute inset-x-0 top-0 h-px" style="background:rgba(242,239,232,0.12);" aria-hidden="true"></div>
<div class="shrink-0 px-4 pt-3 pb-3 border-b" style="background:${FILTER_SURFACE} !important; background-image:none !important; border-color:${FILTER_BORDER} !important;">
<div class="w-10 h-1 rounded-full mx-auto mb-3" style="background:rgba(109,108,103,0.75);" aria-hidden="true"></div>
<div class="flex items-center gap-2">
<button id="filter-close-btn" type="button" class="shrink-0 w-9 h-9 rounded-full border flex items-center justify-center transition-colors" style="background:${FILTER_SURFACE_SOFT} !important; border-color:${FILTER_BORDER} !important; color:${FILTER_TEXT_SECONDARY} !important;" aria-label="Zamknij filtry">
<i class="fas fa-arrow-left text-[12px]"></i>
</button>
<div class="min-w-0 flex-1 pr-2">
<h2 class="text-[15px] font-bold leading-tight" style="color:${FILTER_TEXT_PRIMARY};">Filtry</h2>
<p class="text-[11px] mt-1" style="color:${FILTER_TEXT_MUTED};">Dopasuj listę przepisów do swoich potrzeb.</p>
</div>
<button id="filter-clear-btn" type="button" class="shrink-0 h-8 px-3 rounded-full border text-[11px] font-semibold transition-colors" style="background:${FILTER_SURFACE_SOFT} !important; border-color:${FILTER_BORDER} !important; color:${FILTER_TEXT_SECONDARY} !important;">Wyczyść</button>
</div>
</div> </div>
<div class="flex-1 overflow-y-auto p-6 space-y-8"> <div class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-4 space-y-4" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<div> <section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<h3 class="text-base font-semibold text-black mb-4">Pora posiłku</h3> <p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
<div id="filter-slot-chips" class="flex flex-wrap gap-2.5"></div> <div id="filter-slot-chips" class="flex flex-wrap gap-2"></div>
</section>
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Dieta i tagi</p>
<div id="filter-tag-chips" class="flex flex-wrap gap-2"></div>
</section>
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
<div class="flex items-center justify-between gap-3 mb-3">
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:${FILTER_TEXT_MUTED};">Czas przygotowania</p>
</div> </div>
<div> <span id="time-display-range" class="shrink-0 text-[11px] font-semibold tabular-nums text-right" style="color:${FILTER_TEXT_ACTIVE};">5 min - 120 min</span>
<h3 class="text-base font-semibold text-black mb-4">Dieta i tagi</h3>
<div id="filter-tag-chips" class="flex flex-wrap gap-2.5"></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">120 min</span>
</div> </div>
<div class="px-1"> <div class="px-1">
<input type="range" id="prep-time-slider" min="5" max="120" step="5" value="120" class="w-full appearance-none bg-transparent"> <div class="relative h-9">
<div class="flex justify-between text-xs text-gray-400 mt-3 font-medium"> <div class="prep-time-range-track absolute inset-x-0 top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK};" aria-hidden="true"></div>
<span>5 min</span><span>30 min</span><span>1 godz.</span><span>2 godz.+</span> <div id="prep-time-range-fill" class="prep-time-range-fill absolute top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK_FILL}; left:0%; width:100%;" aria-hidden="true"></div>
</div> <button
type="button"
id="prep-time-min-handle"
class="prep-time-range-handle absolute top-1/2 z-[2] -translate-x-1/2 -translate-y-1/2"
style="left:0%;"
data-time-handle="min"
role="slider"
aria-label="Minimalny czas przygotowania"
aria-valuemin="${PREP_TIME_MIN}"
aria-valuemax="${PREP_TIME_MAX}"
aria-valuenow="${PREP_TIME_MIN}"
aria-valuetext="${PREP_TIME_MIN} min"
></button>
<button
type="button"
id="prep-time-max-handle"
class="prep-time-range-handle absolute top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2"
style="left:100%;"
data-time-handle="max"
role="slider"
aria-label="Maksymalny czas przygotowania"
aria-valuemin="${PREP_TIME_MIN}"
aria-valuemax="${PREP_TIME_MAX}"
aria-valuenow="${PREP_TIME_MAX}"
aria-valuetext="120 min"
></button>
</div> </div>
</div> </div>
</section>
</div> </div>
<div class="p-4 border-t border-gray-200 bg-white mt-auto"> <div class="shrink-0 px-4 pt-2 pb-4" style="background:${FILTER_SURFACE} !important; background-image:none !important; padding-bottom:calc(1rem + env(safe-area-inset-bottom));">
<button id="filter-apply-btn" class="w-full bg-gray-900 hover:bg-black text-white py-3.5 rounded-xl font-semibold shadow-sm transition-colors text-sm"></button> <button id="filter-apply-btn" type="button" class="w-full py-3 rounded-xl border font-semibold text-[13px] transition-colors flex items-center justify-center gap-2" style="background:${FILTER_SURFACE} !important; background-image:none !important; box-shadow:none !important; border-color:${FILTER_BORDER} !important; color:${FILTER_TEXT_ACTIVE} !important;">
<i class="fas fa-check text-[11px]" style="color:${FILTER_TEXT_MUTED};" aria-hidden="true"></i>
<span id="filter-apply-label">Pokaż wyniki</span>
</button>
</div>
</div> </div>
</div> </div>
`; `;
@@ -59,7 +143,121 @@ export function getFilterHTML() {
let localSlots = []; let localSlots = [];
let localTags = []; let localTags = [];
let localMaxMinutes = 120; let localMinMinutes = PREP_TIME_MIN;
let localMaxMinutes = PREP_TIME_MAX;
let closeTimer = null;
function normalizeTimeRange(minMinutes, maxMinutes) {
let nextMin = snapTimeValue(minMinutes);
let nextMax = snapTimeValue(maxMinutes);
nextMin = Math.min(Math.max(nextMin, PREP_TIME_MIN), PREP_TIME_MAX - PREP_TIME_MIN_GAP);
nextMax = Math.max(Math.min(nextMax, PREP_TIME_MAX), PREP_TIME_MIN + PREP_TIME_MIN_GAP);
if (nextMax - nextMin < PREP_TIME_MIN_GAP) {
if (nextMin + PREP_TIME_MIN_GAP <= PREP_TIME_MAX) nextMax = nextMin + PREP_TIME_MIN_GAP;
else nextMin = nextMax - PREP_TIME_MIN_GAP;
}
return { minMinutes: nextMin, maxMinutes: nextMax };
}
function formatTimeValue(minutes) {
return `${minutes} min`;
}
function formatTimeRangeSummary(minMinutes, maxMinutes) {
return `${formatTimeValue(minMinutes)} - ${formatTimeValue(maxMinutes)}`;
}
function getChipStyle(active) {
const background = active ? FILTER_CHIP_ACTIVE : FILTER_SURFACE_SOFT;
const border = active ? FILTER_BORDER_ACTIVE : FILTER_BORDER;
const color = active ? FILTER_TEXT_ACTIVE : FILTER_TEXT_SECONDARY;
return `background:${background} !important; background-image:none !important; box-shadow:none !important; border-color:${border} !important; color:${color} !important;`;
}
function clampTimeValue(value) {
return Math.min(Math.max(value, PREP_TIME_MIN), PREP_TIME_MAX);
}
function snapTimeValue(value) {
const steps = Math.round((clampTimeValue(value) - PREP_TIME_MIN) / PREP_TIME_STEP);
return PREP_TIME_MIN + steps * PREP_TIME_STEP;
}
function getSliderPercent(value) {
return ((value - PREP_TIME_MIN) / (PREP_TIME_MAX - PREP_TIME_MIN)) * 100;
}
function setActiveTimeHandle(activeHandle) {
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
if (!minHandle || !maxHandle) return;
minHandle.style.zIndex = activeHandle === 'min' ? '4' : '2';
maxHandle.style.zIndex = activeHandle === 'max' ? '4' : '3';
}
function syncTimeRangeUI() {
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
const rangeDisplay = document.getElementById('time-display-range');
const rangeFill = document.getElementById('prep-time-range-fill');
const start = getSliderPercent(localMinMinutes);
const end = getSliderPercent(localMaxMinutes);
if (minHandle) {
minHandle.style.left = `${start}%`;
minHandle.setAttribute('aria-valuenow', String(localMinMinutes));
minHandle.setAttribute('aria-valuetext', formatTimeValue(localMinMinutes));
}
if (maxHandle) {
maxHandle.style.left = `${end}%`;
maxHandle.setAttribute('aria-valuenow', String(localMaxMinutes));
maxHandle.setAttribute('aria-valuetext', formatTimeValue(localMaxMinutes));
}
if (rangeDisplay) rangeDisplay.textContent = formatTimeRangeSummary(localMinMinutes, localMaxMinutes);
if (rangeFill) {
rangeFill.style.left = `${start}%`;
rangeFill.style.width = `${Math.max(end - start, 0)}%`;
}
}
function showFilterSheet() {
const view = document.getElementById('filter-view');
const sheet = document.getElementById('filter-sheet');
if (!view || !sheet) return;
clearTimeout(closeTimer);
view.classList.remove('hidden');
view.classList.add('flex', 'filter-open');
view.style.pointerEvents = 'auto';
view.setAttribute('aria-hidden', 'false');
requestAnimationFrame(() => {
view.classList.add('opacity-100');
sheet.style.transform = 'translateY(0)';
});
}
function hideFilterSheet() {
const view = document.getElementById('filter-view');
const sheet = document.getElementById('filter-sheet');
if (!view || !sheet) return;
view.classList.remove('opacity-100', 'filter-open');
view.style.pointerEvents = 'none';
view.setAttribute('aria-hidden', 'true');
sheet.style.transform = 'translateY(100%)';
closeTimer = setTimeout(() => {
view.classList.add('hidden');
view.classList.remove('flex');
}, 300);
}
function renderSlotChips() { function renderSlotChips() {
const wrap = document.getElementById('filter-slot-chips'); const wrap = document.getElementById('filter-slot-chips');
@@ -67,10 +265,7 @@ function renderSlotChips() {
wrap.innerHTML = MEAL_SLOTS.map((slot) => { wrap.innerHTML = MEAL_SLOTS.map((slot) => {
const active = localSlots.includes(slot.id); const active = localSlots.includes(slot.id);
const cls = active return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="px-3 py-1.5 rounded-full border text-[12px] font-semibold transition-colors" style="${getChipStyle(active)}">${escapeHtml(slot.label)}</button>`;
? 'px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors'
: 'px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors';
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="${cls}">${escapeHtml(slot.label)}</button>`;
}).join(''); }).join('');
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => { wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
@@ -92,10 +287,7 @@ function renderTagChips() {
const allTags = collectAllTags(); const allTags = collectAllTags();
wrap.innerHTML = allTags.map((tag) => { wrap.innerHTML = allTags.map((tag) => {
const active = localTags.includes(tag.toLowerCase()); const active = localTags.includes(tag.toLowerCase());
const cls = active return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="px-3 py-1.5 rounded-full border text-[12px] font-semibold transition-colors" style="${getChipStyle(active)}">${escapeHtml(tag)}</button>`;
? 'px-4 py-2 bg-gray-900 text-white rounded-full text-sm font-medium transition-colors'
: 'px-4 py-2 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-full text-sm font-medium transition-colors';
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="${cls}">${escapeHtml(tag)}</button>`;
}).join(''); }).join('');
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => { wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
@@ -111,31 +303,135 @@ function renderTagChips() {
} }
function updateResultCount() { function updateResultCount() {
applyFilters({ slots: localSlots, tags: localTags, maxMinutes: localMaxMinutes }); applyFilters({
slots: localSlots,
tags: localTags,
minMinutes: localMinMinutes,
maxMinutes: localMaxMinutes,
});
const count = getFilteredCount(); const count = getFilteredCount();
const applyBtn = document.getElementById('filter-apply-btn'); const applyBtnLabel = document.getElementById('filter-apply-label');
if (applyBtn) applyBtn.textContent = `Pokaż ${count} wyników`; if (applyBtnLabel) applyBtnLabel.textContent = `Pokaż ${count} wyników`;
} }
export function setupFilter() { export function setupFilter() {
const timeSlider = document.getElementById('prep-time-slider'); const rangeTrack = document.getElementById('prep-time-range-fill')?.parentElement;
const timeDisplay = document.getElementById('time-display'); const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
const filterView = document.getElementById('filter-view');
let activeTimeHandle = null;
if (timeSlider) { function valueFromPointer(clientX) {
timeSlider.addEventListener('input', (e) => { if (!rangeTrack) return PREP_TIME_MIN;
const val = Number(e.target.value); const rect = rangeTrack.getBoundingClientRect();
localMaxMinutes = val; if (rect.width <= 0) return PREP_TIME_MIN;
timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`; const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
return snapTimeValue(PREP_TIME_MIN + ratio * (PREP_TIME_MAX - PREP_TIME_MIN));
}
function applyDraggedTimeValue(handleName, nextValue) {
const value = snapTimeValue(nextValue);
if (handleName === 'min') {
localMinMinutes = Math.min(value, localMaxMinutes - PREP_TIME_MIN_GAP);
localMinMinutes = Math.max(localMinMinutes, PREP_TIME_MIN);
} else {
localMaxMinutes = Math.max(value, localMinMinutes + PREP_TIME_MIN_GAP);
localMaxMinutes = Math.min(localMaxMinutes, PREP_TIME_MAX);
}
syncTimeRangeUI();
updateResultCount(); updateResultCount();
}
function stopTimeHandleDrag() {
activeTimeHandle = null;
window.removeEventListener('pointermove', onWindowPointerMove);
window.removeEventListener('pointerup', stopTimeHandleDrag);
window.removeEventListener('pointercancel', stopTimeHandleDrag);
}
function onWindowPointerMove(e) {
if (!activeTimeHandle) return;
e.preventDefault();
applyDraggedTimeValue(activeTimeHandle, valueFromPointer(e.clientX));
}
function startTimeHandleDrag(handleName, e) {
activeTimeHandle = handleName;
setActiveTimeHandle(handleName);
applyDraggedTimeValue(handleName, valueFromPointer(e.clientX));
window.addEventListener('pointermove', onWindowPointerMove);
window.addEventListener('pointerup', stopTimeHandleDrag);
window.addEventListener('pointercancel', stopTimeHandleDrag);
}
function handleTimeHandleKeydown(handleName, e) {
let nextValue = handleName === 'min' ? localMinMinutes : localMaxMinutes;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') nextValue -= PREP_TIME_STEP;
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') nextValue += PREP_TIME_STEP;
else if (e.key === 'Home') nextValue = PREP_TIME_MIN;
else if (e.key === 'End') nextValue = PREP_TIME_MAX;
else return;
e.preventDefault();
setActiveTimeHandle(handleName);
applyDraggedTimeValue(handleName, nextValue);
}
syncTimeRangeUI();
setActiveTimeHandle('max');
if (minHandle) {
minHandle.addEventListener('pointerdown', (e) => {
e.preventDefault();
startTimeHandleDrag('min', e);
});
minHandle.addEventListener('focus', () => {
setActiveTimeHandle('min');
});
minHandle.addEventListener('keydown', (e) => {
handleTimeHandleKeydown('min', e);
}); });
} }
if (maxHandle) {
maxHandle.addEventListener('pointerdown', (e) => {
e.preventDefault();
startTimeHandleDrag('max', e);
});
maxHandle.addEventListener('focus', () => {
setActiveTimeHandle('max');
});
maxHandle.addEventListener('keydown', (e) => {
handleTimeHandleKeydown('max', e);
});
}
rangeTrack?.addEventListener('pointerdown', (e) => {
if (e.target.closest('[data-time-handle]')) return;
const clickedValue = valueFromPointer(e.clientX);
const distToMin = Math.abs(clickedValue - localMinMinutes);
const distToMax = Math.abs(clickedValue - localMaxMinutes);
const handleName = distToMin <= distToMax ? 'min' : 'max';
e.preventDefault();
startTimeHandleDrag(handleName, e);
});
filterView?.addEventListener('click', (e) => {
if (e.target === filterView) window.closeFilters();
});
document.getElementById('filter-close-btn')?.addEventListener('click', () => { document.getElementById('filter-close-btn')?.addEventListener('click', () => {
window.closeFilters(); window.closeFilters();
}); });
document.getElementById('filter-apply-btn')?.addEventListener('click', () => { document.getElementById('filter-apply-btn')?.addEventListener('click', () => {
applyFilters({ slots: localSlots, tags: localTags, maxMinutes: localMaxMinutes }); applyFilters({
slots: localSlots,
tags: localTags,
minMinutes: localMinMinutes,
maxMinutes: localMaxMinutes,
});
refreshRecipeList(); refreshRecipeList();
window.closeFilters(); window.closeFilters();
}); });
@@ -143,12 +439,17 @@ export function setupFilter() {
document.getElementById('filter-clear-btn')?.addEventListener('click', () => { document.getElementById('filter-clear-btn')?.addEventListener('click', () => {
localSlots = []; localSlots = [];
localTags = []; localTags = [];
localMaxMinutes = 120; localMinMinutes = PREP_TIME_MIN;
if (timeSlider) timeSlider.value = '120'; localMaxMinutes = PREP_TIME_MAX;
if (timeDisplay) timeDisplay.textContent = '120 min'; syncTimeRangeUI();
renderSlotChips(); renderSlotChips();
renderTagChips(); renderTagChips();
applyFilters({ slots: [], tags: [], maxMinutes: 120 }); applyFilters({
slots: [],
tags: [],
minMinutes: PREP_TIME_MIN,
maxMinutes: PREP_TIME_MAX,
});
updateResultCount(); updateResultCount();
}); });
@@ -156,23 +457,24 @@ export function setupFilter() {
const state = getFilterState(); const state = getFilterState();
localSlots = [...state.slots]; localSlots = [...state.slots];
localTags = [...state.tags]; localTags = [...state.tags];
localMaxMinutes = state.maxMinutes; const normalized = normalizeTimeRange(
Number.isFinite(state.minMinutes) ? state.minMinutes : PREP_TIME_MIN,
Number.isFinite(state.maxMinutes) ? state.maxMinutes : PREP_TIME_MAX,
);
localMinMinutes = normalized.minMinutes;
localMaxMinutes = normalized.maxMinutes;
if (timeSlider) timeSlider.value = String(localMaxMinutes); syncTimeRangeUI();
if (timeDisplay) timeDisplay.textContent = localMaxMinutes >= 120 ? 'ponad 120 min' : `${localMaxMinutes} min`;
renderSlotChips(); renderSlotChips();
renderTagChips(); renderTagChips();
updateResultCount(); updateResultCount();
const fv = document.getElementById('filter-view'); showFilterSheet();
fv.classList.remove('hidden');
fv.classList.add('flex');
}; };
window.closeFilters = () => { window.closeFilters = () => {
const fv = document.getElementById('filter-view'); stopTimeHandleDrag();
fv.classList.add('hidden'); hideFilterSheet();
fv.classList.remove('flex');
}; };
} }

View File

@@ -10,6 +10,8 @@ function escapeHtml(s) {
} }
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label])); const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
const DEFAULT_MIN_MINUTES = 5;
const DEFAULT_MAX_MINUTES = 120;
function slotLabelsFor(recipe) { function slotLabelsFor(recipe) {
return (recipe.allowedSlots || []) return (recipe.allowedSlots || [])
@@ -21,15 +23,16 @@ let filterState = {
query: '', query: '',
slots: [], slots: [],
tags: [], tags: [],
maxMinutes: 120, minMinutes: DEFAULT_MIN_MINUTES,
maxMinutes: DEFAULT_MAX_MINUTES,
}; };
function matchesFilters(recipe) { function matchesFilters(recipe) {
const { query, slots, tags, maxMinutes } = filterState; const { query, slots, tags, minMinutes, maxMinutes } = filterState;
if (query) { if (query) {
const q = query.toLowerCase(); const q = query.toLowerCase();
const haystack = `${recipe.title} ${recipe.description || ''} ${(recipe.tags || []).join(' ')}`.toLowerCase(); const haystack = `${recipe.title} ${(recipe.tags || []).join(' ')}`.toLowerCase();
if (!haystack.includes(q)) return false; if (!haystack.includes(q)) return false;
} }
@@ -42,7 +45,8 @@ function matchesFilters(recipe) {
if (!tags.some((t) => recipeTags.includes(t.toLowerCase()))) return false; if (!tags.some((t) => recipeTags.includes(t.toLowerCase()))) return false;
} }
if (recipe.minutes > maxMinutes) return false; if (minMinutes > DEFAULT_MIN_MINUTES && recipe.minutes < minMinutes) return false;
if (maxMinutes < DEFAULT_MAX_MINUTES && recipe.minutes > maxMinutes) return false;
return true; return true;
} }
@@ -54,22 +58,21 @@ function getFilteredRecipes() {
function renderRecipeCard(recipe) { function renderRecipeCard(recipe) {
const labels = slotLabelsFor(recipe); const labels = slotLabelsFor(recipe);
return ` return `
<div onclick="openRecipeDetail('${escapeHtml(recipe.id)}')" class="border border-gray-200 rounded-xl overflow-hidden shadow-sm flex flex-col bg-white cursor-pointer hover:shadow-md transition-shadow"> <div onclick="openRecipeDetail('${escapeHtml(recipe.id)}')" class="rounded-xl overflow-hidden flex flex-col bg-[#393937] cursor-pointer transition-shadow" style="background:#393937 !important; border:none !important; box-shadow:none !important;">
<div class="h-32 bg-[#d4d4d4] relative overflow-hidden"> <div class="h-32 bg-[#d4d4d4] relative overflow-hidden">
${recipe.image ${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">` ? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">`
: `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`} : `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`}
</div> </div>
<div class="p-3 flex flex-col flex-1"> <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">${escapeHtml(recipe.title)}</h3> <h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-[#f1ede4] mb-3 line-clamp-2">${escapeHtml(recipe.title)}</h3>
<p class="text-gray-500 text-xs mb-3 line-clamp-2">${escapeHtml(recipe.description || '')}</p>
<div class="mt-auto"> <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 justify-between text-[11px] text-[#c2bcb2] font-medium mb-2">
<div class="flex items-center gap-1"><i class="fas fa-clock text-gray-400"></i><span>${recipe.minutes} min</span></div> <div class="flex items-center gap-1"><i class="fas fa-clock text-[#8f8b84]"></i><span>${recipe.minutes} min</span></div>
<div class="flex items-center gap-1"><i class="fas fa-fire text-gray-400"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div> <div class="flex items-center gap-1"><i class="fas fa-fire text-[#8f8b84]"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
</div> </div>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
${labels.map((l) => `<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] rounded-md font-medium">${escapeHtml(l)}</span>`).join('')} ${labels.map((l) => `<span class="px-2 py-0.5 bg-[#2f2f2d] text-[#d7d2c8] text-[10px] rounded-md font-medium">${escapeHtml(l)}</span>`).join('')}
</div> </div>
</div> </div>
</div> </div>
@@ -98,20 +101,18 @@ function renderGrid() {
export function getRecipeListHTML() { export function getRecipeListHTML() {
return ` return `
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10"> <div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10" style="background:#2d2e2b !important;">
<div class="p-4 border-b border-gray-200 mt-4 bg-white"> <div class="px-4 pt-3 pb-4 mt-2" style="background:#2d2e2b !important; border:none !important;">
<div class="flex items-center w-full border border-gray-300 rounded-lg bg-white focus-within:border-gray-400 transition-colors"> <div id="recipe-search-shell" class="relative flex items-center w-full overflow-hidden" style="background:#3a3b38 !important; border:1px solid rgba(79,81,76,0.95) !important; border-radius:1.7rem !important; box-shadow:inset 0 1px 0 rgba(255,255,255,0.045), 0 0 0 1px rgba(255,255,255,0.015) !important;">
<div class="pl-3 pr-2 text-gray-400"><i class="fas fa-search"></i></div> <input type="text" id="recipe-search-input" placeholder="Szukaj przepisów..." class="w-full bg-transparent outline-none text-[15px] text-center py-[12px] pl-8 pr-14" style="background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important;">
<input type="text" id="recipe-search-input" placeholder="Szukaj przepisów..." class="flex-1 py-2.5 bg-transparent outline-none text-gray-600 placeholder-gray-400 text-sm"> <button id="recipe-filter-btn" onclick="openFilters()" class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 text-[#c9c3b8] hover:text-[#f0e8dc] flex items-center justify-center transition-colors" style="background:transparent !important; border:none !important; box-shadow:none !important;" aria-label="Otwórz filtry">
<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> <i class="fas fa-sliders-h"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-24 bg-[#2d2e2b]"> <div class="flex-1 overflow-y-auto px-4 pt-4 pb-24 bg-[#2d2e2b]" style="background:#2d2e2b !important;">
<div id="recipe-grid" class="grid grid-cols-2 gap-3"></div> <div id="recipe-grid" class="grid grid-cols-2 gap-3 bg-[#2d2e2b]" style="background:#2d2e2b !important;"></div>
</div> </div>
</div> </div>
`; `;

View File

@@ -2,7 +2,7 @@
"name": "Recipe App", "name": "Recipe App",
"short_name": "Recipe", "short_name": "Recipe",
"description": "Plan posiłków, spiżarnia i zakupy", "description": "Plan posiłków, spiżarnia i zakupy",
"start_url": "./", "start_url": "./?appv=20260406-9",
"scope": "./", "scope": "./",
"display": "standalone", "display": "standalone",
"background_color": "#f3f4f6", "background_color": "#f3f4f6",