import { initRecipePicker } from "./picker.js";
const AFTER_RELOAD = "cookcli-plan-after-reload";
function encodePath(path) {
return path.split("/").map(encodeURIComponent).join("/");
}
export function initPlanner(root, { prefix = "", path, version, servings, strings }) {
const picker = initRecipePicker(document.getElementById("recipe-picker"), { prefix });
const menu = document.getElementById("plan-line-menu");
const target = document.getElementById("plan-target");
const targetForm = target.querySelector("form");
const targetHeading = document.getElementById("plan-target-heading");
const targetDay = document.getElementById("plan-target-day");
const targetMeal = document.getElementById("plan-target-meal");
const targetConfirm = targetForm.querySelector('button[type="submit"]');
const targetCancel = targetForm.querySelector("[data-plan-cancel]");
const error = document.getElementById("plan-error");
const status = document.getElementById("plan-status");
let busy = false;
function showError(message) {
error.querySelector("p").textContent = message;
error.hidden = false;
}
async function change(op, focus) {
if (busy) return;
busy = true;
try {
const response = await fetch(`${prefix}/api/plans/${encodePath(path)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version, ...op })
});
if (response.ok) {
try {
sessionStorage.setItem(AFTER_RELOAD, JSON.stringify(focus));
} catch {
}
location.reload();
return;
}
if (response.status === 409) {
showError(strings.changed);
} else {
const body = await response.json().catch(() => ({}));
showError(body.error ? `${strings.failed} ${body.error}` : strings.failed);
}
} catch {
showError(strings.failed);
}
busy = false;
}
error.querySelector("[data-plan-reload]").addEventListener("click", () => location.reload());
function slotOf(el) {
const meal = el.closest(".plan-meal[data-meal]");
const day = el.closest(".plan-day[data-date]");
return meal && day ? { date: day.dataset.date, meal: meal.dataset.meal } : null;
}
function lineOf(el) {
const line = el.closest(".plan-line");
const slot = line && slotOf(line);
return slot && { ...slot, index: Number(line.dataset.index), text: line.dataset.text };
}
function addButton({ date, meal }) {
return [...root.querySelectorAll(`.plan-day[data-date="${date}"] .plan-meal[data-meal]`)]
.find(el => el.dataset.meal === meal)
?.querySelector("[data-plan-add]");
}
root.addEventListener("click", async event => {
const button = event.target.closest("[data-plan-add]");
if (!button) return;
const slot = slotOf(button);
if (!slot) return;
const choice = await picker.open(servings ? { servings } : {});
if (!choice) return;
const amount = Number(choice.servings);
await change(
{
op: "add",
...slot,
recipe: choice.path,
servings: choice.servings && amount > 0 ? amount : null
},
slot
);
});
const items = [...menu.querySelectorAll('[role="menuitem"]')];
let menuButton = null;
function closeMenu({ focusButton = true } = {}) {
if (!menuButton) return;
menu.hidden = true;
menuButton.setAttribute("aria-expanded", "false");
if (focusButton) menuButton.focus();
menuButton = null;
}
function openMenu(button) {
if (menuButton === button) {
closeMenu();
return;
}
closeMenu({ focusButton: false });
menuButton = button;
button.setAttribute("aria-expanded", "true");
menu.hidden = false;
const rect = button.getBoundingClientRect();
const width = menu.offsetWidth;
const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
menu.style.top = `${rect.bottom + window.scrollY + 4}px`;
menu.style.left = `${left + window.scrollX}px`;
items[0].focus();
}
root.addEventListener("click", event => {
const button = event.target.closest(".plan-line-button");
if (button) openMenu(button);
});
menu.addEventListener("keydown", event => {
const index = items.indexOf(document.activeElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
items[(index + 1) % items.length].focus();
break;
case "ArrowUp":
event.preventDefault();
items[(index - 1 + items.length) % items.length].focus();
break;
case "Home":
event.preventDefault();
items[0].focus();
break;
case "End":
event.preventDefault();
items[items.length - 1].focus();
break;
case "Escape":
event.preventDefault();
closeMenu();
break;
case "Tab":
closeMenu({ focusButton: false });
break;
}
});
document.addEventListener("click", event => {
if (menuButton && !menu.contains(event.target) && !menuButton.contains(event.target)) {
closeMenu({ focusButton: false });
}
});
menu.addEventListener("click", event => {
const item = event.target.closest("[data-plan-action]");
if (!item || !menuButton) return;
const button = menuButton;
const line = lineOf(button);
closeMenu({ focusButton: false });
if (!line) return;
if (item.dataset.planAction === "remove") {
change({ op: "remove", ...line }, { date: line.date, meal: line.meal });
} else {
openTarget(item.dataset.planAction, line, button);
}
});
let pending = null;
function openTarget(op, line, returnTo) {
pending = { op, line, returnTo };
targetHeading.textContent = targetHeading.dataset[op];
targetConfirm.textContent = targetConfirm.dataset[op];
targetDay.value = line.date;
if ([...targetMeal.options].some(option => option.value === line.meal)) {
targetMeal.value = line.meal;
}
target.classList.remove("hidden");
target.classList.add("flex");
targetDay.focus();
}
function closeTarget() {
target.classList.add("hidden");
target.classList.remove("flex");
const returnTo = pending?.returnTo;
pending = null;
if (returnTo?.isConnected) returnTo.focus();
}
targetForm.addEventListener("submit", event => {
event.preventDefault();
if (!pending) return;
const { op, line } = pending;
const to = { date: targetDay.value, meal: targetMeal.value };
target.classList.add("hidden");
target.classList.remove("flex");
pending = null;
change({ op, ...line, to }, to);
});
targetCancel.addEventListener("click", closeTarget);
target.addEventListener("click", event => {
if (event.target === target) closeTarget();
});
target.addEventListener("keydown", event => {
if (event.key === "Escape") {
event.preventDefault();
closeTarget();
} else if (event.key === "Tab") {
const focusable = [targetDay, targetMeal, targetCancel, targetConfirm];
const index = focusable.indexOf(document.activeElement);
event.preventDefault();
focusable[(index + (event.shiftKey ? -1 : 1) + focusable.length) % focusable.length].focus();
}
});
let dragged = null;
const copying = event => event.altKey || event.ctrlKey || event.metaKey;
root.addEventListener("dragstart", event => {
const line = lineOf(event.target);
if (!line) return;
dragged = line;
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData("text/plain", line.text);
});
root.addEventListener("dragend", () => {
dragged = null;
root.querySelectorAll(".plan-drop").forEach(el => el.classList.remove("plan-drop"));
});
root.addEventListener("dragover", event => {
const meal = dragged && event.target.closest(".plan-meal[data-meal]");
if (!meal || !slotOf(meal)) return;
event.preventDefault();
event.dataTransfer.dropEffect = copying(event) ? "copy" : "move";
root.querySelectorAll(".plan-drop").forEach(el => el !== meal && el.classList.remove("plan-drop"));
meal.classList.add("plan-drop");
});
root.addEventListener("dragleave", event => {
const meal = event.target.closest(".plan-meal");
if (meal && !meal.contains(event.relatedTarget)) meal.classList.remove("plan-drop");
});
root.addEventListener("drop", event => {
const meal = event.target.closest(".plan-meal[data-meal]");
const to = meal && slotOf(meal);
const line = dragged;
if (!to || !line) return;
event.preventDefault();
meal.classList.remove("plan-drop");
const op = copying(event) ? "copy" : "move";
if (op === "move" && to.date === line.date && to.meal === line.meal) return;
change({ op, ...line, to }, to);
});
let after = null;
try {
after = JSON.parse(sessionStorage.getItem(AFTER_RELOAD) || "null");
sessionStorage.removeItem(AFTER_RELOAD);
} catch {
after = null;
}
if (after) {
const button = addButton(after);
if (button) {
button.focus({ preventScroll: true });
button.closest(".plan-day")?.scrollIntoView({ block: "nearest" });
}
setTimeout(() => {
status.textContent = strings.saved;
}, 100);
}
}