diff --git a/stacks/recipe/Dockerfile b/stacks/recipe/Dockerfile new file mode 100644 index 0000000..fe5b15a --- /dev/null +++ b/stacks/recipe/Dockerfile @@ -0,0 +1,8 @@ +# Use the lightweight Nginx image +FROM nginx:alpine + +# Copy everything from your repository into the Nginx serving directory +COPY ./ /usr/share/nginx/html + +# Tell Docker this container listens on port 80 +EXPOSE 80 \ No newline at end of file diff --git a/stacks/recipe/css/styles.css b/stacks/recipe/css/styles.css new file mode 100644 index 0000000..8272995 --- /dev/null +++ b/stacks/recipe/css/styles.css @@ -0,0 +1,53 @@ +/* Slider styling */ +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + height: 20px; + width: 20px; + border-radius: 50%; + background: #111827; + cursor: pointer; + margin-top: -8px; +} +input[type=range]::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + cursor: pointer; + background: #e5e7eb; + border-radius: 2px; +} + +/* View Transitions */ +.view-transition { + transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; +} +.slide-in { + transform: translateX(0); + opacity: 1; +} +.slide-out { + transform: translateX(100%); + opacity: 0; + pointer-events: none; +} + +/* Ingredient Active States */ +.ingredient-active .check-box { + background-color: #111827; + border-color: #111827; +} +.ingredient-active .check-icon { + display: block; +} +.ingredient-active .ingredient-text { + text-decoration: line-through; + color: #9ca3af; +} + +/* Utilities */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} \ No newline at end of file diff --git a/stacks/recipe/docker-compose.yaml b/stacks/recipe/docker-compose.yaml new file mode 100644 index 0000000..af506c9 --- /dev/null +++ b/stacks/recipe/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + recipe-mockup: + build: . + container_name: recipe-mockup + restart: unless-stopped + networks: + - homelab_apps + labels: + - "traefik.enable=true" + - "traefik.docker.network=homelab_apps" + - "traefik.http.routers.recipe.rule=Host(`${RECIPE_DOMAIN}`)" + - "traefik.http.routers.recipe.entrypoints=websecure" + - "traefik.http.routers.recipe.tls=true" + - "traefik.http.routers.recipe.tls.certresolver=le" + - "traefik.http.services.recipe.loadbalancer.server.port=80" + + # Authentik Protection + - "traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version" + - "traefik.http.routers.recipe.middlewares=authentik" + +networks: + homelab_apps: + external: true \ No newline at end of file diff --git a/stacks/recipe/index.html b/stacks/recipe/index.html new file mode 100644 index 0000000..544cab0 --- /dev/null +++ b/stacks/recipe/index.html @@ -0,0 +1,36 @@ + + + + + + Recipe App - Modular + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/stacks/recipe/js/app.js b/stacks/recipe/js/app.js new file mode 100644 index 0000000..d7a3d52 --- /dev/null +++ b/stacks/recipe/js/app.js @@ -0,0 +1,104 @@ +import { getRecipeListHTML } from './views/RecipeList.js'; +import { getFilterHTML, setupFilter } from './views/Filter.js'; +import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js'; +import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js'; + +function getBottomNavHTML() { + return ` + + `; +} + +function setupTabs() { + const main = document.getElementById('main-view'); + const planner = document.getElementById('planner-view'); + const nav = document.getElementById('app-bottom-nav'); + if (!main || !planner || !nav) return; + + const activeTab = 'nav-tab flex flex-col items-center gap-1 text-black min-w-[3.5rem]'; + const idleTab = 'nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]'; + + const apply = (tab) => { + const showRecipes = tab === 'recipes'; + main.classList.toggle('hidden', !showRecipes); + planner.classList.toggle('hidden', showRecipes); + + nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => { + const id = btn.getAttribute('data-tab'); + if (btn.hasAttribute('disabled')) return; + if (id === 'recipes' || id === 'planner') { + btn.className = id === tab ? activeTab : idleTab; + } + }); + }; + + nav.addEventListener('click', (e) => { + const btn = e.target.closest('.nav-tab[data-tab]'); + if (!btn || btn.hasAttribute('disabled')) return; + const tab = btn.getAttribute('data-tab'); + if (tab === 'recipes' || tab === 'planner') apply(tab); + }); + + apply('recipes'); +} + +document.addEventListener('DOMContentLoaded', () => { + const appContainer = document.getElementById('app-container'); + + appContainer.innerHTML = ` + ${getRecipeListHTML()} + ${getMealPlannerHTML()} + ${getBottomNavHTML()} + ${getRecipeDetailHTML()} + ${getFilterHTML()} + `; + + setupTabs(); + setupMealPlanner(); + setupFilter(); + setupRecipeDetail(); +}); + +// --- GLOBAL NAVIGATION METHODS --- +window.openRecipeDetail = () => { + const view = document.getElementById('recipe-detail-view'); + // Swap Tailwind classes to slide IN + view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none'); + view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto'); +}; + +window.closeRecipeDetail = () => { + const view = document.getElementById('recipe-detail-view'); + // Swap Tailwind classes to slide OUT + view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto'); + view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none'); +}; + +window.openFilters = () => { + const fv = document.getElementById('filter-view'); + fv.classList.remove('hidden'); + fv.classList.add('flex'); +}; + +window.closeFilters = () => { + const fv = document.getElementById('filter-view'); + fv.classList.add('hidden'); + fv.classList.remove('flex'); +}; \ No newline at end of file diff --git a/stacks/recipe/js/views/Filter.js b/stacks/recipe/js/views/Filter.js new file mode 100644 index 0000000..285692b --- /dev/null +++ b/stacks/recipe/js/views/Filter.js @@ -0,0 +1,63 @@ +export function getFilterHTML() { + return ` + + `; +} + +export function setupFilter() { + const timeSlider = document.getElementById('prep-time-slider'); + const timeDisplay = document.getElementById('time-display'); + + if(timeSlider) { + timeSlider.addEventListener('input', (e) => { + const val = e.target.value; + timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`; + }); + } +} \ No newline at end of file diff --git a/stacks/recipe/js/views/MealPlanner.js b/stacks/recipe/js/views/MealPlanner.js new file mode 100644 index 0000000..96ea896 --- /dev/null +++ b/stacks/recipe/js/views/MealPlanner.js @@ -0,0 +1,307 @@ +const MONTHS_SHORT = [ + 'sty', 'lut', 'mar', 'kwi', 'maj', 'cze', + 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru', +]; +const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd']; + +function startOfDay(d) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +function sameDay(a, b) { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); +} + +function addDays(d, n) { + const x = new Date(d); + x.setDate(x.getDate() + n); + return startOfDay(x); +} + +/** Poniedziałek jako pierwszy dzień tygodnia (PL) */ +function startOfWeekMonday(d) { + const date = startOfDay(d); + const day = date.getDay(); + const diff = day === 0 ? -6 : 1 - day; + return addDays(date, diff); +} + +function startOfMonth(d) { + const x = new Date(d.getFullYear(), d.getMonth(), 1); + return startOfDay(x); +} + +function addMonths(d, n) { + const x = new Date(d); + x.setMonth(x.getMonth() + n); + return startOfDay(x); +} + +function addWeeks(d, n) { + return addDays(d, n * 7); +} + +function weekContains(weekStart, d) { + const t = startOfDay(d).getTime(); + const ws = weekStart.getTime(); + const we = addDays(weekStart, 6).getTime(); + return t >= ws && t <= we; +} + +function sameMonth(a, b) { + return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear(); +} + +function isCalendarOnToday(mode, weekStart, monthAnchor, selected) { + const today = startOfDay(new Date()); + if (!sameDay(selected, today)) return false; + if (mode === 'week') return weekContains(weekStart, today); + return sameMonth(monthAnchor, today); +} + +function syncTodayButton(mode, weekStart, monthAnchor, selected) { + const btn = document.getElementById('cal-go-today'); + if (!btn) return; + const onToday = isCalendarOnToday(mode, weekStart, monthAnchor, selected); + const active = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors'; + const dim = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-100 bg-gray-50 px-2 text-[10px] font-semibold text-gray-400 shadow-none cursor-default transition-colors'; + btn.className = onToday ? dim : active; + btn.disabled = onToday; +} + +export function getMealPlannerHTML() { + return ` + + `; +} + +function renderWeekGrid(weekStart, selected) { + const grid = document.getElementById('calendar-week-grid'); + if (!grid) return; + + const cells = []; + for (let i = 0; i < 7; i++) { + const day = addDays(weekStart, i); + const isSel = selected && sameDay(day, selected); + const isToday = sameDay(day, new Date()); + cells.push(` + + `); + } + grid.innerHTML = cells.join(''); +} + +function renderMonthGrid(monthAnchor, selected) { + const grid = document.getElementById('calendar-month-grid'); + if (!grid) return; + + const first = startOfMonth(monthAnchor); + const startGrid = startOfWeekMonday(first); + const cells = []; + for (let i = 0; i < 42; i++) { + const day = addDays(startGrid, i); + const inMonth = day.getMonth() === first.getMonth(); + const isSel = selected && sameDay(day, selected); + const isToday = sameDay(day, new Date()); + cells.push(` + + `); + } + grid.innerHTML = cells.join(''); +} + +function updatePeriodLabel(mode, weekStart, monthAnchor) { + const el = document.getElementById('cal-period-label'); + if (!el) return; + + if (mode === 'week') { + const end = addDays(weekStart, 6); + const y = weekStart.getFullYear(); + if (weekStart.getMonth() === end.getMonth()) { + el.textContent = `${weekStart.getDate()}–${end.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${y}`; + } else { + el.textContent = `${weekStart.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} – ${end.getDate()} ${MONTHS_SHORT[end.getMonth()]} ${y}`; + } + } else { + const m = monthAnchor.getMonth(); + const y = monthAnchor.getFullYear(); + const monthLong = [ + 'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', + 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień', + ][m]; + el.textContent = `${monthLong} ${y}`; + } +} + +function syncModeToggle(mode) { + const w = document.getElementById('planner-mode-week'); + const m = document.getElementById('planner-mode-month'); + const weekWrap = document.getElementById('calendar-week-wrap'); + const monthWrap = document.getElementById('calendar-month-wrap'); + + const base = 'planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors'; + const active = `${base} bg-white text-gray-900 shadow-sm`; + const idle = `${base} text-gray-400 hover:text-gray-600`; + + if (w && m) { + if (mode === 'week') { + w.className = active; + m.className = idle; + } else { + w.className = idle; + m.className = active; + } + } + if (weekWrap && monthWrap) { + weekWrap.classList.toggle('hidden', mode !== 'week'); + monthWrap.classList.toggle('hidden', mode !== 'month'); + } +} + +function bindDayClicks(container, state, rerender) { + container?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-planner-day]'); + if (!btn) return; + const ts = Number(btn.getAttribute('data-planner-day')); + state.selected = new Date(ts); + rerender(); + }); +} + +export function setupMealPlanner() { + const state = { + mode: 'week', + weekStart: startOfWeekMonday(new Date()), + monthAnchor: startOfDay(new Date()), + selected: startOfDay(new Date()), + }; + + const weekGrid = document.getElementById('calendar-week-grid'); + const monthGrid = document.getElementById('calendar-month-grid'); + + const rerender = () => { + syncModeToggle(state.mode); + updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor); + syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected); + if (state.mode === 'week') { + renderWeekGrid(state.weekStart, state.selected); + } else { + renderMonthGrid(state.monthAnchor, state.selected); + } + }; + + bindDayClicks(weekGrid?.parentElement, state, rerender); + bindDayClicks(monthGrid?.parentElement, state, rerender); + + document.getElementById('planner-cal-mode')?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-cal-mode]'); + if (!btn) return; + const mode = btn.getAttribute('data-cal-mode'); + if (mode !== 'week' && mode !== 'month') return; + state.mode = mode; + if (mode === 'week') { + state.weekStart = startOfWeekMonday(state.selected); + } else { + state.monthAnchor = startOfMonth(state.selected); + } + rerender(); + }); + + document.getElementById('cal-prev')?.addEventListener('click', () => { + if (state.mode === 'week') { + state.weekStart = addWeeks(state.weekStart, -1); + if (!weekContains(state.weekStart, state.selected)) { + state.selected = new Date(state.weekStart); + } + } else { + state.monthAnchor = addMonths(state.monthAnchor, -1); + if (!sameMonth(state.monthAnchor, state.selected)) { + state.selected = startOfMonth(state.monthAnchor); + } + } + rerender(); + }); + + document.getElementById('cal-next')?.addEventListener('click', () => { + if (state.mode === 'week') { + state.weekStart = addWeeks(state.weekStart, 1); + if (!weekContains(state.weekStart, state.selected)) { + state.selected = new Date(state.weekStart); + } + } else { + state.monthAnchor = addMonths(state.monthAnchor, 1); + if (!sameMonth(state.monthAnchor, state.selected)) { + state.selected = startOfMonth(state.monthAnchor); + } + } + rerender(); + }); + + document.getElementById('cal-go-today')?.addEventListener('click', () => { + const today = startOfDay(new Date()); + state.selected = today; + state.weekStart = startOfWeekMonday(today); + state.monthAnchor = startOfMonth(today); + rerender(); + }); + + rerender(); +} diff --git a/stacks/recipe/js/views/RecipeDetail.js b/stacks/recipe/js/views/RecipeDetail.js new file mode 100644 index 0000000..d43ed9b --- /dev/null +++ b/stacks/recipe/js/views/RecipeDetail.js @@ -0,0 +1,292 @@ +export function getRecipeDetailHTML() { + return ` +
+ +
+ + +
+ +
+ Zdjęcie: Serek z owocami +
+ +
+ +
+
+

