portal_publico/static/js/widgets.js

408 lines
15 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_DATA_FORMATO = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit" });
const PID_WIDGET_DATA_HORA_FORMATO = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
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");
const textoWidgetsVazio = widgetsEmpty ? widgetsEmpty.textContent : "";
const PID_WIDGET_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
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) => {
// ev.data é data pura ("AAAA-MM-DD"): monta pelas partes no fuso local
// (new Date("AAAA-MM-DD") seria UTC e cairia no dia anterior).
const [ano, mes, dia] = ev.data.split("-").map(Number);
const horario = ev.horario ? ev.horario.slice(0, 5) : "";
let dateLabel;
if (horario) {
const [hora, minuto] = horario.split(":").map(Number);
// pt-BR devolve "05/10, 09:03": tira a vírgula pra manter o "05/10 09:03" de antes.
dateLabel = PID_WIDGET_DATA_HORA_FORMATO.format(new Date(ano, mes - 1, dia, hora, minuto)).replace(",", "");
} else {
dateLabel = PID_WIDGET_DATA_FORMATO.format(new Date(ano, mes - 1, dia));
}
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="" width="20" height="20" />`
: 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((e) => {
pidAlert(`O novo tamanho do widget não foi salvo e voltará ao anterior ao recarregar a página. ${e.message}`, {
titulo: "Não foi possível salvar o tamanho",
});
});
}, 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));
});
// Falha no conteúdo de um widget fica só nele (e não vira "nenhum compromisso").
const bodies = await Promise.all(
widgets.map((w) =>
widgetBodyFor(w.tipo).catch(() => `<p class="widget-card__empty">${PID_WIDGET_ERRO_CARGA}</p>`)
)
);
widgetBodyCache = {};
widgets.forEach((w, idx) => {
widgetBodyCache[w.tipo] = bodies[idx];
});
if (widgetsEmpty) widgetsEmpty.textContent = textoWidgetsVazio;
renderWidgetsDom();
}
/** renderWidgets com tratamento de erro: falha ao buscar a lista de widgets
* mostra o aviso no lugar do "Nenhum widget adicionado". */
async function carregarWidgets() {
try {
await renderWidgets();
} catch (e) {
widgetsGrid.querySelectorAll(".widget-card").forEach((el) => el.remove());
if (widgetsEmpty) {
widgetsEmpty.textContent = PID_WIDGET_ERRO_CARGA;
widgetsEmpty.hidden = false;
}
}
}
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;
});
const pickerCloseBtn = document.getElementById("widget-picker-close-btn");
if (pickerCloseBtn) {
pickerCloseBtn.addEventListener("click", () => {
pickerModal.hidden = true;
});
}
// Enquanto um widget está sendo adicionado, todas as opções ficam travadas:
// clique duplo mandaria dois POST.
let adicionandoWidget = false;
pickerModal.addEventListener("click", async (event) => {
if (event.target === pickerModal) {
pickerModal.hidden = true;
return;
}
const choice = event.target.closest("[data-widget-choice]");
if (!choice || adicionandoWidget) return;
const key = choice.getAttribute("data-widget-choice");
const opcoes = pickerList.querySelectorAll("[data-widget-choice]");
adicionandoWidget = true;
opcoes.forEach((btn) => {
btn.disabled = true;
});
try {
await pidAddWidget(key);
pickerModal.hidden = true;
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível adicionar o widget" });
return;
} finally {
adicionandoWidget = false;
opcoes.forEach((btn) => {
btn.disabled = false;
});
}
await carregarWidgets();
});
widgetsGrid.addEventListener("click", async (event) => {
const removeBtn = event.target.closest("[data-widget-remove]");
if (!removeBtn || removeBtn.disabled) return;
const tipo = removeBtn.getAttribute("data-widget-remove");
const type = PID_WIDGET_TYPES[tipo];
const confirmado = await pidConfirm(`Remover o widget "${type ? type.label : tipo}" da tela inicial?`, {
titulo: "Remover widget",
textoConfirmar: "Remover",
});
if (!confirmado) return;
removeBtn.disabled = true;
try {
await pidRemoveWidget(tipo);
} catch (e) {
removeBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível remover o widget" });
return;
}
await carregarWidgets();
});
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();
try {
await Promise.all(updates.map(({ widget, index }) => pidUpdateWidget(widget.tipo, { ordem: index })));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível salvar a nova ordem" });
}
// Recarrega sempre do servidor: se algum PATCH falhou, volta à ordem gravada.
await carregarWidgets();
});
widgetsGrid.addEventListener("dragend", () => {
draggedTipo = null;
dropTargetTipo = null;
clearDropHighlight();
widgetsGrid
.querySelectorAll(".widget-card--dragging")
.forEach((el) => el.classList.remove("widget-card--dragging"));
});
await carregarWidgets();
});