320 lines
12 KiB
JavaScript
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">${dateLabel}</span>
|
|
<span class="widget-agenda-row__title">${ev.titulo}</span>
|
|
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${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="${link.icone}" alt="" />`
|
|
: PID_WIDGET_LINK_DEFAULT_ICON;
|
|
return `
|
|
<a class="widget-links-list__row" href="${link.url}" target="_blank" rel="noopener noreferrer">
|
|
${iconHtml}
|
|
<span>${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:${width}px;height:${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();
|
|
});
|