Compare commits
3 Commits
0aa8d12c1e
...
e640b34b0f
| Author | SHA1 | Date | |
|---|---|---|---|
| e640b34b0f | |||
| 36f9f35b0b | |||
| 2b0601956f |
24
.idea/workspace.xml
generated
24
.idea/workspace.xml
generated
@@ -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>
|
||||||
310
index.html
310
index.html
@@ -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-42">
|
||||||
<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,73 @@
|
|||||||
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: 3rem;
|
||||||
|
width: min(calc(100% - 0.5rem), 22.4rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #393937 !important;
|
||||||
|
border: 1px solid #41423f !important;
|
||||||
|
box-shadow:
|
||||||
|
0 5px 10px rgba(0, 0, 0, 0.16),
|
||||||
|
0 14px 22px rgba(0, 0, 0, 0.24),
|
||||||
|
0 22px 34px rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
}
|
||||||
|
#recipe-search-shell::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 11%;
|
||||||
|
right: 11%;
|
||||||
|
bottom: -0.72rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.36);
|
||||||
|
filter: blur(12px);
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#recipe-search-shell:focus-within {
|
||||||
|
background: #393937 !important;
|
||||||
|
border: 1px solid #4a4b47 !important;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 12px rgba(0, 0, 0, 0.18),
|
||||||
|
0 16px 24px rgba(0, 0, 0, 0.24),
|
||||||
|
0 24px 36px rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05) !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,
|
||||||
@@ -299,6 +371,128 @@
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom dock */
|
||||||
|
#app-bottom-nav {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.85rem calc(1.12rem + env(safe-area-inset-bottom));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .bottom-dock {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
width: min(calc(100% - 2rem), 22.4rem);
|
||||||
|
min-height: 3.7rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #393937;
|
||||||
|
border: 1px solid #41423f;
|
||||||
|
box-shadow:
|
||||||
|
0 5px 10px rgba(0, 0, 0, 0.16),
|
||||||
|
0 14px 22px rgba(0, 0, 0, 0.24),
|
||||||
|
0 22px 34px rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .bottom-dock::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 11%;
|
||||||
|
right: 11%;
|
||||||
|
bottom: -0.72rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.36);
|
||||||
|
filter: blur(12px);
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab,
|
||||||
|
#app-bottom-nav .nav-action {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
width: 2.55rem;
|
||||||
|
height: 2.55rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
justify-self: center;
|
||||||
|
color: #ece8e0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
background-color 160ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab i,
|
||||||
|
#app-bottom-nav .nav-action i {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab:hover,
|
||||||
|
#app-bottom-nav .nav-action:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab.is-active {
|
||||||
|
width: 2.95rem;
|
||||||
|
height: 2.95rem;
|
||||||
|
color: #fff;
|
||||||
|
background: #2d2e2b !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||||
|
0 10px 20px rgba(0, 0, 0, 0.24) !important;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab:active,
|
||||||
|
#app-bottom-nav .nav-action:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
#app-bottom-nav button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(255, 255, 255, 0.08),
|
||||||
|
0 0 0 6px rgba(var(--accent-rgb), 0.26) !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
#app-bottom-nav {
|
||||||
|
padding-inline: 0.7rem;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .bottom-dock {
|
||||||
|
width: min(calc(100% - 1.4rem), 21.6rem);
|
||||||
|
min-height: 3.45rem;
|
||||||
|
padding-inline: 0.35rem;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab,
|
||||||
|
#app-bottom-nav .nav-action {
|
||||||
|
width: 2.35rem;
|
||||||
|
height: 2.35rem;
|
||||||
|
}
|
||||||
|
#app-bottom-nav .nav-tab.is-active {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Planner and common interactive surfaces */
|
/* Planner and common interactive surfaces */
|
||||||
#planner-open-ingredients,
|
#planner-open-ingredients,
|
||||||
.planner-pick-recipe,
|
.planner-pick-recipe,
|
||||||
@@ -328,13 +522,113 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="app-container" class="relative flex h-dvh min-h-0 w-full flex-col overflow-hidden bg-white">
|
<div id="app-container" class="relative flex h-dvh min-h-0 w-full flex-col overflow-hidden bg-white">
|
||||||
|
<div class="flex h-full items-center justify-center" style="background:#2d2e2b !important;">
|
||||||
|
<div class="text-[13px] font-medium" style="color:#b7ada1 !important;">Ładowanie...</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
const APP_ASSET_VERSION = '20260406-42';
|
||||||
navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {});
|
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) {
|
||||||
|
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-42';
|
||||||
|
const recoveryKey = `recipe-app-recovery-${appVersion}`;
|
||||||
|
|
||||||
|
function renderBootstrapError(message) {
|
||||||
|
const appContainer = document.getElementById('app-container');
|
||||||
|
if (!appContainer) return;
|
||||||
|
appContainer.innerHTML = `
|
||||||
|
<div class="flex h-full items-center justify-center px-6 text-center" style="background:#2d2e2b !important;">
|
||||||
|
<div class="max-w-[18rem] rounded-[1.5rem] border px-5 py-6" style="background:#2f2f2d !important; border-color:#444442 !important;">
|
||||||
|
<p class="text-sm font-semibold" style="color:#f2efe8 !important;">Aplikacja nie wystartowała</p>
|
||||||
|
<p class="mt-2 text-xs leading-relaxed" style="color:#b7ada1 !important;">${message}</p>
|
||||||
|
<button type="button" onclick="window.location.reload()" class="mt-4 h-10 px-4 rounded-full border text-[12px] font-semibold" style="background:#23221e !important; border-color:#787876 !important; color:#f2efe8 !important;">Odśwież</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRecoveryReload() {
|
||||||
|
if (sessionStorage.getItem(recoveryKey)) return false;
|
||||||
|
sessionStorage.setItem(recoveryKey, '1');
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(APP_VERSION_STORAGE_KEY);
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* Ignore recovery cleanup errors. */
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = new URL(window.location.href);
|
||||||
|
nextUrl.searchParams.set(APP_VERSION_QUERY_KEY, appVersion);
|
||||||
|
nextUrl.searchParams.set('recover', Date.now().toString());
|
||||||
|
window.location.replace(nextUrl.toString());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.__APP_BOOTSTRAP__;
|
||||||
|
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)}`);
|
||||||
|
sessionStorage.removeItem(recoveryKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bootstrap failed', error);
|
||||||
|
const reloading = await tryRecoveryReload();
|
||||||
|
if (!reloading) {
|
||||||
|
renderBootstrapError('Spróbuj odświeżyć aplikację. Jeśli to nie pomoże, otwórz ją ponownie z Safari.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="js/app.js?v=18"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
169
js/app.js
169
js/app.js
@@ -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 `
|
||||||
@@ -16,27 +50,33 @@ function getAppToastHTML() {
|
|||||||
function getBottomNavHTML() {
|
function getBottomNavHTML() {
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
return `
|
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">
|
<nav id="app-bottom-nav" 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]">
|
<div class="bottom-dock">
|
||||||
<i class="fas fa-book text-base" aria-hidden="true"></i>
|
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab is-active" aria-label="Przepisy" aria-current="page">
|
||||||
<span class="text-[9px] font-medium leading-tight text-center">Przepisy</span>
|
<i class="fas fa-book" aria-hidden="true"></i>
|
||||||
</button>
|
</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]">
|
<button type="button" data-tab="planner" id="nav-planner" class="nav-tab" aria-label="Planer">
|
||||||
<i class="far fa-calendar-alt text-base" aria-hidden="true"></i>
|
<i class="far fa-calendar-alt" aria-hidden="true"></i>
|
||||||
<span class="text-[9px] font-medium leading-tight text-center">Planer</span>
|
|
||||||
</button>
|
</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]">
|
<button type="button" data-tab="pantry" id="nav-pantry" class="nav-tab" aria-label="Spiżarnia">
|
||||||
<i class="fas fa-warehouse text-base" aria-hidden="true"></i>
|
<i class="fas fa-warehouse" aria-hidden="true"></i>
|
||||||
<span class="text-[9px] font-medium leading-tight text-center">Spiżarnia</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="nav-theme-toggle" class="flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]" aria-label="Przełącz tryb ciemny/jasny">
|
<button type="button" id="nav-theme-toggle" class="nav-action" aria-label="${isDark ? 'Włącz jasny motyw' : 'Włącz ciemny motyw'}" title="${isDark ? 'Jasny motyw' : 'Ciemny motyw'}">
|
||||||
<i class="${isDark ? 'fas fa-sun' : 'fas fa-moon'} text-base" aria-hidden="true"></i>
|
<i class="${isDark ? 'fas fa-sun' : 'fas fa-moon'}" aria-hidden="true"></i>
|
||||||
<span class="text-[9px] font-medium leading-tight text-center">${isDark ? 'Jasny' : 'Ciemny'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncThemeToggleButton(btn, isDark) {
|
||||||
|
if (!btn) return;
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
if (icon) icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
||||||
|
btn.setAttribute('aria-label', isDark ? 'Włącz jasny motyw' : 'Włącz ciemny motyw');
|
||||||
|
btn.title = isDark ? 'Jasny motyw' : 'Ciemny motyw';
|
||||||
|
}
|
||||||
|
|
||||||
function setupThemeToggle() {
|
function setupThemeToggle() {
|
||||||
const btn = document.getElementById('nav-theme-toggle');
|
const btn = document.getElementById('nav-theme-toggle');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -46,10 +86,7 @@ function setupThemeToggle() {
|
|||||||
const isDark = html.classList.toggle('dark');
|
const isDark = html.classList.toggle('dark');
|
||||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||||
|
|
||||||
const icon = btn.querySelector('i');
|
syncThemeToggleButton(btn, isDark);
|
||||||
const label = btn.querySelector('span');
|
|
||||||
if (icon) icon.className = isDark ? 'fas fa-sun text-base' : 'fas fa-moon text-base';
|
|
||||||
if (label) label.textContent = isDark ? 'Jasny' : 'Ciemny';
|
|
||||||
|
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (meta) meta.setAttribute('content', isDark ? '#161513' : '#f3efe9');
|
if (meta) meta.setAttribute('content', isDark ? '#161513' : '#f3efe9');
|
||||||
@@ -63,9 +100,6 @@ function setupTabs() {
|
|||||||
const nav = document.getElementById('app-bottom-nav');
|
const nav = document.getElementById('app-bottom-nav');
|
||||||
if (!main || !planner || !pantry || !nav) return;
|
if (!main || !planner || !pantry || !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) => {
|
const apply = (tab) => {
|
||||||
main.classList.toggle('hidden', tab !== 'recipes');
|
main.classList.toggle('hidden', tab !== 'recipes');
|
||||||
planner.classList.toggle('hidden', tab !== 'planner');
|
planner.classList.toggle('hidden', tab !== 'planner');
|
||||||
@@ -77,7 +111,10 @@ function setupTabs() {
|
|||||||
const id = btn.getAttribute('data-tab');
|
const id = btn.getAttribute('data-tab');
|
||||||
if (btn.hasAttribute('disabled')) return;
|
if (btn.hasAttribute('disabled')) return;
|
||||||
if (id === 'recipes' || id === 'planner' || id === 'pantry') {
|
if (id === 'recipes' || id === 'planner' || id === 'pantry') {
|
||||||
btn.className = id === tab ? activeTab : idleTab;
|
const isActive = id === tab;
|
||||||
|
btn.classList.toggle('is-active', isActive);
|
||||||
|
if (isActive) btn.setAttribute('aria-current', 'page');
|
||||||
|
else btn.removeAttribute('aria-current');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -96,26 +133,68 @@ function setupTabs() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
let initAppPromise = null;
|
||||||
|
|
||||||
|
function renderAppBootError(message) {
|
||||||
const appContainer = document.getElementById('app-container');
|
const appContainer = document.getElementById('app-container');
|
||||||
|
if (!appContainer) return;
|
||||||
|
|
||||||
appContainer.innerHTML = `
|
appContainer.innerHTML = `
|
||||||
${getRecipeListHTML()}
|
<div class="flex h-full items-center justify-center px-6 text-center" style="background:#2d2e2b !important;">
|
||||||
${getMealPlannerHTML()}
|
<div class="max-w-[18rem] rounded-[1.5rem] border px-5 py-6" style="background:#2f2f2d !important; border-color:#444442 !important;">
|
||||||
${getPantryHTML()}
|
<p class="text-sm font-semibold" style="color:#f2efe8 !important;">Nie udało się uruchomić aplikacji</p>
|
||||||
${getBottomNavHTML()}
|
<p class="mt-2 text-xs leading-relaxed" style="color:#b7ada1 !important;">${message}</p>
|
||||||
${getRecipeDetailHTML()}
|
<button type="button" onclick="window.location.reload()" class="mt-4 h-10 px-4 rounded-full border text-[12px] font-semibold" style="background:#23221e !important; border-color:#787876 !important; color:#f2efe8 !important;">Odśwież aplikację</button>
|
||||||
${getFilterHTML()}
|
</div>
|
||||||
${getMealPlanEditorHTML()}
|
</div>
|
||||||
${getAppToastHTML()}
|
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
setupTabs();
|
async function initApp() {
|
||||||
setupThemeToggle();
|
if (initAppPromise) return initAppPromise;
|
||||||
setupRecipeList();
|
|
||||||
setupMealPlanner();
|
initAppPromise = (async () => {
|
||||||
setupPantry();
|
await moduleLoadPromise;
|
||||||
setupFilter();
|
|
||||||
setupMealPlanEditor();
|
const appContainer = document.getElementById('app-container');
|
||||||
setupRecipeDetail();
|
if (!appContainer) return;
|
||||||
});
|
|
||||||
|
appContainer.innerHTML = `
|
||||||
|
${getRecipeListHTML()}
|
||||||
|
${getMealPlannerHTML()}
|
||||||
|
${getPantryHTML()}
|
||||||
|
${getBottomNavHTML()}
|
||||||
|
${getRecipeDetailHTML()}
|
||||||
|
${getFilterHTML()}
|
||||||
|
${getMealPlanEditorHTML()}
|
||||||
|
${getAppToastHTML()}
|
||||||
|
`;
|
||||||
|
|
||||||
|
setupTabs();
|
||||||
|
setupThemeToggle();
|
||||||
|
setupRecipeList();
|
||||||
|
setupMealPlanner();
|
||||||
|
setupPantry();
|
||||||
|
setupFilter();
|
||||||
|
setupMealPlanEditor();
|
||||||
|
setupRecipeDetail();
|
||||||
|
})().catch((error) => {
|
||||||
|
initAppPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return initAppPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootApp() {
|
||||||
|
initApp().catch((error) => {
|
||||||
|
console.error('Failed to initialize app', error);
|
||||||
|
renderAppBootError('Spróbuj odświeżyć aplikację. Jeśli problem wróci, zamknij ją całkowicie i otwórz ponownie.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', bootApp, { once: true });
|
||||||
|
} else {
|
||||||
|
bootApp();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import { RECIPES } from '../data/catalog.js?v=2';
|
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 } from './RecipeList.js';
|
||||||
|
|
||||||
|
const FILTER_PANEL_TRANSITION = 'opacity 180ms ease, transform 180ms ease';
|
||||||
|
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 = '#393937';
|
||||||
|
const FILTER_TRACK_FILL = '#56534f';
|
||||||
|
const PREP_TIME_MIN = 5;
|
||||||
|
const PREP_TIME_MAX = 120;
|
||||||
|
const PREP_TIME_STEP = 5;
|
||||||
|
const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
|
||||||
|
const FILTER_RECIPE_BLUR = 'blur(3px) saturate(0.94)';
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
@@ -20,46 +39,256 @@ 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-8">
|
#filter-view .prep-time-range-track,
|
||||||
<div>
|
#filter-view .prep-time-range-fill {
|
||||||
<h3 class="text-base font-semibold text-black mb-4">Pora posiłku</h3>
|
border-radius: 9999px;
|
||||||
<div id="filter-slot-chips" class="flex flex-wrap gap-2.5"></div>
|
height: 0.375rem;
|
||||||
</div>
|
}
|
||||||
<div>
|
|
||||||
<h3 class="text-base font-semibold text-black mb-4">Dieta i tagi</h3>
|
#filter-view .prep-time-range-handle {
|
||||||
<div id="filter-tag-chips" class="flex flex-wrap gap-2.5"></div>
|
width: 1rem;
|
||||||
</div>
|
height: 1rem;
|
||||||
<div>
|
border-radius: 9999px;
|
||||||
<div class="flex justify-between items-center mb-4">
|
border: 1px solid rgba(242,239,232,0.16);
|
||||||
<h3 class="text-base font-semibold text-black">Maks. czas przygotowania</h3>
|
background: ${FILTER_TRACK_FILL};
|
||||||
<span id="time-display" class="text-sm font-medium text-gray-600">120 min</span>
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.12);
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div id="filter-view" class="absolute inset-0 z-[55] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:transparent !important; background-image:none !important;" aria-hidden="true">
|
||||||
|
<div id="filter-panel" class="absolute flex flex-col overflow-hidden rounded-[1.5rem] border" style="background:${FILTER_SURFACE} !important; background-image:none !important; border-color:${FILTER_BORDER} !important; opacity:0; transform:translateY(-0.5rem) scale(0.98); transform-origin:top center; transition:${FILTER_PANEL_TRANSITION}; box-shadow:0 18px 40px rgba(0,0,0,0.34), 0 4px 12px rgba(0,0,0,0.18); width:min(calc(100% - 1.5rem), 22rem);">
|
||||||
|
<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-3.5 pt-3 pb-2 flex justify-end" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
||||||
|
<div class="min-w-0 flex items-center justify-end gap-2">
|
||||||
|
<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 class="px-1">
|
</div>
|
||||||
<input type="range" id="prep-time-slider" min="5" max="120" step="5" value="120" class="w-full appearance-none bg-transparent">
|
|
||||||
<div class="flex justify-between text-xs text-gray-400 mt-3 font-medium">
|
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 pb-4 space-y-2.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
||||||
<span>5 min</span><span>30 min</span><span>1 godz.</span><span>2 godz.+</span>
|
<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};">Pora posiłku</p>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="px-1">
|
||||||
|
<div class="relative h-9">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 border-t border-gray-200 bg-white mt-auto">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 positionFilterPanel() {
|
||||||
|
const view = document.getElementById('filter-view');
|
||||||
|
const panel = document.getElementById('filter-panel');
|
||||||
|
const body = document.getElementById('filter-panel-body');
|
||||||
|
const searchShell = document.getElementById('recipe-search-shell');
|
||||||
|
const button = document.getElementById('recipe-filter-btn');
|
||||||
|
if (!view || !panel || !button) return;
|
||||||
|
|
||||||
|
const viewRect = view.getBoundingClientRect();
|
||||||
|
const anchorRect = (searchShell || button).getBoundingClientRect();
|
||||||
|
const gap = 8;
|
||||||
|
const margin = 12;
|
||||||
|
const width = Math.min(anchorRect.width, viewRect.width - margin * 2);
|
||||||
|
const top = Math.max(margin, anchorRect.bottom - viewRect.top + gap);
|
||||||
|
const left = Math.max(
|
||||||
|
margin,
|
||||||
|
Math.min(anchorRect.left - viewRect.left, viewRect.width - width - margin),
|
||||||
|
);
|
||||||
|
const maxHeight = Math.max(220, viewRect.height - top - margin);
|
||||||
|
|
||||||
|
panel.style.width = `${width}px`;
|
||||||
|
panel.style.left = `${left}px`;
|
||||||
|
panel.style.top = `${top}px`;
|
||||||
|
panel.style.maxHeight = `${maxHeight}px`;
|
||||||
|
if (body) body.style.maxHeight = `${maxHeight - 56}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRecipeAreaBlur(visible) {
|
||||||
|
const recipeScroll = document.getElementById('recipe-scroll');
|
||||||
|
if (!recipeScroll) return;
|
||||||
|
|
||||||
|
recipeScroll.style.transition = 'filter 180ms ease, opacity 180ms ease';
|
||||||
|
recipeScroll.style.willChange = 'filter, opacity';
|
||||||
|
recipeScroll.style.filter = visible ? FILTER_RECIPE_BLUR : 'none';
|
||||||
|
recipeScroll.style.opacity = visible ? '0.78' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFilterPanel() {
|
||||||
|
const view = document.getElementById('filter-view');
|
||||||
|
const panel = document.getElementById('filter-panel');
|
||||||
|
if (!view || !panel) return;
|
||||||
|
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
view.classList.remove('hidden');
|
||||||
|
view.classList.add('filter-open');
|
||||||
|
view.style.pointerEvents = 'auto';
|
||||||
|
view.setAttribute('aria-hidden', 'false');
|
||||||
|
positionFilterPanel();
|
||||||
|
setRecipeAreaBlur(true);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
view.classList.add('opacity-100');
|
||||||
|
panel.style.opacity = '1';
|
||||||
|
panel.style.transform = 'translateY(0) scale(1)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideFilterPanel() {
|
||||||
|
const view = document.getElementById('filter-view');
|
||||||
|
const panel = document.getElementById('filter-panel');
|
||||||
|
if (!view || !panel) return;
|
||||||
|
|
||||||
|
view.classList.remove('opacity-100', 'filter-open');
|
||||||
|
view.style.pointerEvents = 'none';
|
||||||
|
view.setAttribute('aria-hidden', 'true');
|
||||||
|
panel.style.opacity = '0';
|
||||||
|
panel.style.transform = 'translateY(-0.5rem) scale(0.98)';
|
||||||
|
setRecipeAreaBlur(false);
|
||||||
|
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
view.classList.add('hidden');
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFilterPanelOpen() {
|
||||||
|
return document.getElementById('filter-view')?.classList.contains('filter-open');
|
||||||
|
}
|
||||||
|
|
||||||
function renderSlotChips() {
|
function renderSlotChips() {
|
||||||
const wrap = document.getElementById('filter-slot-chips');
|
const wrap = document.getElementById('filter-slot-chips');
|
||||||
@@ -67,10 +296,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) => {
|
||||||
@@ -80,7 +306,7 @@ function renderSlotChips() {
|
|||||||
if (idx >= 0) localSlots.splice(idx, 1);
|
if (idx >= 0) localSlots.splice(idx, 1);
|
||||||
else localSlots.push(id);
|
else localSlots.push(id);
|
||||||
renderSlotChips();
|
renderSlotChips();
|
||||||
updateResultCount();
|
syncLiveFilters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,10 +318,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) => {
|
||||||
@@ -105,74 +328,176 @@ function renderTagChips() {
|
|||||||
if (idx >= 0) localTags.splice(idx, 1);
|
if (idx >= 0) localTags.splice(idx, 1);
|
||||||
else localTags.push(tag);
|
else localTags.push(tag);
|
||||||
renderTagChips();
|
renderTagChips();
|
||||||
updateResultCount();
|
syncLiveFilters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateResultCount() {
|
function syncLiveFilters() {
|
||||||
applyFilters({ slots: localSlots, tags: localTags, maxMinutes: localMaxMinutes });
|
applyFilters({
|
||||||
const count = getFilteredCount();
|
slots: localSlots,
|
||||||
const applyBtn = document.getElementById('filter-apply-btn');
|
tags: localTags,
|
||||||
if (applyBtn) applyBtn.textContent = `Pokaż ${count} wyników`;
|
minMinutes: localMinMinutes,
|
||||||
|
maxMinutes: localMaxMinutes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
setRecipeAreaBlur(false);
|
||||||
timeSlider.addEventListener('input', (e) => {
|
|
||||||
const val = Number(e.target.value);
|
function valueFromPointer(clientX) {
|
||||||
localMaxMinutes = val;
|
if (!rangeTrack) return PREP_TIME_MIN;
|
||||||
timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`;
|
const rect = rangeTrack.getBoundingClientRect();
|
||||||
updateResultCount();
|
if (rect.width <= 0) return PREP_TIME_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();
|
||||||
|
syncLiveFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('filter-close-btn')?.addEventListener('click', () => {
|
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.composedPath().includes(document.getElementById('filter-panel'))) return;
|
||||||
window.closeFilters();
|
window.closeFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('filter-apply-btn')?.addEventListener('click', () => {
|
window.addEventListener('resize', () => {
|
||||||
applyFilters({ slots: localSlots, tags: localTags, maxMinutes: localMaxMinutes });
|
if (isFilterPanelOpen()) positionFilterPanel();
|
||||||
refreshRecipeList();
|
});
|
||||||
window.closeFilters();
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && isFilterPanelOpen()) window.closeFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 });
|
syncLiveFilters();
|
||||||
updateResultCount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.openFilters = () => {
|
window.openFilters = () => {
|
||||||
|
if (isFilterPanelOpen()) {
|
||||||
|
window.closeFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
syncLiveFilters();
|
||||||
|
|
||||||
const fv = document.getElementById('filter-view');
|
showFilterPanel();
|
||||||
fv.classList.remove('hidden');
|
|
||||||
fv.classList.add('flex');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.closeFilters = () => {
|
window.closeFilters = () => {
|
||||||
const fv = document.getElementById('filter-view');
|
stopTimeHandleDrag();
|
||||||
fv.classList.add('hidden');
|
hideFilterPanel();
|
||||||
fv.classList.remove('flex');
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ 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;
|
||||||
|
const SEARCH_SHELL_BASE_SHADOW =
|
||||||
|
'0 5px 10px rgba(0,0,0,0.16), 0 14px 22px rgba(0,0,0,0.24), 0 22px 34px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04)';
|
||||||
|
|
||||||
function slotLabelsFor(recipe) {
|
function slotLabelsFor(recipe) {
|
||||||
return (recipe.allowedSlots || [])
|
return (recipe.allowedSlots || [])
|
||||||
@@ -21,15 +25,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 +47,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,28 +60,40 @@ 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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncRecipeScrollShadow() {
|
||||||
|
const scroll = document.getElementById('recipe-scroll');
|
||||||
|
const searchShell = document.getElementById('recipe-search-shell');
|
||||||
|
if (!searchShell) return;
|
||||||
|
|
||||||
|
if (!scroll) {
|
||||||
|
searchShell.style.boxShadow = SEARCH_SHELL_BASE_SHADOW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchShell.style.boxShadow = SEARCH_SHELL_BASE_SHADOW;
|
||||||
|
}
|
||||||
|
|
||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
const grid = document.getElementById('recipe-grid');
|
const grid = document.getElementById('recipe-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
@@ -90,28 +108,28 @@ function renderGrid() {
|
|||||||
<p class="text-sm font-semibold text-gray-700">Brak wyników</p>
|
<p class="text-sm font-semibold text-gray-700">Brak wyników</p>
|
||||||
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Zmień kryteria wyszukiwania lub filtry</p>
|
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Zmień kryteria wyszukiwania lub filtry</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
requestAnimationFrame(syncRecipeScrollShadow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = recipes.map(renderRecipeCard).join('');
|
grid.innerHTML = recipes.map(renderRecipeCard).join('');
|
||||||
|
requestAnimationFrame(syncRecipeScrollShadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 id="recipe-top-bar" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4" style="background:transparent !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="pointer-events-auto relative z-[1] mx-auto flex items-center w-full overflow-hidden" style="width:min(calc(100% - 0.5rem), 22.4rem); background:#393937 !important; border:1px solid #41423f !important; border-radius:999px !important; box-shadow:${SEARCH_SHELL_BASE_SHADOW} !important; transition:box-shadow 180ms ease;">
|
||||||
<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 id="recipe-scroll" class="relative flex-1 overflow-y-auto px-4 pt-20 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>
|
||||||
`;
|
`;
|
||||||
@@ -141,4 +159,7 @@ export function setupRecipeList() {
|
|||||||
filterState.query = e.target.value.trim();
|
filterState.query = e.target.value.trim();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('recipe-scroll')?.addEventListener('scroll', syncRecipeScrollShadow);
|
||||||
|
syncRecipeScrollShadow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-29",
|
||||||
"scope": "./",
|
"scope": "./",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f3f4f6",
|
"background_color": "#f3f4f6",
|
||||||
|
|||||||
Reference in New Issue
Block a user