portal_publico/static/js/widgets.js
2026-09-25 15:45:38 -03:00

320 lines
12 KiB
JavaScript

const PID_WIDGET_TYPES = {
"calendario-individual": {
label: "Calendário Individual",
description: "Próximos compromissos da sua agenda pessoal.",
href: "calendario-individual.html",
linkLabel: "Ver calendário completo",
// Mesmo gate usado pra esconder o item "Calendário Individual" da sidebar
// (data-section="calendario-individual", pidApplyAccessVisibility em
// access.js) — sem isso, um perfil sem esse módulo habilitado ainda
// conseguia adicionar o widget (e, por ele, chegar ao calendário
// completo) mesmo com o link do menu corretamente escondido.
visibleIf: (me) => !!(me.permissoes_efetivas?.["calendario-individual"]?.enabled),
},
"links-favoritos": {
label: "Links Favoritos",
description: "Seus atalhos favoritos de Links & Ferramentas.",
href: "links-ferramentas.html",
linkLabel: "Ver todos os links",
visibleIf: (me) => !!(me.permissoes_efetivas?.["links-ferramentas"]?.apps?.["links-ferramentas-visualizar"]),
},
};
const PID_WIDGET_LINK_DEFAULT_ICON =
'<svg class="widget-links-list__icon widget-links-list__icon--default" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>';
const PID_WIDGET_DEFAULT_WIDTH = 320;
const PID_WIDGET_DEFAULT_HEIGHT = 240;
async function pidFetchWidgets() {
return (await pidApiRequest("/widgets/")) || [];
}
async function pidAddWidget(tipo) {
return pidApiRequest("/widgets/", { method: "POST", body: { tipo } });
}
async function pidRemoveWidget(tipo) {
await pidApiRequest(`/widgets/${encodeURIComponent(tipo)}/`, { method: "DELETE" });
}
async function pidUpdateWidget(tipo, payload) {
return pidApiRequest(`/widgets/${encodeURIComponent(tipo)}/`, { method: "PATCH", body: payload });
}
document.addEventListener("DOMContentLoaded", async () => {
const widgetsGrid = document.getElementById("widgets-grid");
if (!widgetsGrid) return;
const me = await pidGetMe();
if (!me) return;
const widgetsEmpty = document.getElementById("widgets-empty");
const addBtn = document.getElementById("widget-add-btn");
const pickerModal = document.getElementById("widget-picker-modal");
const pickerList = document.getElementById("widget-picker-list");
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function renderCalendarWidgetBody(events) {
const today = todayIso();
const upcoming = events
.filter((ev) => ev.data >= today)
.sort((a, b) => {
const dateCompare = a.data.localeCompare(b.data);
return dateCompare !== 0 ? dateCompare : (a.horario || "").localeCompare(b.horario || "");
})
.slice(0, 5);
if (!upcoming.length) {
return `<p class="widget-card__empty">Nenhum compromisso agendado.</p>`;
}
return upcoming
.map((ev) => {
const [, month, day] = ev.data.split("-");
const horario = ev.horario ? ev.horario.slice(0, 5) : "";
const dateLabel = horario ? `${day}/${month} ${horario}` : `${day}/${month}`;
return `
<div class="widget-agenda-row">
<span class="widget-agenda-row__date">${pidEscapeHtml(dateLabel)}</span>
<span class="widget-agenda-row__title">${pidEscapeHtml(ev.titulo)}</span>
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${pidEscapeHtml(ev.dono_nome)}</span>` : ""}
</div>`;
})
.join("");
}
function renderLinksFavoritosWidgetBody(links, favoriteIds) {
const favoriteSet = new Set(favoriteIds);
const favoritos = links.filter((link) => favoriteSet.has(link.id));
if (!favoritos.length) {
return `<p class="widget-card__empty">Nenhum link favoritado ainda.</p>`;
}
return `
<div class="widget-links-list">
${favoritos
.map((link) => {
const iconHtml = link.icone
? `<img class="widget-links-list__icon" src="${pidEscapeHtml(link.icone)}" alt="" />`
: PID_WIDGET_LINK_DEFAULT_ICON;
return `
<a class="widget-links-list__row" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">
${iconHtml}
<span>${pidEscapeHtml(link.nome)}</span>
</a>`;
})
.join("")}
</div>`;
}
async function widgetBodyFor(typeKey) {
if (typeKey === "calendario-individual") return renderCalendarWidgetBody(await pidFetchEvents());
if (typeKey === "links-favoritos") {
const [links, favoriteIds] = await Promise.all([pidFetchLinks(), pidFetchLinkFavoritos()]);
return renderLinksFavoritosWidgetBody(links, favoriteIds);
}
return "";
}
let widgets = [];
let widgetBodyCache = {};
let resizeObserver = null;
const resizeSaveTimers = new Map();
function widgetCardHtml(widget) {
const type = PID_WIDGET_TYPES[widget.tipo];
const width = widget.largura || PID_WIDGET_DEFAULT_WIDTH;
const height = widget.altura || PID_WIDGET_DEFAULT_HEIGHT;
const body = widgetBodyCache[widget.tipo] || "";
return `
<article class="widget-card" data-widget-id="${widget.tipo}" style="width:${pidEscapeHtml(width)}px;height:${pidEscapeHtml(height)}px;">
<div class="widget-card__header" draggable="true">
<h3>${type.label}</h3>
<button type="button" class="widget-card__remove" data-widget-remove="${widget.tipo}" aria-label="Remover widget" draggable="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
</div>
<div class="widget-card__body">${body}</div>
<a href="${type.href}" class="widget-card__link">${type.linkLabel} →</a>
</article>`;
}
function observeWidgetSizes() {
if (resizeObserver) resizeObserver.disconnect();
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const card = entry.target;
const tipo = card.getAttribute("data-widget-id");
const width = Math.round(card.offsetWidth);
const height = Math.round(card.offsetHeight);
const widget = widgets.find((w) => w.tipo === tipo);
if (!widget || (widget.largura === width && widget.altura === height)) continue;
widget.largura = width;
widget.altura = height;
clearTimeout(resizeSaveTimers.get(tipo));
resizeSaveTimers.set(
tipo,
setTimeout(() => {
pidUpdateWidget(tipo, { largura: width, altura: height }).catch(() => {});
}, 500)
);
}
});
widgetsGrid.querySelectorAll(".widget-card").forEach((card) => resizeObserver.observe(card));
}
function renderWidgetsDom() {
widgetsGrid.querySelectorAll(".widget-card").forEach((el) => el.remove());
const cardsHtml = widgets.map((widget) => widgetCardHtml(widget)).join("");
if (widgetsEmpty) {
widgetsEmpty.insertAdjacentHTML("beforebegin", cardsHtml);
widgetsEmpty.hidden = widgets.length !== 0;
} else {
widgetsGrid.insertAdjacentHTML("beforeend", cardsHtml);
}
observeWidgetSizes();
}
async function renderWidgets() {
widgets = (await pidFetchWidgets()).filter((w) => {
const type = PID_WIDGET_TYPES[w.tipo];
return type && (!type.visibleIf || type.visibleIf(me));
});
const bodies = await Promise.all(widgets.map((w) => widgetBodyFor(w.tipo)));
widgetBodyCache = {};
widgets.forEach((w, idx) => {
widgetBodyCache[w.tipo] = bodies[idx];
});
renderWidgetsDom();
}
async function renderPicker() {
const added = widgets.map((w) => w.tipo);
const available = Object.keys(PID_WIDGET_TYPES).filter((key) => {
if (added.includes(key)) return false;
const type = PID_WIDGET_TYPES[key];
return !type.visibleIf || type.visibleIf(me);
});
pickerList.innerHTML = available.length
? available
.map(
(key) => `
<button type="button" class="widget-picker-option" data-widget-choice="${key}">
<span class="widget-picker-option__title">${PID_WIDGET_TYPES[key].label}</span>
<span class="widget-picker-option__desc">${PID_WIDGET_TYPES[key].description}</span>
</button>`
)
.join("")
: `<p class="widget-picker-empty">Você já adicionou todos os widgets disponíveis.</p>`;
}
addBtn.addEventListener("click", async () => {
await renderPicker();
pickerModal.hidden = false;
});
pickerModal.addEventListener("click", async (event) => {
if (event.target === pickerModal) {
pickerModal.hidden = true;
return;
}
const choice = event.target.closest("[data-widget-choice]");
if (!choice) return;
const key = choice.getAttribute("data-widget-choice");
await pidAddWidget(key);
pickerModal.hidden = true;
await renderWidgets();
});
widgetsGrid.addEventListener("click", async (event) => {
const removeBtn = event.target.closest("[data-widget-remove]");
if (!removeBtn) return;
await pidRemoveWidget(removeBtn.getAttribute("data-widget-remove"));
await renderWidgets();
});
let draggedTipo = null;
let dropTargetTipo = null;
let dropBefore = true;
function clearDropHighlight() {
widgetsGrid.querySelectorAll(".widget-card--drop-before, .widget-card--drop-after").forEach((el) => {
el.classList.remove("widget-card--drop-before", "widget-card--drop-after");
});
}
widgetsGrid.addEventListener("dragstart", (event) => {
const header = event.target.closest(".widget-card__header");
const card = header && header.closest(".widget-card");
if (!card) return;
draggedTipo = card.getAttribute("data-widget-id");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedTipo);
requestAnimationFrame(() => card.classList.add("widget-card--dragging"));
});
widgetsGrid.addEventListener("dragover", (event) => {
if (draggedTipo === null) return;
const card = event.target.closest(".widget-card");
if (!card || card.getAttribute("data-widget-id") === draggedTipo) return;
event.preventDefault();
const rect = card.getBoundingClientRect();
dropBefore = event.clientX < rect.left + rect.width / 2;
dropTargetTipo = card.getAttribute("data-widget-id");
clearDropHighlight();
card.classList.add(dropBefore ? "widget-card--drop-before" : "widget-card--drop-after");
});
widgetsGrid.addEventListener("drop", async (event) => {
if (draggedTipo === null) return;
event.preventDefault();
clearDropHighlight();
const fromTipo = draggedTipo;
const toTipo = dropTargetTipo;
draggedTipo = null;
dropTargetTipo = null;
if (toTipo === null || fromTipo === toTipo) return;
const fromIndex = widgets.findIndex((w) => w.tipo === fromTipo);
if (fromIndex === -1) return;
const reordered = widgets.slice();
const [moved] = reordered.splice(fromIndex, 1);
let insertIndex = reordered.findIndex((w) => w.tipo === toTipo);
if (insertIndex === -1) return;
if (!dropBefore) insertIndex += 1;
reordered.splice(insertIndex, 0, moved);
const updates = reordered
.map((widget, index) => ({ widget, index }))
.filter(({ widget, index }) => widget.ordem !== index);
if (!updates.length) return;
widgets = reordered;
renderWidgetsDom();
await Promise.all(updates.map(({ widget, index }) => pidUpdateWidget(widget.tipo, { ordem: index })));
await renderWidgets();
});
widgetsGrid.addEventListener("dragend", () => {
draggedTipo = null;
dropTargetTipo = null;
clearDropHighlight();
widgetsGrid
.querySelectorAll(".widget-card--dragging")
.forEach((el) => el.classList.remove("widget-card--dragging"));
});
await renderWidgets();
});