408 lines
15 KiB
JavaScript
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();
|
|
});
|