Serek wiejski z orzechami i owocami

+
+ +
+ Śniadanie + Wegetariańskie + Słodkie +
+ +
+
+
5 min
+
642 kcal
+
+ +
+ +
+ 1 + +
+ +
+
+
+ +
+ + + +
+ +
+ +
+
+ Zaznacz, by dodać do listy zakupów +
+ +
    +
  • +
    + Serek wiejski + 200 g +
  • + +
  • +
    + Miód + 10 g +
  • + +
  • +
    + Orzechy włoskie +
    + + 50 g +
    +
  • + +
  • +
    + Truskawki +
    + + 100 g +
    +
  • + +
  • +
    + Borówki ameryk. +
    + + 100 g +
    +
  • +
+ + +
+ + + + + +
+
+ + + +
+
+

Zmień składnik

+ +
+ +
+
+
+ +
+ `; +} + +export function setupRecipeDetail() { + let currentServings = 1; // Domślnie 1 porcja dla tego przepisu + const defaultServings = 1; + let currentlySwapping = null; + + // Dane do dynamicznego modala + const swapOptions = { + 'orzechy': [ + { name: 'Orzechy włoskie', hint: 'Bazowe', color: 'gray' }, + { name: 'Migdały', hint: '+ Białko', color: 'blue' }, + { name: 'Orzechy laskowe', hint: 'Klasyk', color: 'gray' }, + { name: 'Orzechy nerkowca', hint: 'Słodsze', color: 'gray' }, + { name: 'Orzechy pekan', hint: '+ Tłuszcz', color: 'green' } + ], + 'owoce1': [ + { name: 'Truskawki', hint: 'Bazowe', color: 'gray' }, + { name: 'Gruszka konferencja', hint: '+ Węgle', color: 'blue' }, + { name: 'Banany', hint: '+ Kalorie', color: 'green' } + ], + 'owoce2': [ + { name: 'Borówki ameryk.', hint: 'Bazowe', color: 'gray' }, + { name: 'Jagody leśne', hint: 'Sezonowe', color: 'blue' }, + { name: 'Maliny', hint: '- Kalorie', color: 'green' } + ] + }; + + window.switchTab = (tabId, clickedBtn) => { + document.querySelectorAll('.tab-content').forEach(el => { + el.classList.remove('block'); + el.classList.add('hidden'); + }); + + const targetTab = document.getElementById(`tab-${tabId}`); + targetTab.classList.remove('hidden'); + targetTab.classList.add('block'); + targetTab.parentElement.scrollTop = 0; + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold'); + btn.classList.add('text-gray-500', 'border-transparent', 'font-medium'); + }); + + clickedBtn.classList.remove('text-gray-500', 'border-transparent', 'font-medium'); + clickedBtn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold'); + }; + + window.toggleIngredient = (element) => { + element.classList.toggle('ingredient-active'); + }; + + window.changeServings = (delta) => { + const newServings = currentServings + delta; + if (newServings < 1) return; + + currentServings = newServings; + document.getElementById('servings-count').innerText = currentServings; + + const ratio = currentServings / defaultServings; + document.querySelectorAll('.ingredient-amount').forEach(el => { + const baseAmount = parseFloat(el.getAttribute('data-base-amount')); + const unit = el.getAttribute('data-unit'); + + if (!isNaN(baseAmount)) { + let newAmount = baseAmount * ratio; + newAmount = Number.isInteger(newAmount) ? newAmount : parseFloat(newAmount.toFixed(1)); + el.innerText = `${newAmount} ${unit}`; + } + }); + }; + + window.openSwapModal = (type) => { + currentlySwapping = type; + + let title = ''; + if(type === 'orzechy') title = 'Orzechy'; + if(type === 'owoce1') title = 'Owoce bazy'; + if(type === 'owoce2') title = 'Dodatki owocowe'; + + document.getElementById('swap-title-target').innerText = title; + + // Wygeneruj opcje na podstawie słownika + const container = document.getElementById('swap-options-container'); + container.innerHTML = swapOptions[type].map(opt => { + let badgeClass = 'text-gray-600 bg-gray-200'; // Domyślny gray + if (opt.color === 'blue') badgeClass = 'text-blue-600 bg-blue-100'; + if (opt.color === 'green') badgeClass = 'text-green-600 bg-green-100'; + + return ` + + `; + }).join(''); + + const backdrop = document.getElementById('swap-backdrop'); + backdrop.classList.remove('hidden'); + setTimeout(() => backdrop.classList.remove('opacity-0'), 10); + + const modal = document.getElementById('swap-modal'); + modal.classList.remove('translate-y-full'); + modal.classList.add('translate-y-0'); + }; + + window.closeSwapModal = () => { + const backdrop = document.getElementById('swap-backdrop'); + backdrop.classList.add('opacity-0'); + setTimeout(() => backdrop.classList.add('hidden'), 300); + + const modal = document.getElementById('swap-modal'); + modal.classList.remove('translate-y-0'); + modal.classList.add('translate-y-full'); + }; + + window.confirmSwap = (newItemName) => { + if (currentlySwapping === 'orzechy') { + document.getElementById('ingredient-orzechy').innerText = newItemName; + } else if (currentlySwapping === 'owoce1') { + document.getElementById('ingredient-owoce1').innerText = newItemName; + } else if (currentlySwapping === 'owoce2') { + document.getElementById('ingredient-owoce2').innerText = newItemName; + } + closeSwapModal(); + }; +} \ No newline at end of file diff --git a/stacks/recipe/js/views/RecipeList.js b/stacks/recipe/js/views/RecipeList.js new file mode 100644 index 0000000..f1d9428 --- /dev/null +++ b/stacks/recipe/js/views/RecipeList.js @@ -0,0 +1,174 @@ +export function getRecipeListHTML() { + return ` +
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+ Placki +
+
+

Puszyste placki

+

Klasyczne placki na śniadanie

+
+
+
15 min
+
320 kcal
+
+
+ Śniadanie +
+
+
+
+ +
+
+ Sałatka +
+
+

Sałatka z kurczakiem

+

Zielone warzywa z grillowanym kurczakiem

+
+
+
20 min
+
250 kcal
+
+
+ Obiad +
+
+
+
+ +
+
+ Makaron +
+
+

Makaron z pomidorami i bazylią

+

Aromatyczny sos pomidorowy z czosnkiem

+
+
+
30 min
+
450 kcal
+
+
+ Kolacja +
+
+
+
+ +
+
+ Koktajl +
+
+

Koktajl owocowy

+

Mix jagód i jogurtu

+
+
+
5 min
+
180 kcal
+
+
+ Przekąska +
+
+
+
+ +
+
+ Tost z awokado +
+
+

Tost z awokado

+

Chleb na zakwasie z rozgniecionym awokado

+
+
+
10 min
+
220 kcal
+
+
+ Śniadanie +
+
+
+
+ +
+
+ Łosoś +
+
+

Grillowany łosoś

+

Świeży łosoś z masłem cytrynowym

+
+
+
25 min
+
380 kcal
+
+
+ Kolacja +
+
+
+
+ +
+
+ Tacos +
+
+

Tacos z wołowiną

+

Pikantna mielona wołowina ze świeżą salsą

+
+
+
20 min
+
410 kcal
+
+
+ Kolacja +
+
+
+
+ +
+
+ Owsianka +
+
+

Miska owsianki

+

Ciepła owsianka z miodem i orzechami

+
+
+
10 min
+
210 kcal
+
+
+ Śniadanie +
+
+
+
+ +
+
+
+ `; +}